mirror of
https://github.com/layue13/MoonBridge.git
synced 2026-10-26 19:03:49 +00:00
Merge main backend channel into messages completion
This commit is contained in:
commit
d0aca24839
48 files changed
+2410
-2202
No files matched your search
@@ -1,13 +1,13 @@
|
|||||||
# StrataProxy
|
# 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)。
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
plugins {
|
||||||
|
`java-library`
|
||||||
|
`maven-publish`
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "uk.potatolab"
|
||||||
|
version = "0.1.0-SNAPSHOT"
|
||||||
|
|
||||||
|
java {
|
||||||
|
withSourcesJar()
|
||||||
|
withJavadocJar()
|
||||||
|
toolchain.languageVersion.set(JavaLanguageVersion.of(25))
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<JavaCompile>().configureEach {
|
||||||
|
options.release.set(8)
|
||||||
|
options.encoding = "UTF-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
testImplementation(platform("org.junit:junit-bom:5.14.1"))
|
||||||
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||||
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
|
}
|
||||||
|
|
||||||
|
publishing {
|
||||||
|
publications {
|
||||||
|
create<MavenPublication>("backendChannelClient") {
|
||||||
|
from(components["java"])
|
||||||
|
artifactId = "backend-channel-client"
|
||||||
|
pom {
|
||||||
|
name.set("StrataProxy Backend Channel Client")
|
||||||
|
description.set("Java 8-compatible backend control channel SDK.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
name = "giteaPackages"
|
||||||
|
url = uri(providers.environmentVariable("MAVEN_URL")
|
||||||
|
.orElse("https://git.nest.potatolab.uk:8443/api/packages/layue13/maven").get())
|
||||||
|
credentials {
|
||||||
|
username = providers.environmentVariable("MAVEN_USER").orElse("NONE").get()
|
||||||
|
password = providers.environmentVariable("MAVEN_PASSWORD").orElse("NONE").get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+427
@@ -0,0 +1,427 @@
|
|||||||
|
package dev.strataproxy.backendchannel;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.io.DataInputStream;
|
||||||
|
import java.io.DataOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ArrayBlockingQueue;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.CompletionException;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.RejectedExecutionException;
|
||||||
|
import java.util.concurrent.ThreadFactory;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/** Java 8-compatible reconnecting client for StrataProxy backend control channels. */
|
||||||
|
public final class BackendChannelClient implements AutoCloseable {
|
||||||
|
public static final long DEFAULT_REQUEST_TIMEOUT_MILLIS = 5000L;
|
||||||
|
public static final int MAX_PENDING_REQUESTS = 128;
|
||||||
|
public static final int MAX_HANDLER_QUEUE = 128;
|
||||||
|
|
||||||
|
private final String host, instanceId, backendName, gameAddress, generation, keyId;
|
||||||
|
private final int port;
|
||||||
|
private final byte[] secret;
|
||||||
|
private final long heartbeatMillis, requestTimeoutMillis, reconnectMinMillis, reconnectMaxMillis;
|
||||||
|
private final AtomicBoolean closed = new AtomicBoolean();
|
||||||
|
private final AtomicLong nextRequestId = new AtomicLong(1);
|
||||||
|
private final AtomicInteger activeInbound = new AtomicInteger();
|
||||||
|
private final AtomicReference<Session> session = new AtomicReference<Session>();
|
||||||
|
private volatile Session connecting;
|
||||||
|
private final Map<String, BackendChannelHandler> handlers = new ConcurrentHashMap<String, BackendChannelHandler>();
|
||||||
|
private final Map<Long, CompletableFuture<byte[]>> pending = new ConcurrentHashMap<Long, CompletableFuture<byte[]>>();
|
||||||
|
private final ThreadPoolExecutor handlerExecutor = new ThreadPoolExecutor(2, 2, 0L, TimeUnit.MILLISECONDS,
|
||||||
|
new ArrayBlockingQueue<Runnable>(MAX_HANDLER_QUEUE), namedFactory("backend-channel-handler"), new ThreadPoolExecutor.AbortPolicy());
|
||||||
|
private final ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor(2, namedFactory("backend-channel-timer"));
|
||||||
|
private final Object stateMonitor = new Object();
|
||||||
|
private final Object pendingLock = new Object();
|
||||||
|
private volatile boolean registered;
|
||||||
|
private volatile long epoch;
|
||||||
|
private final Thread connectionThread;
|
||||||
|
|
||||||
|
public BackendChannelClient(String host, int port, String instanceId, String backendName, String gameAddress,
|
||||||
|
String generation, String keyId, byte[] secret) {
|
||||||
|
this(host, port, instanceId, backendName, gameAddress, generation, keyId, secret,
|
||||||
|
10_000L, DEFAULT_REQUEST_TIMEOUT_MILLIS, 500L, 10_000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BackendChannelClient(String host, int port, String instanceId, String backendName, String gameAddress,
|
||||||
|
String generation, String keyId, byte[] secret, long heartbeatMillis,
|
||||||
|
long requestTimeoutMillis, long reconnectMinMillis, long reconnectMaxMillis) {
|
||||||
|
if (host == null || host.trim().isEmpty()) throw new IllegalArgumentException("host is required");
|
||||||
|
if (port < 1 || port > 65535) throw new IllegalArgumentException("port out of range");
|
||||||
|
if (secret == null || secret.length < 32) throw new IllegalArgumentException("secret must contain at least 32 bytes");
|
||||||
|
if (heartbeatMillis < 1000L || requestTimeoutMillis < 1L || reconnectMinMillis < 1L || reconnectMaxMillis < reconnectMinMillis) {
|
||||||
|
throw new IllegalArgumentException("invalid timing configuration");
|
||||||
|
}
|
||||||
|
validateIdentity(instanceId, "instanceId"); validateIdentity(backendName, "backendName");
|
||||||
|
validateIdentity(gameAddress, "gameAddress"); validateIdentity(generation, "generation"); validateIdentity(keyId, "keyId");
|
||||||
|
this.host = host; this.port = port; this.instanceId = instanceId; this.backendName = backendName;
|
||||||
|
this.gameAddress = gameAddress; this.generation = generation; this.keyId = keyId;
|
||||||
|
this.secret = Arrays.copyOf(secret, secret.length); this.heartbeatMillis = heartbeatMillis;
|
||||||
|
this.requestTimeoutMillis = requestTimeoutMillis; this.reconnectMinMillis = reconnectMinMillis;
|
||||||
|
this.reconnectMaxMillis = reconnectMaxMillis;
|
||||||
|
this.timer.setRemoveOnCancelPolicy(true);
|
||||||
|
this.connectionThread = new Thread(new Runnable() { public void run() { reconnectLoop(); } }, "backend-channel-connection");
|
||||||
|
this.connectionThread.setDaemon(true);
|
||||||
|
this.connectionThread.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true after REGISTERED and until the active connection is lost. */
|
||||||
|
public boolean isRegistered() { return registered; }
|
||||||
|
|
||||||
|
/** Waits for initial registration or a later reconnect. */
|
||||||
|
public boolean awaitRegistered(long timeout, TimeUnit unit) throws InterruptedException {
|
||||||
|
if (timeout < 0) throw new IllegalArgumentException("timeout must not be negative");
|
||||||
|
long deadline = System.nanoTime() + unit.toNanos(timeout);
|
||||||
|
synchronized (stateMonitor) {
|
||||||
|
while (!registered && !closed.get()) {
|
||||||
|
long left = deadline - System.nanoTime();
|
||||||
|
if (left <= 0) return false;
|
||||||
|
TimeUnit.NANOSECONDS.timedWait(stateMonitor, left);
|
||||||
|
}
|
||||||
|
return registered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Installs one handler for a channel. Closing the returned handle unregisters it. */
|
||||||
|
public AutoCloseable registerHandler(String channel, BackendChannelHandler handler) {
|
||||||
|
validateChannel(channel);
|
||||||
|
if (handler == null) throw new IllegalArgumentException("handler is required");
|
||||||
|
if (handlers.putIfAbsent(channel, handler) != null) throw new IllegalStateException("handler already registered: " + channel);
|
||||||
|
final BackendChannelHandler installed = handler;
|
||||||
|
return new AutoCloseable() {
|
||||||
|
public void close() { handlers.remove(channel, installed); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends a one-way message. Fails immediately if disconnected or backpressured. */
|
||||||
|
public void send(String channel, byte[] payload) throws IOException {
|
||||||
|
validateChannel(channel);
|
||||||
|
if (payload == null || payload.length > Wire.MAX_PAYLOAD_BYTES) throw new IllegalArgumentException("payload length out of bounds");
|
||||||
|
Session active = session.get();
|
||||||
|
if (!registered || active == null) throw new IOException("backend channel is disconnected");
|
||||||
|
active.write(Wire.message(channel, 0L, payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends a request using the configured default timeout. */
|
||||||
|
public CompletableFuture<byte[]> request(String channel, byte[] payload) {
|
||||||
|
return request(channel, payload, requestTimeoutMillis, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends a request. The returned future fails on timeout, disconnect, or non-OK response. */
|
||||||
|
public CompletableFuture<byte[]> request(String channel, byte[] payload, long timeout, TimeUnit unit) {
|
||||||
|
validateChannel(channel);
|
||||||
|
if (payload == null || payload.length > Wire.MAX_PAYLOAD_BYTES) throw new IllegalArgumentException("payload length out of bounds");
|
||||||
|
if (timeout < 1) throw new IllegalArgumentException("timeout must be positive");
|
||||||
|
Session active = session.get();
|
||||||
|
if (!registered || active == null) return failed(new IOException("backend channel is disconnected"));
|
||||||
|
if (pending.size() >= MAX_PENDING_REQUESTS) return failed(new IOException("too many pending requests"));
|
||||||
|
long id = nextRequestId.getAndIncrement();
|
||||||
|
if (id <= 0) return failed(new IOException("request ID space exhausted"));
|
||||||
|
final CompletableFuture<byte[]> result = new CompletableFuture<byte[]>();
|
||||||
|
synchronized (pendingLock) {
|
||||||
|
if (pending.size() >= MAX_PENDING_REQUESTS) return failed(new IOException("too many pending requests"));
|
||||||
|
if (pending.putIfAbsent(id, result) != null) return failed(new IOException("request ID collision"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
active.write(Wire.message(channel, id, payload));
|
||||||
|
} catch (IOException ex) {
|
||||||
|
pending.remove(id, result);
|
||||||
|
result.completeExceptionally(ex);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
final ScheduledFuture<?> timeoutTask;
|
||||||
|
try {
|
||||||
|
timeoutTask = timer.schedule(new Runnable() {
|
||||||
|
public void run() { result.completeExceptionally(new TimeoutException("backend request timed out")); }
|
||||||
|
}, timeout, unit);
|
||||||
|
} catch (RejectedExecutionException stopped) {
|
||||||
|
pending.remove(id, result);
|
||||||
|
result.completeExceptionally(stopped);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.whenComplete((value, error) -> {
|
||||||
|
pending.remove(id, result);
|
||||||
|
timeoutTask.cancel(false);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getEpoch() { return epoch; }
|
||||||
|
|
||||||
|
@Override public void close() {
|
||||||
|
if (!closed.compareAndSet(false, true)) return;
|
||||||
|
Session active = session.getAndSet(null);
|
||||||
|
Session inProgress = connecting;
|
||||||
|
connecting = null;
|
||||||
|
registered = false;
|
||||||
|
synchronized (stateMonitor) { stateMonitor.notifyAll(); }
|
||||||
|
if (active != null) {
|
||||||
|
try { active.write(Wire.empty(Wire.GOODBYE)); } catch (IOException ignored) { }
|
||||||
|
active.close();
|
||||||
|
}
|
||||||
|
if (inProgress != null && inProgress != active) inProgress.close();
|
||||||
|
connectionThread.interrupt();
|
||||||
|
handlerExecutor.shutdownNow();
|
||||||
|
timer.shutdownNow();
|
||||||
|
failPending(new IOException("backend channel client closed"));
|
||||||
|
Arrays.fill(secret, (byte) 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reconnectLoop() {
|
||||||
|
long pause = reconnectMinMillis;
|
||||||
|
while (!closed.get()) {
|
||||||
|
Session active = null;
|
||||||
|
try {
|
||||||
|
Socket socket = new Socket();
|
||||||
|
active = new Session(socket);
|
||||||
|
connecting = active;
|
||||||
|
if (closed.get()) { active.close(); break; }
|
||||||
|
socket.connect(new InetSocketAddress(host, port), 5000);
|
||||||
|
active.initialize();
|
||||||
|
runSession(active);
|
||||||
|
pause = reconnectMinMillis;
|
||||||
|
} catch (InterruptedException interrupted) {
|
||||||
|
if (closed.get()) break;
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Connection details are deliberately not logged because protocol errors can include peer data.
|
||||||
|
} finally {
|
||||||
|
Session prior = session.getAndSet(null);
|
||||||
|
connecting = null;
|
||||||
|
if (prior != null) prior.close();
|
||||||
|
if (active != null) active.close();
|
||||||
|
setRegistered(false);
|
||||||
|
failPending(new IOException("backend channel disconnected"));
|
||||||
|
}
|
||||||
|
if (!closed.get()) {
|
||||||
|
try { Thread.sleep(pause); } catch (InterruptedException e) { if (closed.get()) break; }
|
||||||
|
pause = Math.min(reconnectMaxMillis, pause > reconnectMaxMillis / 2 ? reconnectMaxMillis : pause * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runSession(Session active) throws Exception {
|
||||||
|
active.socket.setSoTimeout(5000);
|
||||||
|
byte[] helloFrame = FrameCodec.read(active.in);
|
||||||
|
Wire.Hello hello = Wire.decodeHello(helloFrame);
|
||||||
|
if (hello.version != Wire.VERSION) throw new IOException("unsupported proxy protocol version");
|
||||||
|
byte[] canonical = Wire.canonicalRegistration(instanceId, backendName, gameAddress, generation, keyId);
|
||||||
|
byte[] signed = new byte[hello.nonce.length + canonical.length];
|
||||||
|
System.arraycopy(hello.nonce, 0, signed, 0, hello.nonce.length);
|
||||||
|
System.arraycopy(canonical, 0, signed, hello.nonce.length, canonical.length);
|
||||||
|
byte[] signature = hmac(secret, signed);
|
||||||
|
active.write(Wire.register(instanceId, backendName, gameAddress, generation, keyId, signature));
|
||||||
|
byte[] registeredFrame = FrameCodec.read(active.in);
|
||||||
|
epoch = Wire.decodeRegistered(registeredFrame);
|
||||||
|
long pongTimeoutMillis = pongTimeoutMillis(heartbeatMillis);
|
||||||
|
active.socket.setSoTimeout((int) Math.min(Integer.MAX_VALUE, pongTimeoutMillis));
|
||||||
|
active.lastPongNanos = System.nanoTime();
|
||||||
|
session.set(active);
|
||||||
|
setRegistered(true);
|
||||||
|
active.heartbeatTask = timer.scheduleAtFixedRate(new Runnable() {
|
||||||
|
public void run() {
|
||||||
|
if (session.get() != active || closed.get()) return;
|
||||||
|
try { active.write(Wire.empty(Wire.HEARTBEAT)); }
|
||||||
|
catch (IOException failure) { active.close(); }
|
||||||
|
}
|
||||||
|
}, heartbeatMillis, heartbeatMillis, TimeUnit.MILLISECONDS);
|
||||||
|
active.livenessTask = timer.scheduleAtFixedRate(new Runnable() {
|
||||||
|
public void run() {
|
||||||
|
if (session.get() != active || closed.get()) return;
|
||||||
|
long age = System.nanoTime() - active.lastPongNanos;
|
||||||
|
if (age >= TimeUnit.MILLISECONDS.toNanos(pongTimeoutMillis)) active.close();
|
||||||
|
}
|
||||||
|
}, heartbeatMillis, heartbeatMillis, TimeUnit.MILLISECONDS);
|
||||||
|
while (!closed.get() && !active.socket.isClosed()) {
|
||||||
|
byte[] frame = FrameCodec.read(active.in);
|
||||||
|
handleFrame(active, frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleFrame(final Session active, byte[] frame) throws IOException {
|
||||||
|
if (frame.length == 0) throw new IOException("empty frame");
|
||||||
|
int type = frame[0] & 0xff;
|
||||||
|
if (type == Wire.PONG) {
|
||||||
|
Wire.validateEmpty(frame, Wire.PONG);
|
||||||
|
active.lastPongNanos = System.nanoTime();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == Wire.MESSAGE) {
|
||||||
|
final Wire.Message message = Wire.decodeMessage(frame);
|
||||||
|
final BackendChannelHandler handler = handlers.get(message.channel);
|
||||||
|
if (message.requestId == 0 && handler == null) return;
|
||||||
|
if (activeInbound.incrementAndGet() > MAX_PENDING_REQUESTS) {
|
||||||
|
activeInbound.decrementAndGet();
|
||||||
|
if (message.requestId != 0) respond(active, message.requestId, Wire.STATUS_ERROR, new byte[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
handlerExecutor.execute(new Runnable() {
|
||||||
|
public void run() {
|
||||||
|
if (handler == null) {
|
||||||
|
respond(active, message.requestId, Wire.STATUS_NO_HANDLER, new byte[0]);
|
||||||
|
activeInbound.decrementAndGet();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
java.util.concurrent.CompletionStage<byte[]> stage = handler.handle(message.payload);
|
||||||
|
if (stage == null) {
|
||||||
|
respond(active, message.requestId, Wire.STATUS_ERROR, new byte[0]);
|
||||||
|
activeInbound.decrementAndGet();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stage.whenComplete((value, error) -> {
|
||||||
|
try {
|
||||||
|
if (message.requestId == 0) return;
|
||||||
|
if (error != null) respond(active, message.requestId, Wire.STATUS_ERROR, errorPayload(error));
|
||||||
|
else respond(active, message.requestId, Wire.STATUS_OK, value == null ? new byte[0] : value);
|
||||||
|
} finally { activeInbound.decrementAndGet(); }
|
||||||
|
});
|
||||||
|
} catch (Throwable error) {
|
||||||
|
try {
|
||||||
|
if (message.requestId != 0) respond(active, message.requestId, Wire.STATUS_ERROR, errorPayload(error));
|
||||||
|
} finally { activeInbound.decrementAndGet(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (RejectedExecutionException full) {
|
||||||
|
activeInbound.decrementAndGet();
|
||||||
|
if (message.requestId != 0) respond(active, message.requestId, Wire.STATUS_ERROR, new byte[0]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == Wire.GOODBYE) { Wire.validateEmpty(frame, Wire.GOODBYE); throw new IOException("proxy closed channel"); }
|
||||||
|
if (type == Wire.RESPONSE) {
|
||||||
|
Wire.Response response = Wire.decodeResponse(frame);
|
||||||
|
CompletableFuture<byte[]> future = pending.get(response.requestId);
|
||||||
|
if (future != null) {
|
||||||
|
if (response.status == Wire.STATUS_OK) future.complete(response.payload);
|
||||||
|
else future.completeExceptionally(new ChannelResponseException(response.status, response.payload));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new IOException("unexpected proxy frame type: " + type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void respond(Session active, long requestId, int status, byte[] payload) {
|
||||||
|
if (requestId == 0 || session.get() != active) return;
|
||||||
|
try {
|
||||||
|
active.write(Wire.response(requestId, status, payload));
|
||||||
|
} catch (Exception ignored) { active.close(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setRegistered(boolean value) {
|
||||||
|
registered = value;
|
||||||
|
synchronized (stateMonitor) { stateMonitor.notifyAll(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void failPending(IOException cause) {
|
||||||
|
for (CompletableFuture<byte[]> future : pending.values()) future.completeExceptionally(cause);
|
||||||
|
pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] hmac(byte[] key, byte[] data) throws GeneralSecurityException {
|
||||||
|
Mac mac = Mac.getInstance("HmacSHA256");
|
||||||
|
mac.init(new SecretKeySpec(key, "HmacSHA256"));
|
||||||
|
return mac.doFinal(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long pongTimeoutMillis(long heartbeatMillis) {
|
||||||
|
long triple = heartbeatMillis > Long.MAX_VALUE / 3L ? Long.MAX_VALUE : heartbeatMillis * 3L;
|
||||||
|
return Math.max(15_000L, triple);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateIdentity(String value, String label) {
|
||||||
|
if (value == null || value.trim().isEmpty() || value.getBytes(StandardCharsets.UTF_8).length > Wire.MAX_STRING_BYTES) {
|
||||||
|
throw new IllegalArgumentException(label + " is empty or too long");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateChannel(String channel) {
|
||||||
|
validateIdentity(channel, "channel");
|
||||||
|
if (!channel.matches("[a-z0-9][a-z0-9_.-]{0,63}:[a-z0-9][a-z0-9_.-]{0,63}")) {
|
||||||
|
throw new IllegalArgumentException("channel must match namespace:name using lowercase ASCII letters, digits, '.', '_' or '-'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] errorPayload(Throwable error) {
|
||||||
|
String message = error.getMessage();
|
||||||
|
if (message == null) message = error.getClass().getSimpleName();
|
||||||
|
byte[] bytes = message.getBytes(StandardCharsets.UTF_8);
|
||||||
|
return bytes.length <= Wire.MAX_PAYLOAD_BYTES ? bytes : Arrays.copyOf(bytes, Wire.MAX_PAYLOAD_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> CompletableFuture<T> failed(Throwable error) {
|
||||||
|
CompletableFuture<T> future = new CompletableFuture<T>();
|
||||||
|
future.completeExceptionally(error);
|
||||||
|
return future;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ThreadFactory namedFactory(final String prefix) {
|
||||||
|
return new ThreadFactory() {
|
||||||
|
private final AtomicLong count = new AtomicLong();
|
||||||
|
public Thread newThread(Runnable task) {
|
||||||
|
Thread thread = new Thread(task, prefix + "-" + count.incrementAndGet());
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class ChannelResponseException extends IOException {
|
||||||
|
private final int status;
|
||||||
|
private final byte[] payload;
|
||||||
|
ChannelResponseException(int status, byte[] payload) {
|
||||||
|
super("backend channel response status: " + status);
|
||||||
|
this.status = status;
|
||||||
|
this.payload = payload;
|
||||||
|
}
|
||||||
|
public int getStatus() { return status; }
|
||||||
|
public byte[] getPayload() { return Arrays.copyOf(payload, payload.length); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Session {
|
||||||
|
final Socket socket;
|
||||||
|
volatile DataInputStream in;
|
||||||
|
volatile DataOutputStream out;
|
||||||
|
volatile java.util.concurrent.ScheduledFuture<?> heartbeatTask;
|
||||||
|
volatile java.util.concurrent.ScheduledFuture<?> livenessTask;
|
||||||
|
volatile long lastPongNanos;
|
||||||
|
Session(Socket socket) { this.socket = socket; }
|
||||||
|
void initialize() throws IOException {
|
||||||
|
this.in = new DataInputStream(socket.getInputStream());
|
||||||
|
this.out = new DataOutputStream(socket.getOutputStream());
|
||||||
|
}
|
||||||
|
synchronized void write(byte[] frame) throws IOException {
|
||||||
|
if (socket.isClosed()) throw new IOException("connection closed");
|
||||||
|
FrameCodec.write(out, frame);
|
||||||
|
}
|
||||||
|
void close() {
|
||||||
|
java.util.concurrent.ScheduledFuture<?> task = heartbeatTask;
|
||||||
|
if (task != null) task.cancel(false);
|
||||||
|
java.util.concurrent.ScheduledFuture<?> liveness = livenessTask;
|
||||||
|
if (liveness != null) liveness.cancel(false);
|
||||||
|
try { socket.close(); } catch (IOException ignored) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package dev.strataproxy.backendchannel;
|
||||||
|
|
||||||
|
import java.util.concurrent.CompletionStage;
|
||||||
|
|
||||||
|
/** Handles an inbound request or one-way message for a registered channel name. */
|
||||||
|
public interface BackendChannelHandler {
|
||||||
|
CompletionStage<byte[]> handle(byte[] payload) throws Exception;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package dev.strataproxy.backendchannel;
|
||||||
|
|
||||||
|
import java.io.DataInputStream;
|
||||||
|
import java.io.DataOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/** Length-prefixed frame I/O. The length and frame type are both included in the transmitted frame. */
|
||||||
|
public final class FrameCodec {
|
||||||
|
private FrameCodec() { }
|
||||||
|
|
||||||
|
public static byte[] read(DataInputStream input) throws IOException {
|
||||||
|
int length = input.readInt();
|
||||||
|
if (length < 1 || length > Wire.MAX_FRAME_BYTES) throw new IOException("frame length out of bounds: " + length);
|
||||||
|
byte[] frame = new byte[length];
|
||||||
|
input.readFully(frame);
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void write(DataOutputStream output, byte[] frame) throws IOException {
|
||||||
|
if (frame == null || frame.length < 1 || frame.length > Wire.MAX_FRAME_BYTES) {
|
||||||
|
throw new IOException("frame length out of bounds");
|
||||||
|
}
|
||||||
|
output.writeInt(frame.length);
|
||||||
|
output.write(frame);
|
||||||
|
output.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package dev.strataproxy.backendchannel;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.DataInputStream;
|
||||||
|
import java.io.DataOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/** Wire constants and codecs shared by the backend SDK and proxy. */
|
||||||
|
public final class Wire {
|
||||||
|
public static final int VERSION = 1;
|
||||||
|
public static final int HELLO = 1;
|
||||||
|
public static final int REGISTER = 2;
|
||||||
|
public static final int REGISTERED = 3;
|
||||||
|
public static final int HEARTBEAT = 4;
|
||||||
|
public static final int PONG = 5;
|
||||||
|
public static final int GOODBYE = 6;
|
||||||
|
public static final int MESSAGE = 7;
|
||||||
|
public static final int RESPONSE = 8;
|
||||||
|
|
||||||
|
public static final int STATUS_OK = 0;
|
||||||
|
public static final int STATUS_NO_HANDLER = 1;
|
||||||
|
public static final int STATUS_ERROR = 2;
|
||||||
|
|
||||||
|
public static final int NONCE_BYTES = 32;
|
||||||
|
public static final int MAX_FRAME_BYTES = 66 * 1024;
|
||||||
|
public static final int MAX_PAYLOAD_BYTES = 64 * 1024;
|
||||||
|
public static final int MAX_STRING_BYTES = 1024;
|
||||||
|
|
||||||
|
private Wire() { }
|
||||||
|
|
||||||
|
public static byte[] hello(int version, byte[] nonce) throws IOException {
|
||||||
|
if (nonce == null || nonce.length != NONCE_BYTES) throw new IllegalArgumentException("nonce must be 32 bytes");
|
||||||
|
ByteArrayOutputStream bytes = new ByteArrayOutputStream(1 + 4 + NONCE_BYTES);
|
||||||
|
DataOutputStream out = new DataOutputStream(bytes);
|
||||||
|
out.writeByte(HELLO);
|
||||||
|
out.writeInt(version);
|
||||||
|
out.write(nonce);
|
||||||
|
return bytes.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Hello decodeHello(byte[] frame) throws IOException {
|
||||||
|
DataInputStream in = body(frame, HELLO);
|
||||||
|
int version = in.readInt();
|
||||||
|
byte[] nonce = new byte[NONCE_BYTES];
|
||||||
|
in.readFully(nonce);
|
||||||
|
requireEnd(in);
|
||||||
|
return new Hello(version, nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] register(String instanceId, String name, String address, String generation,
|
||||||
|
String keyId, byte[] signature) throws IOException {
|
||||||
|
if (signature == null || signature.length != 32) throw new IllegalArgumentException("signature must be 32 bytes");
|
||||||
|
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||||
|
DataOutputStream out = new DataOutputStream(bytes);
|
||||||
|
out.writeByte(REGISTER);
|
||||||
|
writeString(out, instanceId);
|
||||||
|
writeString(out, name);
|
||||||
|
writeString(out, address);
|
||||||
|
writeString(out, generation);
|
||||||
|
writeString(out, keyId);
|
||||||
|
out.write(signature);
|
||||||
|
return bounded(bytes.toByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical HMAC input after the server nonce: five unsigned-short-length-prefixed UTF-8 strings. */
|
||||||
|
public static byte[] canonicalRegistration(String instanceId, String name, String address,
|
||||||
|
String generation, String keyId) throws IOException {
|
||||||
|
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||||
|
DataOutputStream out = new DataOutputStream(bytes);
|
||||||
|
writeString(out, instanceId);
|
||||||
|
writeString(out, name);
|
||||||
|
writeString(out, address);
|
||||||
|
writeString(out, generation);
|
||||||
|
writeString(out, keyId);
|
||||||
|
return bytes.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Registration decodeRegister(byte[] frame) throws IOException {
|
||||||
|
DataInputStream in = body(frame, REGISTER);
|
||||||
|
String instanceId = readString(in);
|
||||||
|
String name = readString(in);
|
||||||
|
String address = readString(in);
|
||||||
|
String generation = readString(in);
|
||||||
|
String keyId = readString(in);
|
||||||
|
byte[] signature = new byte[32];
|
||||||
|
in.readFully(signature);
|
||||||
|
requireEnd(in);
|
||||||
|
return new Registration(instanceId, name, address, generation, keyId, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] registered(long epoch) throws IOException {
|
||||||
|
ByteArrayOutputStream bytes = new ByteArrayOutputStream(9);
|
||||||
|
DataOutputStream out = new DataOutputStream(bytes);
|
||||||
|
out.writeByte(REGISTERED);
|
||||||
|
out.writeLong(epoch);
|
||||||
|
return bytes.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long decodeRegistered(byte[] frame) throws IOException {
|
||||||
|
DataInputStream in = body(frame, REGISTERED);
|
||||||
|
long epoch = in.readLong();
|
||||||
|
requireEnd(in);
|
||||||
|
return epoch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] empty(int type) throws IOException {
|
||||||
|
if (type != HEARTBEAT && type != PONG && type != GOODBYE) throw new IllegalArgumentException("not an empty frame type");
|
||||||
|
return new byte[] { (byte) type };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void validateEmpty(byte[] frame, int type) throws IOException {
|
||||||
|
DataInputStream in = body(frame, type);
|
||||||
|
requireEnd(in);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] message(String channel, long requestId, byte[] payload) throws IOException {
|
||||||
|
requirePayload(payload);
|
||||||
|
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||||
|
DataOutputStream out = new DataOutputStream(bytes);
|
||||||
|
out.writeByte(MESSAGE);
|
||||||
|
writeString(out, channel);
|
||||||
|
out.writeLong(requestId);
|
||||||
|
out.writeInt(payload.length);
|
||||||
|
out.write(payload);
|
||||||
|
return bounded(bytes.toByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Message decodeMessage(byte[] frame) throws IOException {
|
||||||
|
DataInputStream in = body(frame, MESSAGE);
|
||||||
|
String channel = readString(in);
|
||||||
|
long requestId = in.readLong();
|
||||||
|
if (requestId < 0) throw new IOException("negative request ID");
|
||||||
|
byte[] payload = readPayload(in);
|
||||||
|
requireEnd(in);
|
||||||
|
return new Message(channel, requestId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] response(long requestId, int status, byte[] payload) throws IOException {
|
||||||
|
if (requestId <= 0) throw new IllegalArgumentException("requestId must be positive");
|
||||||
|
if (status < STATUS_OK || status > STATUS_ERROR) throw new IllegalArgumentException("invalid response status");
|
||||||
|
requirePayload(payload);
|
||||||
|
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||||
|
DataOutputStream out = new DataOutputStream(bytes);
|
||||||
|
out.writeByte(RESPONSE);
|
||||||
|
out.writeLong(requestId);
|
||||||
|
out.writeByte(status);
|
||||||
|
out.writeInt(payload.length);
|
||||||
|
out.write(payload);
|
||||||
|
return bounded(bytes.toByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Response decodeResponse(byte[] frame) throws IOException {
|
||||||
|
DataInputStream in = body(frame, RESPONSE);
|
||||||
|
long requestId = in.readLong();
|
||||||
|
int status = in.readUnsignedByte();
|
||||||
|
if (requestId <= 0 || status > STATUS_ERROR) throw new IOException("invalid response header");
|
||||||
|
byte[] payload = readPayload(in);
|
||||||
|
requireEnd(in);
|
||||||
|
return new Response(requestId, status, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
static DataInputStream body(byte[] frame, int type) throws IOException {
|
||||||
|
if (frame == null || frame.length < 1 || frame.length > MAX_FRAME_BYTES || (frame[0] & 0xff) != type) {
|
||||||
|
throw new IOException("invalid frame type or size");
|
||||||
|
}
|
||||||
|
return new DataInputStream(new ByteArrayInputStream(frame, 1, frame.length - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] bounded(byte[] frame) throws IOException {
|
||||||
|
if (frame.length > MAX_FRAME_BYTES) throw new IOException("frame exceeds maximum size");
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeString(DataOutputStream out, String value) throws IOException {
|
||||||
|
if (value == null) throw new IllegalArgumentException("string cannot be null");
|
||||||
|
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (encoded.length == 0 || encoded.length > MAX_STRING_BYTES) throw new IllegalArgumentException("string length out of bounds");
|
||||||
|
out.writeShort(encoded.length);
|
||||||
|
out.write(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readString(DataInputStream in) throws IOException {
|
||||||
|
int length = in.readUnsignedShort();
|
||||||
|
if (length == 0 || length > MAX_STRING_BYTES) throw new IOException("string length out of bounds");
|
||||||
|
byte[] encoded = new byte[length];
|
||||||
|
in.readFully(encoded);
|
||||||
|
return new String(encoded, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] readPayload(DataInputStream in) throws IOException {
|
||||||
|
int length = in.readInt();
|
||||||
|
if (length < 0 || length > MAX_PAYLOAD_BYTES) throw new IOException("payload length out of bounds");
|
||||||
|
byte[] payload = new byte[length];
|
||||||
|
in.readFully(payload);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requirePayload(byte[] payload) {
|
||||||
|
if (payload == null || payload.length > MAX_PAYLOAD_BYTES) throw new IllegalArgumentException("payload length out of bounds");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireEnd(DataInputStream in) throws IOException {
|
||||||
|
if (in.read() != -1) throw new IOException("trailing frame data");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Hello {
|
||||||
|
public final int version;
|
||||||
|
public final byte[] nonce;
|
||||||
|
Hello(int version, byte[] nonce) { this.version = version; this.nonce = nonce; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Registration {
|
||||||
|
public final String instanceId, name, address, generation, keyId;
|
||||||
|
public final byte[] signature;
|
||||||
|
Registration(String instanceId, String name, String address, String generation, String keyId, byte[] signature) {
|
||||||
|
this.instanceId = instanceId; this.name = name; this.address = address; this.generation = generation;
|
||||||
|
this.keyId = keyId; this.signature = signature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Message {
|
||||||
|
public final String channel;
|
||||||
|
public final long requestId;
|
||||||
|
public final byte[] payload;
|
||||||
|
Message(String channel, long requestId, byte[] payload) { this.channel = channel; this.requestId = requestId; this.payload = payload; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Response {
|
||||||
|
public final long requestId;
|
||||||
|
public final int status;
|
||||||
|
public final byte[] payload;
|
||||||
|
Response(long requestId, int status, byte[] payload) { this.requestId = requestId; this.status = status; this.payload = payload; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
package dev.strataproxy.backendchannel;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.io.DataInputStream;
|
||||||
|
import java.io.DataOutputStream;
|
||||||
|
import java.net.ServerSocket;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class BackendChannelClientTest {
|
||||||
|
@Test void frameCodecRoundTripsAndRejectsOversize() throws Exception {
|
||||||
|
byte[] frame = Wire.message("demo:echo", 9L, new byte[] { 1, 2, 3 });
|
||||||
|
java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream();
|
||||||
|
DataOutputStream out = new DataOutputStream(bytes);
|
||||||
|
FrameCodec.write(out, frame);
|
||||||
|
byte[] encoded = bytes.toByteArray();
|
||||||
|
assertEquals(frame.length, ByteBuffer.wrap(encoded).getInt());
|
||||||
|
Wire.Message decoded = Wire.decodeMessage(FrameCodec.read(new DataInputStream(new java.io.ByteArrayInputStream(encoded))));
|
||||||
|
assertEquals("demo:echo", decoded.channel);
|
||||||
|
assertEquals(9L, decoded.requestId);
|
||||||
|
assertArrayEquals(new byte[] { 1, 2, 3 }, decoded.payload);
|
||||||
|
assertThrows(java.io.IOException.class, () -> FrameCodec.read(new DataInputStream(new java.io.ByteArrayInputStream(new byte[] { 0, 1, 0, 1 }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void authenticatesRegistersAndExchangesRequestsInBothDirections() throws Exception {
|
||||||
|
final byte[] secret = new byte[32];
|
||||||
|
Arrays.fill(secret, (byte) 0x6a);
|
||||||
|
final ServerSocket server = new ServerSocket(0);
|
||||||
|
CompletableFuture<Void> peer = CompletableFuture.runAsync(() -> {
|
||||||
|
try (Socket socket = server.accept()) {
|
||||||
|
DataInputStream in = new DataInputStream(socket.getInputStream());
|
||||||
|
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
|
||||||
|
byte[] nonce = new byte[Wire.NONCE_BYTES];
|
||||||
|
Arrays.fill(nonce, (byte) 0x33);
|
||||||
|
FrameCodec.write(out, Wire.hello(Wire.VERSION, nonce));
|
||||||
|
Wire.Registration registration = Wire.decodeRegister(FrameCodec.read(in));
|
||||||
|
assertEquals("backend-1", registration.instanceId);
|
||||||
|
byte[] canonical = Wire.canonicalRegistration("backend-1", "island-a", "tcp://127.0.0.1:25565", "gen-1", "key-1");
|
||||||
|
byte[] signed = new byte[nonce.length + canonical.length];
|
||||||
|
System.arraycopy(nonce, 0, signed, 0, nonce.length);
|
||||||
|
System.arraycopy(canonical, 0, signed, nonce.length, canonical.length);
|
||||||
|
Mac mac = Mac.getInstance("HmacSHA256");
|
||||||
|
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
|
||||||
|
assertArrayEquals(mac.doFinal(signed), registration.signature);
|
||||||
|
FrameCodec.write(out, Wire.registered(77L));
|
||||||
|
|
||||||
|
Wire.Message request = Wire.decodeMessage(FrameCodec.read(in));
|
||||||
|
assertEquals("proxy:query", request.channel);
|
||||||
|
assertEquals(1L, request.requestId);
|
||||||
|
FrameCodec.write(out, Wire.response(request.requestId, Wire.STATUS_OK, "answer".getBytes(StandardCharsets.UTF_8)));
|
||||||
|
|
||||||
|
FrameCodec.write(out, Wire.message("backend:echo", 42L, "hello".getBytes(StandardCharsets.UTF_8)));
|
||||||
|
Wire.Response response = Wire.decodeResponse(FrameCodec.read(in));
|
||||||
|
assertEquals(42L, response.requestId);
|
||||||
|
assertEquals(Wire.STATUS_OK, response.status);
|
||||||
|
assertArrayEquals("hello!".getBytes(StandardCharsets.UTF_8), response.payload);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
} finally {
|
||||||
|
try { server.close(); } catch (Exception ignored) { }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
BackendChannelClient client = new BackendChannelClient("127.0.0.1", server.getLocalPort(), "backend-1", "island-a",
|
||||||
|
"tcp://127.0.0.1:25565", "gen-1", "key-1", secret, 10_000L, 2_000L, 100L, 500L);
|
||||||
|
client.registerHandler("backend:echo", payload -> CompletableFuture.completedFuture(
|
||||||
|
(new String(payload, StandardCharsets.UTF_8) + "!").getBytes(StandardCharsets.UTF_8)));
|
||||||
|
try {
|
||||||
|
assertTrue(client.awaitRegistered(5, TimeUnit.SECONDS));
|
||||||
|
assertEquals(77L, client.getEpoch());
|
||||||
|
byte[] answer = client.request("proxy:query", new byte[] { 7 }).get(5, TimeUnit.SECONDS);
|
||||||
|
assertArrayEquals("answer".getBytes(StandardCharsets.UTF_8), answer);
|
||||||
|
peer.get(5, TimeUnit.SECONDS);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void reconnectsAndRegistersAgainAfterConnectionLoss() throws Exception {
|
||||||
|
final byte[] secret = new byte[32];
|
||||||
|
Arrays.fill(secret, (byte) 0x2d);
|
||||||
|
final ServerSocket server = new ServerSocket(0);
|
||||||
|
CompletableFuture<Void> peer = CompletableFuture.runAsync(() -> {
|
||||||
|
try {
|
||||||
|
for (int connection = 1; connection <= 2; connection++) {
|
||||||
|
try (Socket socket = server.accept()) {
|
||||||
|
DataInputStream in = new DataInputStream(socket.getInputStream());
|
||||||
|
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
|
||||||
|
byte[] nonce = new byte[Wire.NONCE_BYTES];
|
||||||
|
Arrays.fill(nonce, (byte) connection);
|
||||||
|
FrameCodec.write(out, Wire.hello(Wire.VERSION, nonce));
|
||||||
|
Wire.Registration registration = Wire.decodeRegister(FrameCodec.read(in));
|
||||||
|
assertEquals("retry-backend", registration.instanceId);
|
||||||
|
FrameCodec.write(out, Wire.registered(connection));
|
||||||
|
if (connection == 2) {
|
||||||
|
Wire.Message request = Wire.decodeMessage(FrameCodec.read(in));
|
||||||
|
FrameCodec.write(out, Wire.response(request.requestId, Wire.STATUS_OK, request.payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
} finally {
|
||||||
|
try { server.close(); } catch (Exception ignored) { }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
BackendChannelClient client = new BackendChannelClient("127.0.0.1", server.getLocalPort(), "retry-backend", "island-b",
|
||||||
|
"tcp://127.0.0.1:25566", "gen-retry", "key-1", secret, 10_000L, 2_000L, 100L, 500L);
|
||||||
|
try {
|
||||||
|
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||||
|
while (client.getEpoch() < 2L && System.nanoTime() < deadline) Thread.sleep(10L);
|
||||||
|
assertEquals(2L, client.getEpoch());
|
||||||
|
assertTrue(client.isRegistered());
|
||||||
|
byte[] payload = new byte[] { 8, 6, 7 };
|
||||||
|
assertArrayEquals(payload, client.request("proxy:retry", payload).get(5, TimeUnit.SECONDS));
|
||||||
|
peer.get(5, TimeUnit.SECONDS);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void disconnectsWhenProxyStopsSendingPongsAndFailsPendingRequest() throws Exception {
|
||||||
|
final byte[] secret = new byte[32];
|
||||||
|
Arrays.fill(secret, (byte) 0x44);
|
||||||
|
final ServerSocket server = new ServerSocket(0);
|
||||||
|
CompletableFuture<Void> peer = CompletableFuture.runAsync(() -> {
|
||||||
|
try (Socket socket = server.accept()) {
|
||||||
|
DataInputStream in = new DataInputStream(socket.getInputStream());
|
||||||
|
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
|
||||||
|
byte[] nonce = new byte[Wire.NONCE_BYTES];
|
||||||
|
Arrays.fill(nonce, (byte) 0x61);
|
||||||
|
FrameCodec.write(out, Wire.hello(Wire.VERSION, nonce));
|
||||||
|
Wire.decodeRegister(FrameCodec.read(in));
|
||||||
|
FrameCodec.write(out, Wire.registered(1L));
|
||||||
|
Wire.decodeMessage(FrameCodec.read(in));
|
||||||
|
while (true) FrameCodec.read(in); // Deliberately consume heartbeats without replying PONG.
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// The client should close this socket after its PONG watchdog expires.
|
||||||
|
} finally {
|
||||||
|
try { server.close(); } catch (Exception ignored) { }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
BackendChannelClient client = new BackendChannelClient("127.0.0.1", server.getLocalPort(), "silent-backend", "island-c",
|
||||||
|
"tcp://127.0.0.1:25567", "gen-silent", "key-1", secret, 1_000L, 60_000L, 100L, 500L);
|
||||||
|
try {
|
||||||
|
assertTrue(client.awaitRegistered(5, TimeUnit.SECONDS));
|
||||||
|
CompletableFuture<byte[]> pending = client.request("proxy:slow", new byte[] { 1 }, 60_000L, TimeUnit.MILLISECONDS);
|
||||||
|
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20);
|
||||||
|
while (client.isRegistered() && System.nanoTime() < deadline) Thread.sleep(25L);
|
||||||
|
assertFalse(client.isRegistered(), "client must expire a connection without PONGs");
|
||||||
|
assertThrows(java.util.concurrent.ExecutionException.class, () -> pending.get(2, TimeUnit.SECONDS));
|
||||||
|
peer.get(2, TimeUnit.SECONDS);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -10,10 +10,10 @@ allprojects {
|
|||||||
|
|
||||||
tasks.register("check") {
|
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
@@ -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 转发没有通用事件派发。
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 后端控制通道
|
||||||
|
|
||||||
|
控制通道已经提供第一版实现:每个后端实例主动连接代理的独立端口,在没有在线玩家时注册,并与代理插件双向交换消息。它不使用 Minecraft PLAY 插件消息,也不使用 HTTP 注册或 DNS 发现。
|
||||||
|
|
||||||
|
## 组成与部署
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
BP[后端插件] --> SDK[Java 8 后端 SDK]
|
||||||
|
SDK -->|每实例一条控制连接| C[代理控制服务]
|
||||||
|
C --> R[后端目录]
|
||||||
|
C --> D[有界消息派发]
|
||||||
|
D --> PP[代理插件 API]
|
||||||
|
P[玩家连接] -->|独立的游戏链路| R
|
||||||
|
```
|
||||||
|
|
||||||
|
代理在 `backendChannel.listen` 启动第二个监听器。每个实例用独立的 `instanceId`、`backendName`、凭据和游戏端口注册;同一 IP 可以承载多个不同端口的实例。代理侧不需要为每个实例启动单独的插件或 HTTP 端点。控制通道只创建动态目录条目,不能覆盖静态后端或其他实例拥有的名称。
|
||||||
|
|
||||||
|
示例配置见 [`strataproxy-channel.example.yml`](../proxy-core/src/main/resources/config/strataproxy-channel.example.yml)。`clients` 中每个实例的 `secret` 至少 32 个 UTF-8 字节,`allowedHosts` 限制它可广告的游戏主机名或 IP,`allowedNamespaces` 限制它可以发往代理插件的消息命名空间。后端注册地址必须是代理实际可达的 `tcp://host:port`;只有代理与后端同机时,才能使用 `127.0.0.1` 作为游戏地址。不要把示例密钥用于实际部署。
|
||||||
|
|
||||||
|
后端插件只依赖 `backend-channel-client`,该模块按 Java 8 字节码编译,不依赖 JDK 25 的 `proxy-plugin-api` 或代理核心。创建一个 `BackendChannelClient` 后,它会连接、注册、发送心跳并在断线后重连。构造器中的 `generation` 应在进程启动时生成一次 UUID,重连期间保持不变。
|
||||||
|
|
||||||
|
### Docker 部署
|
||||||
|
|
||||||
|
代理容器运行 JDK 25,后端容器按服务端要求运行 Java 8;两者加入同一个私有 Docker 网络。代理配置中的 `listen` 和 `backendChannel.listen` 在容器内绑定 `0.0.0.0`,分别监听玩家端口和控制端口。仅将玩家端口发布到宿主机;控制端口只需在容器网络内可达。后端 SDK 的代理地址填 Docker 网络中的代理服务名(例如 `proxy:28081`),游戏地址填代理可达的后端容器名和实际端口(例如 `tcp://island-a:25565`)。`allowedHosts` 应列出这个游戏地址的主机名。这里的容器 DNS 只用于连接已配置的服务名,不承担后端发现或注册。
|
||||||
|
|
||||||
|
每个后端实例使用独立的 `instanceId`、`backendName`、游戏地址和密钥;同一台宿主机上的不同容器不能把 `127.0.0.1` 当作彼此的地址。不要把真实密钥写入镜像或提交到仓库;启动时挂载由部署系统生成的代理配置,并向后端插件注入对应密钥。跨宿主机部署还需要私有加密链路或 TLS 终止器。可运行 `sh smoke/container-channel.sh`,在 Docker bridge 中验证注册、登录、注销和已有玩家连接继续转发。
|
||||||
|
|
||||||
|
```java
|
||||||
|
BackendChannelClient channel = new BackendChannelClient(
|
||||||
|
"127.0.0.1", 28081, "island-a", "island-a", "tcp://127.0.0.1:25565",
|
||||||
|
UUID.randomUUID().toString(), "primary", secretBytes);
|
||||||
|
channel.registerHandler("islands:echo", payload ->
|
||||||
|
CompletableFuture.completedFuture(payload));
|
||||||
|
// 服务端插件关闭时调用 channel.close()。
|
||||||
|
```
|
||||||
|
|
||||||
|
代理插件通过 `PluginContext.backendChannels()` 使用通道。处理器在 `onLoad` 或 `onEnable` 注册,同一通道只能有一个处理器;插件关闭时自动撤销。`BackendMessage` 含代理认证过的 `backendName`、`instanceId` 和连接代次,处理器应使用这些字段判断来源,不应信任负载里自称的身份。
|
||||||
|
|
||||||
|
```java
|
||||||
|
context.backendChannels().subscribe("islands:prepare", message ->
|
||||||
|
prepare(message).thenApply(ignored -> new byte[0]));
|
||||||
|
context.backendChannels().send("island-a", "islands:notice", bytes);
|
||||||
|
context.backendChannels().request("island-a", "islands:echo", bytes);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注册、断线与安全
|
||||||
|
|
||||||
|
代理先发送协议版本和随机挑战。后端以实例 ID、名称、游戏地址、启动代次、密钥 ID 和覆盖这些字段的 HMAC-SHA256 签名注册;验证成功且目录写入完成后,代理返回连接代次。认证失败、名称冲突或广告主机不在允许列表时,不会创建目录条目。重复连接同一实例会接管旧连接,旧连接的回调和清理不能修改新条目。
|
||||||
|
|
||||||
|
SDK 默认每 10 秒发一次心跳,代理默认租约为 30 秒,可在配置中调整。意外断线时,消息调用立即失败,目录条目保留到租约到期;期间玩家连接仍可能尝试该游戏地址。重连并重新认证后恢复消息通道。正常关闭会发送 `GOODBYE` 并立即注销。目录撤销不会强制踢掉已经在该服的玩家。
|
||||||
|
|
||||||
|
控制协议本身没有加密。跨机器部署应把控制监听器放在私有加密链路后,或使用可信 TLS 终止器;HMAC 不能防止窃听。当前每个实例配置一个密钥 ID 和密钥,轮换需要协调更新配置与客户端。代理对未认证连接设握手期限,并限制连接数、帧长、每连接队列、在途请求和入站消息速率。
|
||||||
|
|
||||||
|
## 消息语义
|
||||||
|
|
||||||
|
第一版支持单向 `send` 和带回复的 `request`。消息是二进制负载,最大 64 KiB;代理每连接最多排队 1 MiB 或 128 条出站消息、128 个在途请求,默认请求期限 5 秒。入站处理器使用独立于玩家准入和命令的有界工作池。代理 `send` 返回 `SENT` 只说明写入控制连接,不代表后端插件已处理;断线、过载和无处理器分别有明确失败结果。后端 SDK 也限制在途请求和处理器队列。
|
||||||
|
|
||||||
|
控制通道不保存离线消息,不保证断线前的请求是否已被远端执行,也不跨连接代次自动重放。业务重试需要自己的幂等键;可靠保存的玩法数据仍应写入业务存储。消息收发不进入普通 PLAY 转发路径。
|
||||||
|
|
||||||
|
## 验证范围
|
||||||
|
|
||||||
|
自动化测试覆盖零玩家通信、同 IP 不同端口注册、双向请求、无处理器响应、无效凭据、旧连接接管和意外断线后租约过期。Java 8 SDK 已通过 Java 8 编译;真实 Forge/Uranium 服务端装载、跨机器加密链路和生产负载仍需在目标部署环境验证。源码入口为 [`BackendControlService`](../proxy-core/src/main/java/dev/strataproxy/core/control/BackendControlService.java)、[`BackendChannels`](../proxy-plugin-api/src/main/java/dev/strataproxy/api/BackendChannels.java) 和 [`BackendChannelClient`](../backend-channel-client/src/main/java/dev/strataproxy/backendchannel/BackendChannelClient.java)。
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# 后端发现插件
|
|
||||||
|
|
||||||
使用 DNS 插件时,把示例配置中的主机名换成自己的 DNS 主机名;相对插件目录随配置文件位置一起解析。A 和 AAAA 记录分别刷新:一个地址族查询超时会暂时保留其上次结果,连续三次失败后只撤销该地址族;明确返回无记录会清除该地址族的缓存。两族均无记录时,目录在连续三次查询失败后撤销旧注册。
|
|
||||||
|
|
||||||
### Agent 动态注册插件
|
|
||||||
|
|
||||||
Agent 插件同样由 `plugins.enabled` 显式启用。它通过一个独立 HTTP 端点接收实例注册和续租,再调用通用 `Servers` API;代理核心不包含 Agent 协议或云平台发现逻辑。端点默认只绑定 `127.0.0.1:28080`,请求使用至少 32 字节的共享密钥做 HMAC-SHA256 签名,带 60 秒时间窗和一次性随机 nonce。请求体、并发请求数、实例数和租约时长都有上限。每个 Agent 进程使用新的 UUID generation;活动租约不能被其他 generation 覆盖。注销或过期的 generation 会保留到最大租约时长加 60 秒签名时间窗之后,再由周期清理器删除;这能拦截在租约切换期间延迟到达的旧请求,同时避免 Agent 重启次数累积导致注册耗尽。异常退出后,租约到期会移除后端。
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
plugins:
|
|
||||||
directory: ../plugins
|
|
||||||
enabled:
|
|
||||||
dev.strataproxy.plugins.agent.AgentDiscoveryPlugin:
|
|
||||||
secret: "replace-with-a-private-random-secret-of-32-bytes-or-more"
|
|
||||||
# host: 127.0.0.1
|
|
||||||
# port: "28080"
|
|
||||||
# concurrency: "4"
|
|
||||||
# maxBodyBytes: "4096"
|
|
||||||
# maxLeaseSeconds: "60"
|
|
||||||
# maxInstances: "128"
|
|
||||||
```
|
|
||||||
|
|
||||||
密钥不要提交到仓库;插件和 Agent 必须使用同一个值。跨主机部署时,应绑定到私有网络,并通过可信 TLS 终止器或私有链路保护传输;HMAC 验证身份和请求完整性,但不加密流量。`AgentRegistrationClient` 是仅依赖 JDK 的示例客户端:设置 `STRATAPROXY_AGENT_SECRET`,然后运行 `AgentExampleMain <http-endpoint> <agent-id> <backend-name> <tcp-address>`,其中端点形如 `http://127.0.0.1:28080/registration`。示例每 10 秒续租一次,租约 30 秒;网络故障和服务端暂时不可用时继续重试,租约过期导致旧 generation 被拒绝时换用新 generation 注册;认证或请求格式错误则退出。正常退出时尝试注销,异常退出由租约过期清理。Agent 协议只注册后端名称与地址,不需要消息队列。
|
|
||||||
+7
-5
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 配置和启动
|
## 配置和启动
|
||||||
|
|
||||||
默认配置模板为 [`strataproxy.yml`](../proxy-core/src/main/resources/config/strataproxy.yml)。发行包将配置放在 `config/`,插件 JAR 放在 `plugins/`。相对 `plugins.directory` 从所选配置文件所在目录解析;`plugins.enabled` 以插件实现类全名为键,空映射不启用外部插件。插件可用设置由各插件在启动时校验。
|
默认配置模板为 [`strataproxy.yml`](../proxy-core/src/main/resources/config/strataproxy.yml)。发行包将示例配置放在 `config/`;自有插件 JAR 可放在 `plugins.directory` 指向的目录。相对 `plugins.directory` 从所选配置文件所在目录解析;`plugins.enabled` 以插件实现类全名为键,空映射不启用外部插件。插件可用设置由各插件在启动时校验。
|
||||||
|
|
||||||
本地安装发行包并启动:
|
本地安装发行包并启动:
|
||||||
|
|
||||||
@@ -42,9 +42,11 @@ status:
|
|||||||
|
|
||||||
## 可选后端发现
|
## 可选后端发现
|
||||||
|
|
||||||
DNS 示例配置见 [`strataproxy-dns.example.yml`](../proxy-core/src/main/resources/config/strataproxy-dns.example.yml)。它将静态 `backends` 设为空,并通过 `plugins.enabled` 启用 DNS 插件,设置 DNS 主机名、端口、名称前缀和刷新周期。DNS 查找失败暂时保留旧地址;连续三次失败后移除过期地址。DNS 查询明确返回地址族无记录时,该族地址会按解析结果更新。`localhost` 使用系统回环地址解析;普通主机名由插件自己的解析器查询,不依赖 JVM 的全局地址缓存。
|
后端控制通道示例见 [`strataproxy-channel.example.yml`](../proxy-core/src/main/resources/config/strataproxy-channel.example.yml),使用独立监听端口接收实例注册、心跳和双向消息;使用方法与限制见[后端控制通道](backend-channel-design.md)。
|
||||||
|
|
||||||
Agent 示例见[发现插件](discovery.md)和 [`AgentDiscoveryPlugin`](../plugins/agent-discovery/src/main/java/dev/strataproxy/plugins/agent/AgentDiscoveryPlugin.java)。启用其 HTTP 注册监听前,应在受控网络中配置随机共享密钥,并按插件的端口、并发和租约约束部署。代理核心没有内置云实例发现或健康探测。
|
Docker 容器间的监听、服务名和地址配置见[Docker 部署](backend-channel-design.md#docker-部署);`sh smoke/container-channel.sh` 可运行跨容器烟测。
|
||||||
|
|
||||||
|
控制通道默认关闭。启用后,后端实例通过独立 TCP 连接认证和注册;代理不提供 DNS 发现或 HTTP 注册。代理核心没有内置云实例发现或健康探测。
|
||||||
|
|
||||||
玩家准入事件 API、检查顺序与 Ban 插件边界见[插件接入说明](plugins.md)。连接准入拒绝发生在 Minecraft 协议开始前,因此没有 Login Disconnect 可供显示;玩家准入发生在身份解析后。`ONLINE_BUNGEE` 仍由核心完成会话验证后才发出玩家事件,离线模式的身份由客户端提供且 `authenticated` 为 false。会话通知不保证 Forge 或玩法世界就绪,也不能用于可靠审计或计费。
|
玩家准入事件 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
@@ -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
@@ -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` 的结果代替远端发布验收。
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("strataproxy.java-library-conventions")
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation(project(":proxy-plugin-api"))
|
|
||||||
}
|
|
||||||
-414
@@ -1,414 +0,0 @@
|
|||||||
package dev.strataproxy.plugins.agent;
|
|
||||||
|
|
||||||
import com.sun.net.httpserver.HttpExchange;
|
|
||||||
import com.sun.net.httpserver.HttpServer;
|
|
||||||
import dev.strataproxy.api.Plugin;
|
|
||||||
import dev.strataproxy.api.PluginContext;
|
|
||||||
import dev.strataproxy.api.ServerDefinition;
|
|
||||||
import dev.strataproxy.api.ServerRegistration;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.InetSocketAddress;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URLDecoder;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Base64;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.concurrent.ArrayBlockingQueue;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
|
||||||
import java.util.concurrent.ThreadPoolExecutor;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import javax.crypto.Mac;
|
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
|
||||||
|
|
||||||
/** Optional authenticated HTTP registration endpoint for external instance agents. */
|
|
||||||
public final class AgentDiscoveryPlugin implements Plugin {
|
|
||||||
static final String PATH = "/registration";
|
|
||||||
private static final int ABSOLUTE_BODY_LIMIT = 8192;
|
|
||||||
private static final int MAX_NONCES = 4096;
|
|
||||||
private static final int SIGNATURE_WINDOW_SECONDS = 60;
|
|
||||||
private static final int MAX_RETIRED_GENERATIONS = 65_536;
|
|
||||||
private PluginContext context;
|
|
||||||
private Configuration configuration;
|
|
||||||
private HttpServer server;
|
|
||||||
private ThreadPoolExecutor requests;
|
|
||||||
private ScheduledExecutorService reaper;
|
|
||||||
final LeaseRegistry leases = new LeaseRegistry();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public synchronized void onLoad(PluginContext pluginContext) {
|
|
||||||
if (context != null) throw new IllegalStateException("Agent discovery plugin is already loaded");
|
|
||||||
context = Objects.requireNonNull(pluginContext, "pluginContext");
|
|
||||||
configuration = Configuration.from(context.settings());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public synchronized void onEnable() {
|
|
||||||
if (context == null) throw new IllegalStateException("onLoad must complete before onEnable");
|
|
||||||
if (server != null) return;
|
|
||||||
try {
|
|
||||||
requests = new ThreadPoolExecutor(configuration.concurrency(), configuration.concurrency(), 0,
|
|
||||||
TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(configuration.concurrency() * 8), task -> {
|
|
||||||
Thread thread = new Thread(task, "strataproxy-agent-request");
|
|
||||||
thread.setDaemon(true);
|
|
||||||
return thread;
|
|
||||||
}, new ThreadPoolExecutor.AbortPolicy());
|
|
||||||
server = HttpServer.create(new InetSocketAddress(configuration.host(), configuration.port()), 32);
|
|
||||||
server.setExecutor(requests);
|
|
||||||
server.createContext(PATH, this::handle);
|
|
||||||
reaper = Executors.newSingleThreadScheduledExecutor(task -> {
|
|
||||||
Thread thread = new Thread(task, "strataproxy-agent-lease-reaper");
|
|
||||||
thread.setDaemon(true);
|
|
||||||
return thread;
|
|
||||||
});
|
|
||||||
reaper.scheduleWithFixedDelay(() -> leases.expire(System.currentTimeMillis()), 1, 1, TimeUnit.SECONDS);
|
|
||||||
server.start();
|
|
||||||
context.logger().info("Agent discovery listening on {}:{}", configuration.host(), server.getAddress().getPort());
|
|
||||||
} catch (IOException failure) {
|
|
||||||
stopServices();
|
|
||||||
throw new IllegalStateException("Could not start agent discovery listener", failure);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public synchronized void onDisable() {
|
|
||||||
leases.close();
|
|
||||||
stopServices();
|
|
||||||
}
|
|
||||||
|
|
||||||
private synchronized void stopServices() {
|
|
||||||
if (server != null) {
|
|
||||||
server.stop(0);
|
|
||||||
server = null;
|
|
||||||
}
|
|
||||||
if (requests != null) {
|
|
||||||
requests.shutdownNow();
|
|
||||||
requests = null;
|
|
||||||
}
|
|
||||||
if (reaper != null) {
|
|
||||||
reaper.shutdownNow();
|
|
||||||
reaper = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handle(HttpExchange exchange) throws IOException {
|
|
||||||
int status = 400;
|
|
||||||
String reply = "bad request";
|
|
||||||
try {
|
|
||||||
if (!PATH.equals(exchange.getRequestURI().getPath()) || !"POST".equals(exchange.getRequestMethod())) {
|
|
||||||
status = 404;
|
|
||||||
reply = "not found";
|
|
||||||
} else {
|
|
||||||
String length = exchange.getRequestHeaders().getFirst("Content-Length");
|
|
||||||
if (length != null && parseDecimal(length, -1) > configuration.maxBodyBytes()) {
|
|
||||||
status = 413;
|
|
||||||
reply = "request too large";
|
|
||||||
} else {
|
|
||||||
byte[] body = exchange.getRequestBody().readNBytes(configuration.maxBodyBytes() + 1);
|
|
||||||
if (body.length > configuration.maxBodyBytes()) {
|
|
||||||
status = 413;
|
|
||||||
reply = "request too large";
|
|
||||||
} else if (!authenticate(exchange, body)) {
|
|
||||||
status = 401;
|
|
||||||
reply = "authentication failed";
|
|
||||||
} else {
|
|
||||||
Map<String, String> values = parseForm(body);
|
|
||||||
String agentId = exchange.getRequestHeaders().getFirst("X-Agent-Id");
|
|
||||||
if (!validAgentId(agentId)) throw new BadRequest("invalid agent id");
|
|
||||||
int result = apply(agentId, values);
|
|
||||||
status = result;
|
|
||||||
reply = result == 204 ? "" : result == 201 ? "registered" : "renewed";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Replay replay) {
|
|
||||||
status = 401;
|
|
||||||
reply = "authentication failed";
|
|
||||||
} catch (Conflict conflict) {
|
|
||||||
status = 409;
|
|
||||||
reply = "registration conflict";
|
|
||||||
} catch (BadRequest invalid) {
|
|
||||||
status = 400;
|
|
||||||
reply = "bad request";
|
|
||||||
} catch (RuntimeException failure) {
|
|
||||||
context.logger().warn("Agent registration request failed", failure);
|
|
||||||
status = 503;
|
|
||||||
reply = "temporarily unavailable";
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
|
||||||
byte[] bytes = reply.getBytes(StandardCharsets.UTF_8);
|
|
||||||
if (status == 204) exchange.sendResponseHeaders(status, -1);
|
|
||||||
else {
|
|
||||||
exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
|
|
||||||
exchange.sendResponseHeaders(status, bytes.length);
|
|
||||||
exchange.getResponseBody().write(bytes);
|
|
||||||
}
|
|
||||||
} catch (IOException ignored) {
|
|
||||||
// The client may have disconnected after the state change was committed.
|
|
||||||
}
|
|
||||||
exchange.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean authenticate(HttpExchange exchange, byte[] body) {
|
|
||||||
String agentId = exchange.getRequestHeaders().getFirst("X-Agent-Id");
|
|
||||||
String timestampText = exchange.getRequestHeaders().getFirst("X-Timestamp");
|
|
||||||
String nonce = exchange.getRequestHeaders().getFirst("X-Nonce");
|
|
||||||
String signature = exchange.getRequestHeaders().getFirst("X-Signature");
|
|
||||||
long timestamp = parseDecimal(timestampText, Long.MIN_VALUE);
|
|
||||||
long now = System.currentTimeMillis() / 1000;
|
|
||||||
if (!validAgentId(agentId) || timestamp == Long.MIN_VALUE
|
|
||||||
|| timestamp < now - SIGNATURE_WINDOW_SECONDS || timestamp > now + SIGNATURE_WINDOW_SECONDS
|
|
||||||
|| nonce == null || !nonce.matches("[A-Za-z0-9_-]{16,64}")
|
|
||||||
|| signature == null || !signature.matches("[A-Fa-f0-9]{64}")) return false;
|
|
||||||
String canonical = "POST\n" + PATH + "\n" + timestamp + "\n" + nonce + "\n" + agentId + "\n"
|
|
||||||
+ new String(body, StandardCharsets.UTF_8);
|
|
||||||
byte[] expected = hmac(configuration.secret(), canonical.getBytes(StandardCharsets.UTF_8));
|
|
||||||
byte[] supplied;
|
|
||||||
try { supplied = java.util.HexFormat.of().parseHex(signature); }
|
|
||||||
catch (IllegalArgumentException invalid) { return false; }
|
|
||||||
if (!MessageDigest.isEqual(expected, supplied)) return false;
|
|
||||||
leases.acceptNonce(agentId, nonce, now);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int apply(String agentId, Map<String, String> input) {
|
|
||||||
String action = required(input, "action");
|
|
||||||
UUID generation;
|
|
||||||
try { generation = UUID.fromString(required(input, "generation")); }
|
|
||||||
catch (IllegalArgumentException invalid) { throw new BadRequest("invalid generation"); }
|
|
||||||
if (action.equals("unregister")) {
|
|
||||||
requireKeys(input, Set.of("action", "generation"));
|
|
||||||
leases.unregister(agentId, generation);
|
|
||||||
return 204;
|
|
||||||
}
|
|
||||||
if (!action.equals("register")) throw new BadRequest("invalid action");
|
|
||||||
requireKeys(input, Set.of("action", "generation", "name", "address", "leaseSeconds"));
|
|
||||||
String name = required(input, "name");
|
|
||||||
URI address;
|
|
||||||
try { address = URI.create(required(input, "address")); }
|
|
||||||
catch (IllegalArgumentException invalid) { throw new BadRequest("invalid address"); }
|
|
||||||
int leaseSeconds = integer(input, "leaseSeconds", 5, configuration.maxLeaseSeconds());
|
|
||||||
ServerDefinition definition;
|
|
||||||
try {
|
|
||||||
definition = new ServerDefinition(name, address, Map.of("discovery", "agent"),
|
|
||||||
Map.of("agent.id", agentId, "agent.generation", generation.toString()));
|
|
||||||
} catch (IllegalArgumentException invalid) { throw new BadRequest("invalid server definition"); }
|
|
||||||
return leases.register(agentId, generation, definition, leaseSeconds, System.currentTimeMillis());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Map<String, String> parseForm(byte[] bytes) {
|
|
||||||
String text = new String(bytes, StandardCharsets.UTF_8);
|
|
||||||
if (!java.util.Arrays.equals(text.getBytes(StandardCharsets.UTF_8), bytes)) throw new BadRequest("invalid UTF-8");
|
|
||||||
Map<String, String> values = new LinkedHashMap<>();
|
|
||||||
if (text.isEmpty()) throw new BadRequest("empty body");
|
|
||||||
for (String part : text.split("&", -1)) {
|
|
||||||
int equals = part.indexOf('=');
|
|
||||||
if (equals < 1) throw new BadRequest("invalid form");
|
|
||||||
String key = decode(part.substring(0, equals));
|
|
||||||
String value = decode(part.substring(equals + 1));
|
|
||||||
if (values.putIfAbsent(key, value) != null) throw new BadRequest("duplicate field");
|
|
||||||
}
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String decode(String value) {
|
|
||||||
try { return URLDecoder.decode(value, StandardCharsets.UTF_8); }
|
|
||||||
catch (IllegalArgumentException invalid) { throw new BadRequest("invalid encoding"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void requireKeys(Map<String, String> input, Set<String> keys) {
|
|
||||||
if (!input.keySet().equals(keys)) throw new BadRequest("unexpected fields");
|
|
||||||
}
|
|
||||||
private static String required(Map<String, String> values, String key) {
|
|
||||||
String value = values.get(key);
|
|
||||||
if (value == null || value.isBlank()) throw new BadRequest("missing field");
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
private static int integer(Map<String, String> values, String key, int min, int max) {
|
|
||||||
long parsed = parseDecimal(values.get(key), Long.MIN_VALUE);
|
|
||||||
if (parsed < min || parsed > max) throw new BadRequest("invalid integer");
|
|
||||||
return (int) parsed;
|
|
||||||
}
|
|
||||||
private static long parseDecimal(String value, long fallback) {
|
|
||||||
if (value == null || !value.matches("[0-9]{1,19}")) return fallback;
|
|
||||||
try { return Long.parseLong(value); }
|
|
||||||
catch (NumberFormatException invalid) { return fallback; }
|
|
||||||
}
|
|
||||||
private static boolean validAgentId(String value) {
|
|
||||||
return value != null && value.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}");
|
|
||||||
}
|
|
||||||
private static byte[] hmac(byte[] key, byte[] message) {
|
|
||||||
try {
|
|
||||||
Mac mac = Mac.getInstance("HmacSHA256");
|
|
||||||
mac.init(new SecretKeySpec(key, "HmacSHA256"));
|
|
||||||
return mac.doFinal(message);
|
|
||||||
} catch (java.security.GeneralSecurityException impossible) { throw new IllegalStateException(impossible); }
|
|
||||||
}
|
|
||||||
|
|
||||||
static String sign(byte[] secret, String agentId, String timestamp, String nonce, String body) {
|
|
||||||
String canonical = "POST\n" + PATH + "\n" + timestamp + "\n" + nonce + "\n" + agentId + "\n" + body;
|
|
||||||
return java.util.HexFormat.of().formatHex(hmac(secret, canonical.getBytes(StandardCharsets.UTF_8)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private record Configuration(String host, int port, byte[] secret, int concurrency, int maxBodyBytes,
|
|
||||||
int maxLeaseSeconds, int maxInstances) {
|
|
||||||
static Configuration from(Map<String, String> values) {
|
|
||||||
String secret = values.get("secret");
|
|
||||||
if (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < 32)
|
|
||||||
throw new IllegalArgumentException("setting 'secret' must contain at least 32 UTF-8 bytes");
|
|
||||||
String host = values.getOrDefault("host", "127.0.0.1").trim();
|
|
||||||
if (host.isEmpty()) throw new IllegalArgumentException("setting 'host' must not be blank");
|
|
||||||
int port = setting(values, "port", 28080, 1, 65535);
|
|
||||||
int concurrency = setting(values, "concurrency", 4, 1, 64);
|
|
||||||
int maxBytes = setting(values, "maxBodyBytes", 4096, 256, ABSOLUTE_BODY_LIMIT);
|
|
||||||
int lease = setting(values, "maxLeaseSeconds", 60, 5, 3600);
|
|
||||||
int instances = setting(values, "maxInstances", 128, 1, 4096);
|
|
||||||
return new Configuration(host, port, secret.getBytes(StandardCharsets.UTF_8), concurrency, maxBytes, lease, instances);
|
|
||||||
}
|
|
||||||
private static int setting(Map<String, String> values, String key, int fallback, int min, int max) {
|
|
||||||
String value = values.get(key);
|
|
||||||
if (value == null) return fallback;
|
|
||||||
long parsed = parseDecimal(value, Long.MIN_VALUE);
|
|
||||||
if (parsed < min || parsed > max) throw new IllegalArgumentException("setting '" + key + "' must be " + min + ".." + max);
|
|
||||||
return (int) parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Synchronized lease table; generation tombstones fence delayed requests after expiry/unregister. */
|
|
||||||
final class LeaseRegistry {
|
|
||||||
private final Map<String, Lease> active = new LinkedHashMap<>();
|
|
||||||
private final Map<String, Map<UUID, Long>> retiredGenerations = new LinkedHashMap<>();
|
|
||||||
private final Map<String, String> serverOwners = new LinkedHashMap<>();
|
|
||||||
private final LinkedHashMap<String, Long> nonces = new LinkedHashMap<>();
|
|
||||||
private int retiredGenerationCount;
|
|
||||||
private boolean closed;
|
|
||||||
|
|
||||||
synchronized void acceptNonce(String agent, String nonce, long nowSeconds) {
|
|
||||||
requireOpen();
|
|
||||||
nonces.entrySet().removeIf(entry -> entry.getValue() < nowSeconds - SIGNATURE_WINDOW_SECONDS);
|
|
||||||
String key = agent + ":" + nonce;
|
|
||||||
if (nonces.containsKey(key)) throw new Replay();
|
|
||||||
if (nonces.size() >= MAX_NONCES) throw new Replay();
|
|
||||||
nonces.put(key, nowSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized int register(String agent, UUID generation, ServerDefinition definition, int leaseSeconds, long now) {
|
|
||||||
requireOpen();
|
|
||||||
expire(now);
|
|
||||||
Lease old = active.get(agent);
|
|
||||||
if (old != null && !old.generation.equals(generation)) throw new Conflict();
|
|
||||||
if (old == null) {
|
|
||||||
if (retiredGenerations.getOrDefault(agent, Map.of()).containsKey(generation)) throw new Conflict();
|
|
||||||
if (active.size() >= configuration.maxInstances()) throw new Conflict();
|
|
||||||
if (retiredGenerationCount >= MAX_RETIRED_GENERATIONS) throw new Conflict();
|
|
||||||
String owner = serverOwners.get(definition.name());
|
|
||||||
if (owner != null && !owner.equals(agent)) throw new Conflict();
|
|
||||||
ServerRegistration registration = context.servers().register(definition);
|
|
||||||
serverOwners.put(definition.name(), agent);
|
|
||||||
active.put(agent, new Lease(generation, definition.name(), definition, registration, now + leaseSeconds * 1000L));
|
|
||||||
return 201;
|
|
||||||
}
|
|
||||||
if (!old.name.equals(definition.name())) {
|
|
||||||
if (serverOwners.containsKey(definition.name())) throw new Conflict();
|
|
||||||
ServerRegistration replacement = context.servers().register(definition);
|
|
||||||
try {
|
|
||||||
old.registration.unregister();
|
|
||||||
} catch (RuntimeException failedRemoval) {
|
|
||||||
try { replacement.unregister(); }
|
|
||||||
catch (RuntimeException rollbackFailure) { failedRemoval.addSuppressed(rollbackFailure); }
|
|
||||||
throw failedRemoval;
|
|
||||||
}
|
|
||||||
serverOwners.remove(old.name);
|
|
||||||
serverOwners.put(definition.name(), agent);
|
|
||||||
active.put(agent, new Lease(generation, definition.name(), definition, replacement, now + leaseSeconds * 1000L));
|
|
||||||
} else {
|
|
||||||
old.registration.update(definition);
|
|
||||||
active.put(agent, new Lease(generation, old.name, definition, old.registration, now + leaseSeconds * 1000L));
|
|
||||||
}
|
|
||||||
return 200;
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized void unregister(String agent, UUID generation) {
|
|
||||||
requireOpen();
|
|
||||||
Lease lease = active.get(agent);
|
|
||||||
if (lease == null) {
|
|
||||||
if (!retiredGenerations.getOrDefault(agent, Map.of()).containsKey(generation)) throw new Conflict();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!lease.generation.equals(generation)) throw new Conflict();
|
|
||||||
lease.registration.unregister();
|
|
||||||
active.remove(agent);
|
|
||||||
serverOwners.remove(lease.name, agent);
|
|
||||||
retire(agent, generation, System.currentTimeMillis());
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized void expire(long now) {
|
|
||||||
if (closed) return;
|
|
||||||
for (String id : new ArrayList<>(active.keySet())) {
|
|
||||||
Lease lease = active.get(id);
|
|
||||||
if (lease != null && lease.expiresAtMillis <= now) {
|
|
||||||
try {
|
|
||||||
lease.registration.unregister();
|
|
||||||
active.remove(id);
|
|
||||||
serverOwners.remove(lease.name, id);
|
|
||||||
retire(id, lease.generation, now);
|
|
||||||
context.logger().info("Expired agent registration {}", id);
|
|
||||||
} catch (RuntimeException failure) {
|
|
||||||
context.logger().warn("Could not expire agent registration {}; retrying", id, failure);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pruneRetiredGenerations(now);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void retire(String agent, UUID generation, long now) {
|
|
||||||
Map<UUID, Long> generations = retiredGenerations.computeIfAbsent(agent, ignored -> new LinkedHashMap<>());
|
|
||||||
if (!generations.containsKey(generation)) {
|
|
||||||
generations.put(generation, now + configuration.maxLeaseSeconds() * 1000L
|
|
||||||
+ SIGNATURE_WINDOW_SECONDS * 1000L);
|
|
||||||
retiredGenerationCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void pruneRetiredGenerations(long now) {
|
|
||||||
for (String agent : new ArrayList<>(retiredGenerations.keySet())) {
|
|
||||||
Map<UUID, Long> generations = retiredGenerations.get(agent);
|
|
||||||
generations.entrySet().removeIf(entry -> {
|
|
||||||
if (entry.getValue() > now) return false;
|
|
||||||
retiredGenerationCount--;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
if (generations.isEmpty()) retiredGenerations.remove(agent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized void close() {
|
|
||||||
if (closed) return;
|
|
||||||
closed = true;
|
|
||||||
for (Map.Entry<String, Lease> entry : new ArrayList<>(active.entrySet())) {
|
|
||||||
try { entry.getValue().registration.unregister(); }
|
|
||||||
catch (RuntimeException failure) { context.logger().warn("Could not remove agent registration {}", entry.getKey(), failure); }
|
|
||||||
}
|
|
||||||
active.clear();
|
|
||||||
serverOwners.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void requireOpen() {
|
|
||||||
if (closed) throw new IllegalStateException("agent discovery is closed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private record Lease(UUID generation, String name, ServerDefinition definition, ServerRegistration registration,
|
|
||||||
long expiresAtMillis) { }
|
|
||||||
private static class BadRequest extends RuntimeException { BadRequest(String message) { super(message); } }
|
|
||||||
private static class Conflict extends RuntimeException { }
|
|
||||||
private static class Replay extends RuntimeException { }
|
|
||||||
}
|
|
||||||
-40
@@ -1,40 +0,0 @@
|
|||||||
package dev.strataproxy.plugins.agent;
|
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/** Runnable sample: args are endpoint, agent id, backend name, backend tcp URI. */
|
|
||||||
public final class AgentExampleMain {
|
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(AgentExampleMain.class);
|
|
||||||
|
|
||||||
private AgentExampleMain() { }
|
|
||||||
|
|
||||||
public static void main(String[] args) throws Exception {
|
|
||||||
if (args.length != 4) throw new IllegalArgumentException(
|
|
||||||
"usage: AgentExampleMain <http-endpoint> <agent-id> <backend-name> <tcp-address>");
|
|
||||||
String configuredSecret = System.getenv("STRATAPROXY_AGENT_SECRET");
|
|
||||||
if (configuredSecret == null || configuredSecret.getBytes(StandardCharsets.UTF_8).length < 32)
|
|
||||||
throw new IllegalStateException("Set STRATAPROXY_AGENT_SECRET to the configured 32+ byte secret");
|
|
||||||
var client = new AgentRegistrationClient(URI.create(args[0]), args[1], UUID.randomUUID(),
|
|
||||||
configuredSecret.getBytes(StandardCharsets.UTF_8), args[2], URI.create(args[3]), 30);
|
|
||||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
|
||||||
try { client.unregister(); } catch (Exception ignored) { /* The lease expires if shutdown is abrupt. */ }
|
|
||||||
}, "strataproxy-agent-shutdown"));
|
|
||||||
while (!Thread.currentThread().isInterrupted()) {
|
|
||||||
try {
|
|
||||||
client.registerOrHeartbeat();
|
|
||||||
} catch (AgentRegistrationClient.ResponseException response) {
|
|
||||||
if (response.statusCode() != 409 && response.statusCode() < 500) throw response;
|
|
||||||
LOGGER.warn("Agent registration endpoint returned HTTP {}; retrying", response.statusCode());
|
|
||||||
} catch (IOException networkFailure) {
|
|
||||||
LOGGER.warn("Agent registration request failed; retrying", networkFailure);
|
|
||||||
}
|
|
||||||
Thread.sleep(10_000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-98
@@ -1,98 +0,0 @@
|
|||||||
package dev.strataproxy.plugins.agent;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.SecureRandom;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.UUID;
|
|
||||||
import javax.crypto.Mac;
|
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
|
||||||
|
|
||||||
/** Minimal Java client; fenced generations are replaced after a lease expires. */
|
|
||||||
public final class AgentRegistrationClient {
|
|
||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
|
||||||
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build();
|
|
||||||
private final URI endpoint;
|
|
||||||
private final String agentId;
|
|
||||||
private UUID generation;
|
|
||||||
private final byte[] secret;
|
|
||||||
private final String backendName;
|
|
||||||
private final URI backendAddress;
|
|
||||||
private final int leaseSeconds;
|
|
||||||
|
|
||||||
public AgentRegistrationClient(URI endpoint, String agentId, UUID generation, byte[] secret,
|
|
||||||
String backendName, URI backendAddress, int leaseSeconds) {
|
|
||||||
this.endpoint = endpoint;
|
|
||||||
this.agentId = agentId;
|
|
||||||
this.generation = generation;
|
|
||||||
this.secret = secret.clone();
|
|
||||||
this.backendName = backendName;
|
|
||||||
this.backendAddress = backendAddress;
|
|
||||||
this.leaseSeconds = leaseSeconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static UUID newGeneration() { return UUID.randomUUID(); }
|
|
||||||
|
|
||||||
public synchronized void registerOrHeartbeat() throws IOException, InterruptedException {
|
|
||||||
try {
|
|
||||||
send(registrationBody(generation));
|
|
||||||
} catch (ResponseException conflict) {
|
|
||||||
if (conflict.statusCode() != 409) throw conflict;
|
|
||||||
generation = newGeneration();
|
|
||||||
send(registrationBody(generation));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String registrationBody(UUID currentGeneration) {
|
|
||||||
return "action=register&generation=" + encode(currentGeneration.toString())
|
|
||||||
+ "&name=" + encode(backendName) + "&address=" + encode(backendAddress.toString())
|
|
||||||
+ "&leaseSeconds=" + leaseSeconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized void unregister() throws IOException, InterruptedException {
|
|
||||||
send("action=unregister&generation=" + encode(generation.toString()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void send(String body) throws IOException, InterruptedException {
|
|
||||||
String timestamp = Long.toString(System.currentTimeMillis() / 1000);
|
|
||||||
byte[] bytes = new byte[18];
|
|
||||||
RANDOM.nextBytes(bytes);
|
|
||||||
String nonce = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
|
||||||
String signature = sign(secret, agentId, timestamp, nonce, body);
|
|
||||||
HttpRequest request = HttpRequest.newBuilder(endpoint).timeout(Duration.ofSeconds(5))
|
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
|
|
||||||
.header("X-Agent-Id", agentId).header("X-Timestamp", timestamp)
|
|
||||||
.header("X-Nonce", nonce).header("X-Signature", signature)
|
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)).build();
|
|
||||||
HttpResponse<Void> response = http.send(request, HttpResponse.BodyHandlers.discarding());
|
|
||||||
if (response.statusCode() < 200 || response.statusCode() >= 300)
|
|
||||||
throw new ResponseException(response.statusCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static final class ResponseException extends IOException {
|
|
||||||
private final int statusCode;
|
|
||||||
|
|
||||||
private ResponseException(int statusCode) {
|
|
||||||
super("Registration endpoint returned HTTP " + statusCode);
|
|
||||||
this.statusCode = statusCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int statusCode() { return statusCode; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); }
|
|
||||||
|
|
||||||
private static String sign(byte[] secret, String agentId, String timestamp, String nonce, String body) {
|
|
||||||
String canonical = "POST\n" + AgentDiscoveryPlugin.PATH + "\n" + timestamp + "\n" + nonce + "\n" + agentId + "\n" + body;
|
|
||||||
try {
|
|
||||||
Mac mac = Mac.getInstance("HmacSHA256");
|
|
||||||
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
|
|
||||||
return java.util.HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
|
|
||||||
} catch (java.security.GeneralSecurityException impossible) { throw new IllegalStateException(impossible); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
dev.strataproxy.plugins.agent.AgentDiscoveryPlugin
|
|
||||||
-256
@@ -1,256 +0,0 @@
|
|||||||
package dev.strataproxy.plugins.agent;
|
|
||||||
|
|
||||||
import dev.strataproxy.api.PlayerView;
|
|
||||||
import dev.strataproxy.api.Players;
|
|
||||||
import dev.strataproxy.api.PluginContext;
|
|
||||||
import dev.strataproxy.api.ServerDefinition;
|
|
||||||
import dev.strataproxy.api.ServerRegistration;
|
|
||||||
import dev.strataproxy.api.ServerView;
|
|
||||||
import dev.strataproxy.api.Servers;
|
|
||||||
import dev.strataproxy.api.TransferResult;
|
|
||||||
import dev.strataproxy.api.PlayerIdentity;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.ServerSocket;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
class AgentDiscoveryPluginTest {
|
|
||||||
private static final byte[] SECRET = "test-secret-with-at-least-32-bytes-long".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void lateRequestCannotRegisterAfterPluginShutdown() {
|
|
||||||
RecordingServers servers = new RecordingServers();
|
|
||||||
AgentDiscoveryPlugin plugin = plugin(servers);
|
|
||||||
assertEquals(201, plugin.leases.register("agent-a", UUID.randomUUID(), definition("game-1"), 30, 1_000));
|
|
||||||
|
|
||||||
plugin.onDisable();
|
|
||||||
assertTrue(servers.definitions.isEmpty());
|
|
||||||
assertThrows(IllegalStateException.class, () -> plugin.leases.register(
|
|
||||||
"agent-b", UUID.randomUUID(), definition("game-2"), 30, 1_001));
|
|
||||||
assertTrue(servers.definitions.isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void hmacBindsAgentTimestampNonceAndBodyAndReplayIsRejected() {
|
|
||||||
String signature = AgentDiscoveryPlugin.sign(SECRET, "agent-a", "123", "abcdefghijklmnop", "action=register");
|
|
||||||
assertEquals(signature, AgentDiscoveryPlugin.sign(SECRET, "agent-a", "123", "abcdefghijklmnop", "action=register"));
|
|
||||||
assertNotEquals(signature, AgentDiscoveryPlugin.sign(SECRET, "agent-b", "123", "abcdefghijklmnop", "action=register"));
|
|
||||||
assertNotEquals(signature, AgentDiscoveryPlugin.sign(SECRET, "agent-a", "124", "abcdefghijklmnop", "action=register"));
|
|
||||||
assertNotEquals(signature, AgentDiscoveryPlugin.sign(SECRET, "agent-a", "123", "abcdefghijklmnop", "action=unregister"));
|
|
||||||
|
|
||||||
AgentDiscoveryPlugin plugin = plugin(new RecordingServers());
|
|
||||||
plugin.leases.acceptNonce("agent-a", "abcdefghijklmnop", 1000);
|
|
||||||
assertThrows(RuntimeException.class, () -> plugin.leases.acceptNonce("agent-a", "abcdefghijklmnop", 1000));
|
|
||||||
plugin.onDisable();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void expiredGenerationIsFencedAndNewGenerationCanClaimAfterExpiry() {
|
|
||||||
RecordingServers servers = new RecordingServers();
|
|
||||||
AgentDiscoveryPlugin plugin = plugin(servers);
|
|
||||||
UUID first = UUID.randomUUID();
|
|
||||||
ServerDefinition definition = definition("game-1");
|
|
||||||
assertEquals(201, plugin.leases.register("agent-a", first, definition, 5, 1_000));
|
|
||||||
plugin.leases.expire(6_001);
|
|
||||||
assertEquals(1, servers.unregisterCount);
|
|
||||||
assertThrows(RuntimeException.class, () -> plugin.leases.register("agent-a", first, definition, 5, 6_002));
|
|
||||||
|
|
||||||
UUID restarted = UUID.randomUUID();
|
|
||||||
assertEquals(201, plugin.leases.register("agent-a", restarted, definition, 5, 6_003));
|
|
||||||
assertThrows(RuntimeException.class, () -> plugin.leases.register("agent-a", first, definition, 5, 6_004));
|
|
||||||
plugin.onDisable();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void activeGenerationCannotBeReplacedAndHeartbeatExtendsLease() {
|
|
||||||
RecordingServers servers = new RecordingServers();
|
|
||||||
AgentDiscoveryPlugin plugin = plugin(servers);
|
|
||||||
UUID generation = UUID.randomUUID();
|
|
||||||
assertEquals(201, plugin.leases.register("agent-a", generation, definition("game-1"), 5, 10_000));
|
|
||||||
assertThrows(RuntimeException.class, () -> plugin.leases.register("agent-a", UUID.randomUUID(), definition("game-1"), 5, 11_000));
|
|
||||||
assertEquals(200, plugin.leases.register("agent-a", generation, definition("game-1"), 5, 14_000));
|
|
||||||
plugin.leases.expire(18_500);
|
|
||||||
assertEquals(0, servers.unregisterCount);
|
|
||||||
plugin.leases.expire(19_001);
|
|
||||||
assertEquals(1, servers.unregisterCount);
|
|
||||||
plugin.onDisable();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void moreThanEightExpiredGenerationsCanRotateAndTombstonesArePruned() {
|
|
||||||
RecordingServers servers = new RecordingServers();
|
|
||||||
AgentDiscoveryPlugin plugin = plugin(servers);
|
|
||||||
long now = 100_000;
|
|
||||||
UUID firstGeneration = null;
|
|
||||||
UUID previousGeneration = null;
|
|
||||||
for (int restart = 0; restart < 9; restart++) {
|
|
||||||
UUID generation = UUID.randomUUID();
|
|
||||||
if (firstGeneration == null) firstGeneration = generation;
|
|
||||||
assertEquals(201, plugin.leases.register("agent-restart", generation, definition("game-restart"), 5, now));
|
|
||||||
plugin.leases.expire(now + 5_001);
|
|
||||||
if (previousGeneration != null) {
|
|
||||||
UUID staleGeneration = previousGeneration;
|
|
||||||
long staleAttemptTime = now + 5_002;
|
|
||||||
assertThrows(RuntimeException.class, () -> plugin.leases.register(
|
|
||||||
"agent-restart", staleGeneration, definition("game-restart"), 5, staleAttemptTime));
|
|
||||||
}
|
|
||||||
previousGeneration = generation;
|
|
||||||
now += 5_002;
|
|
||||||
}
|
|
||||||
assertEquals(9, servers.unregisterCount);
|
|
||||||
|
|
||||||
long afterRetention = now + 120_001;
|
|
||||||
plugin.leases.expire(afterRetention);
|
|
||||||
assertEquals(201, plugin.leases.register("agent-restart", firstGeneration,
|
|
||||||
definition("game-restart"), 5, afterRetention + 1));
|
|
||||||
plugin.onDisable();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void listenerRejectsBadHmacAndExactReplayAndCapsRequestBody() throws Exception {
|
|
||||||
RecordingServers servers = new RecordingServers();
|
|
||||||
int port;
|
|
||||||
try (ServerSocket socket = new ServerSocket(0)) { port = socket.getLocalPort(); }
|
|
||||||
AgentDiscoveryPlugin plugin = plugin(servers, port);
|
|
||||||
plugin.onEnable();
|
|
||||||
try {
|
|
||||||
String body = "action=register&generation=" + UUID.randomUUID()
|
|
||||||
+ "&name=game-http&address=tcp%3A%2F%2F127.0.0.1%3A25565&leaseSeconds=30";
|
|
||||||
String agent = "agent-http";
|
|
||||||
String timestamp = Long.toString(System.currentTimeMillis() / 1000);
|
|
||||||
String nonce = "abcdefghijklmnopqrstuvwx";
|
|
||||||
URI endpoint = URI.create("http://127.0.0.1:" + port + AgentDiscoveryPlugin.PATH);
|
|
||||||
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
|
|
||||||
HttpResponse<String> badSignature = post(client, endpoint, agent, timestamp, nonce, "0".repeat(64), body);
|
|
||||||
assertEquals(401, badSignature.statusCode());
|
|
||||||
|
|
||||||
String signature = AgentDiscoveryPlugin.sign(SECRET, agent, timestamp, nonce, body);
|
|
||||||
HttpResponse<String> registered = post(client, endpoint, agent, timestamp, nonce, signature, body);
|
|
||||||
assertEquals(201, registered.statusCode());
|
|
||||||
HttpResponse<String> replay = post(client, endpoint, agent, timestamp, nonce, signature, body);
|
|
||||||
assertEquals(401, replay.statusCode());
|
|
||||||
|
|
||||||
String largeBody = "x".repeat(300);
|
|
||||||
HttpResponse<String> oversized = post(client, endpoint, agent, timestamp, "zyxwvutsrqponmlkjihgfedc", "0".repeat(64), largeBody);
|
|
||||||
assertEquals(413, oversized.statusCode());
|
|
||||||
} finally {
|
|
||||||
plugin.onDisable();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void clientRecoversAfterItsGenerationExpires() throws Exception {
|
|
||||||
RecordingServers servers = new RecordingServers();
|
|
||||||
int port;
|
|
||||||
try (ServerSocket socket = new ServerSocket(0)) { port = socket.getLocalPort(); }
|
|
||||||
AgentDiscoveryPlugin plugin = plugin(servers, port);
|
|
||||||
plugin.onEnable();
|
|
||||||
try {
|
|
||||||
var client = new AgentRegistrationClient(
|
|
||||||
URI.create("http://127.0.0.1:" + port + AgentDiscoveryPlugin.PATH),
|
|
||||||
"agent-recovery", UUID.randomUUID(), SECRET, "game-recovery",
|
|
||||||
URI.create("tcp://127.0.0.1:25565"), 5);
|
|
||||||
client.registerOrHeartbeat();
|
|
||||||
String firstGeneration = servers.definitions.get("game-recovery")
|
|
||||||
.metadata().get("agent.generation");
|
|
||||||
plugin.leases.expire(System.currentTimeMillis() + 5_001);
|
|
||||||
assertFalse(servers.definitions.containsKey("game-recovery"));
|
|
||||||
|
|
||||||
client.registerOrHeartbeat();
|
|
||||||
String renewedGeneration = servers.definitions.get("game-recovery")
|
|
||||||
.metadata().get("agent.generation");
|
|
||||||
assertNotEquals(firstGeneration, renewedGeneration);
|
|
||||||
client.unregister();
|
|
||||||
assertFalse(servers.definitions.containsKey("game-recovery"));
|
|
||||||
} finally {
|
|
||||||
plugin.onDisable();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static HttpResponse<String> post(HttpClient client, URI endpoint, String agent, String timestamp,
|
|
||||||
String nonce, String signature, String body) throws Exception {
|
|
||||||
HttpRequest request = HttpRequest.newBuilder(endpoint).timeout(Duration.ofSeconds(3))
|
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
|
|
||||||
.header("X-Agent-Id", agent).header("X-Timestamp", timestamp)
|
|
||||||
.header("X-Nonce", nonce).header("X-Signature", signature)
|
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)).build();
|
|
||||||
return client.send(request, HttpResponse.BodyHandlers.ofString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static AgentDiscoveryPlugin plugin(RecordingServers servers) {
|
|
||||||
return plugin(servers, 28080);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static AgentDiscoveryPlugin plugin(RecordingServers servers, int port) {
|
|
||||||
AgentDiscoveryPlugin plugin = new AgentDiscoveryPlugin();
|
|
||||||
plugin.onLoad(new PluginContext() {
|
|
||||||
@Override public Players players() {
|
|
||||||
return new Players() {
|
|
||||||
@Override public Optional<PlayerView> find(PlayerIdentity identity) { return Optional.empty(); }
|
|
||||||
@Override public List<PlayerView> online() { return List.of(); }
|
|
||||||
@Override public java.util.concurrent.CompletionStage<dev.strataproxy.api.MessageResult> sendMessage(PlayerIdentity identity, net.kyori.adventure.text.Component message) {
|
|
||||||
return CompletableFuture.failedFuture(new UnsupportedOperationException());
|
|
||||||
}
|
|
||||||
@Override public java.util.concurrent.CompletionStage<dev.strataproxy.api.DisconnectResult> disconnect(PlayerIdentity identity, net.kyori.adventure.text.Component reason) {
|
|
||||||
return CompletableFuture.failedFuture(new UnsupportedOperationException());
|
|
||||||
}
|
|
||||||
@Override public java.util.concurrent.CompletionStage<TransferResult> transfer(PlayerIdentity identity, String backendName) {
|
|
||||||
return CompletableFuture.failedFuture(new UnsupportedOperationException());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@Override public Servers servers() { return servers; }
|
|
||||||
@Override public dev.strataproxy.api.Commands commands() {
|
|
||||||
return (name, handler, completer) -> { throw new UnsupportedOperationException(); };
|
|
||||||
}
|
|
||||||
@Override public dev.strataproxy.api.event.Events events() { throw new UnsupportedOperationException(); }
|
|
||||||
@Override public org.slf4j.Logger logger() { return org.slf4j.LoggerFactory.getLogger("agent-test"); }
|
|
||||||
@Override public Map<String, String> settings() {
|
|
||||||
return Map.of("secret", new String(SECRET, StandardCharsets.UTF_8), "port", Integer.toString(port),
|
|
||||||
"maxBodyBytes", "256");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return plugin;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ServerDefinition definition(String name) {
|
|
||||||
return new ServerDefinition(name, URI.create("tcp://127.0.0.1:25565"), Map.of(), Map.of());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class RecordingServers implements Servers {
|
|
||||||
private final Map<String, ServerDefinition> definitions = new ConcurrentHashMap<>();
|
|
||||||
private int unregisterCount;
|
|
||||||
@Override public Optional<ServerView> find(String backendName) { return Optional.empty(); }
|
|
||||||
@Override public List<ServerView> all() { return List.of(); }
|
|
||||||
@Override public ServerRegistration register(ServerDefinition definition) {
|
|
||||||
definitions.put(definition.name(), definition);
|
|
||||||
return new ServerRegistration() {
|
|
||||||
private boolean active = true;
|
|
||||||
@Override public void update(ServerDefinition replacement) {
|
|
||||||
if (!active) throw new IllegalStateException("stale");
|
|
||||||
definitions.put(replacement.name(), replacement);
|
|
||||||
}
|
|
||||||
@Override public void unregister() {
|
|
||||||
if (!active) throw new IllegalStateException("stale");
|
|
||||||
active = false;
|
|
||||||
definitions.remove(definition.name());
|
|
||||||
unregisterCount++;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("strataproxy.java-library-conventions")
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation(project(":proxy-plugin-api"))
|
|
||||||
implementation(libs.findLibrary("netty-resolver-dns").get())
|
|
||||||
}
|
|
||||||
-449
@@ -1,449 +0,0 @@
|
|||||||
package dev.strataproxy.plugins.dns;
|
|
||||||
|
|
||||||
import dev.strataproxy.api.Plugin;
|
|
||||||
import dev.strataproxy.api.PluginContext;
|
|
||||||
import dev.strataproxy.api.ServerDefinition;
|
|
||||||
import dev.strataproxy.api.ServerRegistration;
|
|
||||||
import io.netty.channel.nio.NioEventLoopGroup;
|
|
||||||
import io.netty.channel.socket.nio.NioDatagramChannel;
|
|
||||||
import io.netty.resolver.ResolvedAddressTypes;
|
|
||||||
import io.netty.resolver.dns.DnsNameResolver;
|
|
||||||
import io.netty.resolver.dns.DnsNameResolverBuilder;
|
|
||||||
import io.netty.resolver.dns.DnsServerAddressStreamProvider;
|
|
||||||
import io.netty.resolver.dns.NoopDnsCache;
|
|
||||||
import io.netty.resolver.dns.NoopDnsCnameCache;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
|
|
||||||
import java.net.IDN;
|
|
||||||
import java.net.Inet4Address;
|
|
||||||
import java.net.InetAddress;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URISyntaxException;
|
|
||||||
import java.net.UnknownHostException;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.HexFormat;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.concurrent.ExecutionException;
|
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
|
|
||||||
/** Discovers TCP backends from the A and AAAA records of a configured DNS name. */
|
|
||||||
public final class DnsDiscoveryPlugin implements Plugin {
|
|
||||||
private static final int MAX_CONSECUTIVE_LOOKUP_FAILURES = 3;
|
|
||||||
private final AddressResolver resolver;
|
|
||||||
private final Object lifecycleLock = new Object();
|
|
||||||
private final Map<String, OwnedRegistration> registrations = new LinkedHashMap<>();
|
|
||||||
private final AtomicBoolean started = new AtomicBoolean();
|
|
||||||
|
|
||||||
private PluginContext context;
|
|
||||||
private Configuration configuration;
|
|
||||||
private ScheduledExecutorService scheduler;
|
|
||||||
private int consecutiveLookupFailures;
|
|
||||||
private volatile boolean closed;
|
|
||||||
|
|
||||||
public DnsDiscoveryPlugin() { this(new RefreshingAddressResolver()); }
|
|
||||||
|
|
||||||
DnsDiscoveryPlugin(AddressResolver resolver) {
|
|
||||||
this.resolver = Objects.requireNonNull(resolver, "resolver");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onLoad(PluginContext pluginContext) {
|
|
||||||
Objects.requireNonNull(pluginContext, "pluginContext");
|
|
||||||
synchronized (lifecycleLock) {
|
|
||||||
if (context != null) {
|
|
||||||
throw new IllegalStateException("DNS discovery plugin is already loaded");
|
|
||||||
}
|
|
||||||
context = pluginContext;
|
|
||||||
configuration = Configuration.from(pluginContext.settings());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onEnable() {
|
|
||||||
synchronized (lifecycleLock) {
|
|
||||||
if (context == null || configuration == null) {
|
|
||||||
throw new IllegalStateException("onLoad must complete before onEnable");
|
|
||||||
}
|
|
||||||
if (closed) {
|
|
||||||
throw new IllegalStateException("DNS discovery plugin cannot be restarted after disable");
|
|
||||||
}
|
|
||||||
if (!started.compareAndSet(false, true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scheduler = Executors.newSingleThreadScheduledExecutor(task -> {
|
|
||||||
Thread thread = new Thread(task, "strataproxy-dns-discovery");
|
|
||||||
thread.setDaemon(true);
|
|
||||||
return thread;
|
|
||||||
});
|
|
||||||
scheduler.scheduleWithFixedDelay(
|
|
||||||
this::refreshSafely,
|
|
||||||
0,
|
|
||||||
configuration.refreshSeconds(),
|
|
||||||
TimeUnit.SECONDS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDisable() {
|
|
||||||
ScheduledExecutorService toStop;
|
|
||||||
synchronized (lifecycleLock) {
|
|
||||||
if (closed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
closed = true;
|
|
||||||
toStop = scheduler;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toStop != null) {
|
|
||||||
toStop.shutdownNow();
|
|
||||||
try {
|
|
||||||
if (!toStop.awaitTermination(5, TimeUnit.SECONDS)) {
|
|
||||||
logger().warn("DNS discovery worker did not stop within 5 seconds");
|
|
||||||
}
|
|
||||||
} catch (InterruptedException interrupted) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
resolver.close();
|
|
||||||
} catch (Exception failure) {
|
|
||||||
logger().warn("Could not close DNS resolver", failure);
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized (lifecycleLock) {
|
|
||||||
for (Map.Entry<String, OwnedRegistration> entry : registrations.entrySet()) {
|
|
||||||
unregisterQuietly(entry.getKey(), entry.getValue().registration());
|
|
||||||
}
|
|
||||||
registrations.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void refreshSafely() {
|
|
||||||
final InetAddress[] resolved;
|
|
||||||
try {
|
|
||||||
resolved = Objects.requireNonNull(resolver.resolve(configuration.host()), "resolver result");
|
|
||||||
} catch (Exception failure) {
|
|
||||||
lookupFailed(failure);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, ServerDefinition> desired;
|
|
||||||
try {
|
|
||||||
desired = definitionsFor(resolved, configuration);
|
|
||||||
} catch (RuntimeException invalidResult) {
|
|
||||||
lookupFailed(invalidResult);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized (lifecycleLock) {
|
|
||||||
if (closed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
consecutiveLookupFailures = 0;
|
|
||||||
reconcile(desired);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void lookupFailed(Exception failure) {
|
|
||||||
synchronized (lifecycleLock) {
|
|
||||||
if (closed) return;
|
|
||||||
int previousFailures = consecutiveLookupFailures;
|
|
||||||
if (consecutiveLookupFailures < MAX_CONSECUTIVE_LOOKUP_FAILURES) consecutiveLookupFailures++;
|
|
||||||
if (consecutiveLookupFailures == 1) {
|
|
||||||
logger().warn("DNS lookup failed for {}; retaining {} previously discovered backend(s)",
|
|
||||||
configuration.host(), registrations.size(), failure);
|
|
||||||
} else if (consecutiveLookupFailures == MAX_CONSECUTIVE_LOOKUP_FAILURES
|
|
||||||
&& (previousFailures < MAX_CONSECUTIVE_LOOKUP_FAILURES || !registrations.isEmpty())) {
|
|
||||||
if (previousFailures < MAX_CONSECUTIVE_LOOKUP_FAILURES) {
|
|
||||||
logger().warn("DNS lookup for {} has failed {} consecutive times; removing stale backends",
|
|
||||||
configuration.host(), consecutiveLookupFailures);
|
|
||||||
}
|
|
||||||
reconcile(Map.of());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void reconcile(Map<String, ServerDefinition> desired) {
|
|
||||||
for (Map.Entry<String, ServerDefinition> entry : desired.entrySet()) {
|
|
||||||
String name = entry.getKey();
|
|
||||||
ServerDefinition definition = entry.getValue();
|
|
||||||
OwnedRegistration existing = registrations.get(name);
|
|
||||||
if (existing == null) {
|
|
||||||
try {
|
|
||||||
ServerRegistration handle = context.servers().register(definition);
|
|
||||||
registrations.put(name, new OwnedRegistration(definition, handle));
|
|
||||||
logger().info("Discovered backend {} at {}", name, definition.address());
|
|
||||||
} catch (RuntimeException failure) {
|
|
||||||
logger().warn("Could not register discovered backend {}", name, failure);
|
|
||||||
}
|
|
||||||
} else if (!existing.definition().equals(definition)) {
|
|
||||||
try {
|
|
||||||
existing.registration().update(definition);
|
|
||||||
registrations.put(name, new OwnedRegistration(definition, existing.registration()));
|
|
||||||
logger().info("Updated discovered backend {} at {}", name, definition.address());
|
|
||||||
} catch (RuntimeException failure) {
|
|
||||||
logger().warn("Could not update discovered backend {}; keeping its previous registration", name,
|
|
||||||
failure);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (String name : Set.copyOf(registrations.keySet())) {
|
|
||||||
if (!desired.containsKey(name)) {
|
|
||||||
OwnedRegistration existing = registrations.get(name);
|
|
||||||
if (existing != null) {
|
|
||||||
try {
|
|
||||||
existing.registration().unregister();
|
|
||||||
registrations.remove(name);
|
|
||||||
logger().info("Removed DNS backend {}", name);
|
|
||||||
} catch (RuntimeException failure) {
|
|
||||||
logger().warn("Could not remove DNS backend {}; it will be retried", name, failure);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void unregisterQuietly(String name, ServerRegistration registration) {
|
|
||||||
try {
|
|
||||||
registration.unregister();
|
|
||||||
logger().info("Removed DNS backend {} during plugin shutdown", name);
|
|
||||||
} catch (RuntimeException failure) {
|
|
||||||
logger().warn("Could not remove DNS backend {} during plugin shutdown", name, failure);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Logger logger() {
|
|
||||||
PluginContext current = context;
|
|
||||||
return current == null ? org.slf4j.LoggerFactory.getLogger(DnsDiscoveryPlugin.class) : current.logger();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Map<String, ServerDefinition> definitionsFor(InetAddress[] addresses, Configuration config) {
|
|
||||||
Map<String, InetAddress> unique = new LinkedHashMap<>();
|
|
||||||
Arrays.stream(addresses)
|
|
||||||
.map(address -> Objects.requireNonNull(address, "DNS answer address"))
|
|
||||||
.sorted(Comparator.comparingInt((InetAddress address) -> address instanceof Inet4Address ? 0 : 1)
|
|
||||||
.thenComparing(address -> HexFormat.of().formatHex(address.getAddress())))
|
|
||||||
.forEach(address -> unique.putIfAbsent(HexFormat.of().formatHex(address.getAddress()), address));
|
|
||||||
|
|
||||||
Map<String, ServerDefinition> result = new LinkedHashMap<>();
|
|
||||||
for (Map.Entry<String, InetAddress> entry : unique.entrySet()) {
|
|
||||||
String name = config.namePrefix() + entry.getKey();
|
|
||||||
URI address;
|
|
||||||
try {
|
|
||||||
address = new URI("tcp", null, entry.getValue().getHostAddress(), config.port(), null, null, null);
|
|
||||||
} catch (URISyntaxException impossibleForValidatedAddress) {
|
|
||||||
throw new IllegalArgumentException("DNS answer could not be represented as a TCP URI",
|
|
||||||
impossibleForValidatedAddress);
|
|
||||||
}
|
|
||||||
ServerDefinition definition = new ServerDefinition(
|
|
||||||
name,
|
|
||||||
address,
|
|
||||||
Map.of("discovery", "dns"),
|
|
||||||
Map.of("dns.host", config.host()));
|
|
||||||
result.put(name, definition);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
@FunctionalInterface
|
|
||||||
interface AddressResolver extends AutoCloseable {
|
|
||||||
InetAddress[] resolve(String host) throws Exception;
|
|
||||||
@Override default void close() throws Exception { }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Queries both DNS families without a resolver cache and fences transient partial answers. */
|
|
||||||
static final class RefreshingAddressResolver implements AddressResolver {
|
|
||||||
private final DnsServerAddressStreamProvider nameServers;
|
|
||||||
private NioEventLoopGroup eventLoops;
|
|
||||||
private DnsNameResolver ipv4;
|
|
||||||
private DnsNameResolver ipv6;
|
|
||||||
private List<InetAddress> lastIpv4 = List.of();
|
|
||||||
private List<InetAddress> lastIpv6 = List.of();
|
|
||||||
private int ipv4Failures;
|
|
||||||
private int ipv6Failures;
|
|
||||||
|
|
||||||
RefreshingAddressResolver() { this(null); }
|
|
||||||
|
|
||||||
RefreshingAddressResolver(DnsServerAddressStreamProvider nameServers) {
|
|
||||||
this.nameServers = nameServers;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public synchronized InetAddress[] resolve(String host) throws Exception {
|
|
||||||
// Netty's localhost hosts-file shortcut returns IPv4 even for an IPv6-only resolver.
|
|
||||||
// This reserved local name is not a dynamic DNS record; retain both system loopback addresses.
|
|
||||||
if ("localhost".equals(host)) return InetAddress.getAllByName(host);
|
|
||||||
if (ipv4 == null) {
|
|
||||||
eventLoops = new NioEventLoopGroup(1, task -> {
|
|
||||||
Thread thread = new Thread(task, "strataproxy-dns-query");
|
|
||||||
thread.setDaemon(true);
|
|
||||||
return thread;
|
|
||||||
});
|
|
||||||
ipv4 = newResolver(ResolvedAddressTypes.IPV4_ONLY);
|
|
||||||
ipv6 = newResolver(ResolvedAddressTypes.IPV6_ONLY);
|
|
||||||
}
|
|
||||||
var ipv4Result = ipv4.resolveAll(host);
|
|
||||||
var ipv6Result = ipv6.resolveAll(host);
|
|
||||||
List<InetAddress> currentIpv4 = null;
|
|
||||||
List<InetAddress> currentIpv6 = null;
|
|
||||||
Exception ipv4Failure = null;
|
|
||||||
Exception ipv6Failure = null;
|
|
||||||
try { currentIpv4 = ipv4Result.get(5, TimeUnit.SECONDS); }
|
|
||||||
catch (InterruptedException interrupted) {
|
|
||||||
ipv4Result.cancel(true);
|
|
||||||
ipv6Result.cancel(true);
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
throw interrupted;
|
|
||||||
}
|
|
||||||
catch (Exception failure) {
|
|
||||||
if (recordAbsent(failure)) currentIpv4 = List.of();
|
|
||||||
else {
|
|
||||||
ipv4Failure = failure;
|
|
||||||
if (!ipv4Result.isDone()) ipv4Result.cancel(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try { currentIpv6 = ipv6Result.get(5, TimeUnit.SECONDS); }
|
|
||||||
catch (InterruptedException interrupted) {
|
|
||||||
ipv6Result.cancel(true);
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
throw interrupted;
|
|
||||||
}
|
|
||||||
catch (Exception failure) {
|
|
||||||
if (recordAbsent(failure)) currentIpv6 = List.of();
|
|
||||||
else {
|
|
||||||
ipv6Failure = failure;
|
|
||||||
if (!ipv6Result.isDone()) ipv6Result.cancel(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (currentIpv4 != null) {
|
|
||||||
lastIpv4 = List.copyOf(currentIpv4);
|
|
||||||
ipv4Failures = 0;
|
|
||||||
} else {
|
|
||||||
ipv4Failures = Math.min(MAX_CONSECUTIVE_LOOKUP_FAILURES, ipv4Failures + 1);
|
|
||||||
if (ipv4Failures == MAX_CONSECUTIVE_LOOKUP_FAILURES) lastIpv4 = List.of();
|
|
||||||
}
|
|
||||||
if (currentIpv6 != null) {
|
|
||||||
lastIpv6 = List.copyOf(currentIpv6);
|
|
||||||
ipv6Failures = 0;
|
|
||||||
} else {
|
|
||||||
ipv6Failures = Math.min(MAX_CONSECUTIVE_LOOKUP_FAILURES, ipv6Failures + 1);
|
|
||||||
if (ipv6Failures == MAX_CONSECUTIVE_LOOKUP_FAILURES) lastIpv6 = List.of();
|
|
||||||
}
|
|
||||||
if (ipv4Failure != null && ipv6Failure != null) throw ipv4Failure;
|
|
||||||
var addresses = new ArrayList<InetAddress>(lastIpv4.size() + lastIpv6.size());
|
|
||||||
addresses.addAll(lastIpv4);
|
|
||||||
addresses.addAll(lastIpv6);
|
|
||||||
if (addresses.isEmpty()) {
|
|
||||||
if (ipv4Failure != null && currentIpv6 != null
|
|
||||||
&& ipv4Failures == MAX_CONSECUTIVE_LOOKUP_FAILURES) return new InetAddress[0];
|
|
||||||
if (ipv6Failure != null && currentIpv4 != null
|
|
||||||
&& ipv6Failures == MAX_CONSECUTIVE_LOOKUP_FAILURES) return new InetAddress[0];
|
|
||||||
if (ipv4Failure != null) throw ipv4Failure;
|
|
||||||
if (ipv6Failure != null) throw ipv6Failure;
|
|
||||||
throw new UnknownHostException(host);
|
|
||||||
}
|
|
||||||
return addresses.toArray(InetAddress[]::new);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean recordAbsent(Exception failure) {
|
|
||||||
Throwable reason = failure;
|
|
||||||
while (reason instanceof ExecutionException && reason.getCause() != null) reason = reason.getCause();
|
|
||||||
return reason instanceof UnknownHostException && reason.getCause() == null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private DnsNameResolver newResolver(ResolvedAddressTypes family) {
|
|
||||||
var builder = new DnsNameResolverBuilder(eventLoops.next())
|
|
||||||
.datagramChannelType(NioDatagramChannel.class)
|
|
||||||
.queryTimeoutMillis(3000)
|
|
||||||
.resolveCache(NoopDnsCache.INSTANCE)
|
|
||||||
.cnameCache(NoopDnsCnameCache.INSTANCE)
|
|
||||||
.resolvedAddressTypes(family);
|
|
||||||
if (nameServers != null) builder.nameServerProvider(nameServers);
|
|
||||||
return builder.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public synchronized void close() {
|
|
||||||
if (ipv4 != null) ipv4.close();
|
|
||||||
if (ipv6 != null) ipv6.close();
|
|
||||||
if (eventLoops != null) {
|
|
||||||
eventLoops.shutdownGracefully(0, 5, TimeUnit.SECONDS).awaitUninterruptibly(5, TimeUnit.SECONDS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private record OwnedRegistration(ServerDefinition definition, ServerRegistration registration) {
|
|
||||||
}
|
|
||||||
|
|
||||||
private record Configuration(String host, int port, String namePrefix, int refreshSeconds) {
|
|
||||||
private static Configuration from(Map<String, String> settings) {
|
|
||||||
Objects.requireNonNull(settings, "settings");
|
|
||||||
for (String key : settings.keySet()) {
|
|
||||||
if (!Set.of("host", "port", "namePrefix", "refreshSeconds").contains(key)) {
|
|
||||||
throw new IllegalArgumentException("unknown DNS plugin setting '" + key + "'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
String host = normalizeHost(required(settings, "host"));
|
|
||||||
int port = integer(settings, "port", 25565, 1, 65535);
|
|
||||||
int refreshSeconds = integer(settings, "refreshSeconds", 30, 1, 86400);
|
|
||||||
String defaultPrefix = "dns-" + host.replace('.', '-');
|
|
||||||
String prefix = settings.getOrDefault("namePrefix", defaultPrefix).trim();
|
|
||||||
if (prefix.isEmpty() || !prefix.matches("[A-Za-z0-9][A-Za-z0-9._-]*")) {
|
|
||||||
throw new IllegalArgumentException("setting 'namePrefix' must start with a letter or digit and "
|
|
||||||
+ "contain only letters, digits, '.', '_' or '-'");
|
|
||||||
}
|
|
||||||
return new Configuration(host, port, prefix + "-", refreshSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String required(Map<String, String> settings, String key) {
|
|
||||||
String value = settings.get(key);
|
|
||||||
if (value == null || value.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("missing required plugin setting '" + key + "'");
|
|
||||||
}
|
|
||||||
return value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int integer(Map<String, String> settings, String key, int defaultValue, int minimum, int maximum) {
|
|
||||||
String value = settings.get(key);
|
|
||||||
if (value == null) {
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
int parsed = Integer.parseInt(value.trim());
|
|
||||||
if (parsed < minimum || parsed > maximum) {
|
|
||||||
throw new IllegalArgumentException("setting '" + key + "' must be between " + minimum + " and "
|
|
||||||
+ maximum);
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
} catch (NumberFormatException invalid) {
|
|
||||||
throw new IllegalArgumentException("setting '" + key + "' must be an integer", invalid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeHost(String host) {
|
|
||||||
if (host.contains(":") || host.endsWith(".")) {
|
|
||||||
throw new IllegalArgumentException("setting 'host' must be a DNS hostname without a trailing dot");
|
|
||||||
}
|
|
||||||
final String ascii;
|
|
||||||
try {
|
|
||||||
ascii = IDN.toASCII(host, IDN.USE_STD3_ASCII_RULES).toLowerCase(Locale.ROOT);
|
|
||||||
} catch (IllegalArgumentException invalid) {
|
|
||||||
throw new IllegalArgumentException("setting 'host' must be a valid DNS hostname", invalid);
|
|
||||||
}
|
|
||||||
if (ascii.length() > 253 || ascii.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("setting 'host' must be a valid DNS hostname");
|
|
||||||
}
|
|
||||||
return ascii;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
dev.strataproxy.plugins.dns.DnsDiscoveryPlugin
|
|
||||||
-389
@@ -1,389 +0,0 @@
|
|||||||
package dev.strataproxy.plugins.dns;
|
|
||||||
|
|
||||||
import dev.strataproxy.api.Players;
|
|
||||||
import dev.strataproxy.api.PluginContext;
|
|
||||||
import dev.strataproxy.api.ServerDefinition;
|
|
||||||
import dev.strataproxy.api.ServerRegistration;
|
|
||||||
import dev.strataproxy.api.ServerView;
|
|
||||||
import dev.strataproxy.api.Servers;
|
|
||||||
import io.netty.resolver.dns.DnsServerAddresses;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.io.DataOutputStream;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.DatagramPacket;
|
|
||||||
import java.net.DatagramSocket;
|
|
||||||
import java.net.InetAddress;
|
|
||||||
import java.net.InetSocketAddress;
|
|
||||||
import java.net.UnknownHostException;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.concurrent.CountDownLatch;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
import java.util.function.BooleanSupplier;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
class DnsDiscoveryPluginTest {
|
|
||||||
@Test
|
|
||||||
void reconcilesAddressChangesAndCleansUpOnDisable() throws Exception {
|
|
||||||
InetAddress firstAddress = address(10, 1, 2, 3);
|
|
||||||
InetAddress secondAddress = address(10, 1, 2, 4);
|
|
||||||
InetAddress thirdAddress = address(10, 1, 2, 5);
|
|
||||||
InetAddress ipv6Address = InetAddress.getByAddress(new byte[]{
|
|
||||||
0x20, 0x01, 0x0d, (byte) 0xb8, 0, 0, 0, 0,
|
|
||||||
0, 0, 0, 0, 0, 0, 0, 1});
|
|
||||||
AtomicReference<InetAddress[]> answer = new AtomicReference<>(
|
|
||||||
new InetAddress[]{firstAddress, firstAddress, secondAddress, ipv6Address});
|
|
||||||
FakeServers servers = new FakeServers();
|
|
||||||
DnsDiscoveryPlugin plugin = new DnsDiscoveryPlugin(host -> answer.get());
|
|
||||||
plugin.onLoad(context(servers, Map.of(
|
|
||||||
"host", "backend.example",
|
|
||||||
"port", "25570",
|
|
||||||
"namePrefix", "forge",
|
|
||||||
"refreshSeconds", "1")));
|
|
||||||
|
|
||||||
plugin.onEnable();
|
|
||||||
await(() -> servers.definitions.size() == 3);
|
|
||||||
String firstName = name("forge-", firstAddress);
|
|
||||||
String secondName = name("forge-", secondAddress);
|
|
||||||
String thirdName = name("forge-", thirdAddress);
|
|
||||||
String ipv6Name = name("forge-", ipv6Address);
|
|
||||||
assertEquals("tcp://10.1.2.3:25570", servers.definitions.get(firstName).address().toString());
|
|
||||||
assertEquals("tcp://10.1.2.4:25570", servers.definitions.get(secondName).address().toString());
|
|
||||||
assertEquals("tcp://[2001:db8:0:0:0:0:0:1]:25570", servers.definitions.get(ipv6Name).address().toString());
|
|
||||||
assertEquals(3, servers.registerCalls.get());
|
|
||||||
assertEquals(List.of(firstName, secondName, ipv6Name), servers.registrationOrder);
|
|
||||||
|
|
||||||
answer.set(new InetAddress[]{thirdAddress, secondAddress, ipv6Address});
|
|
||||||
await(() -> servers.definitions.size() == 3 && servers.definitions.containsKey(thirdName)
|
|
||||||
&& !servers.definitions.containsKey(firstName));
|
|
||||||
assertEquals("tcp://10.1.2.4:25570", servers.definitions.get(secondName).address().toString(),
|
|
||||||
"a retained address must retain its backend identity");
|
|
||||||
assertEquals(4, servers.registerCalls.get(), "only the newly discovered address should register");
|
|
||||||
assertEquals(0, servers.updateCalls.get(), "stable retained addresses should not be reassigned");
|
|
||||||
assertEquals(1, servers.unregisterCalls.get(), "only the removed address should unregister");
|
|
||||||
|
|
||||||
plugin.onDisable();
|
|
||||||
assertTrue(servers.definitions.isEmpty());
|
|
||||||
assertEquals(4, servers.unregisterCalls.get());
|
|
||||||
plugin.onDisable();
|
|
||||||
assertEquals(4, servers.unregisterCalls.get(), "disable should be idempotent");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void retainsRegistrationsAcrossResolutionFailureThenRemovesOnSuccessfulEmptyAnswer() throws Exception {
|
|
||||||
InetAddress ip = address(192, 0, 2, 8);
|
|
||||||
AtomicReference<InetAddress[]> answer = new AtomicReference<>(new InetAddress[]{ip});
|
|
||||||
AtomicReference<Exception> failure = new AtomicReference<>();
|
|
||||||
CountDownLatch failureSeen = new CountDownLatch(1);
|
|
||||||
DnsDiscoveryPlugin plugin = new DnsDiscoveryPlugin(host -> {
|
|
||||||
Exception current = failure.get();
|
|
||||||
if (current != null) {
|
|
||||||
failureSeen.countDown();
|
|
||||||
throw current;
|
|
||||||
}
|
|
||||||
return answer.get();
|
|
||||||
});
|
|
||||||
FakeServers servers = new FakeServers();
|
|
||||||
plugin.onLoad(context(servers, Map.of("host", "pool.example", "refreshSeconds", "1")));
|
|
||||||
plugin.onEnable();
|
|
||||||
await(() -> servers.definitions.size() == 1);
|
|
||||||
|
|
||||||
failure.set(new IOException("temporary resolver failure"));
|
|
||||||
assertTrue(failureSeen.await(3, TimeUnit.SECONDS), "scheduled lookup should observe injected failure");
|
|
||||||
assertEquals(1, servers.definitions.size(), "lookup failure must not remove a live registration");
|
|
||||||
|
|
||||||
failure.set(null);
|
|
||||||
answer.set(new InetAddress[0]);
|
|
||||||
await(servers.definitions::isEmpty);
|
|
||||||
assertEquals(1, servers.unregisterCalls.get());
|
|
||||||
plugin.onDisable();
|
|
||||||
assertTrue(servers.definitions.isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void repeatedMissingDnsAnswerExpiresStaleRegistrationAndRecoveryRegistersAgain() throws Exception {
|
|
||||||
InetAddress first = address(192, 0, 2, 20);
|
|
||||||
InetAddress replacement = address(192, 0, 2, 21);
|
|
||||||
AtomicReference<InetAddress[]> answer = new AtomicReference<>(new InetAddress[]{first});
|
|
||||||
AtomicInteger misses = new AtomicInteger();
|
|
||||||
DnsDiscoveryPlugin plugin = new DnsDiscoveryPlugin(host -> {
|
|
||||||
InetAddress[] current = answer.get();
|
|
||||||
if (current != null) return current;
|
|
||||||
misses.incrementAndGet();
|
|
||||||
throw new UnknownHostException(host);
|
|
||||||
});
|
|
||||||
FakeServers servers = new FakeServers();
|
|
||||||
plugin.onLoad(context(servers, Map.of("host", "pool.example", "refreshSeconds", "1")));
|
|
||||||
try {
|
|
||||||
plugin.onEnable();
|
|
||||||
await(() -> servers.definitions.containsKey(name("dns-pool-example-", first)));
|
|
||||||
answer.set(null);
|
|
||||||
await(() -> misses.get() >= 1);
|
|
||||||
assertEquals(1, servers.definitions.size(), "one transient lookup miss should retain the backend");
|
|
||||||
await(servers.definitions::isEmpty);
|
|
||||||
assertTrue(misses.get() >= 3);
|
|
||||||
assertEquals(1, servers.unregisterCalls.get());
|
|
||||||
|
|
||||||
answer.set(new InetAddress[]{replacement});
|
|
||||||
await(() -> servers.definitions.containsKey(name("dns-pool-example-", replacement)));
|
|
||||||
assertEquals(2, servers.registerCalls.get());
|
|
||||||
} finally {
|
|
||||||
plugin.onDisable();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void resolverKeepsFailedAddressFamilyUntilItsThirdMiss() throws Exception {
|
|
||||||
InetAddress firstV4 = address(192, 0, 2, 10);
|
|
||||||
InetAddress secondV4 = address(192, 0, 2, 11);
|
|
||||||
InetAddress firstV6 = InetAddress.getByAddress(new byte[]{
|
|
||||||
0x20, 0x01, 0x0d, (byte) 0xb8, 0, 0, 0, 0,
|
|
||||||
0, 0, 0, 0, 0, 0, 0, 10});
|
|
||||||
InetAddress secondV6 = InetAddress.getByAddress(new byte[]{
|
|
||||||
0x20, 0x01, 0x0d, (byte) 0xb8, 0, 0, 0, 0,
|
|
||||||
0, 0, 0, 0, 0, 0, 0, 11});
|
|
||||||
AtomicReference<byte[]> v4 = new AtomicReference<>(firstV4.getAddress());
|
|
||||||
AtomicReference<byte[]> v6 = new AtomicReference<>(firstV6.getAddress());
|
|
||||||
AtomicBoolean dropV6 = new AtomicBoolean();
|
|
||||||
AtomicReference<Throwable> dnsFailure = new AtomicReference<>();
|
|
||||||
try (DatagramSocket socket = new DatagramSocket(new InetSocketAddress("127.0.0.1", 0))) {
|
|
||||||
Thread dnsThread = new Thread(() -> {
|
|
||||||
while (!socket.isClosed()) {
|
|
||||||
try {
|
|
||||||
DatagramPacket query = new DatagramPacket(new byte[4096], 4096);
|
|
||||||
socket.receive(query);
|
|
||||||
if (dropV6.get() && dnsQuestionType(query) == 28) continue;
|
|
||||||
byte[] response = dnsAnswer(query, v4.get(), v6.get());
|
|
||||||
socket.send(new DatagramPacket(response, response.length, query.getSocketAddress()));
|
|
||||||
} catch (java.net.SocketException closed) {
|
|
||||||
if (!socket.isClosed()) dnsFailure.set(closed);
|
|
||||||
return;
|
|
||||||
} catch (Throwable failure) {
|
|
||||||
dnsFailure.set(failure);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, "fake-dns-answer-server");
|
|
||||||
dnsThread.setDaemon(true);
|
|
||||||
dnsThread.start();
|
|
||||||
InetSocketAddress endpoint = new InetSocketAddress("127.0.0.1", socket.getLocalPort());
|
|
||||||
try (var resolver = new DnsDiscoveryPlugin.RefreshingAddressResolver(
|
|
||||||
hostname -> DnsServerAddresses.singleton(endpoint).stream())) {
|
|
||||||
assertEquals(Set.of(firstV4, firstV6),
|
|
||||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))));
|
|
||||||
v4.set(secondV4.getAddress());
|
|
||||||
v6.set(secondV6.getAddress());
|
|
||||||
assertEquals(Set.of(secondV4, secondV6),
|
|
||||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))));
|
|
||||||
v6.set(null);
|
|
||||||
assertEquals(Set.of(secondV4),
|
|
||||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))));
|
|
||||||
v6.set(secondV6.getAddress());
|
|
||||||
v4.set(null);
|
|
||||||
assertEquals(Set.of(secondV6),
|
|
||||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))));
|
|
||||||
v4.set(secondV4.getAddress());
|
|
||||||
dropV6.set(true);
|
|
||||||
assertEquals(Set.of(secondV4, secondV6),
|
|
||||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))));
|
|
||||||
assertEquals(Set.of(secondV4, secondV6),
|
|
||||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))));
|
|
||||||
assertEquals(Set.of(secondV4),
|
|
||||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))),
|
|
||||||
"a timed-out AAAA query should retire only its stale address family after three misses");
|
|
||||||
dropV6.set(false);
|
|
||||||
assertEquals(Set.of(secondV4, secondV6),
|
|
||||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))));
|
|
||||||
v4.set(null);
|
|
||||||
dropV6.set(true);
|
|
||||||
assertEquals(Set.of(secondV6),
|
|
||||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))));
|
|
||||||
assertEquals(Set.of(secondV6),
|
|
||||||
Set.copyOf(Arrays.asList(resolver.resolve("backend.dynamic.test"))));
|
|
||||||
assertEquals(0, resolver.resolve("backend.dynamic.test").length,
|
|
||||||
"the last stale address should be removed on its third missed query");
|
|
||||||
}
|
|
||||||
assertNull(dnsFailure.get());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] dnsAnswer(DatagramPacket query, byte[] v4, byte[] v6) throws IOException {
|
|
||||||
byte[] request = query.getData();
|
|
||||||
int length = query.getLength();
|
|
||||||
int offset = 12;
|
|
||||||
while (offset < length && request[offset] != 0) offset += 1 + (request[offset] & 0xff);
|
|
||||||
if (offset + 5 > length) throw new IOException("truncated DNS question");
|
|
||||||
int type = dnsQuestionType(query);
|
|
||||||
int questionLength = offset + 5 - 12;
|
|
||||||
byte[] address = type == 1 ? v4 : type == 28 ? v6 : null;
|
|
||||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
|
||||||
var output = new DataOutputStream(bytes);
|
|
||||||
output.write(request, 0, 2); // Transaction ID.
|
|
||||||
output.writeShort(0x8180); // Standard successful response.
|
|
||||||
output.writeShort(1);
|
|
||||||
output.writeShort(address == null ? 0 : 1);
|
|
||||||
output.writeInt(0);
|
|
||||||
output.write(request, 12, questionLength);
|
|
||||||
if (address != null) {
|
|
||||||
output.writeShort(0xc00c); // Name pointer to the question.
|
|
||||||
output.writeShort(type);
|
|
||||||
output.writeShort(1); // IN class.
|
|
||||||
output.writeInt(0); // TTL: a new DNS answer must be consulted on refresh.
|
|
||||||
output.writeShort(address.length);
|
|
||||||
output.write(address);
|
|
||||||
}
|
|
||||||
return bytes.toByteArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int dnsQuestionType(DatagramPacket query) throws IOException {
|
|
||||||
byte[] request = query.getData();
|
|
||||||
int length = query.getLength();
|
|
||||||
int offset = 12;
|
|
||||||
while (offset < length && request[offset] != 0) offset += 1 + (request[offset] & 0xff);
|
|
||||||
if (offset + 5 > length) throw new IOException("truncated DNS question");
|
|
||||||
return ((request[offset + 1] & 0xff) << 8) | (request[offset + 2] & 0xff);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rejectsInvalidSettingsBeforeStartingWorker() {
|
|
||||||
DnsDiscoveryPlugin obsoleteCapacity = new DnsDiscoveryPlugin(host -> new InetAddress[0]);
|
|
||||||
assertThrows(IllegalArgumentException.class,
|
|
||||||
() -> obsoleteCapacity.onLoad(context(new FakeServers(),
|
|
||||||
Map.of("host", "pool.example", "capacity", "10"))));
|
|
||||||
|
|
||||||
DnsDiscoveryPlugin missingHost = new DnsDiscoveryPlugin(host -> new InetAddress[0]);
|
|
||||||
assertThrows(IllegalArgumentException.class,
|
|
||||||
() -> missingHost.onLoad(context(new FakeServers(), Map.of("port", "25565"))));
|
|
||||||
|
|
||||||
DnsDiscoveryPlugin invalidPort = new DnsDiscoveryPlugin(host -> new InetAddress[0]);
|
|
||||||
assertThrows(IllegalArgumentException.class,
|
|
||||||
() -> invalidPort.onLoad(context(new FakeServers(), Map.of("host", "pool.example", "port", "0"))));
|
|
||||||
|
|
||||||
DnsDiscoveryPlugin invalidPrefix = new DnsDiscoveryPlugin(host -> new InetAddress[0]);
|
|
||||||
assertThrows(IllegalArgumentException.class,
|
|
||||||
() -> invalidPrefix.onLoad(context(new FakeServers(),
|
|
||||||
Map.of("host", "pool.example", "namePrefix", "bad prefix"))));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void serviceLoaderDescriptorNamesPluginImplementation() throws IOException {
|
|
||||||
try (var stream = getClass().getClassLoader().getResourceAsStream(
|
|
||||||
"META-INF/services/dev.strataproxy.api.Plugin")) {
|
|
||||||
assertNotNull(stream);
|
|
||||||
String descriptor = new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
|
||||||
assertTrue(descriptor.lines().anyMatch(DnsDiscoveryPlugin.class.getName()::equals));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PluginContext context(FakeServers servers, Map<String, String> settings) {
|
|
||||||
return new PluginContext() {
|
|
||||||
@Override
|
|
||||||
public Players players() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Servers servers() {
|
|
||||||
return servers;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public dev.strataproxy.api.Commands commands() {
|
|
||||||
return (name, handler, completer) -> { throw new UnsupportedOperationException(); };
|
|
||||||
}
|
|
||||||
@Override public dev.strataproxy.api.event.Events events() { throw new UnsupportedOperationException(); }
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public org.slf4j.Logger logger() {
|
|
||||||
return LoggerFactory.getLogger("dns-discovery-test");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Map<String, String> settings() {
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static InetAddress address(int a, int b, int c, int d) throws Exception {
|
|
||||||
return InetAddress.getByAddress(new byte[]{(byte) a, (byte) b, (byte) c, (byte) d});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String name(String prefix, InetAddress address) {
|
|
||||||
return prefix + java.util.HexFormat.of().formatHex(address.getAddress());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void await(BooleanSupplier condition) throws InterruptedException {
|
|
||||||
long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos();
|
|
||||||
while (!condition.getAsBoolean()) {
|
|
||||||
if (System.nanoTime() >= deadline) {
|
|
||||||
assertTrue(condition.getAsBoolean(), "condition not met before timeout");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Thread.sleep(10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class FakeServers implements Servers {
|
|
||||||
private final Map<String, ServerDefinition> definitions = new ConcurrentHashMap<>();
|
|
||||||
private final List<String> registrationOrder = new java.util.concurrent.CopyOnWriteArrayList<>();
|
|
||||||
private final AtomicInteger registerCalls = new AtomicInteger();
|
|
||||||
private final AtomicInteger updateCalls = new AtomicInteger();
|
|
||||||
private final AtomicInteger unregisterCalls = new AtomicInteger();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Optional<ServerView> find(String backendName) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<ServerView> all() {
|
|
||||||
return new ArrayList<>();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ServerRegistration register(ServerDefinition definition) {
|
|
||||||
if (definitions.putIfAbsent(definition.name(), definition) != null) {
|
|
||||||
throw new IllegalStateException("duplicate backend " + definition.name());
|
|
||||||
}
|
|
||||||
registrationOrder.add(definition.name());
|
|
||||||
registerCalls.incrementAndGet();
|
|
||||||
return new ServerRegistration() {
|
|
||||||
private boolean active = true;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void update(ServerDefinition replacement) {
|
|
||||||
assertTrue(active);
|
|
||||||
assertEquals(definition.name(), replacement.name());
|
|
||||||
definitions.put(replacement.name(), replacement);
|
|
||||||
updateCalls.incrementAndGet();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void unregister() {
|
|
||||||
assertTrue(active);
|
|
||||||
active = false;
|
|
||||||
definitions.remove(definition.name());
|
|
||||||
unregisterCalls.incrementAndGet();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,7 @@ dependencies {
|
|||||||
implementation(libs.findLibrary("adventure-gson").get())
|
implementation(libs.findLibrary("adventure-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
@@ -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")
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
install="$repo_root/proxy-core/build/install/strataproxy"
|
||||||
|
python_image='docker.gitea.com/runner-images@sha256:fd911d7417bfbf0f454530e447da95b58001e1df41bbc5e1a8dd35d432575aae'
|
||||||
|
java_image='eclipse-temurin@sha256:8da0490fa9a3c26867012019565948eef0ee69438f5c75ac28146967bae984b5'
|
||||||
|
suffix=$(python3 -c 'import uuid; print(uuid.uuid4().hex[:10])')
|
||||||
|
network="strataproxy-channel-$suffix"
|
||||||
|
backend="sp-backend-$suffix"
|
||||||
|
proxy="sp-proxy-$suffix"
|
||||||
|
work=$(mktemp -d "${TMPDIR:-/tmp}/strataproxy-channel.XXXXXX")
|
||||||
|
secret=$(python3 -c 'import secrets; print(secrets.token_hex(32))')
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
docker rm -f "$proxy" "$backend" >/dev/null 2>&1 || true
|
||||||
|
docker network rm "$network" >/dev/null 2>&1 || true
|
||||||
|
rm -rf "$work"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT HUP INT TERM
|
||||||
|
|
||||||
|
cd "$repo_root"
|
||||||
|
bash ./gradlew :proxy-core:installDist --no-daemon
|
||||||
|
cat > "$work/config.yml" <<EOF
|
||||||
|
listen: "0.0.0.0:25577"
|
||||||
|
authentication: OFFLINE
|
||||||
|
allowOfflinePublicAccess: true
|
||||||
|
backends: []
|
||||||
|
plugins:
|
||||||
|
directory: "/opt/strataproxy/plugins"
|
||||||
|
enabled: {}
|
||||||
|
backendChannel:
|
||||||
|
listen: "0.0.0.0:28081"
|
||||||
|
clients:
|
||||||
|
container-network-smoke:
|
||||||
|
backendName: "remote"
|
||||||
|
keyId: "primary"
|
||||||
|
secret: "$secret"
|
||||||
|
allowedHosts: ["$backend"]
|
||||||
|
allowedNamespaces: ["smoke"]
|
||||||
|
EOF
|
||||||
|
|
||||||
|
docker network create "$network" >/dev/null
|
||||||
|
docker run -d --network "$network" --name "$backend" \
|
||||||
|
-v "$repo_root:/work:ro" --entrypoint python3 "$python_image" \
|
||||||
|
/work/smoke/container-network.py backend 2 >/dev/null
|
||||||
|
ready=0
|
||||||
|
for attempt in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
if docker logs "$backend" 2>&1 | grep -q 'BACKEND_READY'; then ready=1; break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if [ "$ready" -ne 1 ]; then docker logs "$backend"; exit 1; fi
|
||||||
|
|
||||||
|
docker run -d --network "$network" --name "$proxy" \
|
||||||
|
-v "$install:/opt/strataproxy:ro" -v "$work/config.yml:/opt/strataproxy.yml:ro" \
|
||||||
|
--entrypoint java "$java_image" -cp '/opt/strataproxy/lib/*' \
|
||||||
|
dev.strataproxy.app.ProxyMain --config /opt/strataproxy.yml >/dev/null
|
||||||
|
if ! docker run --rm --network "$network" -v "$repo_root:/work:ro" \
|
||||||
|
-e "STRATAPROXY_CHANNEL_SECRET=$secret" --entrypoint python3 "$python_image" \
|
||||||
|
/work/smoke/container-network.py client control "$proxy" "$backend"; then
|
||||||
|
docker logs "$proxy" || true
|
||||||
|
docker logs "$backend" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
if [ "$(docker inspect -f '{{.State.Running}}' "$backend")" = false ]; then break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if [ "$(docker inspect -f '{{.State.Running}}' "$backend")" != false ] ||
|
||||||
|
[ "$(docker inspect -f '{{.State.ExitCode}}' "$backend")" != 0 ]; then
|
||||||
|
docker logs "$backend"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo 'control: cross-container registration, login, GOODBYE and PLAY probe passed'
|
||||||
+18
-24
@@ -6,7 +6,7 @@ $repoRoot = Split-Path -Parent $PSScriptRoot
|
|||||||
$install = Join-Path $repoRoot 'proxy-core/build/install/strataproxy'
|
$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
@@ -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>")
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
#Requires -Version 7.0
|
|
||||||
param()
|
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
|
||||||
$install = Join-Path $repoRoot 'proxy-core\build\install\strataproxy'
|
|
||||||
$lib = Join-Path $install 'lib'
|
|
||||||
$plugins = Join-Path $install 'plugins'
|
|
||||||
if (-not (Test-Path -LiteralPath $plugins) -or
|
|
||||||
@(Get-ChildItem -LiteralPath $plugins -Filter 'dns-discovery-*.jar').Count -ne 1 -or
|
|
||||||
@(Get-ChildItem -LiteralPath $plugins -Filter 'agent-discovery-*.jar').Count -ne 1) {
|
|
||||||
throw 'Run .\gradlew.bat :proxy-core:installDist before this smoke test'
|
|
||||||
}
|
|
||||||
|
|
||||||
function FreePort {
|
|
||||||
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
|
|
||||||
$listener.Start()
|
|
||||||
try { return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port }
|
|
||||||
finally { $listener.Stop() }
|
|
||||||
}
|
|
||||||
|
|
||||||
function VarInt([int]$value) {
|
|
||||||
$bytes = [System.Collections.Generic.List[byte]]::new()
|
|
||||||
do {
|
|
||||||
$part = $value -band 127
|
|
||||||
$value = $value -shr 7
|
|
||||||
if ($value -ne 0) { $part = $part -bor 128 }
|
|
||||||
$bytes.Add([byte]$part)
|
|
||||||
} while ($value -ne 0)
|
|
||||||
return ,$bytes.ToArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReadVarInt([System.IO.Stream]$stream) {
|
|
||||||
$value = 0
|
|
||||||
for ($shift = 0; $shift -le 28; $shift += 7) {
|
|
||||||
$part = $stream.ReadByte()
|
|
||||||
if ($part -lt 0) { throw 'Unexpected EOF while reading VarInt' }
|
|
||||||
$value = $value -bor (($part -band 127) -shl $shift)
|
|
||||||
if (($part -band 128) -eq 0) { return $value }
|
|
||||||
}
|
|
||||||
throw 'Overlong VarInt'
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReadExactly([System.IO.Stream]$stream, [int]$length) {
|
|
||||||
$bytes = [byte[]]::new($length)
|
|
||||||
$offset = 0
|
|
||||||
while ($offset -lt $length) {
|
|
||||||
$read = $stream.Read($bytes, $offset, $length - $offset)
|
|
||||||
if ($read -eq 0) { throw 'Unexpected EOF while reading packet' }
|
|
||||||
$offset += $read
|
|
||||||
}
|
|
||||||
return ,$bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
function WriteFrame([System.IO.Stream]$stream, [byte[]]$body) {
|
|
||||||
$prefix = VarInt $body.Length
|
|
||||||
$stream.Write($prefix)
|
|
||||||
$stream.Write($body)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusOnline([int]$port) {
|
|
||||||
$client = [System.Net.Sockets.TcpClient]::new()
|
|
||||||
try {
|
|
||||||
$client.Connect([System.Net.IPAddress]::Loopback, $port)
|
|
||||||
$client.ReceiveTimeout = 1000
|
|
||||||
$stream = $client.GetStream()
|
|
||||||
$handshake = [System.Collections.Generic.List[byte]]::new()
|
|
||||||
$handshake.AddRange([byte[]](VarInt 0))
|
|
||||||
$handshake.AddRange([byte[]](VarInt 5))
|
|
||||||
$hostBytes = [System.Text.Encoding]::UTF8.GetBytes('localhost')
|
|
||||||
$handshake.AddRange([byte[]](VarInt $hostBytes.Length))
|
|
||||||
$handshake.AddRange($hostBytes)
|
|
||||||
$handshake.Add(0x63)
|
|
||||||
$handshake.Add(0xdd)
|
|
||||||
$handshake.AddRange([byte[]](VarInt 1))
|
|
||||||
WriteFrame $stream ($handshake.ToArray())
|
|
||||||
WriteFrame $stream ([byte[]]@(0))
|
|
||||||
$body = ReadExactly $stream (ReadVarInt $stream)
|
|
||||||
$packetStream = [System.IO.MemoryStream]::new($body)
|
|
||||||
if ((ReadVarInt $packetStream) -ne 0) { throw 'Unexpected status packet id' }
|
|
||||||
$json = [System.Text.Encoding]::UTF8.GetString((ReadExactly $packetStream (ReadVarInt $packetStream))) | ConvertFrom-Json
|
|
||||||
return [int]$json.players.online
|
|
||||||
} finally { $client.Dispose() }
|
|
||||||
}
|
|
||||||
|
|
||||||
function WaitForOnline([int]$port, [int]$expected) {
|
|
||||||
$last = $null
|
|
||||||
for ($attempt = 0; $attempt -lt 50; $attempt++) {
|
|
||||||
try {
|
|
||||||
$last = StatusOnline $port
|
|
||||||
if ($last -eq $expected) { return }
|
|
||||||
} catch { $last = $_.Exception.Message }
|
|
||||||
Start-Sleep -Milliseconds 100
|
|
||||||
}
|
|
||||||
throw "Expected status online=$expected on port $port; last result=$last"
|
|
||||||
}
|
|
||||||
|
|
||||||
function StartFakeBackend([int]$ProbeCount = 1) {
|
|
||||||
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
|
|
||||||
$listener.Start()
|
|
||||||
$job = Start-ThreadJob -ArgumentList $listener, $ProbeCount -ScriptBlock {
|
|
||||||
param($backendListener, $expectedProbes)
|
|
||||||
function ReadNumber($stream) {
|
|
||||||
$number = 0
|
|
||||||
for ($shift = 0; $shift -le 28; $shift += 7) {
|
|
||||||
$part = $stream.ReadByte()
|
|
||||||
if ($part -lt 0) { throw 'Backend saw unexpected EOF' }
|
|
||||||
$number = $number -bor (($part -band 127) -shl $shift)
|
|
||||||
if (($part -band 128) -eq 0) { return $number }
|
|
||||||
}
|
|
||||||
throw 'Backend saw overlong VarInt'
|
|
||||||
}
|
|
||||||
function ReadFrame($stream) {
|
|
||||||
$length = ReadNumber $stream
|
|
||||||
if ($length -lt 1 -or $length -gt 1048576) { throw "Invalid backend frame length $length" }
|
|
||||||
$body = [byte[]]::new($length)
|
|
||||||
$offset = 0
|
|
||||||
while ($offset -lt $length) {
|
|
||||||
$read = $stream.Read($body, $offset, $length - $offset)
|
|
||||||
if ($read -eq 0) { throw 'Backend saw unexpected EOF' }
|
|
||||||
$offset += $read
|
|
||||||
}
|
|
||||||
return ,$body
|
|
||||||
}
|
|
||||||
function Number([int]$value) {
|
|
||||||
$bytes = [System.Collections.Generic.List[byte]]::new()
|
|
||||||
do {
|
|
||||||
$part = $value -band 127
|
|
||||||
$value = $value -shr 7
|
|
||||||
if ($value -ne 0) { $part = $part -bor 128 }
|
|
||||||
$bytes.Add([byte]$part)
|
|
||||||
} while ($value -ne 0)
|
|
||||||
return ,$bytes.ToArray()
|
|
||||||
}
|
|
||||||
function Send($stream, [byte[]]$body) {
|
|
||||||
$stream.Write((Number $body.Length))
|
|
||||||
$stream.Write($body)
|
|
||||||
}
|
|
||||||
function AddString($list, [string]$value) {
|
|
||||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($value)
|
|
||||||
$list.AddRange([byte[]](Number $bytes.Length))
|
|
||||||
$list.AddRange($bytes)
|
|
||||||
}
|
|
||||||
$client = $backendListener.AcceptTcpClient()
|
|
||||||
try {
|
|
||||||
$client.ReceiveTimeout = 5000
|
|
||||||
$stream = $client.GetStream()
|
|
||||||
$null = ReadFrame $stream # Handshake.
|
|
||||||
$login = ReadFrame $stream
|
|
||||||
$packet = [System.IO.MemoryStream]::new($login)
|
|
||||||
if ((ReadNumber $packet) -ne 0) { throw 'Backend expected Login Start' }
|
|
||||||
$nameLength = ReadNumber $packet
|
|
||||||
if ($nameLength -lt 1 -or $nameLength -gt 64) { throw 'Invalid backend username length' }
|
|
||||||
$nameBytes = [byte[]]::new($nameLength)
|
|
||||||
if ($packet.Read($nameBytes, 0, $nameLength) -ne $nameLength) { throw 'Truncated backend username' }
|
|
||||||
$username = [System.Text.Encoding]::UTF8.GetString($nameBytes)
|
|
||||||
$digest = [System.Security.Cryptography.MD5]::HashData(
|
|
||||||
[System.Text.Encoding]::UTF8.GetBytes("OfflinePlayer:$username"))
|
|
||||||
$digest[6] = [byte](($digest[6] -band 0x0f) -bor 0x30)
|
|
||||||
$digest[8] = [byte](($digest[8] -band 0x3f) -bor 0x80)
|
|
||||||
$hex = [Convert]::ToHexString($digest).ToLowerInvariant()
|
|
||||||
$uuid = '{0}-{1}-{2}-{3}-{4}' -f $hex.Substring(0, 8), $hex.Substring(8, 4),
|
|
||||||
$hex.Substring(12, 4), $hex.Substring(16, 4), $hex.Substring(20, 12)
|
|
||||||
$success = [System.Collections.Generic.List[byte]]::new()
|
|
||||||
$success.Add(2)
|
|
||||||
AddString $success $uuid
|
|
||||||
AddString $success $username
|
|
||||||
Send $stream ($success.ToArray())
|
|
||||||
$join = [byte[]]@(1, 0, 0, 0, 42, 0, 0, 0, 20, 7, 100, 101, 102, 97, 117, 108, 116)
|
|
||||||
Send $stream $join
|
|
||||||
$stream.Flush()
|
|
||||||
for ($index = 0; $index -lt $expectedProbes; $index++) {
|
|
||||||
$probe = ReadFrame $stream
|
|
||||||
if ($probe.Length -ne 2 -or $probe[0] -ne 3 -or $probe[1] -ne (42 + $index)) {
|
|
||||||
throw "Backend did not receive PLAY probe $index"
|
|
||||||
}
|
|
||||||
Send $stream $probe
|
|
||||||
$stream.Flush()
|
|
||||||
}
|
|
||||||
} finally { $client.Dispose() }
|
|
||||||
}
|
|
||||||
return [pscustomobject]@{ Listener = $listener; Job = $job; Port = ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port }
|
|
||||||
}
|
|
||||||
|
|
||||||
function SendOfflineLogin([System.IO.Stream]$stream, [string]$username) {
|
|
||||||
$handshake = [System.Collections.Generic.List[byte]]::new()
|
|
||||||
$handshake.AddRange([byte[]](VarInt 0))
|
|
||||||
$handshake.AddRange([byte[]](VarInt 5))
|
|
||||||
$hostBytes = [System.Text.Encoding]::UTF8.GetBytes('localhost')
|
|
||||||
$handshake.AddRange([byte[]](VarInt $hostBytes.Length))
|
|
||||||
$handshake.AddRange($hostBytes)
|
|
||||||
$handshake.AddRange([byte[]]@(0x63, 0xdd))
|
|
||||||
$handshake.AddRange([byte[]](VarInt 2))
|
|
||||||
WriteFrame $stream ($handshake.ToArray())
|
|
||||||
$login = [System.Collections.Generic.List[byte]]::new()
|
|
||||||
$login.Add(0)
|
|
||||||
$name = [System.Text.Encoding]::UTF8.GetBytes($username)
|
|
||||||
$login.AddRange([byte[]](VarInt $name.Length))
|
|
||||||
$login.AddRange($name)
|
|
||||||
WriteFrame $stream ($login.ToArray())
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoginThroughProxy([int]$port, [switch]$KeepOpen) {
|
|
||||||
$client = [System.Net.Sockets.TcpClient]::new()
|
|
||||||
$returned = $false
|
|
||||||
try {
|
|
||||||
$client.Connect([System.Net.IPAddress]::Loopback, $port)
|
|
||||||
$client.ReceiveTimeout = 5000
|
|
||||||
$stream = $client.GetStream()
|
|
||||||
SendOfflineLogin $stream 'SmokePlayer'
|
|
||||||
$success = ReadExactly $stream (ReadVarInt $stream)
|
|
||||||
if ($success[0] -ne 2) {
|
|
||||||
$reason = [System.Text.Encoding]::UTF8.GetString($success)
|
|
||||||
throw "Expected Login Success, got packet $($success[0]): $reason"
|
|
||||||
}
|
|
||||||
$join = ReadExactly $stream (ReadVarInt $stream)
|
|
||||||
if ($join[0] -ne 1) { throw "Expected Join Game, got packet $($join[0])" }
|
|
||||||
WriteFrame $stream ([byte[]]@(3, 42))
|
|
||||||
$echo = ReadExactly $stream (ReadVarInt $stream)
|
|
||||||
if ($echo.Length -ne 2 -or $echo[0] -ne 3 -or $echo[1] -ne 42) { throw 'PLAY frame did not traverse the discovered backend' }
|
|
||||||
if ($KeepOpen) {
|
|
||||||
$returned = $true
|
|
||||||
return $client
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (-not $returned) { $client.Dispose() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function WaitForLogin([int]$port, [switch]$KeepOpen) {
|
|
||||||
$last = $null
|
|
||||||
for ($attempt = 0; $attempt -lt 50; $attempt++) {
|
|
||||||
try {
|
|
||||||
$result = LoginThroughProxy $port -KeepOpen:$KeepOpen
|
|
||||||
return $result
|
|
||||||
}
|
|
||||||
catch { $last = $_.Exception.Message }
|
|
||||||
Start-Sleep -Milliseconds 100
|
|
||||||
}
|
|
||||||
throw "Discovered backend did not accept login on port $port; last result=$last"
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExpectNoBackend([int]$port) {
|
|
||||||
$client = [System.Net.Sockets.TcpClient]::new()
|
|
||||||
try {
|
|
||||||
$client.Connect([System.Net.IPAddress]::Loopback, $port)
|
|
||||||
$client.ReceiveTimeout = 5000
|
|
||||||
$stream = $client.GetStream()
|
|
||||||
SendOfflineLogin $stream 'NewSmokePlayer'
|
|
||||||
$response = ReadExactly $stream (ReadVarInt $stream)
|
|
||||||
$packet = [System.IO.MemoryStream]::new($response)
|
|
||||||
if ((ReadVarInt $packet) -ne 0) { throw 'New player reached an unregistered backend' }
|
|
||||||
$reason = [System.Text.Encoding]::UTF8.GetString((ReadExactly $packet (ReadVarInt $packet)))
|
|
||||||
if ($reason -notlike '*No server is available*') {
|
|
||||||
throw "New player got an unexpected login rejection: $reason"
|
|
||||||
}
|
|
||||||
} finally { $client.Dispose() }
|
|
||||||
}
|
|
||||||
|
|
||||||
function StartProxy([string]$config, [string]$stdout, [string]$stderr) {
|
|
||||||
$java = (Get-Command java.exe -ErrorAction Stop).Source
|
|
||||||
$classpath = Join-Path $lib '*'
|
|
||||||
return Start-Process -FilePath $java -ArgumentList @(
|
|
||||||
'-cp', ('"{0}"' -f $classpath), 'dev.strataproxy.app.ProxyMain',
|
|
||||||
'--config', ('"{0}"' -f $config)
|
|
||||||
) -WindowStyle Hidden -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr
|
|
||||||
}
|
|
||||||
|
|
||||||
function StopProxy($process) {
|
|
||||||
if ($process -and -not $process.HasExited) {
|
|
||||||
Stop-Process -Id $process.Id -Force
|
|
||||||
Wait-Process -Id $process.Id -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$temporary = Join-Path $repoRoot 'build\installed-discovery-smoke'
|
|
||||||
New-Item -ItemType Directory -Path $temporary -Force | Out-Null
|
|
||||||
$prefix = [guid]::NewGuid().ToString('N')
|
|
||||||
$dnsConfig = Join-Path $temporary "$prefix-dns.yml"
|
|
||||||
$agentConfig = Join-Path $temporary "$prefix-agent.yml"
|
|
||||||
$stdout = Join-Path $temporary "$prefix.stdout.log"
|
|
||||||
$stderr = Join-Path $temporary "$prefix.stderr.log"
|
|
||||||
$dns = $null
|
|
||||||
$agent = $null
|
|
||||||
$dnsBackend = $null
|
|
||||||
$agentBackend = $null
|
|
||||||
$agentClient = $null
|
|
||||||
try {
|
|
||||||
$pluginPath = $plugins.Replace('\', '/')
|
|
||||||
$dnsBackend = StartFakeBackend
|
|
||||||
$dnsPort = FreePort
|
|
||||||
@"
|
|
||||||
listen: "127.0.0.1:$dnsPort"
|
|
||||||
authentication: OFFLINE
|
|
||||||
backends: []
|
|
||||||
plugins:
|
|
||||||
directory: "$pluginPath"
|
|
||||||
enabled:
|
|
||||||
dev.strataproxy.plugins.dns.DnsDiscoveryPlugin:
|
|
||||||
host: localhost
|
|
||||||
port: "$($dnsBackend.Port)"
|
|
||||||
refreshSeconds: "1"
|
|
||||||
"@ | Set-Content -LiteralPath $dnsConfig -Encoding utf8
|
|
||||||
$dns = StartProxy $dnsConfig $stdout $stderr
|
|
||||||
$dnsAddresses = [System.Net.Dns]::GetHostAddresses('localhost').Length
|
|
||||||
WaitForOnline $dnsPort 0
|
|
||||||
WaitForLogin $dnsPort
|
|
||||||
Write-Output "DNS plugin: $dnsAddresses address(es), discovered backend accepted login"
|
|
||||||
$null = Wait-Job -Job $dnsBackend.Job -Timeout 10
|
|
||||||
if ($dnsBackend.Job.State -ne 'Completed') { throw "DNS fake backend job state=$($dnsBackend.Job.State)" }
|
|
||||||
Receive-Job -Job $dnsBackend.Job -ErrorAction Stop | Out-Null
|
|
||||||
Write-Output 'DNS plugin: discovered backend completed Login Success, Join Game, and PLAY relay'
|
|
||||||
StopProxy $dns
|
|
||||||
$dns = $null
|
|
||||||
|
|
||||||
$agentBackend = StartFakeBackend 2
|
|
||||||
$proxyPort = FreePort
|
|
||||||
$agentPort = FreePort
|
|
||||||
while ($agentPort -eq $proxyPort) { $agentPort = FreePort }
|
|
||||||
$secret = [Convert]::ToHexString([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
|
|
||||||
@"
|
|
||||||
listen: "127.0.0.1:$proxyPort"
|
|
||||||
authentication: OFFLINE
|
|
||||||
backends: []
|
|
||||||
plugins:
|
|
||||||
directory: "$pluginPath"
|
|
||||||
enabled:
|
|
||||||
dev.strataproxy.plugins.agent.AgentDiscoveryPlugin:
|
|
||||||
host: "127.0.0.1"
|
|
||||||
port: "$agentPort"
|
|
||||||
secret: "$secret"
|
|
||||||
"@ | Set-Content -LiteralPath $agentConfig -Encoding utf8
|
|
||||||
$agent = StartProxy $agentConfig $stdout $stderr
|
|
||||||
WaitForOnline $proxyPort 0
|
|
||||||
ExpectNoBackend $proxyPort
|
|
||||||
$generation = [guid]::NewGuid().ToString()
|
|
||||||
$agentId = 'installed-smoke'
|
|
||||||
$timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()
|
|
||||||
$nonce = [Convert]::ToHexString([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(16))
|
|
||||||
$endpoint = "http://127.0.0.1:$agentPort/registration"
|
|
||||||
function SendAgent([string]$body) {
|
|
||||||
$canonical = "POST`n/registration`n$timestamp`n$nonce`n$agentId`n$body"
|
|
||||||
$hmac = [System.Security.Cryptography.HMACSHA256]::new([System.Text.Encoding]::UTF8.GetBytes($secret))
|
|
||||||
try { $signature = [Convert]::ToHexString($hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($canonical))) }
|
|
||||||
finally { $hmac.Dispose() }
|
|
||||||
return Invoke-WebRequest -Uri $endpoint -Method Post -Body $body -ContentType 'application/x-www-form-urlencoded; charset=utf-8' -Headers @{
|
|
||||||
'X-Agent-Id' = $agentId
|
|
||||||
'X-Timestamp' = $timestamp
|
|
||||||
'X-Nonce' = $nonce
|
|
||||||
'X-Signature' = $signature
|
|
||||||
} -TimeoutSec 5
|
|
||||||
}
|
|
||||||
$registerBody = "action=register&generation=$generation&name=installed-smoke-backend&address=tcp%3A%2F%2F127.0.0.1%3A$($agentBackend.Port)&leaseSeconds=30"
|
|
||||||
$registered = SendAgent $registerBody
|
|
||||||
if ($registered.StatusCode -ne 201) { throw "Agent registration returned $($registered.StatusCode)" }
|
|
||||||
$agentClient = WaitForLogin $proxyPort -KeepOpen
|
|
||||||
Write-Output "Agent plugin: registration status=$($registered.StatusCode), discovered backend accepted login"
|
|
||||||
Write-Output 'Agent plugin: discovered backend completed Login Success, Join Game, and PLAY relay'
|
|
||||||
$nonce = [Convert]::ToHexString([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(16))
|
|
||||||
$unregistered = SendAgent "action=unregister&generation=$generation"
|
|
||||||
if ($unregistered.StatusCode -ne 204) { throw "Agent unregister returned $($unregistered.StatusCode)" }
|
|
||||||
WaitForOnline $proxyPort 1
|
|
||||||
ExpectNoBackend $proxyPort
|
|
||||||
Write-Output 'Agent plugin: unregister status=204, existing player remains connected'
|
|
||||||
$stream = $agentClient.GetStream()
|
|
||||||
WriteFrame $stream ([byte[]]@(3, 43))
|
|
||||||
$echo = ReadExactly $stream (ReadVarInt $stream)
|
|
||||||
if ($echo.Length -ne 2 -or $echo[0] -ne 3 -or $echo[1] -ne 43) {
|
|
||||||
throw 'Existing PLAY session stopped after Agent unregister'
|
|
||||||
}
|
|
||||||
$agentClient.Dispose()
|
|
||||||
$agentClient = $null
|
|
||||||
$null = Wait-Job -Job $agentBackend.Job -Timeout 10
|
|
||||||
if ($agentBackend.Job.State -ne 'Completed') { throw "Agent fake backend job state=$($agentBackend.Job.State)" }
|
|
||||||
Receive-Job -Job $agentBackend.Job -ErrorAction Stop | Out-Null
|
|
||||||
WaitForOnline $proxyPort 0
|
|
||||||
Write-Output 'Agent plugin: existing PLAY session survived unregister, online=0 after disconnect'
|
|
||||||
} catch {
|
|
||||||
foreach ($backend in @($dnsBackend, $agentBackend)) {
|
|
||||||
if ($backend) {
|
|
||||||
Write-Warning "Fake backend job state=$($backend.Job.State)"
|
|
||||||
Receive-Job -Job $backend.Job -Keep -ErrorAction Continue | Write-Warning
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw
|
|
||||||
} finally {
|
|
||||||
if ($agentClient) { $agentClient.Dispose() }
|
|
||||||
StopProxy $dns
|
|
||||||
StopProxy $agent
|
|
||||||
foreach ($backend in @($dnsBackend, $agentBackend)) {
|
|
||||||
if ($backend) {
|
|
||||||
$backend.Listener.Stop()
|
|
||||||
Stop-Job -Job $backend.Job -ErrorAction SilentlyContinue
|
|
||||||
Remove-Job -Job $backend.Job -Force -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Remove-Item -LiteralPath $dnsConfig, $agentConfig, $stdout, $stderr -ErrorAction SilentlyContinue
|
|
||||||
$secret = $null
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user