From 230f317d0ae5e8b419228a0522901f8fd40d3294 Mon Sep 17 00:00:00 2001 From: Layue13 Date: Sun, 27 Sep 2026 02:52:46 +0800 Subject: [PATCH] feat: replace backend discovery with control channel --- README.md | 6 +- backend-channel-client/build.gradle.kts | 52 ++ .../backendchannel/BackendChannelClient.java | 427 ++++++++++++++ .../backendchannel/BackendChannelHandler.java | 8 + .../backendchannel/FrameCodec.java | 27 + .../dev/strataproxy/backendchannel/Wire.java | 236 ++++++++ .../BackendChannelClientTest.java | 169 ++++++ build.gradle.kts | 4 +- docs/README.md | 2 +- docs/architecture.md | 6 +- docs/backend-channel-design.md | 63 ++ docs/discovery.md | 23 - docs/operations.md | 12 +- docs/plugins.md | 8 +- docs/testing.md | 6 +- plugins/agent-discovery/build.gradle.kts | 7 - .../plugins/agent/AgentDiscoveryPlugin.java | 414 ------------- .../plugins/agent/AgentExampleMain.java | 40 -- .../agent/AgentRegistrationClient.java | 98 --- .../services/dev.strataproxy.api.Plugin | 1 - .../agent/AgentDiscoveryPluginTest.java | 256 -------- plugins/dns-discovery/build.gradle.kts | 8 - .../plugins/dns/DnsDiscoveryPlugin.java | 449 -------------- .../services/dev.strataproxy.api.Plugin | 1 - .../plugins/dns/DnsDiscoveryPluginTest.java | 389 ------------ proxy-core/build.gradle.kts | 8 +- .../strataproxy/app/ProxyConfiguration.java | 56 +- .../java/dev/strataproxy/app/ProxyMain.java | 13 +- .../core/control/BackendChannelTransport.java | 11 + .../core/control/BackendControlService.java | 558 ++++++++++++++++++ .../control/BackendNoHandlerException.java | 8 + .../strataproxy/core/plugin/PluginHost.java | 243 ++++++++ .../config/strataproxy-channel.example.yml | 24 + .../config/strataproxy-dns.example.yml | 20 - .../app/ProxyConfigurationTest.java | 31 + .../control/BackendControlServiceTest.java | 182 ++++++ .../api/BackendChannelSubscription.java | 7 + .../dev/strataproxy/api/BackendChannels.java | 28 + .../dev/strataproxy/api/BackendMessage.java | 36 ++ .../api/BackendMessageHandler.java | 9 + .../strataproxy/api/BackendSendResult.java | 11 + .../dev/strataproxy/api/PluginContext.java | 9 + .../api/BackendChannelsApiTest.java | 26 + settings.gradle.kts | 6 +- smoke/container-channel.sh | 74 +++ smoke/container-network.ps1 | 42 +- smoke/container-network.py | 99 ++-- smoke/installed-discovery.ps1 | 399 ------------- 48 files changed, 2410 insertions(+), 2202 deletions(-) create mode 100644 backend-channel-client/build.gradle.kts create mode 100644 backend-channel-client/src/main/java/dev/strataproxy/backendchannel/BackendChannelClient.java create mode 100644 backend-channel-client/src/main/java/dev/strataproxy/backendchannel/BackendChannelHandler.java create mode 100644 backend-channel-client/src/main/java/dev/strataproxy/backendchannel/FrameCodec.java create mode 100644 backend-channel-client/src/main/java/dev/strataproxy/backendchannel/Wire.java create mode 100644 backend-channel-client/src/test/java/dev/strataproxy/backendchannel/BackendChannelClientTest.java create mode 100644 docs/backend-channel-design.md delete mode 100644 docs/discovery.md delete mode 100644 plugins/agent-discovery/build.gradle.kts delete mode 100644 plugins/agent-discovery/src/main/java/dev/strataproxy/plugins/agent/AgentDiscoveryPlugin.java delete mode 100644 plugins/agent-discovery/src/main/java/dev/strataproxy/plugins/agent/AgentExampleMain.java delete mode 100644 plugins/agent-discovery/src/main/java/dev/strataproxy/plugins/agent/AgentRegistrationClient.java delete mode 100644 plugins/agent-discovery/src/main/resources/META-INF/services/dev.strataproxy.api.Plugin delete mode 100644 plugins/agent-discovery/src/test/java/dev/strataproxy/plugins/agent/AgentDiscoveryPluginTest.java delete mode 100644 plugins/dns-discovery/build.gradle.kts delete mode 100644 plugins/dns-discovery/src/main/java/dev/strataproxy/plugins/dns/DnsDiscoveryPlugin.java delete mode 100644 plugins/dns-discovery/src/main/resources/META-INF/services/dev.strataproxy.api.Plugin delete mode 100644 plugins/dns-discovery/src/test/java/dev/strataproxy/plugins/dns/DnsDiscoveryPluginTest.java create mode 100644 proxy-core/src/main/java/dev/strataproxy/core/control/BackendChannelTransport.java create mode 100644 proxy-core/src/main/java/dev/strataproxy/core/control/BackendControlService.java create mode 100644 proxy-core/src/main/java/dev/strataproxy/core/control/BackendNoHandlerException.java create mode 100644 proxy-core/src/main/resources/config/strataproxy-channel.example.yml delete mode 100644 proxy-core/src/main/resources/config/strataproxy-dns.example.yml create mode 100644 proxy-core/src/test/java/dev/strataproxy/core/control/BackendControlServiceTest.java create mode 100644 proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendChannelSubscription.java create mode 100644 proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendChannels.java create mode 100644 proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendMessage.java create mode 100644 proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendMessageHandler.java create mode 100644 proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendSendResult.java create mode 100644 proxy-plugin-api/src/test/java/dev/strataproxy/api/BackendChannelsApiTest.java create mode 100644 smoke/container-channel.sh delete mode 100644 smoke/installed-discovery.ps1 diff --git a/README.md b/README.md index 4b8d035..9f7b560 100644 --- a/README.md +++ b/README.md @@ -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)。 diff --git a/backend-channel-client/build.gradle.kts b/backend-channel-client/build.gradle.kts new file mode 100644 index 0000000..5bb2c80 --- /dev/null +++ b/backend-channel-client/build.gradle.kts @@ -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().configureEach { + options.release.set(8) + options.encoding = "UTF-8" +} + +tasks.withType().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("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() + } + } + } +} diff --git a/backend-channel-client/src/main/java/dev/strataproxy/backendchannel/BackendChannelClient.java b/backend-channel-client/src/main/java/dev/strataproxy/backendchannel/BackendChannelClient.java new file mode 100644 index 0000000..a23a101 --- /dev/null +++ b/backend-channel-client/src/main/java/dev/strataproxy/backendchannel/BackendChannelClient.java @@ -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 = new AtomicReference(); + private volatile Session connecting; + private final Map handlers = new ConcurrentHashMap(); + private final Map> pending = new ConcurrentHashMap>(); + private final ThreadPoolExecutor handlerExecutor = new ThreadPoolExecutor(2, 2, 0L, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue(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 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 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 result = new CompletableFuture(); + 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 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 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 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 CompletableFuture failed(Throwable error) { + CompletableFuture future = new CompletableFuture(); + 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) { } + } + } +} diff --git a/backend-channel-client/src/main/java/dev/strataproxy/backendchannel/BackendChannelHandler.java b/backend-channel-client/src/main/java/dev/strataproxy/backendchannel/BackendChannelHandler.java new file mode 100644 index 0000000..3a6faf9 --- /dev/null +++ b/backend-channel-client/src/main/java/dev/strataproxy/backendchannel/BackendChannelHandler.java @@ -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 handle(byte[] payload) throws Exception; +} diff --git a/backend-channel-client/src/main/java/dev/strataproxy/backendchannel/FrameCodec.java b/backend-channel-client/src/main/java/dev/strataproxy/backendchannel/FrameCodec.java new file mode 100644 index 0000000..3cdb53f --- /dev/null +++ b/backend-channel-client/src/main/java/dev/strataproxy/backendchannel/FrameCodec.java @@ -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(); + } +} diff --git a/backend-channel-client/src/main/java/dev/strataproxy/backendchannel/Wire.java b/backend-channel-client/src/main/java/dev/strataproxy/backendchannel/Wire.java new file mode 100644 index 0000000..dd44d2e --- /dev/null +++ b/backend-channel-client/src/main/java/dev/strataproxy/backendchannel/Wire.java @@ -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; } + } +} diff --git a/backend-channel-client/src/test/java/dev/strataproxy/backendchannel/BackendChannelClientTest.java b/backend-channel-client/src/test/java/dev/strataproxy/backendchannel/BackendChannelClientTest.java new file mode 100644 index 0000000..300070a --- /dev/null +++ b/backend-channel-client/src/test/java/dev/strataproxy/backendchannel/BackendChannelClientTest.java @@ -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 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 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 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 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(); + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 5c73d0c..453e3bd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") } diff --git a/docs/README.md b/docs/README.md index 11d776b..8843cc9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,7 @@ | [插件开发](plugins.md) | 生命周期、玩家/服务器视图、选服、命令与统一类型化事件 API | | [玩家操作与部署完善设计](player-operations.md) | 消息、主动断开、服务器列表配置与验收边界 | | [运行配置](operations.md) | 认证、接入限制、超时、启动和部署边界 | -| [后端发现](discovery.md) | DNS 与 Agent 插件、配置、注册与租约 | +| [后端控制通道](backend-channel-design.md) | 独立连接、注册与双向消息 | | [验证方法](testing.md) | 自动化测试、烟测、真实客户端与合成性能测量 | 设计约束:核心管理连接和协议;插件负责玩法与访问策略。`ServerView` 是后端目录快照,发现方式是目录的提供者,不引入核心负载观测。准入与会话通知使用同一事件类型、监听器和注册表;不同事件在派发策略上处理结果。常规 PLAY 转发没有通用事件派发。 diff --git a/docs/architecture.md b/docs/architecture.md index 93cfbea..96a73b8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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) diff --git a/docs/backend-channel-design.md b/docs/backend-channel-design.md new file mode 100644 index 0000000..f876f01 --- /dev/null +++ b/docs/backend-channel-design.md @@ -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)。 diff --git a/docs/discovery.md b/docs/discovery.md deleted file mode 100644 index c5259e3..0000000 --- a/docs/discovery.md +++ /dev/null @@ -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://127.0.0.1:28080/registration`。示例每 10 秒续租一次,租约 30 秒;网络故障和服务端暂时不可用时继续重试,租约过期导致旧 generation 被拒绝时换用新 generation 注册;认证或请求格式错误则退出。正常退出时尝试注销,异常退出由租约过期清理。Agent 协议只注册后端名称与地址,不需要消息队列。 diff --git a/docs/operations.md b/docs/operations.md index 75b2a07..63628f9 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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);已有成功样例不构成稳定性或生产验收。 diff --git a/docs/plugins.md b/docs/plugins.md index 27a3f43..34d7174 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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` 作为请求响应。 + +`send(backendName, channel, payload)` 返回 `SENT`、`NOT_CONNECTED` 或 `BACKPRESSURED`;`SENT` 只表示消息写入控制连接。`request(...)` 在五秒内等待后端处理结果,断线、无处理器或超时会异常完成。负载在提交时复制,单条最多 64 KiB;回调在有界工作池运行,不应同步等待数据库或网络任务。配置与交付边界见[后端控制通道](backend-channel-design.md)。 ### 消息与主动断开 diff --git a/docs/testing.md b/docs/testing.md index dc8ae30..4c57b57 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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` 的结果代替远端发布验收。 diff --git a/plugins/agent-discovery/build.gradle.kts b/plugins/agent-discovery/build.gradle.kts deleted file mode 100644 index 99e8c37..0000000 --- a/plugins/agent-discovery/build.gradle.kts +++ /dev/null @@ -1,7 +0,0 @@ -plugins { - id("strataproxy.java-library-conventions") -} - -dependencies { - implementation(project(":proxy-plugin-api")) -} diff --git a/plugins/agent-discovery/src/main/java/dev/strataproxy/plugins/agent/AgentDiscoveryPlugin.java b/plugins/agent-discovery/src/main/java/dev/strataproxy/plugins/agent/AgentDiscoveryPlugin.java deleted file mode 100644 index 5be2154..0000000 --- a/plugins/agent-discovery/src/main/java/dev/strataproxy/plugins/agent/AgentDiscoveryPlugin.java +++ /dev/null @@ -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 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 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 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 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 input, Set keys) { - if (!input.keySet().equals(keys)) throw new BadRequest("unexpected fields"); - } - private static String required(Map 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 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 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 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 active = new LinkedHashMap<>(); - private final Map> retiredGenerations = new LinkedHashMap<>(); - private final Map serverOwners = new LinkedHashMap<>(); - private final LinkedHashMap 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 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 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 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 { } -} diff --git a/plugins/agent-discovery/src/main/java/dev/strataproxy/plugins/agent/AgentExampleMain.java b/plugins/agent-discovery/src/main/java/dev/strataproxy/plugins/agent/AgentExampleMain.java deleted file mode 100644 index ca67d72..0000000 --- a/plugins/agent-discovery/src/main/java/dev/strataproxy/plugins/agent/AgentExampleMain.java +++ /dev/null @@ -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 "); - 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); - } - } -} diff --git a/plugins/agent-discovery/src/main/java/dev/strataproxy/plugins/agent/AgentRegistrationClient.java b/plugins/agent-discovery/src/main/java/dev/strataproxy/plugins/agent/AgentRegistrationClient.java deleted file mode 100644 index ad4052b..0000000 --- a/plugins/agent-discovery/src/main/java/dev/strataproxy/plugins/agent/AgentRegistrationClient.java +++ /dev/null @@ -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 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); } - } -} diff --git a/plugins/agent-discovery/src/main/resources/META-INF/services/dev.strataproxy.api.Plugin b/plugins/agent-discovery/src/main/resources/META-INF/services/dev.strataproxy.api.Plugin deleted file mode 100644 index c636160..0000000 --- a/plugins/agent-discovery/src/main/resources/META-INF/services/dev.strataproxy.api.Plugin +++ /dev/null @@ -1 +0,0 @@ -dev.strataproxy.plugins.agent.AgentDiscoveryPlugin diff --git a/plugins/agent-discovery/src/test/java/dev/strataproxy/plugins/agent/AgentDiscoveryPluginTest.java b/plugins/agent-discovery/src/test/java/dev/strataproxy/plugins/agent/AgentDiscoveryPluginTest.java deleted file mode 100644 index e30ac0b..0000000 --- a/plugins/agent-discovery/src/test/java/dev/strataproxy/plugins/agent/AgentDiscoveryPluginTest.java +++ /dev/null @@ -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 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 registered = post(client, endpoint, agent, timestamp, nonce, signature, body); - assertEquals(201, registered.statusCode()); - HttpResponse replay = post(client, endpoint, agent, timestamp, nonce, signature, body); - assertEquals(401, replay.statusCode()); - - String largeBody = "x".repeat(300); - HttpResponse 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 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 find(PlayerIdentity identity) { return Optional.empty(); } - @Override public List online() { return List.of(); } - @Override public java.util.concurrent.CompletionStage sendMessage(PlayerIdentity identity, String message) { - return CompletableFuture.failedFuture(new UnsupportedOperationException()); - } - @Override public java.util.concurrent.CompletionStage disconnect(PlayerIdentity identity, String reason) { - return CompletableFuture.failedFuture(new UnsupportedOperationException()); - } - @Override public java.util.concurrent.CompletionStage 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) -> { 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 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 definitions = new ConcurrentHashMap<>(); - private int unregisterCount; - @Override public Optional find(String backendName) { return Optional.empty(); } - @Override public List 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++; - } - }; - } - } -} diff --git a/plugins/dns-discovery/build.gradle.kts b/plugins/dns-discovery/build.gradle.kts deleted file mode 100644 index c1373f8..0000000 --- a/plugins/dns-discovery/build.gradle.kts +++ /dev/null @@ -1,8 +0,0 @@ -plugins { - id("strataproxy.java-library-conventions") -} - -dependencies { - implementation(project(":proxy-plugin-api")) - implementation(libs.findLibrary("netty-resolver-dns").get()) -} diff --git a/plugins/dns-discovery/src/main/java/dev/strataproxy/plugins/dns/DnsDiscoveryPlugin.java b/plugins/dns-discovery/src/main/java/dev/strataproxy/plugins/dns/DnsDiscoveryPlugin.java deleted file mode 100644 index a710b9f..0000000 --- a/plugins/dns-discovery/src/main/java/dev/strataproxy/plugins/dns/DnsDiscoveryPlugin.java +++ /dev/null @@ -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 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 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 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 desired) { - for (Map.Entry 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 definitionsFor(InetAddress[] addresses, Configuration config) { - Map 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 result = new LinkedHashMap<>(); - for (Map.Entry 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 lastIpv4 = List.of(); - private List 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 currentIpv4 = null; - List 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(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 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 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 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; - } - } -} diff --git a/plugins/dns-discovery/src/main/resources/META-INF/services/dev.strataproxy.api.Plugin b/plugins/dns-discovery/src/main/resources/META-INF/services/dev.strataproxy.api.Plugin deleted file mode 100644 index 109cee1..0000000 --- a/plugins/dns-discovery/src/main/resources/META-INF/services/dev.strataproxy.api.Plugin +++ /dev/null @@ -1 +0,0 @@ -dev.strataproxy.plugins.dns.DnsDiscoveryPlugin diff --git a/plugins/dns-discovery/src/test/java/dev/strataproxy/plugins/dns/DnsDiscoveryPluginTest.java b/plugins/dns-discovery/src/test/java/dev/strataproxy/plugins/dns/DnsDiscoveryPluginTest.java deleted file mode 100644 index 2e08877..0000000 --- a/plugins/dns-discovery/src/test/java/dev/strataproxy/plugins/dns/DnsDiscoveryPluginTest.java +++ /dev/null @@ -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 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 answer = new AtomicReference<>(new InetAddress[]{ip}); - AtomicReference 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 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 v4 = new AtomicReference<>(firstV4.getAddress()); - AtomicReference v6 = new AtomicReference<>(firstV6.getAddress()); - AtomicBoolean dropV6 = new AtomicBoolean(); - AtomicReference 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 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) -> { 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 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 definitions = new ConcurrentHashMap<>(); - private final List 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 find(String backendName) { - return Optional.empty(); - } - - @Override - public List 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(); - } - }; - } - } -} diff --git a/proxy-core/build.gradle.kts b/proxy-core/build.gradle.kts index c238323..280f9c5 100644 --- a/proxy-core/build.gradle.kts +++ b/proxy-core/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { implementation(libs.findLibrary("jackson-databind").get()) implementation(libs.findLibrary("jackson-yaml").get()) runtimeOnly(libs.findLibrary("logback-classic").get()) + testImplementation(project(":backend-channel-client")) } val installedDistSmokeTest = tasks.register("installedDistSmokeTest") { @@ -86,11 +87,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") } } } diff --git a/proxy-core/src/main/java/dev/strataproxy/app/ProxyConfiguration.java b/proxy-core/src/main/java/dev/strataproxy/app/ProxyConfiguration.java index 9970dea..2756962 100644 --- a/proxy-core/src/main/java/dev/strataproxy/app/ProxyConfiguration.java +++ b/proxy-core/src/main/java/dev/strataproxy/app/ProxyConfiguration.java @@ -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 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 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 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 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 allowedHosts, + Set 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> enabled, Integer initialPlacementTimeoutSeconds, Integer eventTimeoutSeconds) { public Plugins { diff --git a/proxy-core/src/main/java/dev/strataproxy/app/ProxyMain.java b/proxy-core/src/main/java/dev/strataproxy/app/ProxyMain.java index bde2639..2ccb1f3 100644 --- a/proxy-core/src/main/java/dev/strataproxy/app/ProxyMain.java +++ b/proxy-core/src/main/java/dev/strataproxy/app/ProxyMain.java @@ -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); @@ -96,9 +102,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"); diff --git a/proxy-core/src/main/java/dev/strataproxy/core/control/BackendChannelTransport.java b/proxy-core/src/main/java/dev/strataproxy/core/control/BackendChannelTransport.java new file mode 100644 index 0000000..8c06445 --- /dev/null +++ b/proxy-core/src/main/java/dev/strataproxy/core/control/BackendChannelTransport.java @@ -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 send(String backendName, String channel, byte[] payload); + + CompletionStage request(String backendName, String channel, byte[] payload); +} diff --git a/proxy-core/src/main/java/dev/strataproxy/core/control/BackendControlService.java b/proxy-core/src/main/java/dev/strataproxy/core/control/BackendControlService.java new file mode 100644 index 0000000..a476d18 --- /dev/null +++ b/proxy-core/src/main/java/dev/strataproxy/core/control/BackendControlService.java @@ -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> inbound; + private final Object lock = new Object(); + private final Map byInstance = new HashMap<>(); + private final Map 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> 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 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 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 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 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 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 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 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> 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 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 result : pending.values()) + result.completeExceptionally(new IOException("backend control disconnected")); + pending.clear(); + } + } +} diff --git a/proxy-core/src/main/java/dev/strataproxy/core/control/BackendNoHandlerException.java b/proxy-core/src/main/java/dev/strataproxy/core/control/BackendNoHandlerException.java new file mode 100644 index 0000000..55ac34c --- /dev/null +++ b/proxy-core/src/main/java/dev/strataproxy/core/control/BackendNoHandlerException.java @@ -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); + } +} diff --git a/proxy-core/src/main/java/dev/strataproxy/core/plugin/PluginHost.java b/proxy-core/src/main/java/dev/strataproxy/core/plugin/PluginHost.java index e9875c3..fc34bc6 100644 --- a/proxy-core/src/main/java/dev/strataproxy/core/plugin/PluginHost.java +++ b/proxy-core/src/main/java/dev/strataproxy/core/plugin/PluginHost.java @@ -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; @@ -32,6 +37,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; @@ -89,7 +96,9 @@ 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 commands = new ConcurrentHashMap<>(); + private final ConcurrentHashMap backendHandlers = new ConcurrentHashMap<>(); private final ScheduledThreadPoolExecutor timer; private final AsyncEventDispatcher admissionEvents; private final AsyncEventDispatcher notificationEvents; @@ -100,7 +109,9 @@ public final class PluginHost implements AutoCloseable, EventDispatcher { private final List classLoaders = new ArrayList<>(); private final List plugins = new ArrayList<>(); private final Set pendingPlacements = ConcurrentHashMap.newKeySet(); + private final Set> pendingBackendMessages = ConcurrentHashMap.newKeySet(); private volatile State state = State.LOADING; + private volatile BackendChannelTransport backendChannelTransport; private ScheduledFuture notificationDropReporter; private InitialPlacementHandler placementHandler; private volatile Map, List>> eventListeners = Map.of(); @@ -157,6 +168,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, @@ -281,10 +295,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(); @@ -337,6 +353,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 dispatchBackendMessage(BackendMessage message) { + Objects.requireNonNull(message, "message"); + final BackendHandlerRegistration registration; + final CompletableFuture 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 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> placeInitial(PlayerView player) { Objects.requireNonNull(player, "player"); @@ -473,12 +555,17 @@ 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 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")); } callbacks.shutdownNow(); commandWorkers.shutdownNow(); + backendWorkers.shutdownNow(); timer.shutdownNow(); long deadline = System.nanoTime() + shutdownTimeout.toNanos(); for (int index = plugins.size() - 1; index >= 0; index--) { @@ -499,6 +586,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) { @@ -537,6 +626,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) { @@ -545,6 +635,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher { throw new PluginLoadException(id, failure); } finally { context.endEventRegistration(); + context.endBackendChannelRegistration(); } } @@ -583,6 +674,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 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 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 response = Objects.requireNonNull( + transport.request(backendName, channel, copy), "transport request stage"); + CompletableFuture 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) { @@ -620,6 +800,24 @@ public final class PluginHost implements AutoCloseable, EventDispatcher { private record RegisteredCommand(String name, PluginContextImpl context, CommandHandler handler) { } + 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, R> implements EventSubscription, AsyncEventDispatcher.EventHandler { private final PluginContextImpl context; @@ -662,11 +860,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 settings; private final Set> eventSubscriptions = new HashSet<>(); + private final Set backendChannelSubscriptions = new HashSet<>(); private volatile boolean active = true; private boolean eventRegistrationOpen; + private boolean backendChannelRegistrationOpen; private PluginContextImpl(BackendOwner owner, Map settings) { this.owner = owner; @@ -674,6 +875,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); } @@ -682,13 +884,16 @@ 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 settings() { return settings; } private synchronized void deactivateAndRemove() { active = false; eventRegistrationOpen = false; + backendChannelRegistrationOpen = false; revokeEventSubscriptions(); + revokeBackendChannelSubscriptions(); commands.entrySet().removeIf(entry -> entry.getValue().context == this); catalog.removeOwner(owner); } @@ -706,6 +911,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) { @@ -713,6 +931,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"); } @@ -729,6 +954,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 send(String backendName, String channel, byte[] payload) { + return sendBackendMessage(context, backendName, channel, payload); + } + + @Override public CompletionStage request(String backendName, String channel, byte[] payload) { + return requestBackendMessage(context, backendName, channel, payload); + } + } + private final class PluginCommands implements Commands { private final PluginContextImpl context; diff --git a/proxy-core/src/main/resources/config/strataproxy-channel.example.yml b/proxy-core/src/main/resources/config/strataproxy-channel.example.yml new file mode 100644 index 0000000..aaaff91 --- /dev/null +++ b/proxy-core/src/main/resources/config/strataproxy-channel.example.yml @@ -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"] diff --git a/proxy-core/src/main/resources/config/strataproxy-dns.example.yml b/proxy-core/src/main/resources/config/strataproxy-dns.example.yml deleted file mode 100644 index 2c3d179..0000000 --- a/proxy-core/src/main/resources/config/strataproxy-dns.example.yml +++ /dev/null @@ -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" diff --git a/proxy-core/src/test/java/dev/strataproxy/app/ProxyConfigurationTest.java b/proxy-core/src/test/java/dev/strataproxy/app/ProxyConfigurationTest.java index a07c7eb..8e1542c 100644 --- a/proxy-core/src/test/java/dev/strataproxy/app/ProxyConfigurationTest.java +++ b/proxy-core/src/test/java/dev/strataproxy/app/ProxyConfigurationTest.java @@ -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()); diff --git a/proxy-core/src/test/java/dev/strataproxy/core/control/BackendControlServiceTest.java b/proxy-core/src/test/java/dev/strataproxy/core/control/BackendControlServiceTest.java new file mode 100644 index 0000000..0f1244f --- /dev/null +++ b/proxy-core/src/test/java/dev/strataproxy/core/control/BackendControlServiceTest.java @@ -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(); + var pluginContext = new AtomicReference(); + 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 find(PlayerIdentity identity) { return Optional.empty(); } + @Override public List online() { return List.of(); } + @Override public CompletionStage transfer(PlayerIdentity identity, String backendName) { + return CompletableFuture.completedFuture(TransferResult.failed("unavailable")); + } + @Override public CompletionStage sendMessage(PlayerIdentity identity, String message) { + return CompletableFuture.completedFuture(MessageResult.NOT_CONNECTED); + } + @Override public CompletionStage disconnect(PlayerIdentity identity, String reason) { + return CompletableFuture.completedFuture(DisconnectResult.NOT_CONNECTED); + } + }; + } +} diff --git a/proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendChannelSubscription.java b/proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendChannelSubscription.java new file mode 100644 index 0000000..d798266 --- /dev/null +++ b/proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendChannelSubscription.java @@ -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(); +} diff --git a/proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendChannels.java b/proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendChannels.java new file mode 100644 index 0000000..e5ac2ac --- /dev/null +++ b/proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendChannels.java @@ -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 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 request(String backendName, String channel, byte[] payload); +} diff --git a/proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendMessage.java b/proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendMessage.java new file mode 100644 index 0000000..dcad12d --- /dev/null +++ b/proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendMessage.java @@ -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; + } +} diff --git a/proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendMessageHandler.java b/proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendMessageHandler.java new file mode 100644 index 0000000..6484d95 --- /dev/null +++ b/proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendMessageHandler.java @@ -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 handle(BackendMessage message); +} diff --git a/proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendSendResult.java b/proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendSendResult.java new file mode 100644 index 0000000..f5842c9 --- /dev/null +++ b/proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendSendResult.java @@ -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 +} diff --git a/proxy-plugin-api/src/main/java/dev/strataproxy/api/PluginContext.java b/proxy-plugin-api/src/main/java/dev/strataproxy/api/PluginContext.java index fc487ff..da77016 100644 --- a/proxy-plugin-api/src/main/java/dev/strataproxy/api/PluginContext.java +++ b/proxy-plugin-api/src/main/java/dev/strataproxy/api/PluginContext.java @@ -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. */ diff --git a/proxy-plugin-api/src/test/java/dev/strataproxy/api/BackendChannelsApiTest.java b/proxy-plugin-api/src/test/java/dev/strataproxy/api/BackendChannelsApiTest.java new file mode 100644 index 0000000..1d77d18 --- /dev/null +++ b/proxy-plugin-api/src/test/java/dev/strataproxy/api/BackendChannelsApiTest.java @@ -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])); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 1708f99..a2c6229 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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") diff --git a/smoke/container-channel.sh b/smoke/container-channel.sh new file mode 100644 index 0000000..3f4c0ae --- /dev/null +++ b/smoke/container-channel.sh @@ -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" </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' diff --git a/smoke/container-network.ps1 b/smoke/container-network.ps1 index e80dd7b..2148002 100644 --- a/smoke/container-network.ps1 +++ b/smoke/container-network.ps1 @@ -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)" diff --git a/smoke/container-network.py b/smoke/container-network.py index 92c47c6..774138a 100644 --- a/smoke/container-network.py +++ b/smoke/container-network.py @@ -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 | client ") + 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 | client ") diff --git a/smoke/installed-discovery.ps1 b/smoke/installed-discovery.ps1 deleted file mode 100644 index 8274734..0000000 --- a/smoke/installed-discovery.ps1 +++ /dev/null @@ -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 -}