Merge main backend channel into messages completion

This commit is contained in:
layue13 committed 2026-09-27 17:57:21 +08:00
commit d0aca24839
48 files changed
+2410 -2202

No files matched your search

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