mirror of
https://github.com/layue13/MoonBridge.git
synced 2026-10-25 18:46:18 +00:00
Merge main backend channel into messages completion
This commit is contained in:
commit
d0aca24839
48 files changed
+2410
-2202
No files matched your search
@@ -1,13 +1,13 @@
|
||||
# StrataProxy
|
||||
|
||||
面向 Minecraft 1.7.10 Forge 的玩家会话代理:登录认证、协议转发、跨后端转服,以及供插件使用的玩家、服务器目录、命令和类型化事件 API。空岛分配、实例唤醒和 Ban 数据存储由插件实现。
|
||||
面向 Minecraft 1.7.10 Forge 的玩家会话代理:登录认证、协议转发、跨后端转服,以及供插件使用的玩家、服务器目录、命令、类型化事件和后端控制通道 API。空岛分配、实例唤醒和 Ban 数据存储由插件实现。
|
||||
|
||||
## 文档
|
||||
|
||||
- [设计文档索引](docs/README.md)
|
||||
- [架构与边界](docs/architecture.md):会话、协议、线程模型、转服、后端目录。
|
||||
- [插件开发](docs/plugins.md):生命周期、ServerView、选服、Command 和统一类型化事件 API。
|
||||
- [运行配置](docs/operations.md)与[发现插件](docs/discovery.md)。
|
||||
- [运行配置](docs/operations.md)与[后端控制通道](docs/backend-channel-design.md)。
|
||||
- [测试与性能验证](docs/testing.md):构建、烟测、真实客户端记录和基准方法。
|
||||
|
||||
## 本机运行
|
||||
@@ -23,4 +23,4 @@
|
||||
|
||||
## 验证状态
|
||||
|
||||
已完成当前 Prism 整合包的真实 Mojang 认证、连续 20 次转服、DNS/Agent 发行包烟测及同条件流量对照,见[验收记录](benchmarks/results/2026-09-26-prism-pack.md)。验收覆盖单玩家与本机后端;生产容量需按部署环境测量。已知 Uranium 候选服登录停滞的调查见[记录](smoke/results/2026-09-26-uranium-login-stalls.md)及 [Uranium #585](https://git.nest.potatolab.uk:8443/TDLM/Uranium/issues/585)。
|
||||
已完成当前 Prism 整合包的真实 Mojang 认证、连续 20 次转服、历史 DNS/Agent 发行包烟测及同条件流量对照,见[验收记录](benchmarks/results/2026-09-26-prism-pack.md)。验收覆盖单玩家与本机后端;生产容量需按部署环境测量。已知 Uranium 候选服登录停滞的调查见[记录](smoke/results/2026-09-26-uranium-login-stalls.md)及 [Uranium #585](https://git.nest.potatolab.uk:8443/TDLM/Uranium/issues/585)。
|
||||
@@ -0,0 +1,52 @@
|
||||
plugins {
|
||||
`java-library`
|
||||
`maven-publish`
|
||||
}
|
||||
|
||||
group = "uk.potatolab"
|
||||
version = "0.1.0-SNAPSHOT"
|
||||
|
||||
java {
|
||||
withSourcesJar()
|
||||
withJavadocJar()
|
||||
toolchain.languageVersion.set(JavaLanguageVersion.of(25))
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile>().configureEach {
|
||||
options.release.set(8)
|
||||
options.encoding = "UTF-8"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(platform("org.junit:junit-bom:5.14.1"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
|
||||
publishing {
|
||||
publications {
|
||||
create<MavenPublication>("backendChannelClient") {
|
||||
from(components["java"])
|
||||
artifactId = "backend-channel-client"
|
||||
pom {
|
||||
name.set("StrataProxy Backend Channel Client")
|
||||
description.set("Java 8-compatible backend control channel SDK.")
|
||||
}
|
||||
}
|
||||
}
|
||||
repositories {
|
||||
maven {
|
||||
name = "giteaPackages"
|
||||
url = uri(providers.environmentVariable("MAVEN_URL")
|
||||
.orElse("https://git.nest.potatolab.uk:8443/api/packages/layue13/maven").get())
|
||||
credentials {
|
||||
username = providers.environmentVariable("MAVEN_USER").orElse("NONE").get()
|
||||
password = providers.environmentVariable("MAVEN_PASSWORD").orElse("NONE").get()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
package dev.strataproxy.backendchannel;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/** Java 8-compatible reconnecting client for StrataProxy backend control channels. */
|
||||
public final class BackendChannelClient implements AutoCloseable {
|
||||
public static final long DEFAULT_REQUEST_TIMEOUT_MILLIS = 5000L;
|
||||
public static final int MAX_PENDING_REQUESTS = 128;
|
||||
public static final int MAX_HANDLER_QUEUE = 128;
|
||||
|
||||
private final String host, instanceId, backendName, gameAddress, generation, keyId;
|
||||
private final int port;
|
||||
private final byte[] secret;
|
||||
private final long heartbeatMillis, requestTimeoutMillis, reconnectMinMillis, reconnectMaxMillis;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
private final AtomicLong nextRequestId = new AtomicLong(1);
|
||||
private final AtomicInteger activeInbound = new AtomicInteger();
|
||||
private final AtomicReference<Session> session = new AtomicReference<Session>();
|
||||
private volatile Session connecting;
|
||||
private final Map<String, BackendChannelHandler> handlers = new ConcurrentHashMap<String, BackendChannelHandler>();
|
||||
private final Map<Long, CompletableFuture<byte[]>> pending = new ConcurrentHashMap<Long, CompletableFuture<byte[]>>();
|
||||
private final ThreadPoolExecutor handlerExecutor = new ThreadPoolExecutor(2, 2, 0L, TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<Runnable>(MAX_HANDLER_QUEUE), namedFactory("backend-channel-handler"), new ThreadPoolExecutor.AbortPolicy());
|
||||
private final ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor(2, namedFactory("backend-channel-timer"));
|
||||
private final Object stateMonitor = new Object();
|
||||
private final Object pendingLock = new Object();
|
||||
private volatile boolean registered;
|
||||
private volatile long epoch;
|
||||
private final Thread connectionThread;
|
||||
|
||||
public BackendChannelClient(String host, int port, String instanceId, String backendName, String gameAddress,
|
||||
String generation, String keyId, byte[] secret) {
|
||||
this(host, port, instanceId, backendName, gameAddress, generation, keyId, secret,
|
||||
10_000L, DEFAULT_REQUEST_TIMEOUT_MILLIS, 500L, 10_000L);
|
||||
}
|
||||
|
||||
public BackendChannelClient(String host, int port, String instanceId, String backendName, String gameAddress,
|
||||
String generation, String keyId, byte[] secret, long heartbeatMillis,
|
||||
long requestTimeoutMillis, long reconnectMinMillis, long reconnectMaxMillis) {
|
||||
if (host == null || host.trim().isEmpty()) throw new IllegalArgumentException("host is required");
|
||||
if (port < 1 || port > 65535) throw new IllegalArgumentException("port out of range");
|
||||
if (secret == null || secret.length < 32) throw new IllegalArgumentException("secret must contain at least 32 bytes");
|
||||
if (heartbeatMillis < 1000L || requestTimeoutMillis < 1L || reconnectMinMillis < 1L || reconnectMaxMillis < reconnectMinMillis) {
|
||||
throw new IllegalArgumentException("invalid timing configuration");
|
||||
}
|
||||
validateIdentity(instanceId, "instanceId"); validateIdentity(backendName, "backendName");
|
||||
validateIdentity(gameAddress, "gameAddress"); validateIdentity(generation, "generation"); validateIdentity(keyId, "keyId");
|
||||
this.host = host; this.port = port; this.instanceId = instanceId; this.backendName = backendName;
|
||||
this.gameAddress = gameAddress; this.generation = generation; this.keyId = keyId;
|
||||
this.secret = Arrays.copyOf(secret, secret.length); this.heartbeatMillis = heartbeatMillis;
|
||||
this.requestTimeoutMillis = requestTimeoutMillis; this.reconnectMinMillis = reconnectMinMillis;
|
||||
this.reconnectMaxMillis = reconnectMaxMillis;
|
||||
this.timer.setRemoveOnCancelPolicy(true);
|
||||
this.connectionThread = new Thread(new Runnable() { public void run() { reconnectLoop(); } }, "backend-channel-connection");
|
||||
this.connectionThread.setDaemon(true);
|
||||
this.connectionThread.start();
|
||||
}
|
||||
|
||||
/** Returns true after REGISTERED and until the active connection is lost. */
|
||||
public boolean isRegistered() { return registered; }
|
||||
|
||||
/** Waits for initial registration or a later reconnect. */
|
||||
public boolean awaitRegistered(long timeout, TimeUnit unit) throws InterruptedException {
|
||||
if (timeout < 0) throw new IllegalArgumentException("timeout must not be negative");
|
||||
long deadline = System.nanoTime() + unit.toNanos(timeout);
|
||||
synchronized (stateMonitor) {
|
||||
while (!registered && !closed.get()) {
|
||||
long left = deadline - System.nanoTime();
|
||||
if (left <= 0) return false;
|
||||
TimeUnit.NANOSECONDS.timedWait(stateMonitor, left);
|
||||
}
|
||||
return registered;
|
||||
}
|
||||
}
|
||||
|
||||
/** Installs one handler for a channel. Closing the returned handle unregisters it. */
|
||||
public AutoCloseable registerHandler(String channel, BackendChannelHandler handler) {
|
||||
validateChannel(channel);
|
||||
if (handler == null) throw new IllegalArgumentException("handler is required");
|
||||
if (handlers.putIfAbsent(channel, handler) != null) throw new IllegalStateException("handler already registered: " + channel);
|
||||
final BackendChannelHandler installed = handler;
|
||||
return new AutoCloseable() {
|
||||
public void close() { handlers.remove(channel, installed); }
|
||||
};
|
||||
}
|
||||
|
||||
/** Sends a one-way message. Fails immediately if disconnected or backpressured. */
|
||||
public void send(String channel, byte[] payload) throws IOException {
|
||||
validateChannel(channel);
|
||||
if (payload == null || payload.length > Wire.MAX_PAYLOAD_BYTES) throw new IllegalArgumentException("payload length out of bounds");
|
||||
Session active = session.get();
|
||||
if (!registered || active == null) throw new IOException("backend channel is disconnected");
|
||||
active.write(Wire.message(channel, 0L, payload));
|
||||
}
|
||||
|
||||
/** Sends a request using the configured default timeout. */
|
||||
public CompletableFuture<byte[]> request(String channel, byte[] payload) {
|
||||
return request(channel, payload, requestTimeoutMillis, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
/** Sends a request. The returned future fails on timeout, disconnect, or non-OK response. */
|
||||
public CompletableFuture<byte[]> request(String channel, byte[] payload, long timeout, TimeUnit unit) {
|
||||
validateChannel(channel);
|
||||
if (payload == null || payload.length > Wire.MAX_PAYLOAD_BYTES) throw new IllegalArgumentException("payload length out of bounds");
|
||||
if (timeout < 1) throw new IllegalArgumentException("timeout must be positive");
|
||||
Session active = session.get();
|
||||
if (!registered || active == null) return failed(new IOException("backend channel is disconnected"));
|
||||
if (pending.size() >= MAX_PENDING_REQUESTS) return failed(new IOException("too many pending requests"));
|
||||
long id = nextRequestId.getAndIncrement();
|
||||
if (id <= 0) return failed(new IOException("request ID space exhausted"));
|
||||
final CompletableFuture<byte[]> result = new CompletableFuture<byte[]>();
|
||||
synchronized (pendingLock) {
|
||||
if (pending.size() >= MAX_PENDING_REQUESTS) return failed(new IOException("too many pending requests"));
|
||||
if (pending.putIfAbsent(id, result) != null) return failed(new IOException("request ID collision"));
|
||||
}
|
||||
try {
|
||||
active.write(Wire.message(channel, id, payload));
|
||||
} catch (IOException ex) {
|
||||
pending.remove(id, result);
|
||||
result.completeExceptionally(ex);
|
||||
return result;
|
||||
}
|
||||
final ScheduledFuture<?> timeoutTask;
|
||||
try {
|
||||
timeoutTask = timer.schedule(new Runnable() {
|
||||
public void run() { result.completeExceptionally(new TimeoutException("backend request timed out")); }
|
||||
}, timeout, unit);
|
||||
} catch (RejectedExecutionException stopped) {
|
||||
pending.remove(id, result);
|
||||
result.completeExceptionally(stopped);
|
||||
return result;
|
||||
}
|
||||
result.whenComplete((value, error) -> {
|
||||
pending.remove(id, result);
|
||||
timeoutTask.cancel(false);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
public long getEpoch() { return epoch; }
|
||||
|
||||
@Override public void close() {
|
||||
if (!closed.compareAndSet(false, true)) return;
|
||||
Session active = session.getAndSet(null);
|
||||
Session inProgress = connecting;
|
||||
connecting = null;
|
||||
registered = false;
|
||||
synchronized (stateMonitor) { stateMonitor.notifyAll(); }
|
||||
if (active != null) {
|
||||
try { active.write(Wire.empty(Wire.GOODBYE)); } catch (IOException ignored) { }
|
||||
active.close();
|
||||
}
|
||||
if (inProgress != null && inProgress != active) inProgress.close();
|
||||
connectionThread.interrupt();
|
||||
handlerExecutor.shutdownNow();
|
||||
timer.shutdownNow();
|
||||
failPending(new IOException("backend channel client closed"));
|
||||
Arrays.fill(secret, (byte) 0);
|
||||
}
|
||||
|
||||
private void reconnectLoop() {
|
||||
long pause = reconnectMinMillis;
|
||||
while (!closed.get()) {
|
||||
Session active = null;
|
||||
try {
|
||||
Socket socket = new Socket();
|
||||
active = new Session(socket);
|
||||
connecting = active;
|
||||
if (closed.get()) { active.close(); break; }
|
||||
socket.connect(new InetSocketAddress(host, port), 5000);
|
||||
active.initialize();
|
||||
runSession(active);
|
||||
pause = reconnectMinMillis;
|
||||
} catch (InterruptedException interrupted) {
|
||||
if (closed.get()) break;
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception ignored) {
|
||||
// Connection details are deliberately not logged because protocol errors can include peer data.
|
||||
} finally {
|
||||
Session prior = session.getAndSet(null);
|
||||
connecting = null;
|
||||
if (prior != null) prior.close();
|
||||
if (active != null) active.close();
|
||||
setRegistered(false);
|
||||
failPending(new IOException("backend channel disconnected"));
|
||||
}
|
||||
if (!closed.get()) {
|
||||
try { Thread.sleep(pause); } catch (InterruptedException e) { if (closed.get()) break; }
|
||||
pause = Math.min(reconnectMaxMillis, pause > reconnectMaxMillis / 2 ? reconnectMaxMillis : pause * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void runSession(Session active) throws Exception {
|
||||
active.socket.setSoTimeout(5000);
|
||||
byte[] helloFrame = FrameCodec.read(active.in);
|
||||
Wire.Hello hello = Wire.decodeHello(helloFrame);
|
||||
if (hello.version != Wire.VERSION) throw new IOException("unsupported proxy protocol version");
|
||||
byte[] canonical = Wire.canonicalRegistration(instanceId, backendName, gameAddress, generation, keyId);
|
||||
byte[] signed = new byte[hello.nonce.length + canonical.length];
|
||||
System.arraycopy(hello.nonce, 0, signed, 0, hello.nonce.length);
|
||||
System.arraycopy(canonical, 0, signed, hello.nonce.length, canonical.length);
|
||||
byte[] signature = hmac(secret, signed);
|
||||
active.write(Wire.register(instanceId, backendName, gameAddress, generation, keyId, signature));
|
||||
byte[] registeredFrame = FrameCodec.read(active.in);
|
||||
epoch = Wire.decodeRegistered(registeredFrame);
|
||||
long pongTimeoutMillis = pongTimeoutMillis(heartbeatMillis);
|
||||
active.socket.setSoTimeout((int) Math.min(Integer.MAX_VALUE, pongTimeoutMillis));
|
||||
active.lastPongNanos = System.nanoTime();
|
||||
session.set(active);
|
||||
setRegistered(true);
|
||||
active.heartbeatTask = timer.scheduleAtFixedRate(new Runnable() {
|
||||
public void run() {
|
||||
if (session.get() != active || closed.get()) return;
|
||||
try { active.write(Wire.empty(Wire.HEARTBEAT)); }
|
||||
catch (IOException failure) { active.close(); }
|
||||
}
|
||||
}, heartbeatMillis, heartbeatMillis, TimeUnit.MILLISECONDS);
|
||||
active.livenessTask = timer.scheduleAtFixedRate(new Runnable() {
|
||||
public void run() {
|
||||
if (session.get() != active || closed.get()) return;
|
||||
long age = System.nanoTime() - active.lastPongNanos;
|
||||
if (age >= TimeUnit.MILLISECONDS.toNanos(pongTimeoutMillis)) active.close();
|
||||
}
|
||||
}, heartbeatMillis, heartbeatMillis, TimeUnit.MILLISECONDS);
|
||||
while (!closed.get() && !active.socket.isClosed()) {
|
||||
byte[] frame = FrameCodec.read(active.in);
|
||||
handleFrame(active, frame);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleFrame(final Session active, byte[] frame) throws IOException {
|
||||
if (frame.length == 0) throw new IOException("empty frame");
|
||||
int type = frame[0] & 0xff;
|
||||
if (type == Wire.PONG) {
|
||||
Wire.validateEmpty(frame, Wire.PONG);
|
||||
active.lastPongNanos = System.nanoTime();
|
||||
return;
|
||||
}
|
||||
if (type == Wire.MESSAGE) {
|
||||
final Wire.Message message = Wire.decodeMessage(frame);
|
||||
final BackendChannelHandler handler = handlers.get(message.channel);
|
||||
if (message.requestId == 0 && handler == null) return;
|
||||
if (activeInbound.incrementAndGet() > MAX_PENDING_REQUESTS) {
|
||||
activeInbound.decrementAndGet();
|
||||
if (message.requestId != 0) respond(active, message.requestId, Wire.STATUS_ERROR, new byte[0]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
handlerExecutor.execute(new Runnable() {
|
||||
public void run() {
|
||||
if (handler == null) {
|
||||
respond(active, message.requestId, Wire.STATUS_NO_HANDLER, new byte[0]);
|
||||
activeInbound.decrementAndGet();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
java.util.concurrent.CompletionStage<byte[]> stage = handler.handle(message.payload);
|
||||
if (stage == null) {
|
||||
respond(active, message.requestId, Wire.STATUS_ERROR, new byte[0]);
|
||||
activeInbound.decrementAndGet();
|
||||
return;
|
||||
}
|
||||
stage.whenComplete((value, error) -> {
|
||||
try {
|
||||
if (message.requestId == 0) return;
|
||||
if (error != null) respond(active, message.requestId, Wire.STATUS_ERROR, errorPayload(error));
|
||||
else respond(active, message.requestId, Wire.STATUS_OK, value == null ? new byte[0] : value);
|
||||
} finally { activeInbound.decrementAndGet(); }
|
||||
});
|
||||
} catch (Throwable error) {
|
||||
try {
|
||||
if (message.requestId != 0) respond(active, message.requestId, Wire.STATUS_ERROR, errorPayload(error));
|
||||
} finally { activeInbound.decrementAndGet(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException full) {
|
||||
activeInbound.decrementAndGet();
|
||||
if (message.requestId != 0) respond(active, message.requestId, Wire.STATUS_ERROR, new byte[0]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (type == Wire.GOODBYE) { Wire.validateEmpty(frame, Wire.GOODBYE); throw new IOException("proxy closed channel"); }
|
||||
if (type == Wire.RESPONSE) {
|
||||
Wire.Response response = Wire.decodeResponse(frame);
|
||||
CompletableFuture<byte[]> future = pending.get(response.requestId);
|
||||
if (future != null) {
|
||||
if (response.status == Wire.STATUS_OK) future.complete(response.payload);
|
||||
else future.completeExceptionally(new ChannelResponseException(response.status, response.payload));
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new IOException("unexpected proxy frame type: " + type);
|
||||
}
|
||||
|
||||
private void respond(Session active, long requestId, int status, byte[] payload) {
|
||||
if (requestId == 0 || session.get() != active) return;
|
||||
try {
|
||||
active.write(Wire.response(requestId, status, payload));
|
||||
} catch (Exception ignored) { active.close(); }
|
||||
}
|
||||
|
||||
private void setRegistered(boolean value) {
|
||||
registered = value;
|
||||
synchronized (stateMonitor) { stateMonitor.notifyAll(); }
|
||||
}
|
||||
|
||||
private void failPending(IOException cause) {
|
||||
for (CompletableFuture<byte[]> future : pending.values()) future.completeExceptionally(cause);
|
||||
pending.clear();
|
||||
}
|
||||
|
||||
private static byte[] hmac(byte[] key, byte[] data) throws GeneralSecurityException {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(key, "HmacSHA256"));
|
||||
return mac.doFinal(data);
|
||||
}
|
||||
|
||||
private static long pongTimeoutMillis(long heartbeatMillis) {
|
||||
long triple = heartbeatMillis > Long.MAX_VALUE / 3L ? Long.MAX_VALUE : heartbeatMillis * 3L;
|
||||
return Math.max(15_000L, triple);
|
||||
}
|
||||
|
||||
private static void validateIdentity(String value, String label) {
|
||||
if (value == null || value.trim().isEmpty() || value.getBytes(StandardCharsets.UTF_8).length > Wire.MAX_STRING_BYTES) {
|
||||
throw new IllegalArgumentException(label + " is empty or too long");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateChannel(String channel) {
|
||||
validateIdentity(channel, "channel");
|
||||
if (!channel.matches("[a-z0-9][a-z0-9_.-]{0,63}:[a-z0-9][a-z0-9_.-]{0,63}")) {
|
||||
throw new IllegalArgumentException("channel must match namespace:name using lowercase ASCII letters, digits, '.', '_' or '-'");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] errorPayload(Throwable error) {
|
||||
String message = error.getMessage();
|
||||
if (message == null) message = error.getClass().getSimpleName();
|
||||
byte[] bytes = message.getBytes(StandardCharsets.UTF_8);
|
||||
return bytes.length <= Wire.MAX_PAYLOAD_BYTES ? bytes : Arrays.copyOf(bytes, Wire.MAX_PAYLOAD_BYTES);
|
||||
}
|
||||
|
||||
private static <T> CompletableFuture<T> failed(Throwable error) {
|
||||
CompletableFuture<T> future = new CompletableFuture<T>();
|
||||
future.completeExceptionally(error);
|
||||
return future;
|
||||
}
|
||||
|
||||
private static ThreadFactory namedFactory(final String prefix) {
|
||||
return new ThreadFactory() {
|
||||
private final AtomicLong count = new AtomicLong();
|
||||
public Thread newThread(Runnable task) {
|
||||
Thread thread = new Thread(task, prefix + "-" + count.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static final class ChannelResponseException extends IOException {
|
||||
private final int status;
|
||||
private final byte[] payload;
|
||||
ChannelResponseException(int status, byte[] payload) {
|
||||
super("backend channel response status: " + status);
|
||||
this.status = status;
|
||||
this.payload = payload;
|
||||
}
|
||||
public int getStatus() { return status; }
|
||||
public byte[] getPayload() { return Arrays.copyOf(payload, payload.length); }
|
||||
}
|
||||
|
||||
private static final class Session {
|
||||
final Socket socket;
|
||||
volatile DataInputStream in;
|
||||
volatile DataOutputStream out;
|
||||
volatile java.util.concurrent.ScheduledFuture<?> heartbeatTask;
|
||||
volatile java.util.concurrent.ScheduledFuture<?> livenessTask;
|
||||
volatile long lastPongNanos;
|
||||
Session(Socket socket) { this.socket = socket; }
|
||||
void initialize() throws IOException {
|
||||
this.in = new DataInputStream(socket.getInputStream());
|
||||
this.out = new DataOutputStream(socket.getOutputStream());
|
||||
}
|
||||
synchronized void write(byte[] frame) throws IOException {
|
||||
if (socket.isClosed()) throw new IOException("connection closed");
|
||||
FrameCodec.write(out, frame);
|
||||
}
|
||||
void close() {
|
||||
java.util.concurrent.ScheduledFuture<?> task = heartbeatTask;
|
||||
if (task != null) task.cancel(false);
|
||||
java.util.concurrent.ScheduledFuture<?> liveness = livenessTask;
|
||||
if (liveness != null) liveness.cancel(false);
|
||||
try { socket.close(); } catch (IOException ignored) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.strataproxy.backendchannel;
|
||||
|
||||
import java.util.concurrent.CompletionStage;
|
||||
|
||||
/** Handles an inbound request or one-way message for a registered channel name. */
|
||||
public interface BackendChannelHandler {
|
||||
CompletionStage<byte[]> handle(byte[] payload) throws Exception;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package dev.strataproxy.backendchannel;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
/** Length-prefixed frame I/O. The length and frame type are both included in the transmitted frame. */
|
||||
public final class FrameCodec {
|
||||
private FrameCodec() { }
|
||||
|
||||
public static byte[] read(DataInputStream input) throws IOException {
|
||||
int length = input.readInt();
|
||||
if (length < 1 || length > Wire.MAX_FRAME_BYTES) throw new IOException("frame length out of bounds: " + length);
|
||||
byte[] frame = new byte[length];
|
||||
input.readFully(frame);
|
||||
return frame;
|
||||
}
|
||||
|
||||
public static void write(DataOutputStream output, byte[] frame) throws IOException {
|
||||
if (frame == null || frame.length < 1 || frame.length > Wire.MAX_FRAME_BYTES) {
|
||||
throw new IOException("frame length out of bounds");
|
||||
}
|
||||
output.writeInt(frame.length);
|
||||
output.write(frame);
|
||||
output.flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package dev.strataproxy.backendchannel;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** Wire constants and codecs shared by the backend SDK and proxy. */
|
||||
public final class Wire {
|
||||
public static final int VERSION = 1;
|
||||
public static final int HELLO = 1;
|
||||
public static final int REGISTER = 2;
|
||||
public static final int REGISTERED = 3;
|
||||
public static final int HEARTBEAT = 4;
|
||||
public static final int PONG = 5;
|
||||
public static final int GOODBYE = 6;
|
||||
public static final int MESSAGE = 7;
|
||||
public static final int RESPONSE = 8;
|
||||
|
||||
public static final int STATUS_OK = 0;
|
||||
public static final int STATUS_NO_HANDLER = 1;
|
||||
public static final int STATUS_ERROR = 2;
|
||||
|
||||
public static final int NONCE_BYTES = 32;
|
||||
public static final int MAX_FRAME_BYTES = 66 * 1024;
|
||||
public static final int MAX_PAYLOAD_BYTES = 64 * 1024;
|
||||
public static final int MAX_STRING_BYTES = 1024;
|
||||
|
||||
private Wire() { }
|
||||
|
||||
public static byte[] hello(int version, byte[] nonce) throws IOException {
|
||||
if (nonce == null || nonce.length != NONCE_BYTES) throw new IllegalArgumentException("nonce must be 32 bytes");
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream(1 + 4 + NONCE_BYTES);
|
||||
DataOutputStream out = new DataOutputStream(bytes);
|
||||
out.writeByte(HELLO);
|
||||
out.writeInt(version);
|
||||
out.write(nonce);
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
public static Hello decodeHello(byte[] frame) throws IOException {
|
||||
DataInputStream in = body(frame, HELLO);
|
||||
int version = in.readInt();
|
||||
byte[] nonce = new byte[NONCE_BYTES];
|
||||
in.readFully(nonce);
|
||||
requireEnd(in);
|
||||
return new Hello(version, nonce);
|
||||
}
|
||||
|
||||
public static byte[] register(String instanceId, String name, String address, String generation,
|
||||
String keyId, byte[] signature) throws IOException {
|
||||
if (signature == null || signature.length != 32) throw new IllegalArgumentException("signature must be 32 bytes");
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
DataOutputStream out = new DataOutputStream(bytes);
|
||||
out.writeByte(REGISTER);
|
||||
writeString(out, instanceId);
|
||||
writeString(out, name);
|
||||
writeString(out, address);
|
||||
writeString(out, generation);
|
||||
writeString(out, keyId);
|
||||
out.write(signature);
|
||||
return bounded(bytes.toByteArray());
|
||||
}
|
||||
|
||||
/** Canonical HMAC input after the server nonce: five unsigned-short-length-prefixed UTF-8 strings. */
|
||||
public static byte[] canonicalRegistration(String instanceId, String name, String address,
|
||||
String generation, String keyId) throws IOException {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
DataOutputStream out = new DataOutputStream(bytes);
|
||||
writeString(out, instanceId);
|
||||
writeString(out, name);
|
||||
writeString(out, address);
|
||||
writeString(out, generation);
|
||||
writeString(out, keyId);
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
public static Registration decodeRegister(byte[] frame) throws IOException {
|
||||
DataInputStream in = body(frame, REGISTER);
|
||||
String instanceId = readString(in);
|
||||
String name = readString(in);
|
||||
String address = readString(in);
|
||||
String generation = readString(in);
|
||||
String keyId = readString(in);
|
||||
byte[] signature = new byte[32];
|
||||
in.readFully(signature);
|
||||
requireEnd(in);
|
||||
return new Registration(instanceId, name, address, generation, keyId, signature);
|
||||
}
|
||||
|
||||
public static byte[] registered(long epoch) throws IOException {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream(9);
|
||||
DataOutputStream out = new DataOutputStream(bytes);
|
||||
out.writeByte(REGISTERED);
|
||||
out.writeLong(epoch);
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
public static long decodeRegistered(byte[] frame) throws IOException {
|
||||
DataInputStream in = body(frame, REGISTERED);
|
||||
long epoch = in.readLong();
|
||||
requireEnd(in);
|
||||
return epoch;
|
||||
}
|
||||
|
||||
public static byte[] empty(int type) throws IOException {
|
||||
if (type != HEARTBEAT && type != PONG && type != GOODBYE) throw new IllegalArgumentException("not an empty frame type");
|
||||
return new byte[] { (byte) type };
|
||||
}
|
||||
|
||||
public static void validateEmpty(byte[] frame, int type) throws IOException {
|
||||
DataInputStream in = body(frame, type);
|
||||
requireEnd(in);
|
||||
}
|
||||
|
||||
public static byte[] message(String channel, long requestId, byte[] payload) throws IOException {
|
||||
requirePayload(payload);
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
DataOutputStream out = new DataOutputStream(bytes);
|
||||
out.writeByte(MESSAGE);
|
||||
writeString(out, channel);
|
||||
out.writeLong(requestId);
|
||||
out.writeInt(payload.length);
|
||||
out.write(payload);
|
||||
return bounded(bytes.toByteArray());
|
||||
}
|
||||
|
||||
public static Message decodeMessage(byte[] frame) throws IOException {
|
||||
DataInputStream in = body(frame, MESSAGE);
|
||||
String channel = readString(in);
|
||||
long requestId = in.readLong();
|
||||
if (requestId < 0) throw new IOException("negative request ID");
|
||||
byte[] payload = readPayload(in);
|
||||
requireEnd(in);
|
||||
return new Message(channel, requestId, payload);
|
||||
}
|
||||
|
||||
public static byte[] response(long requestId, int status, byte[] payload) throws IOException {
|
||||
if (requestId <= 0) throw new IllegalArgumentException("requestId must be positive");
|
||||
if (status < STATUS_OK || status > STATUS_ERROR) throw new IllegalArgumentException("invalid response status");
|
||||
requirePayload(payload);
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
DataOutputStream out = new DataOutputStream(bytes);
|
||||
out.writeByte(RESPONSE);
|
||||
out.writeLong(requestId);
|
||||
out.writeByte(status);
|
||||
out.writeInt(payload.length);
|
||||
out.write(payload);
|
||||
return bounded(bytes.toByteArray());
|
||||
}
|
||||
|
||||
public static Response decodeResponse(byte[] frame) throws IOException {
|
||||
DataInputStream in = body(frame, RESPONSE);
|
||||
long requestId = in.readLong();
|
||||
int status = in.readUnsignedByte();
|
||||
if (requestId <= 0 || status > STATUS_ERROR) throw new IOException("invalid response header");
|
||||
byte[] payload = readPayload(in);
|
||||
requireEnd(in);
|
||||
return new Response(requestId, status, payload);
|
||||
}
|
||||
|
||||
static DataInputStream body(byte[] frame, int type) throws IOException {
|
||||
if (frame == null || frame.length < 1 || frame.length > MAX_FRAME_BYTES || (frame[0] & 0xff) != type) {
|
||||
throw new IOException("invalid frame type or size");
|
||||
}
|
||||
return new DataInputStream(new ByteArrayInputStream(frame, 1, frame.length - 1));
|
||||
}
|
||||
|
||||
private static byte[] bounded(byte[] frame) throws IOException {
|
||||
if (frame.length > MAX_FRAME_BYTES) throw new IOException("frame exceeds maximum size");
|
||||
return frame;
|
||||
}
|
||||
|
||||
private static void writeString(DataOutputStream out, String value) throws IOException {
|
||||
if (value == null) throw new IllegalArgumentException("string cannot be null");
|
||||
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
|
||||
if (encoded.length == 0 || encoded.length > MAX_STRING_BYTES) throw new IllegalArgumentException("string length out of bounds");
|
||||
out.writeShort(encoded.length);
|
||||
out.write(encoded);
|
||||
}
|
||||
|
||||
private static String readString(DataInputStream in) throws IOException {
|
||||
int length = in.readUnsignedShort();
|
||||
if (length == 0 || length > MAX_STRING_BYTES) throw new IOException("string length out of bounds");
|
||||
byte[] encoded = new byte[length];
|
||||
in.readFully(encoded);
|
||||
return new String(encoded, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static byte[] readPayload(DataInputStream in) throws IOException {
|
||||
int length = in.readInt();
|
||||
if (length < 0 || length > MAX_PAYLOAD_BYTES) throw new IOException("payload length out of bounds");
|
||||
byte[] payload = new byte[length];
|
||||
in.readFully(payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static void requirePayload(byte[] payload) {
|
||||
if (payload == null || payload.length > MAX_PAYLOAD_BYTES) throw new IllegalArgumentException("payload length out of bounds");
|
||||
}
|
||||
|
||||
private static void requireEnd(DataInputStream in) throws IOException {
|
||||
if (in.read() != -1) throw new IOException("trailing frame data");
|
||||
}
|
||||
|
||||
public static final class Hello {
|
||||
public final int version;
|
||||
public final byte[] nonce;
|
||||
Hello(int version, byte[] nonce) { this.version = version; this.nonce = nonce; }
|
||||
}
|
||||
|
||||
public static final class Registration {
|
||||
public final String instanceId, name, address, generation, keyId;
|
||||
public final byte[] signature;
|
||||
Registration(String instanceId, String name, String address, String generation, String keyId, byte[] signature) {
|
||||
this.instanceId = instanceId; this.name = name; this.address = address; this.generation = generation;
|
||||
this.keyId = keyId; this.signature = signature;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Message {
|
||||
public final String channel;
|
||||
public final long requestId;
|
||||
public final byte[] payload;
|
||||
Message(String channel, long requestId, byte[] payload) { this.channel = channel; this.requestId = requestId; this.payload = payload; }
|
||||
}
|
||||
|
||||
public static final class Response {
|
||||
public final long requestId;
|
||||
public final int status;
|
||||
public final byte[] payload;
|
||||
Response(long requestId, int status, byte[] payload) { this.requestId = requestId; this.status = status; this.payload = payload; }
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package dev.strataproxy.backendchannel;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class BackendChannelClientTest {
|
||||
@Test void frameCodecRoundTripsAndRejectsOversize() throws Exception {
|
||||
byte[] frame = Wire.message("demo:echo", 9L, new byte[] { 1, 2, 3 });
|
||||
java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream();
|
||||
DataOutputStream out = new DataOutputStream(bytes);
|
||||
FrameCodec.write(out, frame);
|
||||
byte[] encoded = bytes.toByteArray();
|
||||
assertEquals(frame.length, ByteBuffer.wrap(encoded).getInt());
|
||||
Wire.Message decoded = Wire.decodeMessage(FrameCodec.read(new DataInputStream(new java.io.ByteArrayInputStream(encoded))));
|
||||
assertEquals("demo:echo", decoded.channel);
|
||||
assertEquals(9L, decoded.requestId);
|
||||
assertArrayEquals(new byte[] { 1, 2, 3 }, decoded.payload);
|
||||
assertThrows(java.io.IOException.class, () -> FrameCodec.read(new DataInputStream(new java.io.ByteArrayInputStream(new byte[] { 0, 1, 0, 1 }))));
|
||||
}
|
||||
|
||||
@Test void authenticatesRegistersAndExchangesRequestsInBothDirections() throws Exception {
|
||||
final byte[] secret = new byte[32];
|
||||
Arrays.fill(secret, (byte) 0x6a);
|
||||
final ServerSocket server = new ServerSocket(0);
|
||||
CompletableFuture<Void> peer = CompletableFuture.runAsync(() -> {
|
||||
try (Socket socket = server.accept()) {
|
||||
DataInputStream in = new DataInputStream(socket.getInputStream());
|
||||
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
|
||||
byte[] nonce = new byte[Wire.NONCE_BYTES];
|
||||
Arrays.fill(nonce, (byte) 0x33);
|
||||
FrameCodec.write(out, Wire.hello(Wire.VERSION, nonce));
|
||||
Wire.Registration registration = Wire.decodeRegister(FrameCodec.read(in));
|
||||
assertEquals("backend-1", registration.instanceId);
|
||||
byte[] canonical = Wire.canonicalRegistration("backend-1", "island-a", "tcp://127.0.0.1:25565", "gen-1", "key-1");
|
||||
byte[] signed = new byte[nonce.length + canonical.length];
|
||||
System.arraycopy(nonce, 0, signed, 0, nonce.length);
|
||||
System.arraycopy(canonical, 0, signed, nonce.length, canonical.length);
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
|
||||
assertArrayEquals(mac.doFinal(signed), registration.signature);
|
||||
FrameCodec.write(out, Wire.registered(77L));
|
||||
|
||||
Wire.Message request = Wire.decodeMessage(FrameCodec.read(in));
|
||||
assertEquals("proxy:query", request.channel);
|
||||
assertEquals(1L, request.requestId);
|
||||
FrameCodec.write(out, Wire.response(request.requestId, Wire.STATUS_OK, "answer".getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
FrameCodec.write(out, Wire.message("backend:echo", 42L, "hello".getBytes(StandardCharsets.UTF_8)));
|
||||
Wire.Response response = Wire.decodeResponse(FrameCodec.read(in));
|
||||
assertEquals(42L, response.requestId);
|
||||
assertEquals(Wire.STATUS_OK, response.status);
|
||||
assertArrayEquals("hello!".getBytes(StandardCharsets.UTF_8), response.payload);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
try { server.close(); } catch (Exception ignored) { }
|
||||
}
|
||||
});
|
||||
|
||||
BackendChannelClient client = new BackendChannelClient("127.0.0.1", server.getLocalPort(), "backend-1", "island-a",
|
||||
"tcp://127.0.0.1:25565", "gen-1", "key-1", secret, 10_000L, 2_000L, 100L, 500L);
|
||||
client.registerHandler("backend:echo", payload -> CompletableFuture.completedFuture(
|
||||
(new String(payload, StandardCharsets.UTF_8) + "!").getBytes(StandardCharsets.UTF_8)));
|
||||
try {
|
||||
assertTrue(client.awaitRegistered(5, TimeUnit.SECONDS));
|
||||
assertEquals(77L, client.getEpoch());
|
||||
byte[] answer = client.request("proxy:query", new byte[] { 7 }).get(5, TimeUnit.SECONDS);
|
||||
assertArrayEquals("answer".getBytes(StandardCharsets.UTF_8), answer);
|
||||
peer.get(5, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void reconnectsAndRegistersAgainAfterConnectionLoss() throws Exception {
|
||||
final byte[] secret = new byte[32];
|
||||
Arrays.fill(secret, (byte) 0x2d);
|
||||
final ServerSocket server = new ServerSocket(0);
|
||||
CompletableFuture<Void> peer = CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
for (int connection = 1; connection <= 2; connection++) {
|
||||
try (Socket socket = server.accept()) {
|
||||
DataInputStream in = new DataInputStream(socket.getInputStream());
|
||||
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
|
||||
byte[] nonce = new byte[Wire.NONCE_BYTES];
|
||||
Arrays.fill(nonce, (byte) connection);
|
||||
FrameCodec.write(out, Wire.hello(Wire.VERSION, nonce));
|
||||
Wire.Registration registration = Wire.decodeRegister(FrameCodec.read(in));
|
||||
assertEquals("retry-backend", registration.instanceId);
|
||||
FrameCodec.write(out, Wire.registered(connection));
|
||||
if (connection == 2) {
|
||||
Wire.Message request = Wire.decodeMessage(FrameCodec.read(in));
|
||||
FrameCodec.write(out, Wire.response(request.requestId, Wire.STATUS_OK, request.payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
try { server.close(); } catch (Exception ignored) { }
|
||||
}
|
||||
});
|
||||
|
||||
BackendChannelClient client = new BackendChannelClient("127.0.0.1", server.getLocalPort(), "retry-backend", "island-b",
|
||||
"tcp://127.0.0.1:25566", "gen-retry", "key-1", secret, 10_000L, 2_000L, 100L, 500L);
|
||||
try {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
while (client.getEpoch() < 2L && System.nanoTime() < deadline) Thread.sleep(10L);
|
||||
assertEquals(2L, client.getEpoch());
|
||||
assertTrue(client.isRegistered());
|
||||
byte[] payload = new byte[] { 8, 6, 7 };
|
||||
assertArrayEquals(payload, client.request("proxy:retry", payload).get(5, TimeUnit.SECONDS));
|
||||
peer.get(5, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void disconnectsWhenProxyStopsSendingPongsAndFailsPendingRequest() throws Exception {
|
||||
final byte[] secret = new byte[32];
|
||||
Arrays.fill(secret, (byte) 0x44);
|
||||
final ServerSocket server = new ServerSocket(0);
|
||||
CompletableFuture<Void> peer = CompletableFuture.runAsync(() -> {
|
||||
try (Socket socket = server.accept()) {
|
||||
DataInputStream in = new DataInputStream(socket.getInputStream());
|
||||
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
|
||||
byte[] nonce = new byte[Wire.NONCE_BYTES];
|
||||
Arrays.fill(nonce, (byte) 0x61);
|
||||
FrameCodec.write(out, Wire.hello(Wire.VERSION, nonce));
|
||||
Wire.decodeRegister(FrameCodec.read(in));
|
||||
FrameCodec.write(out, Wire.registered(1L));
|
||||
Wire.decodeMessage(FrameCodec.read(in));
|
||||
while (true) FrameCodec.read(in); // Deliberately consume heartbeats without replying PONG.
|
||||
} catch (Exception ignored) {
|
||||
// The client should close this socket after its PONG watchdog expires.
|
||||
} finally {
|
||||
try { server.close(); } catch (Exception ignored) { }
|
||||
}
|
||||
});
|
||||
|
||||
BackendChannelClient client = new BackendChannelClient("127.0.0.1", server.getLocalPort(), "silent-backend", "island-c",
|
||||
"tcp://127.0.0.1:25567", "gen-silent", "key-1", secret, 1_000L, 60_000L, 100L, 500L);
|
||||
try {
|
||||
assertTrue(client.awaitRegistered(5, TimeUnit.SECONDS));
|
||||
CompletableFuture<byte[]> pending = client.request("proxy:slow", new byte[] { 1 }, 60_000L, TimeUnit.MILLISECONDS);
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20);
|
||||
while (client.isRegistered() && System.nanoTime() < deadline) Thread.sleep(25L);
|
||||
assertFalse(client.isRegistered(), "client must expire a connection without PONGs");
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> pending.get(2, TimeUnit.SECONDS));
|
||||
peer.get(2, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
client.close();
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -10,10 +10,10 @@ allprojects {
|
||||
|
||||
tasks.register("check") {
|
||||
group = "verification"
|
||||
dependsOn(":proxy-plugin-api:check", ":proxy-core:check", ":dns-discovery:check", ":agent-discovery:check")
|
||||
dependsOn(":proxy-plugin-api:check", ":backend-channel-client:check", ":proxy-core:check")
|
||||
}
|
||||
|
||||
tasks.register("publish") {
|
||||
group = "publishing"
|
||||
dependsOn(":proxy-plugin-api:publish")
|
||||
dependsOn(":proxy-plugin-api:publish", ":backend-channel-client:publish")
|
||||
}
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
| [插件开发](plugins.md) | 生命周期、玩家/服务器视图、选服、命令与统一类型化事件 API |
|
||||
| [玩家操作与部署完善设计](player-operations.md) | 消息、主动断开、服务器列表配置与验收边界 |
|
||||
| [运行配置](operations.md) | 认证、接入限制、超时、启动和部署边界 |
|
||||
| [后端发现](discovery.md) | DNS 与 Agent 插件、配置、注册与租约 |
|
||||
| [后端控制通道](backend-channel-design.md) | 独立连接、注册与双向消息 |
|
||||
| [验证方法](testing.md) | 自动化测试、烟测、真实客户端与合成性能测量 |
|
||||
|
||||
设计约束:核心管理连接和协议;插件负责玩法与访问策略。`ServerView` 是后端目录快照,发现方式是目录的提供者,不引入核心负载观测。准入与会话通知使用同一事件类型、监听器和注册表;不同事件在派发策略上处理结果。常规 PLAY 转发没有通用事件派发。
|
||||
@@ -34,7 +34,9 @@ flowchart LR
|
||||
|
||||
静态配置和插件注册共用核心后端目录。插件通过 `Servers.find/all` 取得 `ServerView` 快照;`ServerView` 仅含名称、地址、标签和插件元数据,不含负载、玩家数或容量。插件以 `Servers.register` 获得所有权句柄,可更新或注销自己的条目;不能替换静态配置或其他插件拥有的同名项。重新注册同名项会生成新一代句柄,使旧句柄失效。选服拿到的是当时的后端快照,目录之后更新不改变已经开始的连接。
|
||||
|
||||
默认初始选服遍历当前目录并选择注册顺序中的第一个后端;有玩法选服逻辑时由插件的初始选服回调负责决定。DNS 与 Agent 发现均为可选插件,只调用通用注册 API:核心不包含 DNS/Agent 协议,也不推断后端健康或空岛可用性。DNS 插件独立查询 A/AAAA 并协调自身注册;Agent 插件通过带签名的注册端点协调租约。启用插件关闭时,宿主撤销该插件名下的注册和命令。
|
||||
默认初始选服遍历当前目录并选择注册顺序中的第一个后端;有玩法选服逻辑时由插件的初始选服回调负责决定。代理不推断后端健康或空岛可用性。插件关闭时,宿主撤销该插件名下的注册和命令。
|
||||
|
||||
核心还可启用独立的后端控制通道:每个后端实例以单独连接完成认证、注册和插件消息交互,不依赖玩家连接;短暂断线的注册保留到租约到期,消息调用立即报告连接不可用。控制通道不能覆盖静态后端或插件拥有的名称。详见[后端控制通道](backend-channel-design.md)。
|
||||
|
||||
## 转服、并发与错误边界
|
||||
|
||||
@@ -62,4 +64,4 @@ flowchart LR
|
||||
- 插件 API:[`PluginContext`](../proxy-plugin-api/src/main/java/dev/strataproxy/api/PluginContext.java)、[`Servers`](../proxy-plugin-api/src/main/java/dev/strataproxy/api/Servers.java)、[`Players`](../proxy-plugin-api/src/main/java/dev/strataproxy/api/Players.java)
|
||||
- 会话和转服:[`Session`](../proxy-core/src/main/java/dev/strataproxy/core/session/Session.java)、[`TransferCandidate`](../proxy-core/src/main/java/dev/strataproxy/core/session/TransferCandidate.java)、[`TransferFrameBuffer`](../proxy-core/src/main/java/dev/strataproxy/core/session/TransferFrameBuffer.java)
|
||||
- 目录:[`BackendCatalog`](../proxy-core/src/main/java/dev/strataproxy/core/backend/BackendCatalog.java)、[`InMemoryBackendCatalog`](../proxy-core/src/main/java/dev/strataproxy/core/backend/InMemoryBackendCatalog.java)
|
||||
- 可选发现:[`DnsDiscoveryPlugin`](../plugins/dns-discovery/src/main/java/dev/strataproxy/plugins/dns/DnsDiscoveryPlugin.java)、[`AgentDiscoveryPlugin`](../plugins/agent-discovery/src/main/java/dev/strataproxy/plugins/agent/AgentDiscoveryPlugin.java)
|
||||
- 后端控制通道:[`BackendControlService`](../proxy-core/src/main/java/dev/strataproxy/core/control/BackendControlService.java)
|
||||
@@ -0,0 +1,63 @@
|
||||
# 后端控制通道
|
||||
|
||||
控制通道已经提供第一版实现:每个后端实例主动连接代理的独立端口,在没有在线玩家时注册,并与代理插件双向交换消息。它不使用 Minecraft PLAY 插件消息,也不使用 HTTP 注册或 DNS 发现。
|
||||
|
||||
## 组成与部署
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
BP[后端插件] --> SDK[Java 8 后端 SDK]
|
||||
SDK -->|每实例一条控制连接| C[代理控制服务]
|
||||
C --> R[后端目录]
|
||||
C --> D[有界消息派发]
|
||||
D --> PP[代理插件 API]
|
||||
P[玩家连接] -->|独立的游戏链路| R
|
||||
```
|
||||
|
||||
代理在 `backendChannel.listen` 启动第二个监听器。每个实例用独立的 `instanceId`、`backendName`、凭据和游戏端口注册;同一 IP 可以承载多个不同端口的实例。代理侧不需要为每个实例启动单独的插件或 HTTP 端点。控制通道只创建动态目录条目,不能覆盖静态后端或其他实例拥有的名称。
|
||||
|
||||
示例配置见 [`strataproxy-channel.example.yml`](../proxy-core/src/main/resources/config/strataproxy-channel.example.yml)。`clients` 中每个实例的 `secret` 至少 32 个 UTF-8 字节,`allowedHosts` 限制它可广告的游戏主机名或 IP,`allowedNamespaces` 限制它可以发往代理插件的消息命名空间。后端注册地址必须是代理实际可达的 `tcp://host:port`;只有代理与后端同机时,才能使用 `127.0.0.1` 作为游戏地址。不要把示例密钥用于实际部署。
|
||||
|
||||
后端插件只依赖 `backend-channel-client`,该模块按 Java 8 字节码编译,不依赖 JDK 25 的 `proxy-plugin-api` 或代理核心。创建一个 `BackendChannelClient` 后,它会连接、注册、发送心跳并在断线后重连。构造器中的 `generation` 应在进程启动时生成一次 UUID,重连期间保持不变。
|
||||
|
||||
### Docker 部署
|
||||
|
||||
代理容器运行 JDK 25,后端容器按服务端要求运行 Java 8;两者加入同一个私有 Docker 网络。代理配置中的 `listen` 和 `backendChannel.listen` 在容器内绑定 `0.0.0.0`,分别监听玩家端口和控制端口。仅将玩家端口发布到宿主机;控制端口只需在容器网络内可达。后端 SDK 的代理地址填 Docker 网络中的代理服务名(例如 `proxy:28081`),游戏地址填代理可达的后端容器名和实际端口(例如 `tcp://island-a:25565`)。`allowedHosts` 应列出这个游戏地址的主机名。这里的容器 DNS 只用于连接已配置的服务名,不承担后端发现或注册。
|
||||
|
||||
每个后端实例使用独立的 `instanceId`、`backendName`、游戏地址和密钥;同一台宿主机上的不同容器不能把 `127.0.0.1` 当作彼此的地址。不要把真实密钥写入镜像或提交到仓库;启动时挂载由部署系统生成的代理配置,并向后端插件注入对应密钥。跨宿主机部署还需要私有加密链路或 TLS 终止器。可运行 `sh smoke/container-channel.sh`,在 Docker bridge 中验证注册、登录、注销和已有玩家连接继续转发。
|
||||
|
||||
```java
|
||||
BackendChannelClient channel = new BackendChannelClient(
|
||||
"127.0.0.1", 28081, "island-a", "island-a", "tcp://127.0.0.1:25565",
|
||||
UUID.randomUUID().toString(), "primary", secretBytes);
|
||||
channel.registerHandler("islands:echo", payload ->
|
||||
CompletableFuture.completedFuture(payload));
|
||||
// 服务端插件关闭时调用 channel.close()。
|
||||
```
|
||||
|
||||
代理插件通过 `PluginContext.backendChannels()` 使用通道。处理器在 `onLoad` 或 `onEnable` 注册,同一通道只能有一个处理器;插件关闭时自动撤销。`BackendMessage` 含代理认证过的 `backendName`、`instanceId` 和连接代次,处理器应使用这些字段判断来源,不应信任负载里自称的身份。
|
||||
|
||||
```java
|
||||
context.backendChannels().subscribe("islands:prepare", message ->
|
||||
prepare(message).thenApply(ignored -> new byte[0]));
|
||||
context.backendChannels().send("island-a", "islands:notice", bytes);
|
||||
context.backendChannels().request("island-a", "islands:echo", bytes);
|
||||
```
|
||||
|
||||
## 注册、断线与安全
|
||||
|
||||
代理先发送协议版本和随机挑战。后端以实例 ID、名称、游戏地址、启动代次、密钥 ID 和覆盖这些字段的 HMAC-SHA256 签名注册;验证成功且目录写入完成后,代理返回连接代次。认证失败、名称冲突或广告主机不在允许列表时,不会创建目录条目。重复连接同一实例会接管旧连接,旧连接的回调和清理不能修改新条目。
|
||||
|
||||
SDK 默认每 10 秒发一次心跳,代理默认租约为 30 秒,可在配置中调整。意外断线时,消息调用立即失败,目录条目保留到租约到期;期间玩家连接仍可能尝试该游戏地址。重连并重新认证后恢复消息通道。正常关闭会发送 `GOODBYE` 并立即注销。目录撤销不会强制踢掉已经在该服的玩家。
|
||||
|
||||
控制协议本身没有加密。跨机器部署应把控制监听器放在私有加密链路后,或使用可信 TLS 终止器;HMAC 不能防止窃听。当前每个实例配置一个密钥 ID 和密钥,轮换需要协调更新配置与客户端。代理对未认证连接设握手期限,并限制连接数、帧长、每连接队列、在途请求和入站消息速率。
|
||||
|
||||
## 消息语义
|
||||
|
||||
第一版支持单向 `send` 和带回复的 `request`。消息是二进制负载,最大 64 KiB;代理每连接最多排队 1 MiB 或 128 条出站消息、128 个在途请求,默认请求期限 5 秒。入站处理器使用独立于玩家准入和命令的有界工作池。代理 `send` 返回 `SENT` 只说明写入控制连接,不代表后端插件已处理;断线、过载和无处理器分别有明确失败结果。后端 SDK 也限制在途请求和处理器队列。
|
||||
|
||||
控制通道不保存离线消息,不保证断线前的请求是否已被远端执行,也不跨连接代次自动重放。业务重试需要自己的幂等键;可靠保存的玩法数据仍应写入业务存储。消息收发不进入普通 PLAY 转发路径。
|
||||
|
||||
## 验证范围
|
||||
|
||||
自动化测试覆盖零玩家通信、同 IP 不同端口注册、双向请求、无处理器响应、无效凭据、旧连接接管和意外断线后租约过期。Java 8 SDK 已通过 Java 8 编译;真实 Forge/Uranium 服务端装载、跨机器加密链路和生产负载仍需在目标部署环境验证。源码入口为 [`BackendControlService`](../proxy-core/src/main/java/dev/strataproxy/core/control/BackendControlService.java)、[`BackendChannels`](../proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendChannels.java) 和 [`BackendChannelClient`](../backend-channel-client/src/main/java/dev/strataproxy/backendchannel/BackendChannelClient.java)。
|
||||
@@ -1,23 +0,0 @@
|
||||
# 后端发现插件
|
||||
|
||||
使用 DNS 插件时,把示例配置中的主机名换成自己的 DNS 主机名;相对插件目录随配置文件位置一起解析。A 和 AAAA 记录分别刷新:一个地址族查询超时会暂时保留其上次结果,连续三次失败后只撤销该地址族;明确返回无记录会清除该地址族的缓存。两族均无记录时,目录在连续三次查询失败后撤销旧注册。
|
||||
|
||||
### Agent 动态注册插件
|
||||
|
||||
Agent 插件同样由 `plugins.enabled` 显式启用。它通过一个独立 HTTP 端点接收实例注册和续租,再调用通用 `Servers` API;代理核心不包含 Agent 协议或云平台发现逻辑。端点默认只绑定 `127.0.0.1:28080`,请求使用至少 32 字节的共享密钥做 HMAC-SHA256 签名,带 60 秒时间窗和一次性随机 nonce。请求体、并发请求数、实例数和租约时长都有上限。每个 Agent 进程使用新的 UUID generation;活动租约不能被其他 generation 覆盖。注销或过期的 generation 会保留到最大租约时长加 60 秒签名时间窗之后,再由周期清理器删除;这能拦截在租约切换期间延迟到达的旧请求,同时避免 Agent 重启次数累积导致注册耗尽。异常退出后,租约到期会移除后端。
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
directory: ../plugins
|
||||
enabled:
|
||||
dev.strataproxy.plugins.agent.AgentDiscoveryPlugin:
|
||||
secret: "replace-with-a-private-random-secret-of-32-bytes-or-more"
|
||||
# host: 127.0.0.1
|
||||
# port: "28080"
|
||||
# concurrency: "4"
|
||||
# maxBodyBytes: "4096"
|
||||
# maxLeaseSeconds: "60"
|
||||
# maxInstances: "128"
|
||||
```
|
||||
|
||||
密钥不要提交到仓库;插件和 Agent 必须使用同一个值。跨主机部署时,应绑定到私有网络,并通过可信 TLS 终止器或私有链路保护传输;HMAC 验证身份和请求完整性,但不加密流量。`AgentRegistrationClient` 是仅依赖 JDK 的示例客户端:设置 `STRATAPROXY_AGENT_SECRET`,然后运行 `AgentExampleMain <http-endpoint> <agent-id> <backend-name> <tcp-address>`,其中端点形如 `http://127.0.0.1:28080/registration`。示例每 10 秒续租一次,租约 30 秒;网络故障和服务端暂时不可用时继续重试,租约过期导致旧 generation 被拒绝时换用新 generation 注册;认证或请求格式错误则退出。正常退出时尝试注销,异常退出由租约过期清理。Agent 协议只注册后端名称与地址,不需要消息队列。
|
||||
+7
-5
@@ -4,7 +4,7 @@
|
||||
|
||||
## 配置和启动
|
||||
|
||||
默认配置模板为 [`strataproxy.yml`](../proxy-core/src/main/resources/config/strataproxy.yml)。发行包将配置放在 `config/`,插件 JAR 放在 `plugins/`。相对 `plugins.directory` 从所选配置文件所在目录解析;`plugins.enabled` 以插件实现类全名为键,空映射不启用外部插件。插件可用设置由各插件在启动时校验。
|
||||
默认配置模板为 [`strataproxy.yml`](../proxy-core/src/main/resources/config/strataproxy.yml)。发行包将示例配置放在 `config/`;自有插件 JAR 可放在 `plugins.directory` 指向的目录。相对 `plugins.directory` 从所选配置文件所在目录解析;`plugins.enabled` 以插件实现类全名为键,空映射不启用外部插件。插件可用设置由各插件在启动时校验。
|
||||
|
||||
本地安装发行包并启动:
|
||||
|
||||
@@ -42,9 +42,11 @@ status:
|
||||
|
||||
## 可选后端发现
|
||||
|
||||
DNS 示例配置见 [`strataproxy-dns.example.yml`](../proxy-core/src/main/resources/config/strataproxy-dns.example.yml)。它将静态 `backends` 设为空,并通过 `plugins.enabled` 启用 DNS 插件,设置 DNS 主机名、端口、名称前缀和刷新周期。DNS 查找失败暂时保留旧地址;连续三次失败后移除过期地址。DNS 查询明确返回地址族无记录时,该族地址会按解析结果更新。`localhost` 使用系统回环地址解析;普通主机名由插件自己的解析器查询,不依赖 JVM 的全局地址缓存。
|
||||
后端控制通道示例见 [`strataproxy-channel.example.yml`](../proxy-core/src/main/resources/config/strataproxy-channel.example.yml),使用独立监听端口接收实例注册、心跳和双向消息;使用方法与限制见[后端控制通道](backend-channel-design.md)。
|
||||
|
||||
Agent 示例见[发现插件](discovery.md)和 [`AgentDiscoveryPlugin`](../plugins/agent-discovery/src/main/java/dev/strataproxy/plugins/agent/AgentDiscoveryPlugin.java)。启用其 HTTP 注册监听前,应在受控网络中配置随机共享密钥,并按插件的端口、并发和租约约束部署。代理核心没有内置云实例发现或健康探测。
|
||||
Docker 容器间的监听、服务名和地址配置见[Docker 部署](backend-channel-design.md#docker-部署);`sh smoke/container-channel.sh` 可运行跨容器烟测。
|
||||
|
||||
控制通道默认关闭。启用后,后端实例通过独立 TCP 连接认证和注册;代理不提供 DNS 发现或 HTTP 注册。代理核心没有内置云实例发现或健康探测。
|
||||
|
||||
玩家准入事件 API、检查顺序与 Ban 插件边界见[插件接入说明](plugins.md)。连接准入拒绝发生在 Minecraft 协议开始前,因此没有 Login Disconnect 可供显示;玩家准入发生在身份解析后。`ONLINE_BUNGEE` 仍由核心完成会话验证后才发出玩家事件,离线模式的身份由客户端提供且 `authenticated` 为 false。会话通知不保证 Forge 或玩法世界就绪,也不能用于可靠审计或计费。
|
||||
|
||||
@@ -56,14 +58,14 @@ Agent 示例见[发现插件](discovery.md)和 [`AgentDiscoveryPlugin`](../plugi
|
||||
.\gradlew.bat check
|
||||
```
|
||||
|
||||
该任务包含插件 API、核心、DNS/Agent 插件测试,并在核心 `check` 中依赖安装发行包后的帮助、版本及配置校验烟测。详细烟测、性能测量方法与历史记录见[验证方法](testing.md)。
|
||||
该任务包含插件 API、Java 8 后端 SDK 和核心测试,并在核心 `check` 中依赖安装发行包后的帮助、版本及配置校验烟测。详细烟测、性能测量方法与历史记录见[验证方法](testing.md)。
|
||||
|
||||
现有测试可用于定位行为范围:
|
||||
|
||||
- 登录、认证和协议:`proxy-core/src/test/java/dev/strataproxy/core/auth/` 与 `proxy-core/src/test/java/dev/strataproxy/core/protocol/`
|
||||
- 会话、断开、转服与缓冲:`proxy-core/src/test/java/dev/strataproxy/core/session/`,重点包括 `SessionTransferTest`、`SessionTransferDispatchTest`、`TransferFrameBufferTest`
|
||||
- 插件生命周期、访问检查和 API:`proxy-core/src/test/java/dev/strataproxy/core/plugin/PluginHostTest.java`、`proxy-core/src/test/java/dev/strataproxy/core/session/ProxySessionListenerTest.java`、`proxy-core/src/test/java/dev/strataproxy/app/ProxyConfigurationTest.java` 与 `proxy-plugin-api/src/test/`
|
||||
- 发现器:`plugins/dns-discovery/src/test/` 与 `plugins/agent-discovery/src/test/`
|
||||
- 控制通道:`proxy-core/src/test/java/dev/strataproxy/core/control/` 与 `backend-channel-client/src/test/`
|
||||
|
||||
仓库另有本地 Uranium 协议探针和 Prism Forge 客户端记录,见 [`smoke/results`](../smoke/results/2026-09-25-local-uranium.md)、[转服记录](../smoke/results/2026-09-25-local-uranium-transfer.md) 和 [真实客户端记录](../smoke/results/2026-09-25-prism-forge-client.md)。这些记录支持其明确写出的单机环境、软件版本和探针路径。已知也出现过真实 Forge 客户端转服候选后端登录停滞,见[调查记录](../smoke/results/2026-09-26-uranium-login-stalls.md);已有成功样例不构成稳定性或生产验收。
|
||||
|
||||
|
||||
+7
-1
@@ -17,7 +17,13 @@
|
||||
- `Servers.register(ServerDefinition)` 返回所有权句柄,支持更新与注销。同名条目由原注册者拥有,不能覆盖其他插件或静态配置。同名重新注册后旧句柄失效。
|
||||
- `Players.transfer(identity, backendName)` 异步返回 `TransferResult`;`NETWORK_READY` 表示协议交接完成,不表示玩法数据加载完成。通过插件上下文调用时,完成通知也被转移到 I/O 线程之外。
|
||||
|
||||
插件先自行完成岛屿加载或实例唤醒,再调用转服。发现只是注册后端的一种来源,见[发现插件](discovery.md)。
|
||||
插件先自行完成岛屿加载或实例唤醒,再调用转服。动态后端可通过[后端控制通道](backend-channel-design.md)注册。
|
||||
|
||||
### 后端控制消息
|
||||
|
||||
`PluginContext.backendChannels()` 让代理插件与已注册的后端实例双向通信。后端侧使用独立的 Java 8 `backend-channel-client` SDK,不依赖玩家在线。代理插件在 `onLoad` 或 `onEnable` 调用 `subscribe("命名空间:操作", handler)`;同一通道只允许一个处理器,停用时自动撤销。处理器收到的 `BackendMessage` 含已认证的实例身份和连接代次,返回 `CompletionStage<byte[]>` 作为请求响应。
|
||||
|
||||
`send(backendName, channel, payload)` 返回 `SENT`、`NOT_CONNECTED` 或 `BACKPRESSURED`;`SENT` 只表示消息写入控制连接。`request(...)` 在五秒内等待后端处理结果,断线、无处理器或超时会异常完成。负载在提交时复制,单条最多 64 KiB;回调在有界工作池运行,不应同步等待数据库或网络任务。配置与交付边界见[后端控制通道](backend-channel-design.md)。
|
||||
|
||||
### 消息与主动断开
|
||||
|
||||
|
||||
+3
-3
@@ -5,13 +5,13 @@
|
||||
```powershell
|
||||
.\gradlew.bat check
|
||||
.\gradlew.bat :proxy-core:installedDistSmokeTest
|
||||
.\smoke\installed-discovery.ps1
|
||||
.\smoke\container-network.ps1
|
||||
sh smoke/container-channel.sh
|
||||
```
|
||||
|
||||
`installed-discovery.ps1` 在 PowerShell 7 下使用已安装的发行包,以临时配置分别启动 DNS 和 Agent 插件;它验证两种发现方式下玩家完成离线登录和 PLAY 帧转发。Agent 注销时,已连接玩家继续收发 PLAY 帧,新玩家无法选到已注销的后端;原玩家离开后在线数归零。先运行 `:proxy-core:installDist` 或全量 `check` 来生成发行包。定向测试覆盖目录代次、插件生命周期与超时、协议边界、relay 背压,以及合成 TCP 登录和切换。当前 Prism 整合包的正版登录、连续转服和配对流量测量已完成,见[当前整合包验收](../benchmarks/results/2026-09-26-prism-pack.md)。下面各节的历史结果保留其当时的验证范围。
|
||||
定向测试覆盖控制通道认证与代次、目录租约、插件生命周期与超时、协议边界、relay 背压,以及合成 TCP 登录和切换。当前 Prism 整合包的正版登录、连续转服和配对流量测量已完成,见[当前整合包验收](../benchmarks/results/2026-09-26-prism-pack.md)。下面各节的历史结果保留其当时的验证范围。
|
||||
|
||||
`smoke/container-network.ps1` 使用 Docker bridge 中的独立代理、后端和客户端容器,依次检查静态配置、DNS 插件和 Agent 插件的注册、登录及 PLAY 转发;Agent 模式还验证注销后已连接会话继续转发。PowerShell 7 和 Docker Desktop 是运行前提。一次结果见 [容器网络烟测记录](../smoke/results/2026-09-25-container-network.md)。它验证跨容器网络与 DNS,不能代替跨物理主机或目标 Forge 整合包实测。
|
||||
`smoke/container-network.ps1` 使用 Docker bridge 中的独立代理、后端和客户端容器,依次检查静态配置和控制通道注册、登录及 PLAY 转发;控制通道模式还验证注销后已连接会话继续转发。PowerShell 7 和 Docker Desktop 是运行前提。`smoke/container-channel.sh` 在 Unix shell 下验证控制通道的同一跨容器路径。2026-09-25 的[容器网络烟测记录](../smoke/results/2026-09-25-container-network.md)是旧实现的历史结果,不代表当前通道已在那次运行中验证。容器烟测不能代替跨物理主机或目标 Forge 整合包实测。
|
||||
|
||||
PR 的 `.gitea/workflows/verify.yml` 独立运行 `gradlew check`,不使用 Maven 发布凭据。`.gitea/workflows/ci.yml` 保留默认分支的构建发布入口;发布工作流所需的跨仓库可复用工作流权限和 Maven 凭据由 Gitea 仓库配置提供,不能用本地 `check` 的结果代替远端发布验收。
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
plugins {
|
||||
id("strataproxy.java-library-conventions")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":proxy-plugin-api"))
|
||||
}
|
||||
-414
@@ -1,414 +0,0 @@
|
||||
package dev.strataproxy.plugins.agent;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import dev.strataproxy.api.Plugin;
|
||||
import dev.strataproxy.api.PluginContext;
|
||||
import dev.strataproxy.api.ServerDefinition;
|
||||
import dev.strataproxy.api.ServerRegistration;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/** Optional authenticated HTTP registration endpoint for external instance agents. */
|
||||
public final class AgentDiscoveryPlugin implements Plugin {
|
||||
static final String PATH = "/registration";
|
||||
private static final int ABSOLUTE_BODY_LIMIT = 8192;
|
||||
private static final int MAX_NONCES = 4096;
|
||||
private static final int SIGNATURE_WINDOW_SECONDS = 60;
|
||||
private static final int MAX_RETIRED_GENERATIONS = 65_536;
|
||||
private PluginContext context;
|
||||
private Configuration configuration;
|
||||
private HttpServer server;
|
||||
private ThreadPoolExecutor requests;
|
||||
private ScheduledExecutorService reaper;
|
||||
final LeaseRegistry leases = new LeaseRegistry();
|
||||
|
||||
@Override
|
||||
public synchronized void onLoad(PluginContext pluginContext) {
|
||||
if (context != null) throw new IllegalStateException("Agent discovery plugin is already loaded");
|
||||
context = Objects.requireNonNull(pluginContext, "pluginContext");
|
||||
configuration = Configuration.from(context.settings());
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void onEnable() {
|
||||
if (context == null) throw new IllegalStateException("onLoad must complete before onEnable");
|
||||
if (server != null) return;
|
||||
try {
|
||||
requests = new ThreadPoolExecutor(configuration.concurrency(), configuration.concurrency(), 0,
|
||||
TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(configuration.concurrency() * 8), task -> {
|
||||
Thread thread = new Thread(task, "strataproxy-agent-request");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}, new ThreadPoolExecutor.AbortPolicy());
|
||||
server = HttpServer.create(new InetSocketAddress(configuration.host(), configuration.port()), 32);
|
||||
server.setExecutor(requests);
|
||||
server.createContext(PATH, this::handle);
|
||||
reaper = Executors.newSingleThreadScheduledExecutor(task -> {
|
||||
Thread thread = new Thread(task, "strataproxy-agent-lease-reaper");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
reaper.scheduleWithFixedDelay(() -> leases.expire(System.currentTimeMillis()), 1, 1, TimeUnit.SECONDS);
|
||||
server.start();
|
||||
context.logger().info("Agent discovery listening on {}:{}", configuration.host(), server.getAddress().getPort());
|
||||
} catch (IOException failure) {
|
||||
stopServices();
|
||||
throw new IllegalStateException("Could not start agent discovery listener", failure);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void onDisable() {
|
||||
leases.close();
|
||||
stopServices();
|
||||
}
|
||||
|
||||
private synchronized void stopServices() {
|
||||
if (server != null) {
|
||||
server.stop(0);
|
||||
server = null;
|
||||
}
|
||||
if (requests != null) {
|
||||
requests.shutdownNow();
|
||||
requests = null;
|
||||
}
|
||||
if (reaper != null) {
|
||||
reaper.shutdownNow();
|
||||
reaper = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void handle(HttpExchange exchange) throws IOException {
|
||||
int status = 400;
|
||||
String reply = "bad request";
|
||||
try {
|
||||
if (!PATH.equals(exchange.getRequestURI().getPath()) || !"POST".equals(exchange.getRequestMethod())) {
|
||||
status = 404;
|
||||
reply = "not found";
|
||||
} else {
|
||||
String length = exchange.getRequestHeaders().getFirst("Content-Length");
|
||||
if (length != null && parseDecimal(length, -1) > configuration.maxBodyBytes()) {
|
||||
status = 413;
|
||||
reply = "request too large";
|
||||
} else {
|
||||
byte[] body = exchange.getRequestBody().readNBytes(configuration.maxBodyBytes() + 1);
|
||||
if (body.length > configuration.maxBodyBytes()) {
|
||||
status = 413;
|
||||
reply = "request too large";
|
||||
} else if (!authenticate(exchange, body)) {
|
||||
status = 401;
|
||||
reply = "authentication failed";
|
||||
} else {
|
||||
Map<String, String> values = parseForm(body);
|
||||
String agentId = exchange.getRequestHeaders().getFirst("X-Agent-Id");
|
||||
if (!validAgentId(agentId)) throw new BadRequest("invalid agent id");
|
||||
int result = apply(agentId, values);
|
||||
status = result;
|
||||
reply = result == 204 ? "" : result == 201 ? "registered" : "renewed";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Replay replay) {
|
||||
status = 401;
|
||||
reply = "authentication failed";
|
||||
} catch (Conflict conflict) {
|
||||
status = 409;
|
||||
reply = "registration conflict";
|
||||
} catch (BadRequest invalid) {
|
||||
status = 400;
|
||||
reply = "bad request";
|
||||
} catch (RuntimeException failure) {
|
||||
context.logger().warn("Agent registration request failed", failure);
|
||||
status = 503;
|
||||
reply = "temporarily unavailable";
|
||||
} finally {
|
||||
try {
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||
byte[] bytes = reply.getBytes(StandardCharsets.UTF_8);
|
||||
if (status == 204) exchange.sendResponseHeaders(status, -1);
|
||||
else {
|
||||
exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// The client may have disconnected after the state change was committed.
|
||||
}
|
||||
exchange.close();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean authenticate(HttpExchange exchange, byte[] body) {
|
||||
String agentId = exchange.getRequestHeaders().getFirst("X-Agent-Id");
|
||||
String timestampText = exchange.getRequestHeaders().getFirst("X-Timestamp");
|
||||
String nonce = exchange.getRequestHeaders().getFirst("X-Nonce");
|
||||
String signature = exchange.getRequestHeaders().getFirst("X-Signature");
|
||||
long timestamp = parseDecimal(timestampText, Long.MIN_VALUE);
|
||||
long now = System.currentTimeMillis() / 1000;
|
||||
if (!validAgentId(agentId) || timestamp == Long.MIN_VALUE
|
||||
|| timestamp < now - SIGNATURE_WINDOW_SECONDS || timestamp > now + SIGNATURE_WINDOW_SECONDS
|
||||
|| nonce == null || !nonce.matches("[A-Za-z0-9_-]{16,64}")
|
||||
|| signature == null || !signature.matches("[A-Fa-f0-9]{64}")) return false;
|
||||
String canonical = "POST\n" + PATH + "\n" + timestamp + "\n" + nonce + "\n" + agentId + "\n"
|
||||
+ new String(body, StandardCharsets.UTF_8);
|
||||
byte[] expected = hmac(configuration.secret(), canonical.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] supplied;
|
||||
try { supplied = java.util.HexFormat.of().parseHex(signature); }
|
||||
catch (IllegalArgumentException invalid) { return false; }
|
||||
if (!MessageDigest.isEqual(expected, supplied)) return false;
|
||||
leases.acceptNonce(agentId, nonce, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
private int apply(String agentId, Map<String, String> input) {
|
||||
String action = required(input, "action");
|
||||
UUID generation;
|
||||
try { generation = UUID.fromString(required(input, "generation")); }
|
||||
catch (IllegalArgumentException invalid) { throw new BadRequest("invalid generation"); }
|
||||
if (action.equals("unregister")) {
|
||||
requireKeys(input, Set.of("action", "generation"));
|
||||
leases.unregister(agentId, generation);
|
||||
return 204;
|
||||
}
|
||||
if (!action.equals("register")) throw new BadRequest("invalid action");
|
||||
requireKeys(input, Set.of("action", "generation", "name", "address", "leaseSeconds"));
|
||||
String name = required(input, "name");
|
||||
URI address;
|
||||
try { address = URI.create(required(input, "address")); }
|
||||
catch (IllegalArgumentException invalid) { throw new BadRequest("invalid address"); }
|
||||
int leaseSeconds = integer(input, "leaseSeconds", 5, configuration.maxLeaseSeconds());
|
||||
ServerDefinition definition;
|
||||
try {
|
||||
definition = new ServerDefinition(name, address, Map.of("discovery", "agent"),
|
||||
Map.of("agent.id", agentId, "agent.generation", generation.toString()));
|
||||
} catch (IllegalArgumentException invalid) { throw new BadRequest("invalid server definition"); }
|
||||
return leases.register(agentId, generation, definition, leaseSeconds, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
private static Map<String, String> parseForm(byte[] bytes) {
|
||||
String text = new String(bytes, StandardCharsets.UTF_8);
|
||||
if (!java.util.Arrays.equals(text.getBytes(StandardCharsets.UTF_8), bytes)) throw new BadRequest("invalid UTF-8");
|
||||
Map<String, String> values = new LinkedHashMap<>();
|
||||
if (text.isEmpty()) throw new BadRequest("empty body");
|
||||
for (String part : text.split("&", -1)) {
|
||||
int equals = part.indexOf('=');
|
||||
if (equals < 1) throw new BadRequest("invalid form");
|
||||
String key = decode(part.substring(0, equals));
|
||||
String value = decode(part.substring(equals + 1));
|
||||
if (values.putIfAbsent(key, value) != null) throw new BadRequest("duplicate field");
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private static String decode(String value) {
|
||||
try { return URLDecoder.decode(value, StandardCharsets.UTF_8); }
|
||||
catch (IllegalArgumentException invalid) { throw new BadRequest("invalid encoding"); }
|
||||
}
|
||||
|
||||
private static void requireKeys(Map<String, String> input, Set<String> keys) {
|
||||
if (!input.keySet().equals(keys)) throw new BadRequest("unexpected fields");
|
||||
}
|
||||
private static String required(Map<String, String> values, String key) {
|
||||
String value = values.get(key);
|
||||
if (value == null || value.isBlank()) throw new BadRequest("missing field");
|
||||
return value;
|
||||
}
|
||||
private static int integer(Map<String, String> values, String key, int min, int max) {
|
||||
long parsed = parseDecimal(values.get(key), Long.MIN_VALUE);
|
||||
if (parsed < min || parsed > max) throw new BadRequest("invalid integer");
|
||||
return (int) parsed;
|
||||
}
|
||||
private static long parseDecimal(String value, long fallback) {
|
||||
if (value == null || !value.matches("[0-9]{1,19}")) return fallback;
|
||||
try { return Long.parseLong(value); }
|
||||
catch (NumberFormatException invalid) { return fallback; }
|
||||
}
|
||||
private static boolean validAgentId(String value) {
|
||||
return value != null && value.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}");
|
||||
}
|
||||
private static byte[] hmac(byte[] key, byte[] message) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(key, "HmacSHA256"));
|
||||
return mac.doFinal(message);
|
||||
} catch (java.security.GeneralSecurityException impossible) { throw new IllegalStateException(impossible); }
|
||||
}
|
||||
|
||||
static String sign(byte[] secret, String agentId, String timestamp, String nonce, String body) {
|
||||
String canonical = "POST\n" + PATH + "\n" + timestamp + "\n" + nonce + "\n" + agentId + "\n" + body;
|
||||
return java.util.HexFormat.of().formatHex(hmac(secret, canonical.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
private record Configuration(String host, int port, byte[] secret, int concurrency, int maxBodyBytes,
|
||||
int maxLeaseSeconds, int maxInstances) {
|
||||
static Configuration from(Map<String, String> values) {
|
||||
String secret = values.get("secret");
|
||||
if (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < 32)
|
||||
throw new IllegalArgumentException("setting 'secret' must contain at least 32 UTF-8 bytes");
|
||||
String host = values.getOrDefault("host", "127.0.0.1").trim();
|
||||
if (host.isEmpty()) throw new IllegalArgumentException("setting 'host' must not be blank");
|
||||
int port = setting(values, "port", 28080, 1, 65535);
|
||||
int concurrency = setting(values, "concurrency", 4, 1, 64);
|
||||
int maxBytes = setting(values, "maxBodyBytes", 4096, 256, ABSOLUTE_BODY_LIMIT);
|
||||
int lease = setting(values, "maxLeaseSeconds", 60, 5, 3600);
|
||||
int instances = setting(values, "maxInstances", 128, 1, 4096);
|
||||
return new Configuration(host, port, secret.getBytes(StandardCharsets.UTF_8), concurrency, maxBytes, lease, instances);
|
||||
}
|
||||
private static int setting(Map<String, String> values, String key, int fallback, int min, int max) {
|
||||
String value = values.get(key);
|
||||
if (value == null) return fallback;
|
||||
long parsed = parseDecimal(value, Long.MIN_VALUE);
|
||||
if (parsed < min || parsed > max) throw new IllegalArgumentException("setting '" + key + "' must be " + min + ".." + max);
|
||||
return (int) parsed;
|
||||
}
|
||||
}
|
||||
|
||||
/** Synchronized lease table; generation tombstones fence delayed requests after expiry/unregister. */
|
||||
final class LeaseRegistry {
|
||||
private final Map<String, Lease> active = new LinkedHashMap<>();
|
||||
private final Map<String, Map<UUID, Long>> retiredGenerations = new LinkedHashMap<>();
|
||||
private final Map<String, String> serverOwners = new LinkedHashMap<>();
|
||||
private final LinkedHashMap<String, Long> nonces = new LinkedHashMap<>();
|
||||
private int retiredGenerationCount;
|
||||
private boolean closed;
|
||||
|
||||
synchronized void acceptNonce(String agent, String nonce, long nowSeconds) {
|
||||
requireOpen();
|
||||
nonces.entrySet().removeIf(entry -> entry.getValue() < nowSeconds - SIGNATURE_WINDOW_SECONDS);
|
||||
String key = agent + ":" + nonce;
|
||||
if (nonces.containsKey(key)) throw new Replay();
|
||||
if (nonces.size() >= MAX_NONCES) throw new Replay();
|
||||
nonces.put(key, nowSeconds);
|
||||
}
|
||||
|
||||
synchronized int register(String agent, UUID generation, ServerDefinition definition, int leaseSeconds, long now) {
|
||||
requireOpen();
|
||||
expire(now);
|
||||
Lease old = active.get(agent);
|
||||
if (old != null && !old.generation.equals(generation)) throw new Conflict();
|
||||
if (old == null) {
|
||||
if (retiredGenerations.getOrDefault(agent, Map.of()).containsKey(generation)) throw new Conflict();
|
||||
if (active.size() >= configuration.maxInstances()) throw new Conflict();
|
||||
if (retiredGenerationCount >= MAX_RETIRED_GENERATIONS) throw new Conflict();
|
||||
String owner = serverOwners.get(definition.name());
|
||||
if (owner != null && !owner.equals(agent)) throw new Conflict();
|
||||
ServerRegistration registration = context.servers().register(definition);
|
||||
serverOwners.put(definition.name(), agent);
|
||||
active.put(agent, new Lease(generation, definition.name(), definition, registration, now + leaseSeconds * 1000L));
|
||||
return 201;
|
||||
}
|
||||
if (!old.name.equals(definition.name())) {
|
||||
if (serverOwners.containsKey(definition.name())) throw new Conflict();
|
||||
ServerRegistration replacement = context.servers().register(definition);
|
||||
try {
|
||||
old.registration.unregister();
|
||||
} catch (RuntimeException failedRemoval) {
|
||||
try { replacement.unregister(); }
|
||||
catch (RuntimeException rollbackFailure) { failedRemoval.addSuppressed(rollbackFailure); }
|
||||
throw failedRemoval;
|
||||
}
|
||||
serverOwners.remove(old.name);
|
||||
serverOwners.put(definition.name(), agent);
|
||||
active.put(agent, new Lease(generation, definition.name(), definition, replacement, now + leaseSeconds * 1000L));
|
||||
} else {
|
||||
old.registration.update(definition);
|
||||
active.put(agent, new Lease(generation, old.name, definition, old.registration, now + leaseSeconds * 1000L));
|
||||
}
|
||||
return 200;
|
||||
}
|
||||
|
||||
synchronized void unregister(String agent, UUID generation) {
|
||||
requireOpen();
|
||||
Lease lease = active.get(agent);
|
||||
if (lease == null) {
|
||||
if (!retiredGenerations.getOrDefault(agent, Map.of()).containsKey(generation)) throw new Conflict();
|
||||
return;
|
||||
}
|
||||
if (!lease.generation.equals(generation)) throw new Conflict();
|
||||
lease.registration.unregister();
|
||||
active.remove(agent);
|
||||
serverOwners.remove(lease.name, agent);
|
||||
retire(agent, generation, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
synchronized void expire(long now) {
|
||||
if (closed) return;
|
||||
for (String id : new ArrayList<>(active.keySet())) {
|
||||
Lease lease = active.get(id);
|
||||
if (lease != null && lease.expiresAtMillis <= now) {
|
||||
try {
|
||||
lease.registration.unregister();
|
||||
active.remove(id);
|
||||
serverOwners.remove(lease.name, id);
|
||||
retire(id, lease.generation, now);
|
||||
context.logger().info("Expired agent registration {}", id);
|
||||
} catch (RuntimeException failure) {
|
||||
context.logger().warn("Could not expire agent registration {}; retrying", id, failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
pruneRetiredGenerations(now);
|
||||
}
|
||||
|
||||
private void retire(String agent, UUID generation, long now) {
|
||||
Map<UUID, Long> generations = retiredGenerations.computeIfAbsent(agent, ignored -> new LinkedHashMap<>());
|
||||
if (!generations.containsKey(generation)) {
|
||||
generations.put(generation, now + configuration.maxLeaseSeconds() * 1000L
|
||||
+ SIGNATURE_WINDOW_SECONDS * 1000L);
|
||||
retiredGenerationCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private void pruneRetiredGenerations(long now) {
|
||||
for (String agent : new ArrayList<>(retiredGenerations.keySet())) {
|
||||
Map<UUID, Long> generations = retiredGenerations.get(agent);
|
||||
generations.entrySet().removeIf(entry -> {
|
||||
if (entry.getValue() > now) return false;
|
||||
retiredGenerationCount--;
|
||||
return true;
|
||||
});
|
||||
if (generations.isEmpty()) retiredGenerations.remove(agent);
|
||||
}
|
||||
}
|
||||
|
||||
synchronized void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (Map.Entry<String, Lease> entry : new ArrayList<>(active.entrySet())) {
|
||||
try { entry.getValue().registration.unregister(); }
|
||||
catch (RuntimeException failure) { context.logger().warn("Could not remove agent registration {}", entry.getKey(), failure); }
|
||||
}
|
||||
active.clear();
|
||||
serverOwners.clear();
|
||||
}
|
||||
|
||||
private void requireOpen() {
|
||||
if (closed) throw new IllegalStateException("agent discovery is closed");
|
||||
}
|
||||
}
|
||||
private record Lease(UUID generation, String name, ServerDefinition definition, ServerRegistration registration,
|
||||
long expiresAtMillis) { }
|
||||
private static class BadRequest extends RuntimeException { BadRequest(String message) { super(message); } }
|
||||
private static class Conflict extends RuntimeException { }
|
||||
private static class Replay extends RuntimeException { }
|
||||
}
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
package dev.strataproxy.plugins.agent;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Runnable sample: args are endpoint, agent id, backend name, backend tcp URI. */
|
||||
public final class AgentExampleMain {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(AgentExampleMain.class);
|
||||
|
||||
private AgentExampleMain() { }
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
if (args.length != 4) throw new IllegalArgumentException(
|
||||
"usage: AgentExampleMain <http-endpoint> <agent-id> <backend-name> <tcp-address>");
|
||||
String configuredSecret = System.getenv("STRATAPROXY_AGENT_SECRET");
|
||||
if (configuredSecret == null || configuredSecret.getBytes(StandardCharsets.UTF_8).length < 32)
|
||||
throw new IllegalStateException("Set STRATAPROXY_AGENT_SECRET to the configured 32+ byte secret");
|
||||
var client = new AgentRegistrationClient(URI.create(args[0]), args[1], UUID.randomUUID(),
|
||||
configuredSecret.getBytes(StandardCharsets.UTF_8), args[2], URI.create(args[3]), 30);
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
try { client.unregister(); } catch (Exception ignored) { /* The lease expires if shutdown is abrupt. */ }
|
||||
}, "strataproxy-agent-shutdown"));
|
||||
while (!Thread.currentThread().isInterrupted()) {
|
||||
try {
|
||||
client.registerOrHeartbeat();
|
||||
} catch (AgentRegistrationClient.ResponseException response) {
|
||||
if (response.statusCode() != 409 && response.statusCode() < 500) throw response;
|
||||
LOGGER.warn("Agent registration endpoint returned HTTP {}; retrying", response.statusCode());
|
||||
} catch (IOException networkFailure) {
|
||||
LOGGER.warn("Agent registration request failed; retrying", networkFailure);
|
||||
}
|
||||
Thread.sleep(10_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
package dev.strataproxy.plugins.agent;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/** Minimal Java client; fenced generations are replaced after a lease expires. */
|
||||
public final class AgentRegistrationClient {
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build();
|
||||
private final URI endpoint;
|
||||
private final String agentId;
|
||||
private UUID generation;
|
||||
private final byte[] secret;
|
||||
private final String backendName;
|
||||
private final URI backendAddress;
|
||||
private final int leaseSeconds;
|
||||
|
||||
public AgentRegistrationClient(URI endpoint, String agentId, UUID generation, byte[] secret,
|
||||
String backendName, URI backendAddress, int leaseSeconds) {
|
||||
this.endpoint = endpoint;
|
||||
this.agentId = agentId;
|
||||
this.generation = generation;
|
||||
this.secret = secret.clone();
|
||||
this.backendName = backendName;
|
||||
this.backendAddress = backendAddress;
|
||||
this.leaseSeconds = leaseSeconds;
|
||||
}
|
||||
|
||||
public static UUID newGeneration() { return UUID.randomUUID(); }
|
||||
|
||||
public synchronized void registerOrHeartbeat() throws IOException, InterruptedException {
|
||||
try {
|
||||
send(registrationBody(generation));
|
||||
} catch (ResponseException conflict) {
|
||||
if (conflict.statusCode() != 409) throw conflict;
|
||||
generation = newGeneration();
|
||||
send(registrationBody(generation));
|
||||
}
|
||||
}
|
||||
|
||||
private String registrationBody(UUID currentGeneration) {
|
||||
return "action=register&generation=" + encode(currentGeneration.toString())
|
||||
+ "&name=" + encode(backendName) + "&address=" + encode(backendAddress.toString())
|
||||
+ "&leaseSeconds=" + leaseSeconds;
|
||||
}
|
||||
|
||||
public synchronized void unregister() throws IOException, InterruptedException {
|
||||
send("action=unregister&generation=" + encode(generation.toString()));
|
||||
}
|
||||
|
||||
private void send(String body) throws IOException, InterruptedException {
|
||||
String timestamp = Long.toString(System.currentTimeMillis() / 1000);
|
||||
byte[] bytes = new byte[18];
|
||||
RANDOM.nextBytes(bytes);
|
||||
String nonce = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
String signature = sign(secret, agentId, timestamp, nonce, body);
|
||||
HttpRequest request = HttpRequest.newBuilder(endpoint).timeout(Duration.ofSeconds(5))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
|
||||
.header("X-Agent-Id", agentId).header("X-Timestamp", timestamp)
|
||||
.header("X-Nonce", nonce).header("X-Signature", signature)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)).build();
|
||||
HttpResponse<Void> response = http.send(request, HttpResponse.BodyHandlers.discarding());
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300)
|
||||
throw new ResponseException(response.statusCode());
|
||||
}
|
||||
|
||||
public static final class ResponseException extends IOException {
|
||||
private final int statusCode;
|
||||
|
||||
private ResponseException(int statusCode) {
|
||||
super("Registration endpoint returned HTTP " + statusCode);
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
|
||||
public int statusCode() { return statusCode; }
|
||||
}
|
||||
|
||||
private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); }
|
||||
|
||||
private static String sign(byte[] secret, String agentId, String timestamp, String nonce, String body) {
|
||||
String canonical = "POST\n" + AgentDiscoveryPlugin.PATH + "\n" + timestamp + "\n" + nonce + "\n" + agentId + "\n" + body;
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
|
||||
return java.util.HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (java.security.GeneralSecurityException impossible) { throw new IllegalStateException(impossible); }
|
||||
}
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
dev.strataproxy.plugins.agent.AgentDiscoveryPlugin
|
||||
-256
@@ -1,256 +0,0 @@
|
||||
package dev.strataproxy.plugins.agent;
|
||||
|
||||
import dev.strataproxy.api.PlayerView;
|
||||
import dev.strataproxy.api.Players;
|
||||
import dev.strataproxy.api.PluginContext;
|
||||
import dev.strataproxy.api.ServerDefinition;
|
||||
import dev.strataproxy.api.ServerRegistration;
|
||||
import dev.strataproxy.api.ServerView;
|
||||
import dev.strataproxy.api.Servers;
|
||||
import dev.strataproxy.api.TransferResult;
|
||||
import dev.strataproxy.api.PlayerIdentity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AgentDiscoveryPluginTest {
|
||||
private static final byte[] SECRET = "test-secret-with-at-least-32-bytes-long".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
|
||||
@Test
|
||||
void lateRequestCannotRegisterAfterPluginShutdown() {
|
||||
RecordingServers servers = new RecordingServers();
|
||||
AgentDiscoveryPlugin plugin = plugin(servers);
|
||||
assertEquals(201, plugin.leases.register("agent-a", UUID.randomUUID(), definition("game-1"), 30, 1_000));
|
||||
|
||||
plugin.onDisable();
|
||||
assertTrue(servers.definitions.isEmpty());
|
||||
assertThrows(IllegalStateException.class, () -> plugin.leases.register(
|
||||
"agent-b", UUID.randomUUID(), definition("game-2"), 30, 1_001));
|
||||
assertTrue(servers.definitions.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hmacBindsAgentTimestampNonceAndBodyAndReplayIsRejected() {
|
||||
String signature = AgentDiscoveryPlugin.sign(SECRET, "agent-a", "123", "abcdefghijklmnop", "action=register");
|
||||
assertEquals(signature, AgentDiscoveryPlugin.sign(SECRET, "agent-a", "123", "abcdefghijklmnop", "action=register"));
|
||||
assertNotEquals(signature, AgentDiscoveryPlugin.sign(SECRET, "agent-b", "123", "abcdefghijklmnop", "action=register"));
|
||||
assertNotEquals(signature, AgentDiscoveryPlugin.sign(SECRET, "agent-a", "124", "abcdefghijklmnop", "action=register"));
|
||||
assertNotEquals(signature, AgentDiscoveryPlugin.sign(SECRET, "agent-a", "123", "abcdefghijklmnop", "action=unregister"));
|
||||
|
||||
AgentDiscoveryPlugin plugin = plugin(new RecordingServers());
|
||||
plugin.leases.acceptNonce("agent-a", "abcdefghijklmnop", 1000);
|
||||
assertThrows(RuntimeException.class, () -> plugin.leases.acceptNonce("agent-a", "abcdefghijklmnop", 1000));
|
||||
plugin.onDisable();
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredGenerationIsFencedAndNewGenerationCanClaimAfterExpiry() {
|
||||
RecordingServers servers = new RecordingServers();
|
||||
AgentDiscoveryPlugin plugin = plugin(servers);
|
||||
UUID first = UUID.randomUUID();
|
||||
ServerDefinition definition = definition("game-1");
|
||||
assertEquals(201, plugin.leases.register("agent-a", first, definition, 5, 1_000));
|
||||
plugin.leases.expire(6_001);
|
||||
assertEquals(1, servers.unregisterCount);
|
||||
assertThrows(RuntimeException.class, () -> plugin.leases.register("agent-a", first, definition, 5, 6_002));
|
||||
|
||||
UUID restarted = UUID.randomUUID();
|
||||
assertEquals(201, plugin.leases.register("agent-a", restarted, definition, 5, 6_003));
|
||||
assertThrows(RuntimeException.class, () -> plugin.leases.register("agent-a", first, definition, 5, 6_004));
|
||||
plugin.onDisable();
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeGenerationCannotBeReplacedAndHeartbeatExtendsLease() {
|
||||
RecordingServers servers = new RecordingServers();
|
||||
AgentDiscoveryPlugin plugin = plugin(servers);
|
||||
UUID generation = UUID.randomUUID();
|
||||
assertEquals(201, plugin.leases.register("agent-a", generation, definition("game-1"), 5, 10_000));
|
||||
assertThrows(RuntimeException.class, () -> plugin.leases.register("agent-a", UUID.randomUUID(), definition("game-1"), 5, 11_000));
|
||||
assertEquals(200, plugin.leases.register("agent-a", generation, definition("game-1"), 5, 14_000));
|
||||
plugin.leases.expire(18_500);
|
||||
assertEquals(0, servers.unregisterCount);
|
||||
plugin.leases.expire(19_001);
|
||||
assertEquals(1, servers.unregisterCount);
|
||||
plugin.onDisable();
|
||||
}
|
||||
|
||||
@Test
|
||||
void moreThanEightExpiredGenerationsCanRotateAndTombstonesArePruned() {
|
||||
RecordingServers servers = new RecordingServers();
|
||||
AgentDiscoveryPlugin plugin = plugin(servers);
|
||||
long now = 100_000;
|
||||
UUID firstGeneration = null;
|
||||
UUID previousGeneration = null;
|
||||
for (int restart = 0; restart < 9; restart++) {
|
||||
UUID generation = UUID.randomUUID();
|
||||
if (firstGeneration == null) firstGeneration = generation;
|
||||
assertEquals(201, plugin.leases.register("agent-restart", generation, definition("game-restart"), 5, now));
|
||||
plugin.leases.expire(now + 5_001);
|
||||
if (previousGeneration != null) {
|
||||
UUID staleGeneration = previousGeneration;
|
||||
long staleAttemptTime = now + 5_002;
|
||||
assertThrows(RuntimeException.class, () -> plugin.leases.register(
|
||||
"agent-restart", staleGeneration, definition("game-restart"), 5, staleAttemptTime));
|
||||
}
|
||||
previousGeneration = generation;
|
||||
now += 5_002;
|
||||
}
|
||||
assertEquals(9, servers.unregisterCount);
|
||||
|
||||
long afterRetention = now + 120_001;
|
||||
plugin.leases.expire(afterRetention);
|
||||
assertEquals(201, plugin.leases.register("agent-restart", firstGeneration,
|
||||
definition("game-restart"), 5, afterRetention + 1));
|
||||
plugin.onDisable();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listenerRejectsBadHmacAndExactReplayAndCapsRequestBody() throws Exception {
|
||||
RecordingServers servers = new RecordingServers();
|
||||
int port;
|
||||
try (ServerSocket socket = new ServerSocket(0)) { port = socket.getLocalPort(); }
|
||||
AgentDiscoveryPlugin plugin = plugin(servers, port);
|
||||
plugin.onEnable();
|
||||
try {
|
||||
String body = "action=register&generation=" + UUID.randomUUID()
|
||||
+ "&name=game-http&address=tcp%3A%2F%2F127.0.0.1%3A25565&leaseSeconds=30";
|
||||
String agent = "agent-http";
|
||||
String timestamp = Long.toString(System.currentTimeMillis() / 1000);
|
||||
String nonce = "abcdefghijklmnopqrstuvwx";
|
||||
URI endpoint = URI.create("http://127.0.0.1:" + port + AgentDiscoveryPlugin.PATH);
|
||||
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
|
||||
HttpResponse<String> badSignature = post(client, endpoint, agent, timestamp, nonce, "0".repeat(64), body);
|
||||
assertEquals(401, badSignature.statusCode());
|
||||
|
||||
String signature = AgentDiscoveryPlugin.sign(SECRET, agent, timestamp, nonce, body);
|
||||
HttpResponse<String> registered = post(client, endpoint, agent, timestamp, nonce, signature, body);
|
||||
assertEquals(201, registered.statusCode());
|
||||
HttpResponse<String> replay = post(client, endpoint, agent, timestamp, nonce, signature, body);
|
||||
assertEquals(401, replay.statusCode());
|
||||
|
||||
String largeBody = "x".repeat(300);
|
||||
HttpResponse<String> oversized = post(client, endpoint, agent, timestamp, "zyxwvutsrqponmlkjihgfedc", "0".repeat(64), largeBody);
|
||||
assertEquals(413, oversized.statusCode());
|
||||
} finally {
|
||||
plugin.onDisable();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientRecoversAfterItsGenerationExpires() throws Exception {
|
||||
RecordingServers servers = new RecordingServers();
|
||||
int port;
|
||||
try (ServerSocket socket = new ServerSocket(0)) { port = socket.getLocalPort(); }
|
||||
AgentDiscoveryPlugin plugin = plugin(servers, port);
|
||||
plugin.onEnable();
|
||||
try {
|
||||
var client = new AgentRegistrationClient(
|
||||
URI.create("http://127.0.0.1:" + port + AgentDiscoveryPlugin.PATH),
|
||||
"agent-recovery", UUID.randomUUID(), SECRET, "game-recovery",
|
||||
URI.create("tcp://127.0.0.1:25565"), 5);
|
||||
client.registerOrHeartbeat();
|
||||
String firstGeneration = servers.definitions.get("game-recovery")
|
||||
.metadata().get("agent.generation");
|
||||
plugin.leases.expire(System.currentTimeMillis() + 5_001);
|
||||
assertFalse(servers.definitions.containsKey("game-recovery"));
|
||||
|
||||
client.registerOrHeartbeat();
|
||||
String renewedGeneration = servers.definitions.get("game-recovery")
|
||||
.metadata().get("agent.generation");
|
||||
assertNotEquals(firstGeneration, renewedGeneration);
|
||||
client.unregister();
|
||||
assertFalse(servers.definitions.containsKey("game-recovery"));
|
||||
} finally {
|
||||
plugin.onDisable();
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpResponse<String> post(HttpClient client, URI endpoint, String agent, String timestamp,
|
||||
String nonce, String signature, String body) throws Exception {
|
||||
HttpRequest request = HttpRequest.newBuilder(endpoint).timeout(Duration.ofSeconds(3))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
|
||||
.header("X-Agent-Id", agent).header("X-Timestamp", timestamp)
|
||||
.header("X-Nonce", nonce).header("X-Signature", signature)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)).build();
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static AgentDiscoveryPlugin plugin(RecordingServers servers) {
|
||||
return plugin(servers, 28080);
|
||||
}
|
||||
|
||||
private static AgentDiscoveryPlugin plugin(RecordingServers servers, int port) {
|
||||
AgentDiscoveryPlugin plugin = new AgentDiscoveryPlugin();
|
||||
plugin.onLoad(new PluginContext() {
|
||||
@Override public Players players() {
|
||||
return new Players() {
|
||||
@Override public Optional<PlayerView> find(PlayerIdentity identity) { return Optional.empty(); }
|
||||
@Override public List<PlayerView> online() { return List.of(); }
|
||||
@Override public java.util.concurrent.CompletionStage<dev.strataproxy.api.MessageResult> sendMessage(PlayerIdentity identity, net.kyori.adventure.text.Component message) {
|
||||
return CompletableFuture.failedFuture(new UnsupportedOperationException());
|
||||
}
|
||||
@Override public java.util.concurrent.CompletionStage<dev.strataproxy.api.DisconnectResult> disconnect(PlayerIdentity identity, net.kyori.adventure.text.Component reason) {
|
||||
return CompletableFuture.failedFuture(new UnsupportedOperationException());
|
||||
}
|
||||
@Override public java.util.concurrent.CompletionStage<TransferResult> transfer(PlayerIdentity identity, String backendName) {
|
||||
return CompletableFuture.failedFuture(new UnsupportedOperationException());
|
||||
}
|
||||
};
|
||||
}
|
||||
@Override public Servers servers() { return servers; }
|
||||
@Override public dev.strataproxy.api.Commands commands() {
|
||||
return (name, handler, completer) -> { throw new UnsupportedOperationException(); };
|
||||
}
|
||||
@Override public dev.strataproxy.api.event.Events events() { throw new UnsupportedOperationException(); }
|
||||
@Override public org.slf4j.Logger logger() { return org.slf4j.LoggerFactory.getLogger("agent-test"); }
|
||||
@Override public Map<String, String> settings() {
|
||||
return Map.of("secret", new String(SECRET, StandardCharsets.UTF_8), "port", Integer.toString(port),
|
||||
"maxBodyBytes", "256");
|
||||
}
|
||||
});
|
||||
return plugin;
|
||||
}
|
||||
|
||||
private static ServerDefinition definition(String name) {
|
||||
return new ServerDefinition(name, URI.create("tcp://127.0.0.1:25565"), Map.of(), Map.of());
|
||||
}
|
||||
|
||||
private static final class RecordingServers implements Servers {
|
||||
private final Map<String, ServerDefinition> definitions = new ConcurrentHashMap<>();
|
||||
private int unregisterCount;
|
||||
@Override public Optional<ServerView> find(String backendName) { return Optional.empty(); }
|
||||
@Override public List<ServerView> all() { return List.of(); }
|
||||
@Override public ServerRegistration register(ServerDefinition definition) {
|
||||
definitions.put(definition.name(), definition);
|
||||
return new ServerRegistration() {
|
||||
private boolean active = true;
|
||||
@Override public void update(ServerDefinition replacement) {
|
||||
if (!active) throw new IllegalStateException("stale");
|
||||
definitions.put(replacement.name(), replacement);
|
||||
}
|
||||
@Override public void unregister() {
|
||||
if (!active) throw new IllegalStateException("stale");
|
||||
active = false;
|
||||
definitions.remove(definition.name());
|
||||
unregisterCount++;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
plugins {
|
||||
id("strataproxy.java-library-conventions")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":proxy-plugin-api"))
|
||||
implementation(libs.findLibrary("netty-resolver-dns").get())
|
||||
}
|
||||
-449
@@ -1,449 +0,0 @@
|
||||
package dev.strataproxy.plugins.dns;
|
||||
|
||||
import dev.strataproxy.api.Plugin;
|
||||
import dev.strataproxy.api.PluginContext;
|
||||
import dev.strataproxy.api.ServerDefinition;
|
||||
import dev.strataproxy.api.ServerRegistration;
|
||||
import io.netty.channel.nio.NioEventLoopGroup;
|
||||
import io.netty.channel.socket.nio.NioDatagramChannel;
|
||||
import io.netty.resolver.ResolvedAddressTypes;
|
||||
import io.netty.resolver.dns.DnsNameResolver;
|
||||
import io.netty.resolver.dns.DnsNameResolverBuilder;
|
||||
import io.netty.resolver.dns.DnsServerAddressStreamProvider;
|
||||
import io.netty.resolver.dns.NoopDnsCache;
|
||||
import io.netty.resolver.dns.NoopDnsCnameCache;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.net.IDN;
|
||||
import java.net.Inet4Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Discovers TCP backends from the A and AAAA records of a configured DNS name. */
|
||||
public final class DnsDiscoveryPlugin implements Plugin {
|
||||
private static final int MAX_CONSECUTIVE_LOOKUP_FAILURES = 3;
|
||||
private final AddressResolver resolver;
|
||||
private final Object lifecycleLock = new Object();
|
||||
private final Map<String, OwnedRegistration> registrations = new LinkedHashMap<>();
|
||||
private final AtomicBoolean started = new AtomicBoolean();
|
||||
|
||||
private PluginContext context;
|
||||
private Configuration configuration;
|
||||
private ScheduledExecutorService scheduler;
|
||||
private int consecutiveLookupFailures;
|
||||
private volatile boolean closed;
|
||||
|
||||
public DnsDiscoveryPlugin() { this(new RefreshingAddressResolver()); }
|
||||
|
||||
DnsDiscoveryPlugin(AddressResolver resolver) {
|
||||
this.resolver = Objects.requireNonNull(resolver, "resolver");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoad(PluginContext pluginContext) {
|
||||
Objects.requireNonNull(pluginContext, "pluginContext");
|
||||
synchronized (lifecycleLock) {
|
||||
if (context != null) {
|
||||
throw new IllegalStateException("DNS discovery plugin is already loaded");
|
||||
}
|
||||
context = pluginContext;
|
||||
configuration = Configuration.from(pluginContext.settings());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
synchronized (lifecycleLock) {
|
||||
if (context == null || configuration == null) {
|
||||
throw new IllegalStateException("onLoad must complete before onEnable");
|
||||
}
|
||||
if (closed) {
|
||||
throw new IllegalStateException("DNS discovery plugin cannot be restarted after disable");
|
||||
}
|
||||
if (!started.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
scheduler = Executors.newSingleThreadScheduledExecutor(task -> {
|
||||
Thread thread = new Thread(task, "strataproxy-dns-discovery");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
scheduler.scheduleWithFixedDelay(
|
||||
this::refreshSafely,
|
||||
0,
|
||||
configuration.refreshSeconds(),
|
||||
TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
ScheduledExecutorService toStop;
|
||||
synchronized (lifecycleLock) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
toStop = scheduler;
|
||||
}
|
||||
|
||||
if (toStop != null) {
|
||||
toStop.shutdownNow();
|
||||
try {
|
||||
if (!toStop.awaitTermination(5, TimeUnit.SECONDS)) {
|
||||
logger().warn("DNS discovery worker did not stop within 5 seconds");
|
||||
}
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
resolver.close();
|
||||
} catch (Exception failure) {
|
||||
logger().warn("Could not close DNS resolver", failure);
|
||||
}
|
||||
|
||||
synchronized (lifecycleLock) {
|
||||
for (Map.Entry<String, OwnedRegistration> entry : registrations.entrySet()) {
|
||||
unregisterQuietly(entry.getKey(), entry.getValue().registration());
|
||||
}
|
||||
registrations.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshSafely() {
|
||||
final InetAddress[] resolved;
|
||||
try {
|
||||
resolved = Objects.requireNonNull(resolver.resolve(configuration.host()), "resolver result");
|
||||
} catch (Exception failure) {
|
||||
lookupFailed(failure);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, ServerDefinition> desired;
|
||||
try {
|
||||
desired = definitionsFor(resolved, configuration);
|
||||
} catch (RuntimeException invalidResult) {
|
||||
lookupFailed(invalidResult);
|
||||
return;
|
||||
}
|
||||
|
||||
synchronized (lifecycleLock) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
consecutiveLookupFailures = 0;
|
||||
reconcile(desired);
|
||||
}
|
||||
}
|
||||
|
||||
private void lookupFailed(Exception failure) {
|
||||
synchronized (lifecycleLock) {
|
||||
if (closed) return;
|
||||
int previousFailures = consecutiveLookupFailures;
|
||||
if (consecutiveLookupFailures < MAX_CONSECUTIVE_LOOKUP_FAILURES) consecutiveLookupFailures++;
|
||||
if (consecutiveLookupFailures == 1) {
|
||||
logger().warn("DNS lookup failed for {}; retaining {} previously discovered backend(s)",
|
||||
configuration.host(), registrations.size(), failure);
|
||||
} else if (consecutiveLookupFailures == MAX_CONSECUTIVE_LOOKUP_FAILURES
|
||||
&& (previousFailures < MAX_CONSECUTIVE_LOOKUP_FAILURES || !registrations.isEmpty())) {
|
||||
if (previousFailures < MAX_CONSECUTIVE_LOOKUP_FAILURES) {
|
||||
logger().warn("DNS lookup for {} has failed {} consecutive times; removing stale backends",
|
||||
configuration.host(), consecutiveLookupFailures);
|
||||
}
|
||||
reconcile(Map.of());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void reconcile(Map<String, ServerDefinition> desired) {
|
||||
for (Map.Entry<String, ServerDefinition> entry : desired.entrySet()) {
|
||||
String name = entry.getKey();
|
||||
ServerDefinition definition = entry.getValue();
|
||||
OwnedRegistration existing = registrations.get(name);
|
||||
if (existing == null) {
|
||||
try {
|
||||
ServerRegistration handle = context.servers().register(definition);
|
||||
registrations.put(name, new OwnedRegistration(definition, handle));
|
||||
logger().info("Discovered backend {} at {}", name, definition.address());
|
||||
} catch (RuntimeException failure) {
|
||||
logger().warn("Could not register discovered backend {}", name, failure);
|
||||
}
|
||||
} else if (!existing.definition().equals(definition)) {
|
||||
try {
|
||||
existing.registration().update(definition);
|
||||
registrations.put(name, new OwnedRegistration(definition, existing.registration()));
|
||||
logger().info("Updated discovered backend {} at {}", name, definition.address());
|
||||
} catch (RuntimeException failure) {
|
||||
logger().warn("Could not update discovered backend {}; keeping its previous registration", name,
|
||||
failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (String name : Set.copyOf(registrations.keySet())) {
|
||||
if (!desired.containsKey(name)) {
|
||||
OwnedRegistration existing = registrations.get(name);
|
||||
if (existing != null) {
|
||||
try {
|
||||
existing.registration().unregister();
|
||||
registrations.remove(name);
|
||||
logger().info("Removed DNS backend {}", name);
|
||||
} catch (RuntimeException failure) {
|
||||
logger().warn("Could not remove DNS backend {}; it will be retried", name, failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void unregisterQuietly(String name, ServerRegistration registration) {
|
||||
try {
|
||||
registration.unregister();
|
||||
logger().info("Removed DNS backend {} during plugin shutdown", name);
|
||||
} catch (RuntimeException failure) {
|
||||
logger().warn("Could not remove DNS backend {} during plugin shutdown", name, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private Logger logger() {
|
||||
PluginContext current = context;
|
||||
return current == null ? org.slf4j.LoggerFactory.getLogger(DnsDiscoveryPlugin.class) : current.logger();
|
||||
}
|
||||
|
||||
private static Map<String, ServerDefinition> definitionsFor(InetAddress[] addresses, Configuration config) {
|
||||
Map<String, InetAddress> unique = new LinkedHashMap<>();
|
||||
Arrays.stream(addresses)
|
||||
.map(address -> Objects.requireNonNull(address, "DNS answer address"))
|
||||
.sorted(Comparator.comparingInt((InetAddress address) -> address instanceof Inet4Address ? 0 : 1)
|
||||
.thenComparing(address -> HexFormat.of().formatHex(address.getAddress())))
|
||||
.forEach(address -> unique.putIfAbsent(HexFormat.of().formatHex(address.getAddress()), address));
|
||||
|
||||
Map<String, ServerDefinition> result = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, InetAddress> entry : unique.entrySet()) {
|
||||
String name = config.namePrefix() + entry.getKey();
|
||||
URI address;
|
||||
try {
|
||||
address = new URI("tcp", null, entry.getValue().getHostAddress(), config.port(), null, null, null);
|
||||
} catch (URISyntaxException impossibleForValidatedAddress) {
|
||||
throw new IllegalArgumentException("DNS answer could not be represented as a TCP URI",
|
||||
impossibleForValidatedAddress);
|
||||
}
|
||||
ServerDefinition definition = new ServerDefinition(
|
||||
name,
|
||||
address,
|
||||
Map.of("discovery", "dns"),
|
||||
Map.of("dns.host", config.host()));
|
||||
result.put(name, definition);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface AddressResolver extends AutoCloseable {
|
||||
InetAddress[] resolve(String host) throws Exception;
|
||||
@Override default void close() throws Exception { }
|
||||
}
|
||||
|
||||
/** Queries both DNS families without a resolver cache and fences transient partial answers. */
|
||||
static final class RefreshingAddressResolver implements AddressResolver {
|
||||
private final DnsServerAddressStreamProvider nameServers;
|
||||
private NioEventLoopGroup eventLoops;
|
||||
private DnsNameResolver ipv4;
|
||||
private DnsNameResolver ipv6;
|
||||
private List<InetAddress> lastIpv4 = List.of();
|
||||
private List<InetAddress> lastIpv6 = List.of();
|
||||
private int ipv4Failures;
|
||||
private int ipv6Failures;
|
||||
|
||||
RefreshingAddressResolver() { this(null); }
|
||||
|
||||
RefreshingAddressResolver(DnsServerAddressStreamProvider nameServers) {
|
||||
this.nameServers = nameServers;
|
||||
}
|
||||
|
||||
@Override public synchronized InetAddress[] resolve(String host) throws Exception {
|
||||
// Netty's localhost hosts-file shortcut returns IPv4 even for an IPv6-only resolver.
|
||||
// This reserved local name is not a dynamic DNS record; retain both system loopback addresses.
|
||||
if ("localhost".equals(host)) return InetAddress.getAllByName(host);
|
||||
if (ipv4 == null) {
|
||||
eventLoops = new NioEventLoopGroup(1, task -> {
|
||||
Thread thread = new Thread(task, "strataproxy-dns-query");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
ipv4 = newResolver(ResolvedAddressTypes.IPV4_ONLY);
|
||||
ipv6 = newResolver(ResolvedAddressTypes.IPV6_ONLY);
|
||||
}
|
||||
var ipv4Result = ipv4.resolveAll(host);
|
||||
var ipv6Result = ipv6.resolveAll(host);
|
||||
List<InetAddress> currentIpv4 = null;
|
||||
List<InetAddress> currentIpv6 = null;
|
||||
Exception ipv4Failure = null;
|
||||
Exception ipv6Failure = null;
|
||||
try { currentIpv4 = ipv4Result.get(5, TimeUnit.SECONDS); }
|
||||
catch (InterruptedException interrupted) {
|
||||
ipv4Result.cancel(true);
|
||||
ipv6Result.cancel(true);
|
||||
Thread.currentThread().interrupt();
|
||||
throw interrupted;
|
||||
}
|
||||
catch (Exception failure) {
|
||||
if (recordAbsent(failure)) currentIpv4 = List.of();
|
||||
else {
|
||||
ipv4Failure = failure;
|
||||
if (!ipv4Result.isDone()) ipv4Result.cancel(true);
|
||||
}
|
||||
}
|
||||
try { currentIpv6 = ipv6Result.get(5, TimeUnit.SECONDS); }
|
||||
catch (InterruptedException interrupted) {
|
||||
ipv6Result.cancel(true);
|
||||
Thread.currentThread().interrupt();
|
||||
throw interrupted;
|
||||
}
|
||||
catch (Exception failure) {
|
||||
if (recordAbsent(failure)) currentIpv6 = List.of();
|
||||
else {
|
||||
ipv6Failure = failure;
|
||||
if (!ipv6Result.isDone()) ipv6Result.cancel(true);
|
||||
}
|
||||
}
|
||||
if (currentIpv4 != null) {
|
||||
lastIpv4 = List.copyOf(currentIpv4);
|
||||
ipv4Failures = 0;
|
||||
} else {
|
||||
ipv4Failures = Math.min(MAX_CONSECUTIVE_LOOKUP_FAILURES, ipv4Failures + 1);
|
||||
if (ipv4Failures == MAX_CONSECUTIVE_LOOKUP_FAILURES) lastIpv4 = List.of();
|
||||
}
|
||||
if (currentIpv6 != null) {
|
||||
lastIpv6 = List.copyOf(currentIpv6);
|
||||
ipv6Failures = 0;
|
||||
} else {
|
||||
ipv6Failures = Math.min(MAX_CONSECUTIVE_LOOKUP_FAILURES, ipv6Failures + 1);
|
||||
if (ipv6Failures == MAX_CONSECUTIVE_LOOKUP_FAILURES) lastIpv6 = List.of();
|
||||
}
|
||||
if (ipv4Failure != null && ipv6Failure != null) throw ipv4Failure;
|
||||
var addresses = new ArrayList<InetAddress>(lastIpv4.size() + lastIpv6.size());
|
||||
addresses.addAll(lastIpv4);
|
||||
addresses.addAll(lastIpv6);
|
||||
if (addresses.isEmpty()) {
|
||||
if (ipv4Failure != null && currentIpv6 != null
|
||||
&& ipv4Failures == MAX_CONSECUTIVE_LOOKUP_FAILURES) return new InetAddress[0];
|
||||
if (ipv6Failure != null && currentIpv4 != null
|
||||
&& ipv6Failures == MAX_CONSECUTIVE_LOOKUP_FAILURES) return new InetAddress[0];
|
||||
if (ipv4Failure != null) throw ipv4Failure;
|
||||
if (ipv6Failure != null) throw ipv6Failure;
|
||||
throw new UnknownHostException(host);
|
||||
}
|
||||
return addresses.toArray(InetAddress[]::new);
|
||||
}
|
||||
|
||||
private static boolean recordAbsent(Exception failure) {
|
||||
Throwable reason = failure;
|
||||
while (reason instanceof ExecutionException && reason.getCause() != null) reason = reason.getCause();
|
||||
return reason instanceof UnknownHostException && reason.getCause() == null;
|
||||
}
|
||||
|
||||
private DnsNameResolver newResolver(ResolvedAddressTypes family) {
|
||||
var builder = new DnsNameResolverBuilder(eventLoops.next())
|
||||
.datagramChannelType(NioDatagramChannel.class)
|
||||
.queryTimeoutMillis(3000)
|
||||
.resolveCache(NoopDnsCache.INSTANCE)
|
||||
.cnameCache(NoopDnsCnameCache.INSTANCE)
|
||||
.resolvedAddressTypes(family);
|
||||
if (nameServers != null) builder.nameServerProvider(nameServers);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@Override public synchronized void close() {
|
||||
if (ipv4 != null) ipv4.close();
|
||||
if (ipv6 != null) ipv6.close();
|
||||
if (eventLoops != null) {
|
||||
eventLoops.shutdownGracefully(0, 5, TimeUnit.SECONDS).awaitUninterruptibly(5, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record OwnedRegistration(ServerDefinition definition, ServerRegistration registration) {
|
||||
}
|
||||
|
||||
private record Configuration(String host, int port, String namePrefix, int refreshSeconds) {
|
||||
private static Configuration from(Map<String, String> settings) {
|
||||
Objects.requireNonNull(settings, "settings");
|
||||
for (String key : settings.keySet()) {
|
||||
if (!Set.of("host", "port", "namePrefix", "refreshSeconds").contains(key)) {
|
||||
throw new IllegalArgumentException("unknown DNS plugin setting '" + key + "'");
|
||||
}
|
||||
}
|
||||
String host = normalizeHost(required(settings, "host"));
|
||||
int port = integer(settings, "port", 25565, 1, 65535);
|
||||
int refreshSeconds = integer(settings, "refreshSeconds", 30, 1, 86400);
|
||||
String defaultPrefix = "dns-" + host.replace('.', '-');
|
||||
String prefix = settings.getOrDefault("namePrefix", defaultPrefix).trim();
|
||||
if (prefix.isEmpty() || !prefix.matches("[A-Za-z0-9][A-Za-z0-9._-]*")) {
|
||||
throw new IllegalArgumentException("setting 'namePrefix' must start with a letter or digit and "
|
||||
+ "contain only letters, digits, '.', '_' or '-'");
|
||||
}
|
||||
return new Configuration(host, port, prefix + "-", refreshSeconds);
|
||||
}
|
||||
|
||||
private static String required(Map<String, String> settings, String key) {
|
||||
String value = settings.get(key);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("missing required plugin setting '" + key + "'");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static int integer(Map<String, String> settings, String key, int defaultValue, int minimum, int maximum) {
|
||||
String value = settings.get(key);
|
||||
if (value == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
int parsed = Integer.parseInt(value.trim());
|
||||
if (parsed < minimum || parsed > maximum) {
|
||||
throw new IllegalArgumentException("setting '" + key + "' must be between " + minimum + " and "
|
||||
+ maximum);
|
||||
}
|
||||
return parsed;
|
||||
} catch (NumberFormatException invalid) {
|
||||
throw new IllegalArgumentException("setting '" + key + "' must be an integer", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeHost(String host) {
|
||||
if (host.contains(":") || host.endsWith(".")) {
|
||||
throw new IllegalArgumentException("setting 'host' must be a DNS hostname without a trailing dot");
|
||||
}
|
||||
final String ascii;
|
||||
try {
|
||||
ascii = IDN.toASCII(host, IDN.USE_STD3_ASCII_RULES).toLowerCase(Locale.ROOT);
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
throw new IllegalArgumentException("setting 'host' must be a valid DNS hostname", invalid);
|
||||
}
|
||||
if (ascii.length() > 253 || ascii.isBlank()) {
|
||||
throw new IllegalArgumentException("setting 'host' must be a valid DNS hostname");
|
||||
}
|
||||
return ascii;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
dev.strataproxy.plugins.dns.DnsDiscoveryPlugin
|
||||
-389
@@ -1,389 +0,0 @@
|
||||
package dev.strataproxy.plugins.dns;
|
||||
|
||||
import dev.strataproxy.api.Players;
|
||||
import dev.strataproxy.api.PluginContext;
|
||||
import dev.strataproxy.api.ServerDefinition;
|
||||
import dev.strataproxy.api.ServerRegistration;
|
||||
import dev.strataproxy.api.ServerView;
|
||||
import dev.strataproxy.api.Servers;
|
||||
import io.netty.resolver.dns.DnsServerAddresses;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.DatagramPacket;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class DnsDiscoveryPluginTest {
|
||||
@Test
|
||||
void reconcilesAddressChangesAndCleansUpOnDisable() throws Exception {
|
||||
InetAddress firstAddress = address(10, 1, 2, 3);
|
||||
InetAddress secondAddress = address(10, 1, 2, 4);
|
||||
InetAddress thirdAddress = address(10, 1, 2, 5);
|
||||
InetAddress ipv6Address = InetAddress.getByAddress(new byte[]{
|
||||
0x20, 0x01, 0x0d, (byte) 0xb8, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 1});
|
||||
AtomicReference<InetAddress[]> answer = new AtomicReference<>(
|
||||
new InetAddress[]{firstAddress, firstAddress, secondAddress, ipv6Address});
|
||||
FakeServers servers = new FakeServers();
|
||||
DnsDiscoveryPlugin plugin = new DnsDiscoveryPlugin(host -> answer.get());
|
||||
plugin.onLoad(context(servers, Map.of(
|
||||
"host", "backend.example",
|
||||
"port", "25570",
|
||||
"namePrefix", "forge",
|
||||
"refreshSeconds", "1")));
|
||||
|
||||
plugin.onEnable();
|
||||
await(() -> servers.definitions.size() == 3);
|
||||
String firstName = name("forge-", firstAddress);
|
||||
String secondName = name("forge-", secondAddress);
|
||||
String thirdName = name("forge-", thirdAddress);
|
||||
String ipv6Name = name("forge-", ipv6Address);
|
||||
assertEquals("tcp://10.1.2.3:25570", servers.definitions.get(firstName).address().toString());
|
||||
assertEquals("tcp://10.1.2.4:25570", servers.definitions.get(secondName).address().toString());
|
||||
assertEquals("tcp://[2001:db8:0:0:0:0:0:1]:25570", servers.definitions.get(ipv6Name).address().toString());
|
||||
assertEquals(3, servers.registerCalls.get());
|
||||
assertEquals(List.of(firstName, secondName, ipv6Name), servers.registrationOrder);
|
||||
|
||||
answer.set(new InetAddress[]{thirdAddress, secondAddress, ipv6Address});
|
||||
await(() -> servers.definitions.size() == 3 && servers.definitions.containsKey(thirdName)
|
||||
&& !servers.definitions.containsKey(firstName));
|
||||
assertEquals("tcp://10.1.2.4:25570", servers.definitions.get(secondName).address().toString(),
|
||||
"a retained address must retain its backend identity");
|
||||
assertEquals(4, servers.registerCalls.get(), "only the newly discovered address should register");
|
||||
assertEquals(0, servers.updateCalls.get(), "stable retained addresses should not be reassigned");
|
||||
assertEquals(1, servers.unregisterCalls.get(), "only the removed address should unregister");
|
||||
|
||||
plugin.onDisable();
|
||||
assertTrue(servers.definitions.isEmpty());
|
||||
assertEquals(4, servers.unregisterCalls.get());
|
||||
plugin.onDisable();
|
||||
assertEquals(4, servers.unregisterCalls.get(), "disable should be idempotent");
|
||||
}
|
||||
|
||||
@Test
|
||||
void retainsRegistrationsAcrossResolutionFailureThenRemovesOnSuccessfulEmptyAnswer() throws Exception {
|
||||
InetAddress ip = address(192, 0, 2, 8);
|
||||
AtomicReference<InetAddress[]> answer = new AtomicReference<>(new InetAddress[]{ip});
|
||||
AtomicReference<Exception> failure = new AtomicReference<>();
|
||||
CountDownLatch failureSeen = new CountDownLatch(1);
|
||||
DnsDiscoveryPlugin plugin = new DnsDiscoveryPlugin(host -> {
|
||||
Exception current = failure.get();
|
||||
if (current != null) {
|
||||
failureSeen.countDown();
|
||||
throw current;
|
||||
}
|
||||
return answer.get();
|
||||
});
|
||||
FakeServers servers = new FakeServers();
|
||||
plugin.onLoad(context(servers, Map.of("host", "pool.example", "refreshSeconds", "1")));
|
||||
plugin.onEnable();
|
||||
await(() -> servers.definitions.size() == 1);
|
||||
|
||||
failure.set(new IOException("temporary resolver failure"));
|
||||
assertTrue(failureSeen.await(3, TimeUnit.SECONDS), "scheduled lookup should observe injected failure");
|
||||
assertEquals(1, servers.definitions.size(), "lookup failure must not remove a live registration");
|
||||
|
||||
failure.set(null);
|
||||
answer.set(new InetAddress[0]);
|
||||
await(servers.definitions::isEmpty);
|
||||
assertEquals(1, servers.unregisterCalls.get());
|
||||
plugin.onDisable();
|
||||
assertTrue(servers.definitions.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedMissingDnsAnswerExpiresStaleRegistrationAndRecoveryRegistersAgain() throws Exception {
|
||||
InetAddress first = address(192, 0, 2, 20);
|
||||
InetAddress replacement = address(192, 0, 2, 21);
|
||||
AtomicReference<InetAddress[]> answer = new AtomicReference<>(new InetAddress[]{first});
|
||||
AtomicInteger misses = new AtomicInteger();
|
||||
DnsDiscoveryPlugin plugin = new DnsDiscoveryPlugin(host -> {
|
||||
InetAddress[] current = answer.get();
|
||||
if (current != null) return current;
|
||||
misses.incrementAndGet();
|
||||
throw new UnknownHostException(host);
|
||||
});
|
||||
FakeServers servers = new FakeServers();
|
||||
plugin.onLoad(context(servers, Map.of("host", "pool.example", "refreshSeconds", "1")));
|
||||
try {
|
||||
plugin.onEnable();
|
||||
await(() -> servers.definitions.containsKey(name("dns-pool-example-", first)));
|
||||
answer.set(null);
|
||||
await(() -> misses.get() >= 1);
|
||||
assertEquals(1, servers.definitions.size(), "one transient lookup miss should retain the backend");
|
||||
await(servers.definitions::isEmpty);
|
||||
assertTrue(misses.get() >= 3);
|
||||
assertEquals(1, servers.unregisterCalls.get());
|
||||
|
||||
answer.set(new InetAddress[]{replacement});
|
||||
await(() -> servers.definitions.containsKey(name("dns-pool-example-", replacement)));
|
||||
assertEquals(2, servers.registerCalls.get());
|
||||
} finally {
|
||||
plugin.onDisable();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolverKeepsFailedAddressFamilyUntilItsThirdMiss() throws Exception {
|
||||
InetAddress firstV4 = address(192, 0, 2, 10);
|
||||
InetAddress secondV4 = address(192, 0, 2, 11);
|
||||
InetAddress firstV6 = InetAddress.getByAddress(new byte[]{
|
||||
0x20, 0x01, 0x0d, (byte) 0xb8, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 10});
|
||||
InetAddress secondV6 = InetAddress.getByAddress(new byte[]{
|
||||
0x20, 0x01, 0x0d, (byte) 0xb8, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 11});
|
||||
AtomicReference<byte[]> v4 = new AtomicReference<>(firstV4.getAddress());
|
||||
AtomicReference<byte[]> v6 = new AtomicReference<>(firstV6.getAddress());
|
||||
AtomicBoolean dropV6 = new AtomicBoolean();
|
||||
AtomicReference<Throwable> dnsFailure = new AtomicReference<>();
|
||||
try (DatagramSocket socket = new DatagramSocket(new InetSocketAddress("127.0.0.1", 0))) {
|
||||
Thread dnsThread = new Thread(() -> {
|
||||
while (!socket.isClosed()) {
|
||||
try {
|
||||
DatagramPacket query = new DatagramPacket(new byte[4096], 4096);
|
||||
socket.receive(query);
|
||||
if (dropV6.get() && dnsQuestionType(query) == 28) continue;
|
||||
byte[] response = dnsAnswer(query, v4.get(), v6.get());
|
||||
socket.send(new DatagramPacket(response, response.length, query.getSocketAddress()));
|
||||
} catch (java.net.SocketException closed) {
|
||||
if (!socket.isClosed()) dnsFailure.set(closed);
|
||||
return;
|
||||
} catch (Throwable failure) {
|
||||
dnsFailure.set(failure);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, "fake-dns-answer-server");
|
||||
dnsThread.setDaemon(true);
|
||||
dnsThread.start();
|
||||
InetSocketAddress endpoint = new InetSocketAddress("127.0.0.1", socket.getLocalPort());
|
||||
try (var resolver = new DnsDiscoveryPlugin.RefreshingAddressResolver(
|
||||
hostname -> DnsServerAddresses.singleton(endpoint).stream())) {
|
||||
assertEquals(Set.of(firstV4, firstV6),
|
||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))));
|
||||
v4.set(secondV4.getAddress());
|
||||
v6.set(secondV6.getAddress());
|
||||
assertEquals(Set.of(secondV4, secondV6),
|
||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))));
|
||||
v6.set(null);
|
||||
assertEquals(Set.of(secondV4),
|
||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))));
|
||||
v6.set(secondV6.getAddress());
|
||||
v4.set(null);
|
||||
assertEquals(Set.of(secondV6),
|
||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))));
|
||||
v4.set(secondV4.getAddress());
|
||||
dropV6.set(true);
|
||||
assertEquals(Set.of(secondV4, secondV6),
|
||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))));
|
||||
assertEquals(Set.of(secondV4, secondV6),
|
||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))));
|
||||
assertEquals(Set.of(secondV4),
|
||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))),
|
||||
"a timed-out AAAA query should retire only its stale address family after three misses");
|
||||
dropV6.set(false);
|
||||
assertEquals(Set.of(secondV4, secondV6),
|
||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))));
|
||||
v4.set(null);
|
||||
dropV6.set(true);
|
||||
assertEquals(Set.of(secondV6),
|
||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))));
|
||||
assertEquals(Set.of(secondV6),
|
||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))));
|
||||
assertEquals(0, resolver.resolve("backend.dynamic.test").length,
|
||||
"the last stale address should be removed on its third missed query");
|
||||
}
|
||||
assertNull(dnsFailure.get());
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] dnsAnswer(DatagramPacket query, byte[] v4, byte[] v6) throws IOException {
|
||||
byte[] request = query.getData();
|
||||
int length = query.getLength();
|
||||
int offset = 12;
|
||||
while (offset < length && request[offset] != 0) offset += 1 + (request[offset] & 0xff);
|
||||
if (offset + 5 > length) throw new IOException("truncated DNS question");
|
||||
int type = dnsQuestionType(query);
|
||||
int questionLength = offset + 5 - 12;
|
||||
byte[] address = type == 1 ? v4 : type == 28 ? v6 : null;
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
var output = new DataOutputStream(bytes);
|
||||
output.write(request, 0, 2); // Transaction ID.
|
||||
output.writeShort(0x8180); // Standard successful response.
|
||||
output.writeShort(1);
|
||||
output.writeShort(address == null ? 0 : 1);
|
||||
output.writeInt(0);
|
||||
output.write(request, 12, questionLength);
|
||||
if (address != null) {
|
||||
output.writeShort(0xc00c); // Name pointer to the question.
|
||||
output.writeShort(type);
|
||||
output.writeShort(1); // IN class.
|
||||
output.writeInt(0); // TTL: a new DNS answer must be consulted on refresh.
|
||||
output.writeShort(address.length);
|
||||
output.write(address);
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
private static int dnsQuestionType(DatagramPacket query) throws IOException {
|
||||
byte[] request = query.getData();
|
||||
int length = query.getLength();
|
||||
int offset = 12;
|
||||
while (offset < length && request[offset] != 0) offset += 1 + (request[offset] & 0xff);
|
||||
if (offset + 5 > length) throw new IOException("truncated DNS question");
|
||||
return ((request[offset + 1] & 0xff) << 8) | (request[offset + 2] & 0xff);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidSettingsBeforeStartingWorker() {
|
||||
DnsDiscoveryPlugin obsoleteCapacity = new DnsDiscoveryPlugin(host -> new InetAddress[0]);
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> obsoleteCapacity.onLoad(context(new FakeServers(),
|
||||
Map.of("host", "pool.example", "capacity", "10"))));
|
||||
|
||||
DnsDiscoveryPlugin missingHost = new DnsDiscoveryPlugin(host -> new InetAddress[0]);
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> missingHost.onLoad(context(new FakeServers(), Map.of("port", "25565"))));
|
||||
|
||||
DnsDiscoveryPlugin invalidPort = new DnsDiscoveryPlugin(host -> new InetAddress[0]);
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> invalidPort.onLoad(context(new FakeServers(), Map.of("host", "pool.example", "port", "0"))));
|
||||
|
||||
DnsDiscoveryPlugin invalidPrefix = new DnsDiscoveryPlugin(host -> new InetAddress[0]);
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> invalidPrefix.onLoad(context(new FakeServers(),
|
||||
Map.of("host", "pool.example", "namePrefix", "bad prefix"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceLoaderDescriptorNamesPluginImplementation() throws IOException {
|
||||
try (var stream = getClass().getClassLoader().getResourceAsStream(
|
||||
"META-INF/services/dev.strataproxy.api.Plugin")) {
|
||||
assertNotNull(stream);
|
||||
String descriptor = new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
assertTrue(descriptor.lines().anyMatch(DnsDiscoveryPlugin.class.getName()::equals));
|
||||
}
|
||||
}
|
||||
|
||||
private static PluginContext context(FakeServers servers, Map<String, String> settings) {
|
||||
return new PluginContext() {
|
||||
@Override
|
||||
public Players players() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Servers servers() {
|
||||
return servers;
|
||||
}
|
||||
|
||||
@Override public dev.strataproxy.api.Commands commands() {
|
||||
return (name, handler, completer) -> { throw new UnsupportedOperationException(); };
|
||||
}
|
||||
@Override public dev.strataproxy.api.event.Events events() { throw new UnsupportedOperationException(); }
|
||||
|
||||
@Override
|
||||
public org.slf4j.Logger logger() {
|
||||
return LoggerFactory.getLogger("dns-discovery-test");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> settings() {
|
||||
return settings;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static InetAddress address(int a, int b, int c, int d) throws Exception {
|
||||
return InetAddress.getByAddress(new byte[]{(byte) a, (byte) b, (byte) c, (byte) d});
|
||||
}
|
||||
|
||||
private static String name(String prefix, InetAddress address) {
|
||||
return prefix + java.util.HexFormat.of().formatHex(address.getAddress());
|
||||
}
|
||||
|
||||
private static void await(BooleanSupplier condition) throws InterruptedException {
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos();
|
||||
while (!condition.getAsBoolean()) {
|
||||
if (System.nanoTime() >= deadline) {
|
||||
assertTrue(condition.getAsBoolean(), "condition not met before timeout");
|
||||
return;
|
||||
}
|
||||
Thread.sleep(10);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FakeServers implements Servers {
|
||||
private final Map<String, ServerDefinition> definitions = new ConcurrentHashMap<>();
|
||||
private final List<String> registrationOrder = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||
private final AtomicInteger registerCalls = new AtomicInteger();
|
||||
private final AtomicInteger updateCalls = new AtomicInteger();
|
||||
private final AtomicInteger unregisterCalls = new AtomicInteger();
|
||||
|
||||
@Override
|
||||
public Optional<ServerView> find(String backendName) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ServerView> all() {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServerRegistration register(ServerDefinition definition) {
|
||||
if (definitions.putIfAbsent(definition.name(), definition) != null) {
|
||||
throw new IllegalStateException("duplicate backend " + definition.name());
|
||||
}
|
||||
registrationOrder.add(definition.name());
|
||||
registerCalls.incrementAndGet();
|
||||
return new ServerRegistration() {
|
||||
private boolean active = true;
|
||||
|
||||
@Override
|
||||
public void update(ServerDefinition replacement) {
|
||||
assertTrue(active);
|
||||
assertEquals(definition.name(), replacement.name());
|
||||
definitions.put(replacement.name(), replacement);
|
||||
updateCalls.incrementAndGet();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unregister() {
|
||||
assertTrue(active);
|
||||
active = false;
|
||||
definitions.remove(definition.name());
|
||||
unregisterCalls.incrementAndGet();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ dependencies {
|
||||
implementation(libs.findLibrary("adventure-gson").get())
|
||||
implementation(libs.findLibrary("adventure-plain").get())
|
||||
runtimeOnly(libs.findLibrary("logback-classic").get())
|
||||
testImplementation(project(":backend-channel-client"))
|
||||
}
|
||||
|
||||
val installedDistSmokeTest = tasks.register<Exec>("installedDistSmokeTest") {
|
||||
@@ -88,11 +89,8 @@ distributions {
|
||||
from("src/main/resources/config") {
|
||||
into("config")
|
||||
}
|
||||
from(project(":dns-discovery").tasks.named("jar")) {
|
||||
into("plugins")
|
||||
}
|
||||
from(project(":agent-discovery").tasks.named("jar")) {
|
||||
into("plugins")
|
||||
from(project(":backend-channel-client").tasks.named("jar")) {
|
||||
into("backend-client")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,15 @@ package dev.strataproxy.app;
|
||||
import io.netty.util.NetUtil;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Configuration understood by the new proxy runtime. */
|
||||
public record ProxyConfiguration(String listen, Authentication authentication, List<Backend> backends,
|
||||
Plugins plugins, Integer maxConnections, boolean allowOfflinePublicAccess,
|
||||
Status status) {
|
||||
Status status, BackendChannel backendChannel) {
|
||||
public enum Authentication { OFFLINE, ONLINE_BUNGEE }
|
||||
|
||||
public ProxyConfiguration {
|
||||
@@ -40,7 +42,13 @@ public record ProxyConfiguration(String listen, Authentication authentication, L
|
||||
}
|
||||
|
||||
public ProxyConfiguration(String listen, Authentication authentication, List<Backend> backends) {
|
||||
this(listen, authentication, backends, null, null, false, null);
|
||||
this(listen, authentication, backends, null, null, false, null, null);
|
||||
}
|
||||
|
||||
public ProxyConfiguration(String listen, Authentication authentication, List<Backend> backends,
|
||||
Plugins plugins, Integer maxConnections, boolean allowOfflinePublicAccess,
|
||||
Status status) {
|
||||
this(listen, authentication, backends, plugins, maxConnections, allowOfflinePublicAccess, status, null);
|
||||
}
|
||||
|
||||
/** User-facing server list values; icon validation needs the config file location and happens in the loader. */
|
||||
@@ -121,6 +129,50 @@ public record ProxyConfiguration(String listen, Authentication authentication, L
|
||||
}
|
||||
}
|
||||
|
||||
/** Optional listener for backend registration and control messages, independent of player traffic. */
|
||||
public record BackendChannel(String listen, Map<String, Client> clients, Integer leaseSeconds,
|
||||
Integer maxConnections) {
|
||||
public BackendChannel {
|
||||
if (listen == null || listen.isBlank()) throw new IllegalArgumentException("backendChannel.listen is required");
|
||||
parseAddress(listen);
|
||||
if (parseAddress(listen).getPort() == 0)
|
||||
throw new IllegalArgumentException("backendChannel.listen port must be positive");
|
||||
clients = clients == null ? Map.of() : Map.copyOf(clients);
|
||||
if (clients.isEmpty()) throw new IllegalArgumentException("backendChannel.clients must not be empty");
|
||||
for (String instanceId : clients.keySet()) {
|
||||
if (!instanceId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"))
|
||||
throw new IllegalArgumentException("invalid backendChannel client id: " + instanceId);
|
||||
}
|
||||
leaseSeconds = leaseSeconds == null ? 30 : leaseSeconds;
|
||||
if (leaseSeconds < 5 || leaseSeconds > 3600)
|
||||
throw new IllegalArgumentException("backendChannel.leaseSeconds must be between 5 and 3600");
|
||||
maxConnections = maxConnections == null ? 128 : maxConnections;
|
||||
if (maxConnections < 1 || maxConnections > 4096)
|
||||
throw new IllegalArgumentException("backendChannel.maxConnections must be between 1 and 4096");
|
||||
}
|
||||
|
||||
public InetSocketAddress listenAddress() { return parseAddress(listen); }
|
||||
}
|
||||
|
||||
public record Client(String backendName, String keyId, String secret, Set<String> allowedHosts,
|
||||
Set<String> allowedNamespaces) {
|
||||
public Client {
|
||||
if (backendName == null || backendName.isBlank())
|
||||
throw new IllegalArgumentException("backendChannel client backendName is required");
|
||||
if (keyId == null || !keyId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"))
|
||||
throw new IllegalArgumentException("backendChannel client keyId is invalid");
|
||||
if (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < 32)
|
||||
throw new IllegalArgumentException("backendChannel client secret must contain at least 32 UTF-8 bytes");
|
||||
allowedHosts = allowedHosts == null ? Set.of() : Set.copyOf(allowedHosts);
|
||||
if (allowedHosts.isEmpty() || allowedHosts.stream().anyMatch(String::isBlank))
|
||||
throw new IllegalArgumentException("backendChannel client allowedHosts must not be empty");
|
||||
allowedNamespaces = allowedNamespaces == null ? Set.of() : Set.copyOf(allowedNamespaces);
|
||||
if (allowedNamespaces.isEmpty() || allowedNamespaces.stream()
|
||||
.anyMatch(namespace -> !namespace.matches("[a-z0-9][a-z0-9_.-]{0,63}")))
|
||||
throw new IllegalArgumentException("backendChannel client allowedNamespaces is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
public record Plugins(String directory, Map<String, Map<String, String>> enabled,
|
||||
Integer initialPlacementTimeoutSeconds, Integer eventTimeoutSeconds) {
|
||||
public Plugins {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.strataproxy.app;
|
||||
|
||||
import dev.strataproxy.core.backend.InMemoryBackendCatalog;
|
||||
import dev.strataproxy.core.control.BackendControlService;
|
||||
import dev.strataproxy.core.auth.MojangSessionVerifier;
|
||||
import dev.strataproxy.core.plugin.PluginHost;
|
||||
import dev.strataproxy.core.session.ProxySessionListener;
|
||||
@@ -81,11 +82,16 @@ public final class ProxyMain {
|
||||
: new ProxySessionListener(configuration.listenAddress(), catalog,
|
||||
new MojangSessionVerifier(Duration.ofSeconds(5), verifierWorkers), placementTimeout);
|
||||
listener.setServerListStatus(serverListStatus);
|
||||
try (var plugins = new PluginHost(catalog, listener, placementTimeout, eventTimeout)) {
|
||||
try (var plugins = new PluginHost(catalog, listener, placementTimeout, eventTimeout);
|
||||
var control = configuration.backendChannel() == null ? null
|
||||
: new BackendControlService(configuration.backendChannel(), catalog,
|
||||
plugins::dispatchBackendMessage)) {
|
||||
if (control != null) plugins.setBackendChannelTransport(control);
|
||||
listener.setMaxConnections(configuration.maxConnections());
|
||||
plugins.loadPlugins(pluginDirectory(configPath, configuration.plugins().directory()),
|
||||
configuration.plugins().enabled());
|
||||
plugins.enable();
|
||||
if (control != null) control.start();
|
||||
listener.setEvents(plugins, eventTimeout);
|
||||
listener.setPlacement(plugins::placeInitial);
|
||||
listener.setCommandDispatcher(plugins::dispatchCommand);
|
||||
@@ -97,9 +103,10 @@ public final class ProxyMain {
|
||||
listener.close().toCompletableFuture().join();
|
||||
} finally {
|
||||
try {
|
||||
plugins.close();
|
||||
if (control != null) control.close();
|
||||
} finally {
|
||||
if (verifierWorkers != null) verifierWorkers.shutdownNow();
|
||||
try { plugins.close(); }
|
||||
finally { if (verifierWorkers != null) verifierWorkers.shutdownNow(); }
|
||||
}
|
||||
}
|
||||
}, "strataproxy-shutdown");
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package dev.strataproxy.core.control;
|
||||
|
||||
import dev.strataproxy.api.BackendSendResult;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
|
||||
/** Outbound operations exposed to the proxy plugin host. */
|
||||
public interface BackendChannelTransport {
|
||||
CompletionStage<BackendSendResult> send(String backendName, String channel, byte[] payload);
|
||||
|
||||
CompletionStage<byte[]> request(String backendName, String channel, byte[] payload);
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
package dev.strataproxy.core.control;
|
||||
|
||||
import dev.strataproxy.api.BackendMessage;
|
||||
import dev.strataproxy.api.BackendSendResult;
|
||||
import dev.strataproxy.app.ProxyConfiguration;
|
||||
import dev.strataproxy.core.backend.BackendCatalog;
|
||||
import dev.strataproxy.core.backend.BackendHandle;
|
||||
import dev.strataproxy.core.backend.BackendId;
|
||||
import dev.strataproxy.core.backend.BackendOwner;
|
||||
import dev.strataproxy.core.backend.BackendRegistration;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.EOFException;
|
||||
import java.io.IOException;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.net.SocketException;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** Independent authenticated backend connection for registration and plugin control messages. */
|
||||
public final class BackendControlService implements BackendChannelTransport, AutoCloseable {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(BackendControlService.class);
|
||||
private static final int VERSION = 1;
|
||||
private static final int MAX_FRAME = 66 * 1024;
|
||||
private static final int MAX_PAYLOAD = 65_536;
|
||||
private static final long MAX_QUEUED_BYTES = 1_048_576;
|
||||
private static final int MAX_REQUESTS = 128;
|
||||
private static final int HELLO = 1, REGISTER = 2, REGISTERED = 3, HEARTBEAT = 4,
|
||||
PONG = 5, GOODBYE = 6, MESSAGE = 7, RESPONSE = 8;
|
||||
private static final int OK = 0, NO_HANDLER = 1, ERROR = 2;
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(5);
|
||||
|
||||
private final ProxyConfiguration.BackendChannel configuration;
|
||||
private final BackendCatalog catalog;
|
||||
private final Function<BackendMessage, CompletionStage<byte[]>> inbound;
|
||||
private final Object lock = new Object();
|
||||
private final Map<String, Lease> byInstance = new HashMap<>();
|
||||
private final Map<String, Lease> byBackend = new HashMap<>();
|
||||
private final Semaphore connectionSlots;
|
||||
private final ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor(
|
||||
Thread.ofVirtual().name("strataproxy-control-timer", 0).factory());
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
private final AtomicLong nextEpoch = new AtomicLong();
|
||||
private volatile boolean closed;
|
||||
private ServerSocket listener;
|
||||
|
||||
public BackendControlService(ProxyConfiguration.BackendChannel configuration, BackendCatalog catalog,
|
||||
Function<BackendMessage, CompletionStage<byte[]>> inbound) {
|
||||
this.configuration = Objects.requireNonNull(configuration, "configuration");
|
||||
this.catalog = Objects.requireNonNull(catalog, "catalog");
|
||||
this.inbound = Objects.requireNonNull(inbound, "inbound");
|
||||
this.connectionSlots = new Semaphore(configuration.maxConnections());
|
||||
}
|
||||
|
||||
public synchronized void start() throws IOException {
|
||||
if (listener != null || closed) throw new IllegalStateException("control listener already started or closed");
|
||||
ServerSocket server = new ServerSocket();
|
||||
try {
|
||||
server.bind(configuration.listenAddress(), 32);
|
||||
listener = server;
|
||||
timer.scheduleAtFixedRate(this::expireLeases, 1, 1, TimeUnit.SECONDS);
|
||||
Thread.ofVirtual().name("strataproxy-control-accept").start(this::acceptLoop);
|
||||
LOGGER.info("Backend control listening on {}", server.getLocalSocketAddress());
|
||||
} catch (RuntimeException | IOException failure) {
|
||||
server.close();
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
public java.net.SocketAddress localAddress() {
|
||||
ServerSocket server = listener;
|
||||
return server == null ? null : server.getLocalSocketAddress();
|
||||
}
|
||||
|
||||
private void acceptLoop() {
|
||||
while (!closed) {
|
||||
try {
|
||||
Socket socket = listener.accept();
|
||||
if (!connectionSlots.tryAcquire()) {
|
||||
socket.close();
|
||||
continue;
|
||||
}
|
||||
Thread.ofVirtual().name("strataproxy-control-peer").start(() -> {
|
||||
try { serve(socket); }
|
||||
finally { connectionSlots.release(); }
|
||||
});
|
||||
} catch (IOException failure) {
|
||||
if (!closed) LOGGER.warn("Backend control accept failed", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void serve(Socket socket) {
|
||||
Connection connection = new Connection(socket);
|
||||
try {
|
||||
socket.setTcpNoDelay(true);
|
||||
socket.setSoTimeout(5_000);
|
||||
byte[] nonce = new byte[32];
|
||||
random.nextBytes(nonce);
|
||||
connection.write(HELLO, out -> { out.writeInt(VERSION); out.write(nonce); });
|
||||
Frame register = readFrame(connection.input);
|
||||
if (register.type != REGISTER) throw new IOException("expected backend registration");
|
||||
Registration requested = parseRegistration(register.input, nonce);
|
||||
register(connection, requested);
|
||||
connection.write(REGISTERED, out -> out.writeLong(connection.epoch));
|
||||
socket.setSoTimeout(configuration.leaseSeconds() * 1_000);
|
||||
while (!closed && connection.live.get()) {
|
||||
Frame frame = readFrame(connection.input);
|
||||
if (!isCurrent(connection)) break;
|
||||
switch (frame.type) {
|
||||
case HEARTBEAT -> {
|
||||
requireEmpty(frame.input);
|
||||
renew(connection);
|
||||
connection.write(PONG, ignored -> { });
|
||||
}
|
||||
case GOODBYE -> {
|
||||
requireEmpty(frame.input);
|
||||
unregister(connection);
|
||||
return;
|
||||
}
|
||||
case MESSAGE -> receiveMessage(connection, frame.input);
|
||||
case RESPONSE -> receiveResponse(connection, frame.input);
|
||||
default -> throw new IOException("unexpected backend control frame " + frame.type);
|
||||
}
|
||||
}
|
||||
} catch (EOFException | SocketException expected) {
|
||||
// A connection loss retains registration until its lease expires.
|
||||
} catch (Exception failure) {
|
||||
if (!closed) LOGGER.debug("Backend control connection ended: {}", failure.toString());
|
||||
} finally {
|
||||
disconnect(connection);
|
||||
}
|
||||
}
|
||||
|
||||
private Registration parseRegistration(DataInputStream input, byte[] nonce) throws Exception {
|
||||
String instanceId = readString(input, 64);
|
||||
String name = readString(input, 128);
|
||||
String address = readString(input, 1024);
|
||||
String generation = readString(input, 64);
|
||||
String keyId = readString(input, 64);
|
||||
byte[] signature = input.readNBytes(32);
|
||||
if (signature.length != 32) throw new EOFException();
|
||||
requireEmpty(input);
|
||||
UUID.fromString(generation);
|
||||
ProxyConfiguration.Client client = configuration.clients().get(instanceId);
|
||||
if (client == null || !client.backendName().equals(name) || !client.keyId().equals(keyId))
|
||||
throw new IOException("backend identity rejected");
|
||||
URI uri = URI.create(address);
|
||||
new BackendRegistration(new BackendId(name), new BackendOwner("backend-control:" + instanceId, 0), uri);
|
||||
if (!client.allowedHosts().contains(uri.getHost())) throw new IOException("advertised backend host rejected");
|
||||
ByteArrayOutputStream canonical = new ByteArrayOutputStream();
|
||||
canonical.write(nonce);
|
||||
DataOutputStream signed = new DataOutputStream(canonical);
|
||||
writeString(signed, instanceId);
|
||||
writeString(signed, name);
|
||||
writeString(signed, address);
|
||||
writeString(signed, generation);
|
||||
writeString(signed, keyId);
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(client.secret().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
if (!MessageDigest.isEqual(signature, mac.doFinal(canonical.toByteArray())))
|
||||
throw new IOException("backend signature rejected");
|
||||
return new Registration(instanceId, name, generation, uri, client.allowedNamespaces());
|
||||
}
|
||||
|
||||
private void register(Connection connection, Registration requested) throws IOException {
|
||||
synchronized (lock) {
|
||||
if (closed) throw new IOException("control service is closed");
|
||||
Lease conflict = byBackend.get(requested.name);
|
||||
if (conflict != null && !conflict.instanceId.equals(requested.instanceId))
|
||||
throw new IOException("backend name already registered");
|
||||
Lease prior = byInstance.get(requested.instanceId);
|
||||
if (prior != null && !prior.name.equals(requested.name))
|
||||
throw new IOException("instance cannot change backend name");
|
||||
long epoch = nextEpoch.incrementAndGet();
|
||||
BackendOwner owner = new BackendOwner("backend-control:" + requested.instanceId, epoch);
|
||||
BackendRegistration registration = new BackendRegistration(new BackendId(requested.name), owner,
|
||||
requested.address, Map.of("discovery", "control"),
|
||||
Map.of("instance.id", requested.instanceId, "instance.generation", requested.generation));
|
||||
BackendHandle handle;
|
||||
try { handle = catalog.register(registration).handle(); }
|
||||
catch (RuntimeException conflictFailure) { throw new IOException("backend registration rejected", conflictFailure); }
|
||||
connection.instanceId = requested.instanceId;
|
||||
connection.name = requested.name;
|
||||
connection.epoch = epoch;
|
||||
connection.allowedNamespaces = requested.allowedNamespaces;
|
||||
Lease replacement = new Lease(requested.instanceId, requested.name, requested.generation, epoch, handle,
|
||||
System.nanoTime() + TimeUnit.SECONDS.toNanos(configuration.leaseSeconds()), connection);
|
||||
byInstance.put(requested.instanceId, replacement);
|
||||
byBackend.put(requested.name, replacement);
|
||||
if (prior != null && prior.connection != null) prior.connection.close();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isCurrent(Connection connection) {
|
||||
synchronized (lock) {
|
||||
Lease lease = byInstance.get(connection.instanceId);
|
||||
return lease != null && lease.connection == connection && lease.epoch() == connection.epoch;
|
||||
}
|
||||
}
|
||||
|
||||
private void renew(Connection connection) {
|
||||
synchronized (lock) {
|
||||
Lease lease = byInstance.get(connection.instanceId);
|
||||
if (lease != null && lease.connection == connection)
|
||||
lease.expiresAtNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(configuration.leaseSeconds());
|
||||
}
|
||||
}
|
||||
|
||||
private void unregister(Connection connection) {
|
||||
synchronized (lock) {
|
||||
Lease lease = byInstance.get(connection.instanceId);
|
||||
if (lease != null && lease.connection == connection) removeLease(lease);
|
||||
}
|
||||
}
|
||||
|
||||
private void disconnect(Connection connection) {
|
||||
connection.close();
|
||||
synchronized (lock) {
|
||||
Lease lease = byInstance.get(connection.instanceId);
|
||||
if (lease != null && lease.connection == connection) lease.connection = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void expireLeases() {
|
||||
synchronized (lock) {
|
||||
long now = System.nanoTime();
|
||||
for (Lease lease : java.util.List.copyOf(byInstance.values())) {
|
||||
if (lease.expiresAtNanos <= now) removeLease(lease);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void removeLease(Lease lease) {
|
||||
if (byInstance.get(lease.instanceId) != lease) return;
|
||||
byInstance.remove(lease.instanceId);
|
||||
byBackend.remove(lease.name, lease);
|
||||
catalog.remove(lease.handle);
|
||||
if (lease.connection != null) lease.connection.close();
|
||||
}
|
||||
|
||||
private void receiveMessage(Connection connection, DataInputStream input) throws IOException {
|
||||
String channel = readString(input, 128);
|
||||
validateChannel(channel);
|
||||
if (!connection.allowedNamespaces.contains(channel.substring(0, channel.indexOf(':'))))
|
||||
throw new IOException("backend message namespace rejected");
|
||||
if (!connection.allowMessage()) throw new IOException("backend message rate exceeded");
|
||||
long requestId = input.readLong();
|
||||
byte[] payload = readPayload(input);
|
||||
requireEmpty(input);
|
||||
if (connection.inboundRequests.incrementAndGet() > MAX_REQUESTS) {
|
||||
connection.inboundRequests.decrementAndGet();
|
||||
throw new IOException("too many backend requests");
|
||||
}
|
||||
BackendMessage message = new BackendMessage(connection.name, connection.instanceId,
|
||||
connection.epoch, channel, payload);
|
||||
CompletionStage<byte[]> result;
|
||||
try { result = Objects.requireNonNull(inbound.apply(message), "handler result"); }
|
||||
catch (Throwable failure) { result = CompletableFuture.failedFuture(failure); }
|
||||
result.whenComplete((reply, failure) -> {
|
||||
connection.inboundRequests.decrementAndGet();
|
||||
if (requestId == 0 || !isCurrent(connection)) return;
|
||||
Throwable cause = failure instanceof java.util.concurrent.CompletionException && failure.getCause() != null
|
||||
? failure.getCause() : failure;
|
||||
int status = failure == null ? OK : cause instanceof BackendNoHandlerException ? NO_HANDLER : ERROR;
|
||||
byte[] response = failure == null && reply != null ? reply.clone() : new byte[0];
|
||||
if (response.length > MAX_PAYLOAD) { status = ERROR; response = new byte[0]; }
|
||||
int finalStatus = status;
|
||||
byte[] finalResponse = response;
|
||||
Thread.ofVirtual().start(() -> {
|
||||
try { connection.write(RESPONSE, out -> {
|
||||
out.writeLong(requestId);
|
||||
out.writeByte(finalStatus);
|
||||
writePayload(out, finalResponse);
|
||||
}); }
|
||||
catch (IOException ignored) { connection.close(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void receiveResponse(Connection connection, DataInputStream input) throws IOException {
|
||||
long requestId = input.readLong();
|
||||
int status = input.readUnsignedByte();
|
||||
byte[] payload = readPayload(input);
|
||||
requireEmpty(input);
|
||||
CompletableFuture<byte[]> request = connection.pending.remove(requestId);
|
||||
if (request != null) {
|
||||
if (status == OK) request.complete(payload);
|
||||
else request.completeExceptionally(new IOException("backend response status " + status));
|
||||
}
|
||||
}
|
||||
|
||||
@Override public CompletionStage<BackendSendResult> send(String backendName, String channel, byte[] payload) {
|
||||
validateMessage(channel, payload);
|
||||
Connection connection = current(backendName);
|
||||
if (connection == null) return CompletableFuture.completedFuture(BackendSendResult.NOT_CONNECTED);
|
||||
byte[] copy = payload.clone();
|
||||
if (!connection.reserve(copy.length)) return CompletableFuture.completedFuture(BackendSendResult.BACKPRESSURED);
|
||||
CompletableFuture<BackendSendResult> result = new CompletableFuture<>();
|
||||
Thread.ofVirtual().start(() -> {
|
||||
try {
|
||||
connection.write(MESSAGE, out -> {
|
||||
writeString(out, channel);
|
||||
out.writeLong(0);
|
||||
writePayload(out, copy);
|
||||
});
|
||||
result.complete(BackendSendResult.SENT);
|
||||
} catch (IOException failure) { connection.close(); result.completeExceptionally(failure); }
|
||||
finally { connection.release(copy.length); }
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override public CompletionStage<byte[]> request(String backendName, String channel, byte[] payload) {
|
||||
validateMessage(channel, payload);
|
||||
Connection connection = current(backendName);
|
||||
if (connection == null) return CompletableFuture.failedFuture(new IOException("backend control is disconnected"));
|
||||
byte[] copy = payload.clone();
|
||||
if (!connection.reserve(copy.length))
|
||||
return CompletableFuture.failedFuture(new IOException("backend control is backpressured"));
|
||||
if (!connection.requestSlots.tryAcquire()) {
|
||||
connection.release(copy.length);
|
||||
return CompletableFuture.failedFuture(new IOException("backend control has too many pending requests"));
|
||||
}
|
||||
long id = connection.nextRequest.getAndIncrement();
|
||||
if (id <= 0) {
|
||||
connection.release(copy.length);
|
||||
connection.requestSlots.release();
|
||||
return CompletableFuture.failedFuture(new IOException("backend request ID exhausted"));
|
||||
}
|
||||
CompletableFuture<byte[]> result = new CompletableFuture<>();
|
||||
connection.pending.put(id, result);
|
||||
final ScheduledFuture<?> timeout;
|
||||
try {
|
||||
timeout = timer.schedule(() -> result.completeExceptionally(new IOException("backend request timed out")),
|
||||
REQUEST_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
|
||||
} catch (RuntimeException failure) {
|
||||
connection.pending.remove(id, result);
|
||||
connection.requestSlots.release();
|
||||
connection.release(copy.length);
|
||||
result.completeExceptionally(failure);
|
||||
return result;
|
||||
}
|
||||
result.whenComplete((ignored, failure) -> {
|
||||
connection.pending.remove(id, result);
|
||||
timeout.cancel(false);
|
||||
connection.requestSlots.release();
|
||||
});
|
||||
Thread.ofVirtual().start(() -> {
|
||||
try { connection.write(MESSAGE, out -> {
|
||||
writeString(out, channel);
|
||||
out.writeLong(id);
|
||||
writePayload(out, copy);
|
||||
}); }
|
||||
catch (IOException failure) { connection.close(); result.completeExceptionally(failure); }
|
||||
finally { connection.release(copy.length); }
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private Connection current(String name) {
|
||||
synchronized (lock) {
|
||||
Lease lease = byBackend.get(name);
|
||||
return lease == null || lease.connection == null || !lease.connection.live.get()
|
||||
? null : lease.connection;
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateMessage(String channel, byte[] payload) {
|
||||
validateChannel(channel);
|
||||
Objects.requireNonNull(payload, "payload");
|
||||
if (payload.length > MAX_PAYLOAD) throw new IllegalArgumentException("backend payload exceeds 64 KiB");
|
||||
}
|
||||
|
||||
private static void validateChannel(String channel) {
|
||||
if (channel == null || !channel.matches("[a-z0-9][a-z0-9_.-]{0,63}:[a-z0-9][a-z0-9_.-]{0,63}"))
|
||||
throw new IllegalArgumentException("invalid backend message channel");
|
||||
}
|
||||
|
||||
private static Frame readFrame(DataInputStream input) throws IOException {
|
||||
int length = input.readInt();
|
||||
if (length < 1 || length > MAX_FRAME) throw new IOException("invalid backend control frame length");
|
||||
byte[] bytes = input.readNBytes(length);
|
||||
if (bytes.length != length) throw new EOFException();
|
||||
DataInputStream body = new DataInputStream(new ByteArrayInputStream(bytes));
|
||||
return new Frame(body.readUnsignedByte(), body);
|
||||
}
|
||||
|
||||
private static String readString(DataInputStream input, int maximum) throws IOException {
|
||||
int length = input.readUnsignedShort();
|
||||
if (length > maximum) throw new IOException("control string too long");
|
||||
byte[] bytes = input.readNBytes(length);
|
||||
if (bytes.length != length) throw new EOFException();
|
||||
String decoded = new String(bytes, StandardCharsets.UTF_8);
|
||||
if (!Arrays.equals(decoded.getBytes(StandardCharsets.UTF_8), bytes)) throw new IOException("invalid UTF-8");
|
||||
return decoded;
|
||||
}
|
||||
|
||||
private static void writeString(DataOutputStream output, String value) throws IOException {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > 65_535) throw new IOException("control string too long");
|
||||
output.writeShort(bytes.length);
|
||||
output.write(bytes);
|
||||
}
|
||||
|
||||
private static byte[] readPayload(DataInputStream input) throws IOException {
|
||||
int length = input.readInt();
|
||||
if (length < 0 || length > MAX_PAYLOAD) throw new IOException("invalid backend payload length");
|
||||
byte[] bytes = input.readNBytes(length);
|
||||
if (bytes.length != length) throw new EOFException();
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static void writePayload(DataOutputStream output, byte[] bytes) throws IOException {
|
||||
output.writeInt(bytes.length);
|
||||
output.write(bytes);
|
||||
}
|
||||
|
||||
private static void requireEmpty(DataInputStream input) throws IOException {
|
||||
if (input.available() != 0) throw new IOException("trailing backend control bytes");
|
||||
}
|
||||
|
||||
@Override public synchronized void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (listener != null) {
|
||||
try { listener.close(); } catch (IOException ignored) { }
|
||||
}
|
||||
timer.shutdownNow();
|
||||
synchronized (lock) {
|
||||
for (Lease lease : java.util.List.copyOf(byInstance.values())) removeLease(lease);
|
||||
}
|
||||
}
|
||||
|
||||
private record Registration(String instanceId, String name, String generation, URI address,
|
||||
java.util.Set<String> allowedNamespaces) { }
|
||||
private record Frame(int type, DataInputStream input) { }
|
||||
private interface Writer { void write(DataOutputStream output) throws IOException; }
|
||||
|
||||
private static final class Lease {
|
||||
private final String instanceId, name, generation;
|
||||
private final long epoch;
|
||||
private final BackendHandle handle;
|
||||
private long expiresAtNanos;
|
||||
private Connection connection;
|
||||
private Lease(String instanceId, String name, String generation, long epoch, BackendHandle handle,
|
||||
long expiresAtNanos, Connection connection) {
|
||||
this.instanceId = instanceId;
|
||||
this.name = name;
|
||||
this.generation = generation;
|
||||
this.epoch = epoch;
|
||||
this.handle = handle;
|
||||
this.expiresAtNanos = expiresAtNanos;
|
||||
this.connection = connection;
|
||||
}
|
||||
private long epoch() { return epoch; }
|
||||
}
|
||||
|
||||
private static final class Connection {
|
||||
private final Socket socket;
|
||||
private final DataInputStream input;
|
||||
private final DataOutputStream output;
|
||||
private final Object writeLock = new Object();
|
||||
private final AtomicBoolean live = new AtomicBoolean(true);
|
||||
private final AtomicLong queuedBytes = new AtomicLong();
|
||||
private final AtomicInteger queuedMessages = new AtomicInteger();
|
||||
private final AtomicLong nextRequest = new AtomicLong(1);
|
||||
private final ConcurrentHashMap<Long, CompletableFuture<byte[]>> pending = new ConcurrentHashMap<>();
|
||||
private final AtomicInteger inboundRequests = new AtomicInteger();
|
||||
private final Semaphore requestSlots = new Semaphore(MAX_REQUESTS);
|
||||
private String instanceId, name;
|
||||
private java.util.Set<String> allowedNamespaces = java.util.Set.of();
|
||||
private long epoch;
|
||||
private long rateWindowNanos = System.nanoTime();
|
||||
private int messagesInWindow;
|
||||
private Connection(Socket socket) {
|
||||
this.socket = socket;
|
||||
try {
|
||||
this.input = new DataInputStream(socket.getInputStream());
|
||||
this.output = new DataOutputStream(socket.getOutputStream());
|
||||
} catch (IOException failure) { throw new IllegalStateException(failure); }
|
||||
}
|
||||
private boolean reserve(int size) {
|
||||
if (queuedMessages.incrementAndGet() > MAX_REQUESTS) {
|
||||
queuedMessages.decrementAndGet();
|
||||
return false;
|
||||
}
|
||||
for (;;) {
|
||||
long used = queuedBytes.get();
|
||||
if (used + size > MAX_QUEUED_BYTES) {
|
||||
queuedMessages.decrementAndGet();
|
||||
return false;
|
||||
}
|
||||
if (queuedBytes.compareAndSet(used, used + size)) return true;
|
||||
}
|
||||
}
|
||||
private void release(int size) {
|
||||
queuedBytes.addAndGet(-size);
|
||||
queuedMessages.decrementAndGet();
|
||||
}
|
||||
private boolean allowMessage() {
|
||||
long now = System.nanoTime();
|
||||
if (now - rateWindowNanos >= TimeUnit.SECONDS.toNanos(1)) {
|
||||
rateWindowNanos = now;
|
||||
messagesInWindow = 0;
|
||||
}
|
||||
return ++messagesInWindow <= 200;
|
||||
}
|
||||
private void write(int type, Writer writer) throws IOException {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
DataOutputStream frame = new DataOutputStream(bytes);
|
||||
frame.writeByte(type);
|
||||
writer.write(frame);
|
||||
if (bytes.size() > MAX_FRAME) throw new IOException("control frame too large");
|
||||
synchronized (writeLock) {
|
||||
if (!live.get()) throw new SocketException("backend control disconnected");
|
||||
output.writeInt(bytes.size());
|
||||
bytes.writeTo(output);
|
||||
output.flush();
|
||||
}
|
||||
}
|
||||
private void close() {
|
||||
if (!live.compareAndSet(true, false)) return;
|
||||
try { socket.close(); } catch (IOException ignored) { }
|
||||
for (CompletableFuture<byte[]> result : pending.values())
|
||||
result.completeExceptionally(new IOException("backend control disconnected"));
|
||||
pending.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.strataproxy.core.control;
|
||||
|
||||
/** No proxy plugin currently owns the requested backend message channel. */
|
||||
public final class BackendNoHandlerException extends IllegalStateException {
|
||||
public BackendNoHandlerException(String channel) {
|
||||
super("No active plugin handler for backend channel " + channel);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
package dev.strataproxy.core.plugin;
|
||||
|
||||
import dev.strataproxy.api.AccessDecision;
|
||||
import dev.strataproxy.api.BackendChannelSubscription;
|
||||
import dev.strataproxy.api.BackendChannels;
|
||||
import dev.strataproxy.api.BackendMessage;
|
||||
import dev.strataproxy.api.BackendMessageHandler;
|
||||
import dev.strataproxy.api.BackendSendResult;
|
||||
import dev.strataproxy.api.MessageResult;
|
||||
import dev.strataproxy.api.DisconnectResult;
|
||||
import dev.strataproxy.api.event.ConnectionAdmissionEvent;
|
||||
@@ -35,6 +40,8 @@ import dev.strataproxy.core.backend.BackendId;
|
||||
import dev.strataproxy.core.backend.BackendOwner;
|
||||
import dev.strataproxy.core.backend.BackendRegistration;
|
||||
import dev.strataproxy.core.backend.BackendView;
|
||||
import dev.strataproxy.core.control.BackendChannelTransport;
|
||||
import dev.strataproxy.core.control.BackendNoHandlerException;
|
||||
import dev.strataproxy.core.event.EventDispatcher;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -99,9 +106,11 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
private final Duration shutdownTimeout;
|
||||
private final ThreadPoolExecutor callbacks;
|
||||
private final ThreadPoolExecutor commandWorkers;
|
||||
private final ThreadPoolExecutor backendWorkers;
|
||||
private final ConcurrentHashMap<String, RegisteredCommand> commands = new ConcurrentHashMap<>();
|
||||
private final Set<PendingCompletion> pendingCompletions = ConcurrentHashMap.newKeySet();
|
||||
private final AtomicInteger pendingCompletionCount = new AtomicInteger();
|
||||
private final ConcurrentHashMap<String, BackendHandlerRegistration> backendHandlers = new ConcurrentHashMap<>();
|
||||
private final ScheduledThreadPoolExecutor timer;
|
||||
private final AsyncEventDispatcher admissionEvents;
|
||||
private final AsyncEventDispatcher notificationEvents;
|
||||
@@ -112,7 +121,9 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
private final List<URLClassLoader> classLoaders = new ArrayList<>();
|
||||
private final List<LoadedPlugin> plugins = new ArrayList<>();
|
||||
private final Set<PendingPlacement> pendingPlacements = ConcurrentHashMap.newKeySet();
|
||||
private final Set<CompletableFuture<byte[]>> pendingBackendMessages = ConcurrentHashMap.newKeySet();
|
||||
private volatile State state = State.LOADING;
|
||||
private volatile BackendChannelTransport backendChannelTransport;
|
||||
private ScheduledFuture<?> notificationDropReporter;
|
||||
private InitialPlacementHandler placementHandler;
|
||||
private volatile Map<Class<?>, List<EventRegistration<?, ?>>> eventListeners = Map.of();
|
||||
@@ -169,6 +180,9 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
this.commandWorkers = new ThreadPoolExecutor(callbackThreads, callbackThreads, 0, TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<>(callbackQueueCapacity), namedThreads("strataproxy-plugin-command"),
|
||||
new ThreadPoolExecutor.AbortPolicy());
|
||||
this.backendWorkers = new ThreadPoolExecutor(2, 2, 0, TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<>(128), namedThreads("strataproxy-plugin-backend"),
|
||||
new ThreadPoolExecutor.AbortPolicy());
|
||||
this.timer = new ScheduledThreadPoolExecutor(1, namedThreads("strataproxy-plugin-timeout"));
|
||||
this.timer.setRemoveOnCancelPolicy(true);
|
||||
this.admissionPolicy = new AsyncEventDispatcher.EventPolicy<>(AccessDecision::allow,
|
||||
@@ -293,10 +307,12 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
for (LoadedPlugin loaded : plugins) {
|
||||
loaded.enabled = true;
|
||||
loaded.context.beginEventRegistration();
|
||||
loaded.context.beginBackendChannelRegistration();
|
||||
try {
|
||||
loaded.plugin.onEnable();
|
||||
} finally {
|
||||
loaded.context.endEventRegistration();
|
||||
loaded.context.endBackendChannelRegistration();
|
||||
}
|
||||
}
|
||||
freezeEventRegistrations();
|
||||
@@ -349,6 +365,72 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
return dispatcher.dispatch(event, (List) registrations, (AsyncEventDispatcher.EventPolicy) policy);
|
||||
}
|
||||
|
||||
/** Installs the control service used by plugin backend-channel operations. */
|
||||
public synchronized void setBackendChannelTransport(BackendChannelTransport transport) {
|
||||
if (state == State.CLOSED) throw new IllegalStateException("Plugin host is closed");
|
||||
backendChannelTransport = Objects.requireNonNull(transport, "transport");
|
||||
}
|
||||
|
||||
/** Dispatches an authenticated request from a backend to the proxy plugin registered for its channel. */
|
||||
public CompletionStage<byte[]> dispatchBackendMessage(BackendMessage message) {
|
||||
Objects.requireNonNull(message, "message");
|
||||
final BackendHandlerRegistration registration;
|
||||
final CompletableFuture<byte[]> result = new CompletableFuture<>();
|
||||
final ScheduledFuture<?> timeout;
|
||||
synchronized (this) {
|
||||
if (state != State.ENABLED) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("Plugin host is not enabled"));
|
||||
}
|
||||
registration = backendHandlers.get(message.channel());
|
||||
if (registration == null || !registration.isActive()) {
|
||||
return CompletableFuture.failedFuture(new BackendNoHandlerException(message.channel()));
|
||||
}
|
||||
pendingBackendMessages.add(result);
|
||||
try {
|
||||
timeout = timer.schedule(() -> result.completeExceptionally(
|
||||
new TimeoutException("Backend channel handler exceeded five seconds")),
|
||||
5, TimeUnit.SECONDS);
|
||||
} catch (RuntimeException failure) {
|
||||
pendingBackendMessages.remove(result);
|
||||
return CompletableFuture.failedFuture(failure);
|
||||
}
|
||||
}
|
||||
result.whenComplete((ignored, failure) -> {
|
||||
timeout.cancel(false);
|
||||
pendingBackendMessages.remove(result);
|
||||
});
|
||||
Runnable invocation = () -> {
|
||||
if (result.isDone()) return;
|
||||
if (state != State.ENABLED || !registration.isActive()
|
||||
|| backendHandlers.get(message.channel()) != registration) {
|
||||
result.completeExceptionally(new IllegalStateException("Backend channel handler is inactive"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
CompletionStage<byte[]> response = Objects.requireNonNull(
|
||||
registration.handler.handle(message), "backend message handler stage");
|
||||
response.whenComplete((payload, failure) -> {
|
||||
if (failure != null) {
|
||||
result.completeExceptionally(failure);
|
||||
} else if (payload == null) {
|
||||
result.completeExceptionally(new IllegalStateException(
|
||||
"Backend message handler returned null"));
|
||||
} else {
|
||||
result.complete(payload.clone());
|
||||
}
|
||||
});
|
||||
} catch (Throwable failure) {
|
||||
result.completeExceptionally(failure);
|
||||
}
|
||||
};
|
||||
try {
|
||||
backendWorkers.execute(invocation);
|
||||
} catch (RejectedExecutionException overloaded) {
|
||||
result.completeExceptionally(new PluginOverloadedException(overloaded));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Returns empty when no plugin handles placement; plugin code always runs off the caller's event loop. */
|
||||
public CompletionStage<Optional<PlacementDecision>> placeInitial(PlayerView player) {
|
||||
Objects.requireNonNull(player, "player");
|
||||
@@ -640,6 +722,10 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
if (notificationDropReporter != null) notificationDropReporter.cancel(false);
|
||||
logNotificationDrops();
|
||||
for (LoadedPlugin loaded : plugins) loaded.context.revokeEventSubscriptions();
|
||||
for (LoadedPlugin loaded : plugins) loaded.context.revokeBackendChannelSubscriptions();
|
||||
for (CompletableFuture<byte[]> request : pendingBackendMessages) {
|
||||
request.completeExceptionally(new IllegalStateException("Plugin host closed"));
|
||||
}
|
||||
for (PendingPlacement request : pendingPlacements) {
|
||||
request.decided().set(true);
|
||||
request.result().completeExceptionally(new IllegalStateException("Plugin host closed"));
|
||||
@@ -649,6 +735,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
}
|
||||
callbacks.shutdownNow();
|
||||
commandWorkers.shutdownNow();
|
||||
backendWorkers.shutdownNow();
|
||||
timer.shutdownNow();
|
||||
long deadline = System.nanoTime() + shutdownTimeout.toNanos();
|
||||
for (int index = plugins.size() - 1; index >= 0; index--) {
|
||||
@@ -669,6 +756,8 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
placementHandler = null;
|
||||
eventListeners = Map.of();
|
||||
eventRegistrations.clear();
|
||||
backendHandlers.clear();
|
||||
backendChannelTransport = null;
|
||||
}
|
||||
|
||||
private void disableWithinDeadline(LoadedPlugin loaded, long deadline) {
|
||||
@@ -707,6 +796,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
LoadedPlugin loaded = new LoadedPlugin(plugin, owner, context);
|
||||
plugins.add(loaded);
|
||||
context.beginEventRegistration();
|
||||
context.beginBackendChannelRegistration();
|
||||
try {
|
||||
plugin.onLoad(context);
|
||||
} catch (Throwable failure) {
|
||||
@@ -715,6 +805,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
throw new PluginLoadException(id, failure);
|
||||
} finally {
|
||||
context.endEventRegistration();
|
||||
context.endBackendChannelRegistration();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -753,6 +844,95 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
private BackendChannelSubscription registerBackendHandler(
|
||||
PluginContextImpl context, String channel, BackendMessageHandler handler) {
|
||||
Objects.requireNonNull(channel, "channel");
|
||||
Objects.requireNonNull(handler, "handler");
|
||||
if (!channel.matches("[a-z0-9][a-z0-9_.-]{0,63}:[a-z0-9][a-z0-9_.-]{0,63}")) {
|
||||
throw new IllegalArgumentException("invalid backend channel: " + channel);
|
||||
}
|
||||
synchronized (this) {
|
||||
synchronized (context) {
|
||||
context.requireBackendChannelRegistrationOpen();
|
||||
BackendHandlerRegistration registration = new BackendHandlerRegistration(context, channel, handler);
|
||||
if (backendHandlers.putIfAbsent(channel, registration) != null) {
|
||||
throw new IllegalArgumentException("Backend channel already registered: " + channel);
|
||||
}
|
||||
context.backendChannelSubscriptions.add(registration);
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private CompletionStage<BackendSendResult> sendBackendMessage(
|
||||
PluginContextImpl context, String backendName, String channel, byte[] payload) {
|
||||
Objects.requireNonNull(backendName, "backendName");
|
||||
Objects.requireNonNull(channel, "channel");
|
||||
byte[] copy = Objects.requireNonNull(payload, "payload").clone();
|
||||
BackendChannelTransport transport;
|
||||
synchronized (this) {
|
||||
synchronized (context) {
|
||||
context.requireActive();
|
||||
if (state == State.CLOSED) return CompletableFuture.failedFuture(
|
||||
new IllegalStateException("Plugin host is closed"));
|
||||
transport = backendChannelTransport;
|
||||
}
|
||||
if (transport == null) return CompletableFuture.failedFuture(
|
||||
new IllegalStateException("Backend channel transport is not available"));
|
||||
try {
|
||||
return Objects.requireNonNull(transport.send(backendName, channel, copy), "transport send stage");
|
||||
} catch (Throwable failure) {
|
||||
return CompletableFuture.failedFuture(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private CompletionStage<byte[]> requestBackendMessage(
|
||||
PluginContextImpl context, String backendName, String channel, byte[] payload) {
|
||||
Objects.requireNonNull(backendName, "backendName");
|
||||
Objects.requireNonNull(channel, "channel");
|
||||
byte[] copy = Objects.requireNonNull(payload, "payload").clone();
|
||||
BackendChannelTransport transport;
|
||||
synchronized (this) {
|
||||
synchronized (context) {
|
||||
context.requireActive();
|
||||
if (state == State.CLOSED) return CompletableFuture.failedFuture(
|
||||
new IllegalStateException("Plugin host is closed"));
|
||||
transport = backendChannelTransport;
|
||||
}
|
||||
if (transport == null) return CompletableFuture.failedFuture(
|
||||
new IllegalStateException("Backend channel transport is not available"));
|
||||
try {
|
||||
CompletionStage<byte[]> response = Objects.requireNonNull(
|
||||
transport.request(backendName, channel, copy), "transport request stage");
|
||||
CompletableFuture<byte[]> result = new CompletableFuture<>();
|
||||
pendingBackendMessages.add(result);
|
||||
ScheduledFuture<?> timeout;
|
||||
try {
|
||||
timeout = timer.schedule(() -> result.completeExceptionally(
|
||||
new TimeoutException("Backend request exceeded five seconds")),
|
||||
5, TimeUnit.SECONDS);
|
||||
} catch (RuntimeException failure) {
|
||||
pendingBackendMessages.remove(result);
|
||||
return CompletableFuture.failedFuture(failure);
|
||||
}
|
||||
result.whenComplete((ignored, failure) -> {
|
||||
timeout.cancel(false);
|
||||
pendingBackendMessages.remove(result);
|
||||
});
|
||||
response.whenComplete((reply, failure) -> {
|
||||
if (failure != null) result.completeExceptionally(failure);
|
||||
else if (reply == null) result.completeExceptionally(
|
||||
new IllegalStateException("Backend channel transport returned null"));
|
||||
else result.complete(reply.clone());
|
||||
});
|
||||
return result;
|
||||
} catch (Throwable failure) {
|
||||
return CompletableFuture.failedFuture(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void logNotificationDrops() {
|
||||
long count = notificationDrops.getAndSet(0);
|
||||
if (count != 0) {
|
||||
@@ -823,6 +1003,24 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
private final class BackendHandlerRegistration implements BackendChannelSubscription {
|
||||
private final PluginContextImpl context;
|
||||
private final String channel;
|
||||
private final BackendMessageHandler handler;
|
||||
private final AtomicBoolean active = new AtomicBoolean(true);
|
||||
|
||||
private BackendHandlerRegistration(PluginContextImpl context, String channel, BackendMessageHandler handler) {
|
||||
this.context = context;
|
||||
this.channel = channel;
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
private boolean isActive() { return active.get() && context.active; }
|
||||
@Override public void close() {
|
||||
if (active.compareAndSet(true, false)) backendHandlers.remove(channel, this);
|
||||
}
|
||||
}
|
||||
|
||||
private final class EventRegistration<E extends Event<R>, R>
|
||||
implements EventSubscription, AsyncEventDispatcher.EventHandler<R> {
|
||||
private final PluginContextImpl context;
|
||||
@@ -865,11 +1063,14 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
private final PluginServers servers;
|
||||
private final Commands pluginCommands;
|
||||
private final Events pluginEvents;
|
||||
private final BackendChannels pluginBackendChannels;
|
||||
private final Logger logger;
|
||||
private final Map<String, String> settings;
|
||||
private final Set<EventRegistration<?, ?>> eventSubscriptions = new HashSet<>();
|
||||
private final Set<BackendHandlerRegistration> backendChannelSubscriptions = new HashSet<>();
|
||||
private volatile boolean active = true;
|
||||
private boolean eventRegistrationOpen;
|
||||
private boolean backendChannelRegistrationOpen;
|
||||
|
||||
private PluginContextImpl(BackendOwner owner, Map<String, String> settings) {
|
||||
this.owner = owner;
|
||||
@@ -877,6 +1078,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
this.servers = new PluginServers(this);
|
||||
this.pluginCommands = new PluginCommands(this);
|
||||
this.pluginEvents = new PluginEvents(this);
|
||||
this.pluginBackendChannels = new PluginBackendChannels(this);
|
||||
this.logger = LoggerFactory.getLogger("plugin." + owner.id());
|
||||
this.settings = Map.copyOf(settings);
|
||||
}
|
||||
@@ -885,12 +1087,14 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
@Override public Servers servers() { return servers; }
|
||||
@Override public Commands commands() { return pluginCommands; }
|
||||
@Override public Events events() { return pluginEvents; }
|
||||
@Override public BackendChannels backendChannels() { return pluginBackendChannels; }
|
||||
@Override public Logger logger() { return logger; }
|
||||
@Override public Map<String, String> settings() { return settings; }
|
||||
|
||||
private synchronized void deactivateAndRemove() {
|
||||
active = false;
|
||||
eventRegistrationOpen = false;
|
||||
backendChannelRegistrationOpen = false;
|
||||
revokeEventSubscriptions();
|
||||
for (RegisteredCommand command : commands.values()) {
|
||||
if (command.context == this) {
|
||||
@@ -899,6 +1103,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
}
|
||||
}
|
||||
}
|
||||
revokeBackendChannelSubscriptions();
|
||||
commands.entrySet().removeIf(entry -> entry.getValue().context == this);
|
||||
catalog.removeOwner(owner);
|
||||
}
|
||||
@@ -916,6 +1121,19 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
|
||||
private synchronized void endEventRegistration() { eventRegistrationOpen = false; }
|
||||
|
||||
private synchronized void beginBackendChannelRegistration() {
|
||||
if (!active) throw new IllegalStateException("Plugin context is inactive");
|
||||
backendChannelRegistrationOpen = true;
|
||||
}
|
||||
|
||||
private synchronized void endBackendChannelRegistration() { backendChannelRegistrationOpen = false; }
|
||||
|
||||
private synchronized void revokeBackendChannelSubscriptions() {
|
||||
backendChannelRegistrationOpen = false;
|
||||
backendChannelSubscriptions.forEach(BackendHandlerRegistration::close);
|
||||
backendChannelSubscriptions.clear();
|
||||
}
|
||||
|
||||
private synchronized void requireEventRegistrationOpen() {
|
||||
requireActive();
|
||||
if (!eventRegistrationOpen || state != State.LOADING) {
|
||||
@@ -923,6 +1141,13 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void requireBackendChannelRegistrationOpen() {
|
||||
requireActive();
|
||||
if (!backendChannelRegistrationOpen || state != State.LOADING) {
|
||||
throw new IllegalStateException("Backend channel subscriptions are only allowed during onLoad or onEnable");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireActive() {
|
||||
if (!active) throw new IllegalStateException("Plugin context is inactive");
|
||||
}
|
||||
@@ -939,6 +1164,24 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
private final class PluginBackendChannels implements BackendChannels {
|
||||
private final PluginContextImpl context;
|
||||
|
||||
private PluginBackendChannels(PluginContextImpl context) { this.context = context; }
|
||||
|
||||
@Override public BackendChannelSubscription subscribe(String channel, BackendMessageHandler handler) {
|
||||
return registerBackendHandler(context, channel, handler);
|
||||
}
|
||||
|
||||
@Override public CompletionStage<BackendSendResult> send(String backendName, String channel, byte[] payload) {
|
||||
return sendBackendMessage(context, backendName, channel, payload);
|
||||
}
|
||||
|
||||
@Override public CompletionStage<byte[]> request(String backendName, String channel, byte[] payload) {
|
||||
return requestBackendMessage(context, backendName, channel, payload);
|
||||
}
|
||||
}
|
||||
|
||||
private final class PluginCommands implements Commands {
|
||||
private final PluginContextImpl context;
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Example backend control channel. Replace the secret before use.
|
||||
listen: "127.0.0.1:25577"
|
||||
authentication: OFFLINE
|
||||
backends: []
|
||||
plugins:
|
||||
directory: "../plugins"
|
||||
enabled: {}
|
||||
backendChannel:
|
||||
listen: "127.0.0.1:28081"
|
||||
leaseSeconds: 30
|
||||
maxConnections: 128
|
||||
clients:
|
||||
island-a:
|
||||
backendName: "island-a"
|
||||
keyId: "primary"
|
||||
secret: "replace-with-a-random-secret-of-32-bytes-or-more"
|
||||
allowedHosts: ["127.0.0.1"]
|
||||
allowedNamespaces: ["islands"]
|
||||
island-b:
|
||||
backendName: "island-b"
|
||||
keyId: "primary"
|
||||
secret: "replace-with-another-random-secret-of-32-bytes"
|
||||
allowedHosts: ["127.0.0.1"]
|
||||
allowedNamespaces: ["islands"]
|
||||
@@ -1,20 +0,0 @@
|
||||
# Example: enable the DNS plugin instead of configured static backends.
|
||||
# Replace the host with a DNS name that resolves to your backend nodes.
|
||||
listen: "127.0.0.1:25577"
|
||||
authentication: OFFLINE
|
||||
maxConnections: 4096
|
||||
status:
|
||||
motd: "StrataProxy"
|
||||
maxPlayers: 100
|
||||
# icon: "server-icon.png" # Optional 64x64 PNG, relative to this config (maximum 64 KiB).
|
||||
backends: []
|
||||
plugins:
|
||||
directory: "../plugins"
|
||||
initialPlacementTimeoutSeconds: 15
|
||||
eventTimeoutSeconds: 5
|
||||
enabled:
|
||||
dev.strataproxy.plugins.dns.DnsDiscoveryPlugin:
|
||||
host: "backends.example.net"
|
||||
port: "25565"
|
||||
namePrefix: "discovered"
|
||||
refreshSeconds: "30"
|
||||
@@ -252,6 +252,37 @@ final class ProxyConfigurationTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadsBackendControlCredentialsAndRejectsMissingNamespace() throws Exception {
|
||||
Path config = Files.createTempFile("strataproxy-backend-channel", ".yml");
|
||||
try {
|
||||
String yaml = """
|
||||
listen: "127.0.0.1:25577"
|
||||
authentication: OFFLINE
|
||||
backends: []
|
||||
backendChannel:
|
||||
listen: "127.0.0.1:28081"
|
||||
clients:
|
||||
island-a:
|
||||
backendName: island-a
|
||||
keyId: primary
|
||||
secret: "test-secret-at-least-thirty-two-bytes-long"
|
||||
allowedHosts: ["127.0.0.1"]
|
||||
allowedNamespaces: [islands]
|
||||
""";
|
||||
Files.writeString(config, yaml);
|
||||
var channel = new ProxyConfigurationLoader().load(config).backendChannel();
|
||||
assertEquals(28081, channel.listenAddress().getPort());
|
||||
assertEquals("island-a", channel.clients().get("island-a").backendName());
|
||||
assertEquals(java.util.Set.of("islands"), channel.clients().get("island-a").allowedNamespaces());
|
||||
Files.writeString(config, yaml.replace("allowedNamespaces: [islands]", "allowedNamespaces: []"));
|
||||
assertThrows(com.fasterxml.jackson.databind.JsonMappingException.class,
|
||||
() -> new ProxyConfigurationLoader().load(config));
|
||||
} finally {
|
||||
Files.deleteIfExists(config);
|
||||
}
|
||||
}
|
||||
|
||||
private static void writePng(Path destination, int width, int height) throws Exception {
|
||||
var image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
|
||||
ImageIO.write(image, "PNG", destination.toFile());
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package dev.strataproxy.core.control;
|
||||
|
||||
import dev.strataproxy.api.*;
|
||||
import dev.strataproxy.app.ProxyConfiguration;
|
||||
import dev.strataproxy.backendchannel.BackendChannelClient;
|
||||
import dev.strataproxy.backendchannel.FrameCodec;
|
||||
import dev.strataproxy.backendchannel.Wire;
|
||||
import dev.strataproxy.core.backend.BackendId;
|
||||
import dev.strataproxy.core.backend.InMemoryBackendCatalog;
|
||||
import dev.strataproxy.core.plugin.PluginHost;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class BackendControlServiceTest {
|
||||
private static final String SECRET = "test-secret-at-least-thirty-two-bytes-long";
|
||||
|
||||
@Test void twoInstancesOnOneHostCommunicateWithNoPlayers() throws Exception {
|
||||
int port = freePort();
|
||||
var catalog = new InMemoryBackendCatalog();
|
||||
var received = new AtomicReference<BackendMessage>();
|
||||
var pluginContext = new AtomicReference<PluginContext>();
|
||||
Plugin plugin = new Plugin() {
|
||||
@Override public void onLoad(PluginContext context) {
|
||||
pluginContext.set(context);
|
||||
context.backendChannels().subscribe("islands:prepare", message -> {
|
||||
received.set(message);
|
||||
return CompletableFuture.completedFuture("prepared".getBytes(StandardCharsets.UTF_8));
|
||||
});
|
||||
}
|
||||
};
|
||||
var config = new ProxyConfiguration.BackendChannel("127.0.0.1:" + port, Map.of(
|
||||
"island-a", client("island-a"), "island-b", client("island-b")), 5, 8);
|
||||
try (var host = new PluginHost(catalog, noPlayers(), Duration.ofSeconds(1));
|
||||
var control = new BackendControlService(config, catalog, host::dispatchBackendMessage)) {
|
||||
host.setBackendChannelTransport(control);
|
||||
host.load(List.of(plugin));
|
||||
host.enable();
|
||||
control.start();
|
||||
try (var a = backend(port, "island-a", 25565);
|
||||
var b = backend(port, "island-b", 25566)) {
|
||||
a.registerHandler("islands:echo", payload -> CompletableFuture.completedFuture(payload));
|
||||
b.registerHandler("islands:echo", payload -> CompletableFuture.completedFuture(payload));
|
||||
assertTrue(a.awaitRegistered(5, TimeUnit.SECONDS));
|
||||
assertTrue(b.awaitRegistered(5, TimeUnit.SECONDS));
|
||||
assertEquals(2, catalog.snapshot().size());
|
||||
assertEquals("tcp://127.0.0.1:25565", catalog.find(new BackendId("island-a")).orElseThrow().address().toString());
|
||||
assertEquals("tcp://127.0.0.1:25566", catalog.find(new BackendId("island-b")).orElseThrow().address().toString());
|
||||
assertEquals("prepared", new String(a.request("islands:prepare", "hello".getBytes(StandardCharsets.UTF_8))
|
||||
.get(5, TimeUnit.SECONDS), StandardCharsets.UTF_8));
|
||||
assertEquals("island-a", received.get().instanceId());
|
||||
assertTrue(received.get().epoch() > 0);
|
||||
assertEquals("hello", new String(received.get().payload(), StandardCharsets.UTF_8));
|
||||
var missing = assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> a.request("islands:missing", new byte[0]).get(5, TimeUnit.SECONDS));
|
||||
assertEquals(Wire.STATUS_NO_HANDLER,
|
||||
((BackendChannelClient.ChannelResponseException) missing.getCause()).getStatus());
|
||||
assertEquals("world", new String(pluginContext.get().backendChannels().request("island-b", "islands:echo",
|
||||
"world".getBytes(StandardCharsets.UTF_8)).toCompletableFuture().get(5, TimeUnit.SECONDS),
|
||||
StandardCharsets.UTF_8));
|
||||
assertEquals(BackendSendResult.SENT, pluginContext.get().backendChannels().send("island-a",
|
||||
"islands:echo", new byte[]{1}).toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
a.close();
|
||||
await(() -> catalog.find(new BackendId("island-a")).isEmpty());
|
||||
assertTrue(catalog.find(new BackendId("island-b")).isPresent());
|
||||
}
|
||||
await(() -> catalog.snapshot().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void badCredentialNeverRegistersBackend() throws Exception {
|
||||
int port = freePort();
|
||||
var catalog = new InMemoryBackendCatalog();
|
||||
var config = new ProxyConfiguration.BackendChannel("127.0.0.1:" + port,
|
||||
Map.of("island-a", client("island-a")), 5, 8);
|
||||
try (var control = new BackendControlService(config, catalog,
|
||||
ignored -> CompletableFuture.completedFuture(new byte[0]))) {
|
||||
control.start();
|
||||
try (var invalid = new BackendChannelClient("127.0.0.1", port, "island-a", "island-a",
|
||||
"tcp://127.0.0.1:25565", UUID.randomUUID().toString(), "primary",
|
||||
"wrong-secret-at-least-thirty-two-bytes".getBytes(StandardCharsets.UTF_8),
|
||||
1000, 1000, 100, 200)) {
|
||||
assertFalse(invalid.awaitRegistered(700, TimeUnit.MILLISECONDS));
|
||||
assertTrue(catalog.snapshot().isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void replacementFencesOldConnectionAndAbruptLossExpiresLease() throws Exception {
|
||||
int port = freePort();
|
||||
var catalog = new InMemoryBackendCatalog();
|
||||
var config = new ProxyConfiguration.BackendChannel("127.0.0.1:" + port,
|
||||
Map.of("island-a", client("island-a")), 5, 8);
|
||||
try (var control = new BackendControlService(config, catalog,
|
||||
ignored -> CompletableFuture.completedFuture(new byte[0]))) {
|
||||
control.start();
|
||||
try (var old = rawRegistered(port); var current = rawRegistered(port)) {
|
||||
assertTrue(catalog.find(new BackendId("island-a")).isPresent());
|
||||
assertEquals(BackendSendResult.SENT, control.send("island-a", "islands:ping", new byte[0])
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals(Wire.MESSAGE, FrameCodec.read(
|
||||
new java.io.DataInputStream(current.getInputStream()))[0]);
|
||||
}
|
||||
assertTrue(catalog.find(new BackendId("island-a")).isPresent(),
|
||||
"an abrupt socket close retains the lease briefly");
|
||||
await(7, () -> catalog.find(new BackendId("island-a")).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
private static ProxyConfiguration.Client client(String name) {
|
||||
return new ProxyConfiguration.Client(name, "primary", SECRET, Set.of("127.0.0.1"), Set.of("islands"));
|
||||
}
|
||||
private static BackendChannelClient backend(int port, String name, int gamePort) {
|
||||
return new BackendChannelClient("127.0.0.1", port, name, name,
|
||||
"tcp://127.0.0.1:" + gamePort, UUID.randomUUID().toString(), "primary",
|
||||
SECRET.getBytes(StandardCharsets.UTF_8), 1000, 5000, 100, 500);
|
||||
}
|
||||
private static int freePort() throws Exception {
|
||||
try (var socket = new ServerSocket(0)) { return socket.getLocalPort(); }
|
||||
}
|
||||
private static Socket rawRegistered(int port) throws Exception {
|
||||
var socket = new Socket("127.0.0.1", port);
|
||||
socket.setSoTimeout(2000);
|
||||
var input = new java.io.DataInputStream(socket.getInputStream());
|
||||
var output = new java.io.DataOutputStream(socket.getOutputStream());
|
||||
var hello = Wire.decodeHello(FrameCodec.read(input));
|
||||
String generation = UUID.randomUUID().toString();
|
||||
byte[] canonical = Wire.canonicalRegistration("island-a", "island-a", "tcp://127.0.0.1:25565",
|
||||
generation, "primary");
|
||||
byte[] signed = new byte[hello.nonce.length + canonical.length];
|
||||
System.arraycopy(hello.nonce, 0, signed, 0, hello.nonce.length);
|
||||
System.arraycopy(canonical, 0, signed, hello.nonce.length, canonical.length);
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
FrameCodec.write(output, Wire.register("island-a", "island-a", "tcp://127.0.0.1:25565",
|
||||
generation, "primary", mac.doFinal(signed)));
|
||||
assertTrue(Wire.decodeRegistered(FrameCodec.read(input)) > 0);
|
||||
return socket;
|
||||
}
|
||||
private static void await(BooleanSupplier condition) throws Exception {
|
||||
await(3, condition);
|
||||
}
|
||||
private static void await(int seconds, BooleanSupplier condition) throws Exception {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds);
|
||||
while (!condition.getAsBoolean()) {
|
||||
if (System.nanoTime() >= deadline) fail("condition not met");
|
||||
Thread.sleep(10);
|
||||
}
|
||||
}
|
||||
private static Players noPlayers() {
|
||||
return new Players() {
|
||||
@Override public Optional<PlayerView> find(PlayerIdentity identity) { return Optional.empty(); }
|
||||
@Override public List<PlayerView> online() { return List.of(); }
|
||||
@Override public CompletionStage<TransferResult> transfer(PlayerIdentity identity, String backendName) {
|
||||
return CompletableFuture.completedFuture(TransferResult.failed("unavailable"));
|
||||
}
|
||||
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, String message) {
|
||||
return CompletableFuture.completedFuture(MessageResult.NOT_CONNECTED);
|
||||
}
|
||||
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, String reason) {
|
||||
return CompletableFuture.completedFuture(DisconnectResult.NOT_CONNECTED);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package dev.strataproxy.api;
|
||||
|
||||
/** A registered backend channel handler. Closing the subscription more than once is harmless. */
|
||||
public interface BackendChannelSubscription extends AutoCloseable {
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package dev.strataproxy.api;
|
||||
|
||||
import java.util.concurrent.CompletionStage;
|
||||
|
||||
/** Sends messages to, and registers request handlers for, connected backend instances. */
|
||||
public interface BackendChannels {
|
||||
/**
|
||||
* Registers the handler for a channel. A channel may have only one handler at a time.
|
||||
* Closing the returned subscription unregisters the handler.
|
||||
*/
|
||||
BackendChannelSubscription subscribe(String channel, BackendMessageHandler handler);
|
||||
|
||||
/**
|
||||
* Sends a one-way message to a backend. A successful {@link BackendSendResult#SENT} means
|
||||
* the message was written to the control connection; it does not confirm application handling.
|
||||
* The implementation copies {@code payload} before this method returns, so the caller may
|
||||
* reuse or mutate its array after the call.
|
||||
*/
|
||||
CompletionStage<BackendSendResult> send(String backendName, String channel, byte[] payload);
|
||||
|
||||
/**
|
||||
* Sends a request and completes with the backend handler's response. The stage completes
|
||||
* exceptionally if the backend is unavailable, the default five-second request deadline
|
||||
* expires, or transport fails. The implementation copies {@code payload} before this method
|
||||
* returns, so the caller may reuse or mutate its array after the call.
|
||||
*/
|
||||
CompletionStage<byte[]> request(String backendName, String channel, byte[] payload);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package dev.strataproxy.api;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** An inbound request from a backend, with identity taken from its authenticated connection. */
|
||||
public record BackendMessage(
|
||||
String backendName,
|
||||
String instanceId,
|
||||
long epoch,
|
||||
String channel,
|
||||
byte[] payload) {
|
||||
|
||||
public BackendMessage {
|
||||
backendName = requireText(backendName, "backendName");
|
||||
instanceId = requireText(instanceId, "instanceId");
|
||||
if (epoch <= 0) {
|
||||
throw new IllegalArgumentException("epoch must be positive");
|
||||
}
|
||||
channel = requireText(channel, "channel");
|
||||
payload = Objects.requireNonNull(payload, "payload").clone();
|
||||
}
|
||||
|
||||
/** Returns a copy so callers cannot mutate the message held by the proxy. */
|
||||
@Override
|
||||
public byte[] payload() {
|
||||
return payload.clone();
|
||||
}
|
||||
|
||||
private static String requireText(String value, String name) {
|
||||
Objects.requireNonNull(value, name);
|
||||
if (value.isBlank()) {
|
||||
throw new IllegalArgumentException(name + " must not be blank");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package dev.strataproxy.api;
|
||||
|
||||
import java.util.concurrent.CompletionStage;
|
||||
|
||||
/** Handles a request received from a backend and produces its response payload. */
|
||||
@FunctionalInterface
|
||||
public interface BackendMessageHandler {
|
||||
CompletionStage<byte[]> handle(BackendMessage message);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package dev.strataproxy.api;
|
||||
|
||||
/** Immediate outcome of writing a one-way message to a backend connection. */
|
||||
public enum BackendSendResult {
|
||||
/** The message was written to the control connection. */
|
||||
SENT,
|
||||
/** The named backend has no currently connected instance. */
|
||||
NOT_CONNECTED,
|
||||
/** The bounded outbound queue is full or the connection is not writable. */
|
||||
BACKPRESSURED
|
||||
}
|
||||
@@ -14,6 +14,15 @@ public interface PluginContext {
|
||||
|
||||
Events events();
|
||||
|
||||
/**
|
||||
* Returns the service for messaging with independently connected backend instances.
|
||||
*
|
||||
* @throws UnsupportedOperationException when the hosting proxy does not provide backend channels
|
||||
*/
|
||||
default BackendChannels backendChannels() {
|
||||
throw new UnsupportedOperationException("Backend channels are not available");
|
||||
}
|
||||
|
||||
Logger logger();
|
||||
|
||||
/** Immutable configuration assigned to this plugin by the proxy. */
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package dev.strataproxy.api;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class BackendChannelsApiTest {
|
||||
@Test
|
||||
void inboundPayloadIsCopiedOnConstructionAndAccess() {
|
||||
byte[] input = {1, 2};
|
||||
var message = new BackendMessage("lobby", "instance-1", 1, "example:ping", input);
|
||||
|
||||
input[0] = 9;
|
||||
byte[] firstRead = message.payload();
|
||||
firstRead[1] = 9;
|
||||
|
||||
assertArrayEquals(new byte[] {1, 2}, message.payload());
|
||||
}
|
||||
|
||||
@Test
|
||||
void inboundMessageRequiresAValidConnectionEpoch() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new BackendMessage("lobby", "instance-1", 0, "example:ping", new byte[0]));
|
||||
}
|
||||
}
|
||||
+1
-5
@@ -22,9 +22,5 @@ rootProject.name = "strataproxy"
|
||||
include(
|
||||
"proxy-core",
|
||||
"proxy-plugin-api",
|
||||
"dns-discovery",
|
||||
"agent-discovery",
|
||||
"backend-channel-client",
|
||||
)
|
||||
|
||||
project(":dns-discovery").projectDir = file("plugins/dns-discovery")
|
||||
project(":agent-discovery").projectDir = file("plugins/agent-discovery")
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
install="$repo_root/proxy-core/build/install/strataproxy"
|
||||
python_image='docker.gitea.com/runner-images@sha256:fd911d7417bfbf0f454530e447da95b58001e1df41bbc5e1a8dd35d432575aae'
|
||||
java_image='eclipse-temurin@sha256:8da0490fa9a3c26867012019565948eef0ee69438f5c75ac28146967bae984b5'
|
||||
suffix=$(python3 -c 'import uuid; print(uuid.uuid4().hex[:10])')
|
||||
network="strataproxy-channel-$suffix"
|
||||
backend="sp-backend-$suffix"
|
||||
proxy="sp-proxy-$suffix"
|
||||
work=$(mktemp -d "${TMPDIR:-/tmp}/strataproxy-channel.XXXXXX")
|
||||
secret=$(python3 -c 'import secrets; print(secrets.token_hex(32))')
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$proxy" "$backend" >/dev/null 2>&1 || true
|
||||
docker network rm "$network" >/dev/null 2>&1 || true
|
||||
rm -rf "$work"
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
cd "$repo_root"
|
||||
bash ./gradlew :proxy-core:installDist --no-daemon
|
||||
cat > "$work/config.yml" <<EOF
|
||||
listen: "0.0.0.0:25577"
|
||||
authentication: OFFLINE
|
||||
allowOfflinePublicAccess: true
|
||||
backends: []
|
||||
plugins:
|
||||
directory: "/opt/strataproxy/plugins"
|
||||
enabled: {}
|
||||
backendChannel:
|
||||
listen: "0.0.0.0:28081"
|
||||
clients:
|
||||
container-network-smoke:
|
||||
backendName: "remote"
|
||||
keyId: "primary"
|
||||
secret: "$secret"
|
||||
allowedHosts: ["$backend"]
|
||||
allowedNamespaces: ["smoke"]
|
||||
EOF
|
||||
|
||||
docker network create "$network" >/dev/null
|
||||
docker run -d --network "$network" --name "$backend" \
|
||||
-v "$repo_root:/work:ro" --entrypoint python3 "$python_image" \
|
||||
/work/smoke/container-network.py backend 2 >/dev/null
|
||||
ready=0
|
||||
for attempt in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if docker logs "$backend" 2>&1 | grep -q 'BACKEND_READY'; then ready=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$ready" -ne 1 ]; then docker logs "$backend"; exit 1; fi
|
||||
|
||||
docker run -d --network "$network" --name "$proxy" \
|
||||
-v "$install:/opt/strataproxy:ro" -v "$work/config.yml:/opt/strataproxy.yml:ro" \
|
||||
--entrypoint java "$java_image" -cp '/opt/strataproxy/lib/*' \
|
||||
dev.strataproxy.app.ProxyMain --config /opt/strataproxy.yml >/dev/null
|
||||
if ! docker run --rm --network "$network" -v "$repo_root:/work:ro" \
|
||||
-e "STRATAPROXY_CHANNEL_SECRET=$secret" --entrypoint python3 "$python_image" \
|
||||
/work/smoke/container-network.py client control "$proxy" "$backend"; then
|
||||
docker logs "$proxy" || true
|
||||
docker logs "$backend" || true
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$backend")" = false ]; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$backend")" != false ] ||
|
||||
[ "$(docker inspect -f '{{.State.ExitCode}}' "$backend")" != 0 ]; then
|
||||
docker logs "$backend"
|
||||
exit 1
|
||||
fi
|
||||
echo 'control: cross-container registration, login, GOODBYE and PLAY probe passed'
|
||||
+18
-24
@@ -6,7 +6,7 @@ $repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$install = Join-Path $repoRoot 'proxy-core/build/install/strataproxy'
|
||||
$work = Join-Path $repoRoot 'build/container-network-smoke'
|
||||
$pythonImage = 'docker.gitea.com/runner-images@sha256:fd911d7417bfbf0f454530e447da95b58001e1df41bbc5e1a8dd35d432575aae'
|
||||
$javaImage = 'eclipse-temurin@sha256:97014c4b396021f9ddb7d592a7dbedb0c4e4215c29e03dc01c393558aefb71c2'
|
||||
$javaImage = 'eclipse-temurin@sha256:8da0490fa9a3c26867012019565948eef0ee69438f5c75ac28146967bae984b5'
|
||||
$suffix = [guid]::NewGuid().ToString('N').Substring(0, 10)
|
||||
$network = "strataproxy-smoke-$suffix"
|
||||
$createdNetwork = $false
|
||||
@@ -52,11 +52,11 @@ try {
|
||||
InvokeDocker @('network', 'create', $network) | Out-Null
|
||||
$createdNetwork = $true
|
||||
|
||||
foreach ($mode in @('static', 'dns', 'agent')) {
|
||||
foreach ($mode in @('static', 'control')) {
|
||||
$backend = "sp-backend-$suffix"
|
||||
$proxy = "sp-proxy-$suffix"
|
||||
$config = Join-Path $work "$suffix-$mode.yml"
|
||||
$probes = if ($mode -eq 'agent') { 2 } else { 1 }
|
||||
$probes = if ($mode -eq 'control') { 2 } else { 1 }
|
||||
$backendConfig = if ($mode -eq 'static') {
|
||||
@"
|
||||
backends:
|
||||
@@ -64,27 +64,20 @@ backends:
|
||||
address: "${backend}:25565"
|
||||
"@
|
||||
} else { 'backends: []' }
|
||||
$enabled = switch ($mode) {
|
||||
'static' { ' enabled: {}' }
|
||||
'dns' {
|
||||
@"
|
||||
enabled:
|
||||
dev.strataproxy.plugins.dns.DnsDiscoveryPlugin:
|
||||
host: "$backend"
|
||||
port: "25565"
|
||||
refreshSeconds: "1"
|
||||
"@
|
||||
}
|
||||
'agent' {
|
||||
@"
|
||||
enabled:
|
||||
dev.strataproxy.plugins.agent.AgentDiscoveryPlugin:
|
||||
host: "0.0.0.0"
|
||||
port: "28080"
|
||||
$enabled = ' enabled: {}'
|
||||
$controlConfig = if ($mode -eq 'control') {
|
||||
@"
|
||||
backendChannel:
|
||||
listen: "0.0.0.0:28081"
|
||||
clients:
|
||||
container-network-smoke:
|
||||
backendName: "remote"
|
||||
keyId: "primary"
|
||||
secret: "$secret"
|
||||
allowedHosts: ["$backend"]
|
||||
allowedNamespaces: ["smoke"]
|
||||
"@
|
||||
}
|
||||
}
|
||||
} else { '' }
|
||||
@"
|
||||
listen: "0.0.0.0:25577"
|
||||
authentication: OFFLINE
|
||||
@@ -93,6 +86,7 @@ plugins:
|
||||
directory: "/opt/strataproxy/plugins"
|
||||
$enabled
|
||||
$backendConfig
|
||||
$controlConfig
|
||||
"@ | Set-Content -LiteralPath $config -Encoding utf8
|
||||
|
||||
try {
|
||||
@@ -105,12 +99,12 @@ $backendConfig
|
||||
'--entrypoint', 'java', $javaImage, '-cp', '/opt/strataproxy/lib/*',
|
||||
'dev.strataproxy.app.ProxyMain', '--config', '/opt/strataproxy.yml') | Out-Null
|
||||
$clientArgs = @('run', '--rm', '--network', $network, '-v', "${repoRoot}:/work:ro")
|
||||
if ($mode -eq 'agent') { $clientArgs += @('-e', "STRATAPROXY_AGENT_SECRET=$secret") }
|
||||
if ($mode -eq 'control') { $clientArgs += @('-e', "STRATAPROXY_CHANNEL_SECRET=$secret") }
|
||||
$clientArgs += @('--entrypoint', 'python3', $pythonImage,
|
||||
'/work/smoke/container-network.py', 'client', $mode, $proxy, $backend)
|
||||
InvokeDocker $clientArgs | Write-Output
|
||||
WaitBackendExit $backend
|
||||
Write-Output "$mode`: cross-container DNS/TCP login and PLAY probe passed"
|
||||
Write-Output "$mode`: cross-container TCP login and PLAY probe passed"
|
||||
} catch {
|
||||
Write-Warning "Proxy log ($mode): $(& docker logs $proxy 2>&1 | Select-Object -Last 20)"
|
||||
Write-Warning "Backend log ($mode): $(& docker logs $backend 2>&1 | Select-Object -Last 20)"
|
||||
|
||||
+58
-41
@@ -4,12 +4,9 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
|
||||
@@ -179,50 +176,70 @@ def probe(peer, value):
|
||||
raise AssertionError(f"PLAY echo mismatch: {received!r} != {expected!r}")
|
||||
|
||||
|
||||
def agent_request(host, secret, agent_id, fields):
|
||||
body = urllib.parse.urlencode(fields).encode("utf-8")
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = secrets.token_hex(16)
|
||||
canonical = b"POST\n/registration\n" + timestamp.encode() + b"\n" + nonce.encode() \
|
||||
+ b"\n" + agent_id.encode() + b"\n" + body
|
||||
signature = hmac.new(secret.encode("utf-8"), canonical, hashlib.sha256).hexdigest()
|
||||
request = urllib.request.Request(f"http://{host}:28080/registration", data=body,
|
||||
headers={"X-Agent-Id": agent_id, "X-Timestamp": timestamp,
|
||||
"X-Nonce": nonce, "X-Signature": signature}, method="POST")
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
return response.status
|
||||
def control_frame(peer, body):
|
||||
peer.sendall(len(body).to_bytes(4, "big") + body)
|
||||
|
||||
|
||||
def read_control_frame(peer):
|
||||
length = int.from_bytes(read_exact(peer, 4), "big")
|
||||
if not 1 <= length <= 66 * 1024:
|
||||
raise ValueError(f"invalid control frame length {length}")
|
||||
return read_exact(peer, length)
|
||||
|
||||
|
||||
def control_string(value):
|
||||
encoded = value.encode("utf-8")
|
||||
return len(encoded).to_bytes(2, "big") + encoded
|
||||
|
||||
|
||||
def control_register(host, backend_host, secret):
|
||||
peer = connect(host, 28081)
|
||||
try:
|
||||
hello = read_control_frame(peer)
|
||||
if len(hello) != 37 or hello[0] != 1 or int.from_bytes(hello[1:5], "big") != 1:
|
||||
raise ValueError("invalid control HELLO")
|
||||
fields = ("container-network-smoke", "remote", f"tcp://{backend_host}:25565",
|
||||
str(uuid.uuid4()), "primary")
|
||||
canonical = b"".join(control_string(value) for value in fields)
|
||||
signature = hmac.new(secret.encode("utf-8"), hello[5:] + canonical, hashlib.sha256).digest()
|
||||
control_frame(peer, b"\x02" + canonical + signature)
|
||||
registered = read_control_frame(peer)
|
||||
if len(registered) != 9 or registered[0] != 3 or int.from_bytes(registered[1:], "big") < 1:
|
||||
raise ValueError("control registration was not accepted")
|
||||
return peer
|
||||
except BaseException:
|
||||
peer.close()
|
||||
raise
|
||||
|
||||
|
||||
def client(mode, host, backend_host):
|
||||
wait_online(host, 0)
|
||||
if mode == "agent":
|
||||
secret = os.environ["STRATAPROXY_AGENT_SECRET"]
|
||||
generation = str(uuid.uuid4())
|
||||
agent_id = "container-network-smoke"
|
||||
status = agent_request(host, secret, agent_id, {
|
||||
"action": "register", "generation": generation, "name": "remote",
|
||||
"address": f"tcp://{backend_host}:25565", "leaseSeconds": "30"})
|
||||
if status != 201:
|
||||
raise AssertionError(f"agent registration returned {status}")
|
||||
with wait_login(host) as peer:
|
||||
probe(peer, 42)
|
||||
if mode == "agent":
|
||||
status = agent_request(host, secret, agent_id,
|
||||
{"action": "unregister", "generation": generation})
|
||||
if status != 204:
|
||||
raise AssertionError(f"agent unregister returned {status}")
|
||||
wait_online(host, 1)
|
||||
probe(peer, 43)
|
||||
control = None
|
||||
if mode == "control":
|
||||
control = control_register(host, backend_host, os.environ["STRATAPROXY_CHANNEL_SECRET"])
|
||||
try:
|
||||
with wait_login(host) as peer:
|
||||
probe(peer, 42)
|
||||
if mode == "control":
|
||||
control_frame(control, b"\x06")
|
||||
control.close()
|
||||
control = None
|
||||
if mode == "control":
|
||||
wait_online(host, 1)
|
||||
probe(peer, 43)
|
||||
finally:
|
||||
if control is not None:
|
||||
control.close()
|
||||
wait_online(host, 0)
|
||||
print(f"CLIENT_OK mode={mode} backend={backend_host} login=1 play_probes={2 if mode == 'agent' else 1}",
|
||||
print(f"CLIENT_OK mode={mode} backend={backend_host} login=1 play_probes={2 if mode == 'control' else 1}",
|
||||
flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
match sys.argv[1:]:
|
||||
case ["backend", probes]:
|
||||
backend(int(probes))
|
||||
case ["client", mode, host, backend_host]:
|
||||
client(mode, host, backend_host)
|
||||
case _:
|
||||
raise SystemExit("usage: container-network.py backend <probes> | client <static|dns|agent> <proxy> <backend>")
|
||||
args = sys.argv[1:]
|
||||
if len(args) == 2 and args[0] == "backend":
|
||||
backend(int(args[1]))
|
||||
elif len(args) == 4 and args[0] == "client" and args[1] in ("static", "control"):
|
||||
client(args[1], args[2], args[3])
|
||||
else:
|
||||
raise SystemExit("usage: container-network.py backend <probes> | client <static|control> <proxy> <backend>")
|
||||
@@ -1,399 +0,0 @@
|
||||
#Requires -Version 7.0
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$install = Join-Path $repoRoot 'proxy-core\build\install\strataproxy'
|
||||
$lib = Join-Path $install 'lib'
|
||||
$plugins = Join-Path $install 'plugins'
|
||||
if (-not (Test-Path -LiteralPath $plugins) -or
|
||||
@(Get-ChildItem -LiteralPath $plugins -Filter 'dns-discovery-*.jar').Count -ne 1 -or
|
||||
@(Get-ChildItem -LiteralPath $plugins -Filter 'agent-discovery-*.jar').Count -ne 1) {
|
||||
throw 'Run .\gradlew.bat :proxy-core:installDist before this smoke test'
|
||||
}
|
||||
|
||||
function FreePort {
|
||||
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
|
||||
$listener.Start()
|
||||
try { return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port }
|
||||
finally { $listener.Stop() }
|
||||
}
|
||||
|
||||
function VarInt([int]$value) {
|
||||
$bytes = [System.Collections.Generic.List[byte]]::new()
|
||||
do {
|
||||
$part = $value -band 127
|
||||
$value = $value -shr 7
|
||||
if ($value -ne 0) { $part = $part -bor 128 }
|
||||
$bytes.Add([byte]$part)
|
||||
} while ($value -ne 0)
|
||||
return ,$bytes.ToArray()
|
||||
}
|
||||
|
||||
function ReadVarInt([System.IO.Stream]$stream) {
|
||||
$value = 0
|
||||
for ($shift = 0; $shift -le 28; $shift += 7) {
|
||||
$part = $stream.ReadByte()
|
||||
if ($part -lt 0) { throw 'Unexpected EOF while reading VarInt' }
|
||||
$value = $value -bor (($part -band 127) -shl $shift)
|
||||
if (($part -band 128) -eq 0) { return $value }
|
||||
}
|
||||
throw 'Overlong VarInt'
|
||||
}
|
||||
|
||||
function ReadExactly([System.IO.Stream]$stream, [int]$length) {
|
||||
$bytes = [byte[]]::new($length)
|
||||
$offset = 0
|
||||
while ($offset -lt $length) {
|
||||
$read = $stream.Read($bytes, $offset, $length - $offset)
|
||||
if ($read -eq 0) { throw 'Unexpected EOF while reading packet' }
|
||||
$offset += $read
|
||||
}
|
||||
return ,$bytes
|
||||
}
|
||||
|
||||
function WriteFrame([System.IO.Stream]$stream, [byte[]]$body) {
|
||||
$prefix = VarInt $body.Length
|
||||
$stream.Write($prefix)
|
||||
$stream.Write($body)
|
||||
}
|
||||
|
||||
function StatusOnline([int]$port) {
|
||||
$client = [System.Net.Sockets.TcpClient]::new()
|
||||
try {
|
||||
$client.Connect([System.Net.IPAddress]::Loopback, $port)
|
||||
$client.ReceiveTimeout = 1000
|
||||
$stream = $client.GetStream()
|
||||
$handshake = [System.Collections.Generic.List[byte]]::new()
|
||||
$handshake.AddRange([byte[]](VarInt 0))
|
||||
$handshake.AddRange([byte[]](VarInt 5))
|
||||
$hostBytes = [System.Text.Encoding]::UTF8.GetBytes('localhost')
|
||||
$handshake.AddRange([byte[]](VarInt $hostBytes.Length))
|
||||
$handshake.AddRange($hostBytes)
|
||||
$handshake.Add(0x63)
|
||||
$handshake.Add(0xdd)
|
||||
$handshake.AddRange([byte[]](VarInt 1))
|
||||
WriteFrame $stream ($handshake.ToArray())
|
||||
WriteFrame $stream ([byte[]]@(0))
|
||||
$body = ReadExactly $stream (ReadVarInt $stream)
|
||||
$packetStream = [System.IO.MemoryStream]::new($body)
|
||||
if ((ReadVarInt $packetStream) -ne 0) { throw 'Unexpected status packet id' }
|
||||
$json = [System.Text.Encoding]::UTF8.GetString((ReadExactly $packetStream (ReadVarInt $packetStream))) | ConvertFrom-Json
|
||||
return [int]$json.players.online
|
||||
} finally { $client.Dispose() }
|
||||
}
|
||||
|
||||
function WaitForOnline([int]$port, [int]$expected) {
|
||||
$last = $null
|
||||
for ($attempt = 0; $attempt -lt 50; $attempt++) {
|
||||
try {
|
||||
$last = StatusOnline $port
|
||||
if ($last -eq $expected) { return }
|
||||
} catch { $last = $_.Exception.Message }
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
throw "Expected status online=$expected on port $port; last result=$last"
|
||||
}
|
||||
|
||||
function StartFakeBackend([int]$ProbeCount = 1) {
|
||||
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
|
||||
$listener.Start()
|
||||
$job = Start-ThreadJob -ArgumentList $listener, $ProbeCount -ScriptBlock {
|
||||
param($backendListener, $expectedProbes)
|
||||
function ReadNumber($stream) {
|
||||
$number = 0
|
||||
for ($shift = 0; $shift -le 28; $shift += 7) {
|
||||
$part = $stream.ReadByte()
|
||||
if ($part -lt 0) { throw 'Backend saw unexpected EOF' }
|
||||
$number = $number -bor (($part -band 127) -shl $shift)
|
||||
if (($part -band 128) -eq 0) { return $number }
|
||||
}
|
||||
throw 'Backend saw overlong VarInt'
|
||||
}
|
||||
function ReadFrame($stream) {
|
||||
$length = ReadNumber $stream
|
||||
if ($length -lt 1 -or $length -gt 1048576) { throw "Invalid backend frame length $length" }
|
||||
$body = [byte[]]::new($length)
|
||||
$offset = 0
|
||||
while ($offset -lt $length) {
|
||||
$read = $stream.Read($body, $offset, $length - $offset)
|
||||
if ($read -eq 0) { throw 'Backend saw unexpected EOF' }
|
||||
$offset += $read
|
||||
}
|
||||
return ,$body
|
||||
}
|
||||
function Number([int]$value) {
|
||||
$bytes = [System.Collections.Generic.List[byte]]::new()
|
||||
do {
|
||||
$part = $value -band 127
|
||||
$value = $value -shr 7
|
||||
if ($value -ne 0) { $part = $part -bor 128 }
|
||||
$bytes.Add([byte]$part)
|
||||
} while ($value -ne 0)
|
||||
return ,$bytes.ToArray()
|
||||
}
|
||||
function Send($stream, [byte[]]$body) {
|
||||
$stream.Write((Number $body.Length))
|
||||
$stream.Write($body)
|
||||
}
|
||||
function AddString($list, [string]$value) {
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($value)
|
||||
$list.AddRange([byte[]](Number $bytes.Length))
|
||||
$list.AddRange($bytes)
|
||||
}
|
||||
$client = $backendListener.AcceptTcpClient()
|
||||
try {
|
||||
$client.ReceiveTimeout = 5000
|
||||
$stream = $client.GetStream()
|
||||
$null = ReadFrame $stream # Handshake.
|
||||
$login = ReadFrame $stream
|
||||
$packet = [System.IO.MemoryStream]::new($login)
|
||||
if ((ReadNumber $packet) -ne 0) { throw 'Backend expected Login Start' }
|
||||
$nameLength = ReadNumber $packet
|
||||
if ($nameLength -lt 1 -or $nameLength -gt 64) { throw 'Invalid backend username length' }
|
||||
$nameBytes = [byte[]]::new($nameLength)
|
||||
if ($packet.Read($nameBytes, 0, $nameLength) -ne $nameLength) { throw 'Truncated backend username' }
|
||||
$username = [System.Text.Encoding]::UTF8.GetString($nameBytes)
|
||||
$digest = [System.Security.Cryptography.MD5]::HashData(
|
||||
[System.Text.Encoding]::UTF8.GetBytes("OfflinePlayer:$username"))
|
||||
$digest[6] = [byte](($digest[6] -band 0x0f) -bor 0x30)
|
||||
$digest[8] = [byte](($digest[8] -band 0x3f) -bor 0x80)
|
||||
$hex = [Convert]::ToHexString($digest).ToLowerInvariant()
|
||||
$uuid = '{0}-{1}-{2}-{3}-{4}' -f $hex.Substring(0, 8), $hex.Substring(8, 4),
|
||||
$hex.Substring(12, 4), $hex.Substring(16, 4), $hex.Substring(20, 12)
|
||||
$success = [System.Collections.Generic.List[byte]]::new()
|
||||
$success.Add(2)
|
||||
AddString $success $uuid
|
||||
AddString $success $username
|
||||
Send $stream ($success.ToArray())
|
||||
$join = [byte[]]@(1, 0, 0, 0, 42, 0, 0, 0, 20, 7, 100, 101, 102, 97, 117, 108, 116)
|
||||
Send $stream $join
|
||||
$stream.Flush()
|
||||
for ($index = 0; $index -lt $expectedProbes; $index++) {
|
||||
$probe = ReadFrame $stream
|
||||
if ($probe.Length -ne 2 -or $probe[0] -ne 3 -or $probe[1] -ne (42 + $index)) {
|
||||
throw "Backend did not receive PLAY probe $index"
|
||||
}
|
||||
Send $stream $probe
|
||||
$stream.Flush()
|
||||
}
|
||||
} finally { $client.Dispose() }
|
||||
}
|
||||
return [pscustomobject]@{ Listener = $listener; Job = $job; Port = ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port }
|
||||
}
|
||||
|
||||
function SendOfflineLogin([System.IO.Stream]$stream, [string]$username) {
|
||||
$handshake = [System.Collections.Generic.List[byte]]::new()
|
||||
$handshake.AddRange([byte[]](VarInt 0))
|
||||
$handshake.AddRange([byte[]](VarInt 5))
|
||||
$hostBytes = [System.Text.Encoding]::UTF8.GetBytes('localhost')
|
||||
$handshake.AddRange([byte[]](VarInt $hostBytes.Length))
|
||||
$handshake.AddRange($hostBytes)
|
||||
$handshake.AddRange([byte[]]@(0x63, 0xdd))
|
||||
$handshake.AddRange([byte[]](VarInt 2))
|
||||
WriteFrame $stream ($handshake.ToArray())
|
||||
$login = [System.Collections.Generic.List[byte]]::new()
|
||||
$login.Add(0)
|
||||
$name = [System.Text.Encoding]::UTF8.GetBytes($username)
|
||||
$login.AddRange([byte[]](VarInt $name.Length))
|
||||
$login.AddRange($name)
|
||||
WriteFrame $stream ($login.ToArray())
|
||||
}
|
||||
|
||||
function LoginThroughProxy([int]$port, [switch]$KeepOpen) {
|
||||
$client = [System.Net.Sockets.TcpClient]::new()
|
||||
$returned = $false
|
||||
try {
|
||||
$client.Connect([System.Net.IPAddress]::Loopback, $port)
|
||||
$client.ReceiveTimeout = 5000
|
||||
$stream = $client.GetStream()
|
||||
SendOfflineLogin $stream 'SmokePlayer'
|
||||
$success = ReadExactly $stream (ReadVarInt $stream)
|
||||
if ($success[0] -ne 2) {
|
||||
$reason = [System.Text.Encoding]::UTF8.GetString($success)
|
||||
throw "Expected Login Success, got packet $($success[0]): $reason"
|
||||
}
|
||||
$join = ReadExactly $stream (ReadVarInt $stream)
|
||||
if ($join[0] -ne 1) { throw "Expected Join Game, got packet $($join[0])" }
|
||||
WriteFrame $stream ([byte[]]@(3, 42))
|
||||
$echo = ReadExactly $stream (ReadVarInt $stream)
|
||||
if ($echo.Length -ne 2 -or $echo[0] -ne 3 -or $echo[1] -ne 42) { throw 'PLAY frame did not traverse the discovered backend' }
|
||||
if ($KeepOpen) {
|
||||
$returned = $true
|
||||
return $client
|
||||
}
|
||||
} finally {
|
||||
if (-not $returned) { $client.Dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
function WaitForLogin([int]$port, [switch]$KeepOpen) {
|
||||
$last = $null
|
||||
for ($attempt = 0; $attempt -lt 50; $attempt++) {
|
||||
try {
|
||||
$result = LoginThroughProxy $port -KeepOpen:$KeepOpen
|
||||
return $result
|
||||
}
|
||||
catch { $last = $_.Exception.Message }
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
throw "Discovered backend did not accept login on port $port; last result=$last"
|
||||
}
|
||||
|
||||
function ExpectNoBackend([int]$port) {
|
||||
$client = [System.Net.Sockets.TcpClient]::new()
|
||||
try {
|
||||
$client.Connect([System.Net.IPAddress]::Loopback, $port)
|
||||
$client.ReceiveTimeout = 5000
|
||||
$stream = $client.GetStream()
|
||||
SendOfflineLogin $stream 'NewSmokePlayer'
|
||||
$response = ReadExactly $stream (ReadVarInt $stream)
|
||||
$packet = [System.IO.MemoryStream]::new($response)
|
||||
if ((ReadVarInt $packet) -ne 0) { throw 'New player reached an unregistered backend' }
|
||||
$reason = [System.Text.Encoding]::UTF8.GetString((ReadExactly $packet (ReadVarInt $packet)))
|
||||
if ($reason -notlike '*No server is available*') {
|
||||
throw "New player got an unexpected login rejection: $reason"
|
||||
}
|
||||
} finally { $client.Dispose() }
|
||||
}
|
||||
|
||||
function StartProxy([string]$config, [string]$stdout, [string]$stderr) {
|
||||
$java = (Get-Command java.exe -ErrorAction Stop).Source
|
||||
$classpath = Join-Path $lib '*'
|
||||
return Start-Process -FilePath $java -ArgumentList @(
|
||||
'-cp', ('"{0}"' -f $classpath), 'dev.strataproxy.app.ProxyMain',
|
||||
'--config', ('"{0}"' -f $config)
|
||||
) -WindowStyle Hidden -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr
|
||||
}
|
||||
|
||||
function StopProxy($process) {
|
||||
if ($process -and -not $process.HasExited) {
|
||||
Stop-Process -Id $process.Id -Force
|
||||
Wait-Process -Id $process.Id -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
$temporary = Join-Path $repoRoot 'build\installed-discovery-smoke'
|
||||
New-Item -ItemType Directory -Path $temporary -Force | Out-Null
|
||||
$prefix = [guid]::NewGuid().ToString('N')
|
||||
$dnsConfig = Join-Path $temporary "$prefix-dns.yml"
|
||||
$agentConfig = Join-Path $temporary "$prefix-agent.yml"
|
||||
$stdout = Join-Path $temporary "$prefix.stdout.log"
|
||||
$stderr = Join-Path $temporary "$prefix.stderr.log"
|
||||
$dns = $null
|
||||
$agent = $null
|
||||
$dnsBackend = $null
|
||||
$agentBackend = $null
|
||||
$agentClient = $null
|
||||
try {
|
||||
$pluginPath = $plugins.Replace('\', '/')
|
||||
$dnsBackend = StartFakeBackend
|
||||
$dnsPort = FreePort
|
||||
@"
|
||||
listen: "127.0.0.1:$dnsPort"
|
||||
authentication: OFFLINE
|
||||
backends: []
|
||||
plugins:
|
||||
directory: "$pluginPath"
|
||||
enabled:
|
||||
dev.strataproxy.plugins.dns.DnsDiscoveryPlugin:
|
||||
host: localhost
|
||||
port: "$($dnsBackend.Port)"
|
||||
refreshSeconds: "1"
|
||||
"@ | Set-Content -LiteralPath $dnsConfig -Encoding utf8
|
||||
$dns = StartProxy $dnsConfig $stdout $stderr
|
||||
$dnsAddresses = [System.Net.Dns]::GetHostAddresses('localhost').Length
|
||||
WaitForOnline $dnsPort 0
|
||||
WaitForLogin $dnsPort
|
||||
Write-Output "DNS plugin: $dnsAddresses address(es), discovered backend accepted login"
|
||||
$null = Wait-Job -Job $dnsBackend.Job -Timeout 10
|
||||
if ($dnsBackend.Job.State -ne 'Completed') { throw "DNS fake backend job state=$($dnsBackend.Job.State)" }
|
||||
Receive-Job -Job $dnsBackend.Job -ErrorAction Stop | Out-Null
|
||||
Write-Output 'DNS plugin: discovered backend completed Login Success, Join Game, and PLAY relay'
|
||||
StopProxy $dns
|
||||
$dns = $null
|
||||
|
||||
$agentBackend = StartFakeBackend 2
|
||||
$proxyPort = FreePort
|
||||
$agentPort = FreePort
|
||||
while ($agentPort -eq $proxyPort) { $agentPort = FreePort }
|
||||
$secret = [Convert]::ToHexString([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
|
||||
@"
|
||||
listen: "127.0.0.1:$proxyPort"
|
||||
authentication: OFFLINE
|
||||
backends: []
|
||||
plugins:
|
||||
directory: "$pluginPath"
|
||||
enabled:
|
||||
dev.strataproxy.plugins.agent.AgentDiscoveryPlugin:
|
||||
host: "127.0.0.1"
|
||||
port: "$agentPort"
|
||||
secret: "$secret"
|
||||
"@ | Set-Content -LiteralPath $agentConfig -Encoding utf8
|
||||
$agent = StartProxy $agentConfig $stdout $stderr
|
||||
WaitForOnline $proxyPort 0
|
||||
ExpectNoBackend $proxyPort
|
||||
$generation = [guid]::NewGuid().ToString()
|
||||
$agentId = 'installed-smoke'
|
||||
$timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()
|
||||
$nonce = [Convert]::ToHexString([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(16))
|
||||
$endpoint = "http://127.0.0.1:$agentPort/registration"
|
||||
function SendAgent([string]$body) {
|
||||
$canonical = "POST`n/registration`n$timestamp`n$nonce`n$agentId`n$body"
|
||||
$hmac = [System.Security.Cryptography.HMACSHA256]::new([System.Text.Encoding]::UTF8.GetBytes($secret))
|
||||
try { $signature = [Convert]::ToHexString($hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($canonical))) }
|
||||
finally { $hmac.Dispose() }
|
||||
return Invoke-WebRequest -Uri $endpoint -Method Post -Body $body -ContentType 'application/x-www-form-urlencoded; charset=utf-8' -Headers @{
|
||||
'X-Agent-Id' = $agentId
|
||||
'X-Timestamp' = $timestamp
|
||||
'X-Nonce' = $nonce
|
||||
'X-Signature' = $signature
|
||||
} -TimeoutSec 5
|
||||
}
|
||||
$registerBody = "action=register&generation=$generation&name=installed-smoke-backend&address=tcp%3A%2F%2F127.0.0.1%3A$($agentBackend.Port)&leaseSeconds=30"
|
||||
$registered = SendAgent $registerBody
|
||||
if ($registered.StatusCode -ne 201) { throw "Agent registration returned $($registered.StatusCode)" }
|
||||
$agentClient = WaitForLogin $proxyPort -KeepOpen
|
||||
Write-Output "Agent plugin: registration status=$($registered.StatusCode), discovered backend accepted login"
|
||||
Write-Output 'Agent plugin: discovered backend completed Login Success, Join Game, and PLAY relay'
|
||||
$nonce = [Convert]::ToHexString([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(16))
|
||||
$unregistered = SendAgent "action=unregister&generation=$generation"
|
||||
if ($unregistered.StatusCode -ne 204) { throw "Agent unregister returned $($unregistered.StatusCode)" }
|
||||
WaitForOnline $proxyPort 1
|
||||
ExpectNoBackend $proxyPort
|
||||
Write-Output 'Agent plugin: unregister status=204, existing player remains connected'
|
||||
$stream = $agentClient.GetStream()
|
||||
WriteFrame $stream ([byte[]]@(3, 43))
|
||||
$echo = ReadExactly $stream (ReadVarInt $stream)
|
||||
if ($echo.Length -ne 2 -or $echo[0] -ne 3 -or $echo[1] -ne 43) {
|
||||
throw 'Existing PLAY session stopped after Agent unregister'
|
||||
}
|
||||
$agentClient.Dispose()
|
||||
$agentClient = $null
|
||||
$null = Wait-Job -Job $agentBackend.Job -Timeout 10
|
||||
if ($agentBackend.Job.State -ne 'Completed') { throw "Agent fake backend job state=$($agentBackend.Job.State)" }
|
||||
Receive-Job -Job $agentBackend.Job -ErrorAction Stop | Out-Null
|
||||
WaitForOnline $proxyPort 0
|
||||
Write-Output 'Agent plugin: existing PLAY session survived unregister, online=0 after disconnect'
|
||||
} catch {
|
||||
foreach ($backend in @($dnsBackend, $agentBackend)) {
|
||||
if ($backend) {
|
||||
Write-Warning "Fake backend job state=$($backend.Job.State)"
|
||||
Receive-Job -Job $backend.Job -Keep -ErrorAction Continue | Write-Warning
|
||||
}
|
||||
}
|
||||
throw
|
||||
} finally {
|
||||
if ($agentClient) { $agentClient.Dispose() }
|
||||
StopProxy $dns
|
||||
StopProxy $agent
|
||||
foreach ($backend in @($dnsBackend, $agentBackend)) {
|
||||
if ($backend) {
|
||||
$backend.Listener.Stop()
|
||||
Stop-Job -Job $backend.Job -ErrorAction SilentlyContinue
|
||||
Remove-Job -Job $backend.Job -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
Remove-Item -LiteralPath $dnsConfig, $agentConfig, $stdout, $stderr -ErrorAction SilentlyContinue
|
||||
$secret = $null
|
||||
}
|
||||
Reference in new issue
Block a user