diff --git a/.gitignore b/.gitignore index 7782df37..8e33b330 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ dist/bin/ *.tmp *.bak *.swp + +# wrangler dev +node_modules/ +.wrangler/ diff --git a/README.md b/README.md index 98f12f6c..e7286669 100644 --- a/README.md +++ b/README.md @@ -234,11 +234,12 @@ api.onConnectionStateChanged(state -> { - [x] player locator service - [ ] more channel types and lifecycle hooks -- [ ] **web interface** - - [ ] ... - -- [ ] **admin GUI** - - [ ] ... +- [ ] **web editor (`/cb editor`)** + - [x] live session over a Cloudflare relay (no open ports, no self-hosting) + - [x] signed messages, secret only in the editor link + - [x] create, edit and delete scripts with inline validation errors + - [ ] config editing (without secrets and transport settings) + - [ ] backend config editing see the [open issues](https://github.com/objz/CommandBridge/issues) for bugs and feature requests. diff --git a/backends/build.gradle.kts b/backends/build.gradle.kts index 6015540e..731d9962 100644 --- a/backends/build.gradle.kts +++ b/backends/build.gradle.kts @@ -31,6 +31,9 @@ tasks.jar { dependencies { testImplementation(libs.junit.jupiter) + testImplementation("org.spongepowered:configurate-yaml:4.2.0") + testImplementation("org.snakeyaml:snakeyaml-engine:2.10") + testImplementation("dev.jorel:commandapi-spigot-core:11.2.0") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/backends/bukkit/src/main/java/dev/objz/commandbridge/bukkit/Adapter.java b/backends/bukkit/src/main/java/dev/objz/commandbridge/bukkit/Adapter.java index 3ad46ff8..70cac1dd 100644 --- a/backends/bukkit/src/main/java/dev/objz/commandbridge/bukkit/Adapter.java +++ b/backends/bukkit/src/main/java/dev/objz/commandbridge/bukkit/Adapter.java @@ -13,6 +13,8 @@ import dev.objz.commandbridge.backends.platform.ScheduleHandle; import dev.objz.commandbridge.backends.platform.cmd.ClientCommands; import dev.objz.commandbridge.backends.platform.cmd.CommandExecutor; +import dev.objz.commandbridge.backends.platform.cmd.PairCommand; +import dev.objz.commandbridge.backends.setup.PairClient; import dev.objz.commandbridge.config.ConfigManager; import dev.objz.commandbridge.config.model.BackendsConfig; import dev.objz.commandbridge.config.model.EndpointType; @@ -38,6 +40,7 @@ public final class Adapter implements PlatformAdapter { private BackendClient client; + private RegistrationHandler registrationHandler; private BackendsConfig cfg; private Path dataDir; private JavaPlugin plugin; @@ -53,6 +56,13 @@ public void load(PlatformEnv env, JavaPlugin plugin) throws Exception { this.cfg = cfgMgr.current(); if (!ok || cfg == null) { configOk = false; + Log.info(PairClient.UNPAIRED_HINT); + return; + } + // the shipped config can't authenticate anywhere, so don't keep trying + if (PairClient.isUnpaired(cfg)) { + configOk = false; + Log.info(PairClient.UNPAIRED_HINT); return; } @@ -66,11 +76,14 @@ public void load(PlatformEnv env, JavaPlugin plugin) throws Exception { @Override public void start(PlatformEnv env) throws Exception { + if (this.dataDir == null) + this.dataDir = PathsUtil.normalizeDataDir(env.dataDir()); + // register /cbc pair even when the config is fresh or broken, since that's when it's needed + ClientCommands.register(() -> client, + PairCommand.subcommand(this, dataDir, Bukkit.getBukkitVersion().split("-")[0])); if (!configOk) { return; } - if (this.dataDir == null) - this.dataDir = PathsUtil.normalizeDataDir(env.dataDir()); if (this.cfg == null) { var cfgMgr = new ConfigManager<>(dataDir, BackendsConfig.class); if (!cfgMgr.load()) { @@ -89,6 +102,11 @@ public void start(PlatformEnv env) throws Exception { Bukkit::isPrimaryThread, task -> Bukkit.getScheduler().runTask(plugin, task)); + startClient(); + Bukkit.getPluginManager().registerEvents(new PlayerListener(), plugin); + } + + private void startClient() { this.client = cfg.endpointType() == EndpointType.REDIS ? new RedisClient(cfg, dataDir, this) : new WsClient(cfg, dataDir, this); @@ -100,9 +118,7 @@ public void start(PlatformEnv env) throws Exception { client.scheduleReconnection(); } - ClientCommands.register(client); - - RegistrationHandler registrationHandler = new RegistrationHandler(client); + registrationHandler = new RegistrationHandler(client); client.inboundRouter().register(MessageType.REGISTER_COMMANDS, registrationHandler); client.inboundRouter().register(MessageType.EXECUTE_COMMAND, new ExecuteCommandHandler(commandExecutor)); @@ -115,7 +131,37 @@ public void start(PlatformEnv env) throws Exception { Location.BACKEND)); client.onAuthenticated(this::sendPlayerList); - Bukkit.getPluginManager().registerEvents(new PlayerListener(), plugin); + } + + @Override + public void applyNewConfig() { + Bukkit.getScheduler().runTask(plugin, () -> { + try { + if (registrationHandler != null) + registrationHandler.unregisterAll(); + if (client != null) + client.close(); + var cfgMgr = new ConfigManager<>(dataDir, BackendsConfig.class); + if (!cfgMgr.load()) { + Log.error("The paired config.yml is not valid"); + return; + } + this.cfg = cfgMgr.current(); + Log.setDebug(cfg.debug()); + if (commandExecutor == null) + commandExecutor = new BukkitExecutor(plugin); + if (!configOk) { + configOk = true; + Log.installThreadMarshalling( + Bukkit::isPrimaryThread, + task -> Bukkit.getScheduler().runTask(plugin, task)); + Bukkit.getPluginManager().registerEvents(new PlayerListener(), plugin); + } + startClient(); + } catch (Exception e) { + Log.error(e, "Could not connect with the new config: {}", e.getMessage()); + } + }); } @Override diff --git a/backends/folia/src/main/java/dev/objz/commandbridge/folia/Adapter.java b/backends/folia/src/main/java/dev/objz/commandbridge/folia/Adapter.java index 6d5df447..910edaa2 100644 --- a/backends/folia/src/main/java/dev/objz/commandbridge/folia/Adapter.java +++ b/backends/folia/src/main/java/dev/objz/commandbridge/folia/Adapter.java @@ -13,6 +13,8 @@ import dev.objz.commandbridge.backends.platform.ScheduleHandle; import dev.objz.commandbridge.backends.platform.cmd.ClientCommands; import dev.objz.commandbridge.backends.platform.cmd.CommandExecutor; +import dev.objz.commandbridge.backends.platform.cmd.PairCommand; +import dev.objz.commandbridge.backends.setup.PairClient; import dev.objz.commandbridge.config.ConfigManager; import dev.objz.commandbridge.config.model.BackendsConfig; import dev.objz.commandbridge.config.model.EndpointType; @@ -39,6 +41,7 @@ public final class Adapter implements PlatformAdapter { private BackendClient client; + private RegistrationHandler registrationHandler; private BackendsConfig cfg; private Path dataDir; private JavaPlugin plugin; @@ -54,6 +57,13 @@ public void load(PlatformEnv env, JavaPlugin plugin) throws Exception { this.cfg = cfgMgr.current(); if (!ok || cfg == null) { configOk = false; + Log.info(PairClient.UNPAIRED_HINT); + return; + } + // the shipped config can't authenticate anywhere, so don't keep trying + if (PairClient.isUnpaired(cfg)) { + configOk = false; + Log.info(PairClient.UNPAIRED_HINT); return; } @@ -67,11 +77,14 @@ public void load(PlatformEnv env, JavaPlugin plugin) throws Exception { @Override public void start(PlatformEnv env) throws Exception { + if (this.dataDir == null) + this.dataDir = PathsUtil.normalizeDataDir(env.dataDir()); + // register /cbc pair even when the config is fresh or broken, since that's when it's needed + ClientCommands.register(() -> client, + PairCommand.subcommand(this, dataDir, Bukkit.getBukkitVersion().split("-")[0])); if (!configOk) { return; } - if (this.dataDir == null) - this.dataDir = PathsUtil.normalizeDataDir(env.dataDir()); if (this.cfg == null) { var cfgMgr = new ConfigManager<>(dataDir, BackendsConfig.class); if (!cfgMgr.load()) { @@ -86,6 +99,11 @@ public void start(PlatformEnv env) throws Exception { this.commandExecutor = new FoliaExecutor(plugin); } + startClient(); + Bukkit.getPluginManager().registerEvents(new PlayerListener(), plugin); + } + + private void startClient() { this.client = cfg.endpointType() == EndpointType.REDIS ? new RedisClient(cfg, dataDir, this) : new WsClient(cfg, dataDir, this); @@ -97,9 +115,7 @@ public void start(PlatformEnv env) throws Exception { client.scheduleReconnection(); } - ClientCommands.register(client); - - RegistrationHandler registrationHandler = new RegistrationHandler(client); + registrationHandler = new RegistrationHandler(client); client.inboundRouter().register(MessageType.REGISTER_COMMANDS, registrationHandler); client.inboundRouter().register(MessageType.EXECUTE_COMMAND, new ExecuteCommandHandler(commandExecutor)); @@ -112,7 +128,34 @@ public void start(PlatformEnv env) throws Exception { Location.BACKEND)); client.onAuthenticated(this::sendPlayerList); - Bukkit.getPluginManager().registerEvents(new PlayerListener(), plugin); + } + + @Override + public void applyNewConfig() { + Bukkit.getGlobalRegionScheduler().execute(plugin, () -> { + try { + if (registrationHandler != null) + registrationHandler.unregisterAll(); + if (client != null) + client.close(); + var cfgMgr = new ConfigManager<>(dataDir, BackendsConfig.class); + if (!cfgMgr.load()) { + Log.error("The paired config.yml is not valid"); + return; + } + this.cfg = cfgMgr.current(); + Log.setDebug(cfg.debug()); + if (commandExecutor == null) + commandExecutor = new FoliaExecutor(plugin); + if (!configOk) { + configOk = true; + Bukkit.getPluginManager().registerEvents(new PlayerListener(), plugin); + } + startClient(); + } catch (Exception e) { + Log.error(e, "Could not connect with the new config: {}", e.getMessage()); + } + }); } @Override diff --git a/backends/paper/src/main/java/dev/objz/commandbridge/paper/Adapter.java b/backends/paper/src/main/java/dev/objz/commandbridge/paper/Adapter.java index a1d0a80b..6389184c 100644 --- a/backends/paper/src/main/java/dev/objz/commandbridge/paper/Adapter.java +++ b/backends/paper/src/main/java/dev/objz/commandbridge/paper/Adapter.java @@ -13,6 +13,8 @@ import dev.objz.commandbridge.backends.platform.ScheduleHandle; import dev.objz.commandbridge.backends.platform.cmd.ClientCommands; import dev.objz.commandbridge.backends.platform.cmd.CommandExecutor; +import dev.objz.commandbridge.backends.platform.cmd.PairCommand; +import dev.objz.commandbridge.backends.setup.PairClient; import dev.objz.commandbridge.config.ConfigManager; import dev.objz.commandbridge.config.model.BackendsConfig; import dev.objz.commandbridge.config.model.EndpointType; @@ -38,6 +40,7 @@ public final class Adapter implements PlatformAdapter { private BackendClient client; + private RegistrationHandler registrationHandler; private BackendsConfig cfg; private Path dataDir; private JavaPlugin plugin; @@ -53,6 +56,13 @@ public void load(PlatformEnv env, JavaPlugin plugin) throws Exception { this.cfg = cfgMgr.current(); if (!ok || cfg == null) { configOk = false; + Log.info(PairClient.UNPAIRED_HINT); + return; + } + // the shipped config can't authenticate anywhere, so don't keep trying + if (PairClient.isUnpaired(cfg)) { + configOk = false; + Log.info(PairClient.UNPAIRED_HINT); return; } @@ -66,11 +76,14 @@ public void load(PlatformEnv env, JavaPlugin plugin) throws Exception { @Override public void start(PlatformEnv env) throws Exception { + if (this.dataDir == null) + this.dataDir = PathsUtil.normalizeDataDir(env.dataDir()); + // register /cbc pair even when the config is fresh or broken, since that's when it's needed + ClientCommands.register(() -> client, + PairCommand.subcommand(this, dataDir, Bukkit.getBukkitVersion().split("-")[0])); if (!configOk) { return; } - if (this.dataDir == null) - this.dataDir = PathsUtil.normalizeDataDir(env.dataDir()); if (this.cfg == null) { var cfgMgr = new ConfigManager<>(dataDir, BackendsConfig.class); if (!cfgMgr.load()) { @@ -89,6 +102,11 @@ public void start(PlatformEnv env) throws Exception { Bukkit::isPrimaryThread, task -> Bukkit.getScheduler().runTask(plugin, task)); + startClient(); + Bukkit.getPluginManager().registerEvents(new PlayerListener(), plugin); + } + + private void startClient() { this.client = cfg.endpointType() == EndpointType.REDIS ? new RedisClient(cfg, dataDir, this) : new WsClient(cfg, dataDir, this); @@ -100,9 +118,7 @@ public void start(PlatformEnv env) throws Exception { client.scheduleReconnection(); } - ClientCommands.register(client); - - RegistrationHandler registrationHandler = new RegistrationHandler(client); + registrationHandler = new RegistrationHandler(client); client.inboundRouter().register(MessageType.REGISTER_COMMANDS, registrationHandler); client.inboundRouter().register(MessageType.EXECUTE_COMMAND, new ExecuteCommandHandler(commandExecutor)); @@ -115,7 +131,37 @@ public void start(PlatformEnv env) throws Exception { Location.BACKEND)); client.onAuthenticated(this::sendPlayerList); - Bukkit.getPluginManager().registerEvents(new PlayerListener(), plugin); + } + + @Override + public void applyNewConfig() { + Bukkit.getScheduler().runTask(plugin, () -> { + try { + if (registrationHandler != null) + registrationHandler.unregisterAll(); + if (client != null) + client.close(); + var cfgMgr = new ConfigManager<>(dataDir, BackendsConfig.class); + if (!cfgMgr.load()) { + Log.error("The paired config.yml is not valid"); + return; + } + this.cfg = cfgMgr.current(); + Log.setDebug(cfg.debug()); + if (commandExecutor == null) + commandExecutor = new PaperExecutor(plugin); + if (!configOk) { + configOk = true; + Log.installThreadMarshalling( + Bukkit::isPrimaryThread, + task -> Bukkit.getScheduler().runTask(plugin, task)); + Bukkit.getPluginManager().registerEvents(new PlayerListener(), plugin); + } + startClient(); + } catch (Exception e) { + Log.error(e, "Could not connect with the new config: {}", e.getMessage()); + } + }); } @Override diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/api/BackendCommandBridgeImpl.java b/backends/src/main/java/dev/objz/commandbridge/backends/api/BackendCommandBridgeImpl.java index b1dd520c..5fad65dc 100644 --- a/backends/src/main/java/dev/objz/commandbridge/backends/api/BackendCommandBridgeImpl.java +++ b/backends/src/main/java/dev/objz/commandbridge/backends/api/BackendCommandBridgeImpl.java @@ -4,6 +4,8 @@ import dev.objz.commandbridge.api.CommandBridgeProvider; import dev.objz.commandbridge.api.channel.ChannelPayload; import dev.objz.commandbridge.api.channel.MessageChannel; +import dev.objz.commandbridge.api.channel.command.CommandPayload; +import dev.objz.commandbridge.api.channel.command.RunAs; import dev.objz.commandbridge.api.message.MessageContext; import dev.objz.commandbridge.api.message.MessageListener; import dev.objz.commandbridge.api.message.ServerEventListener; @@ -12,9 +14,11 @@ import dev.objz.commandbridge.api.platform.Platform; import dev.objz.commandbridge.api.platform.PlayerLocator; import dev.objz.commandbridge.backends.net.client.BackendClient; +import dev.objz.commandbridge.backends.platform.PlatformAdapter; import dev.objz.commandbridge.logging.Log; import dev.objz.commandbridge.net.channel.PluginMessageChannel; import dev.objz.commandbridge.net.payloads.PluginMessage; +import dev.objz.commandbridge.net.payloads.cmd.ExecuteCommand; import dev.objz.commandbridge.net.proto.Envelope; import dev.objz.commandbridge.net.proto.MessageType; @@ -39,6 +43,8 @@ public final class BackendCommandBridgeImpl implements CommandBridgeAPI { private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(15); private final BackendClient client; + private final String localServerId; + private final PlatformAdapter adapter; private final ConcurrentHashMap, MessageChannel> channels = new ConcurrentHashMap<>(); private final ConcurrentHashMap> payloadTypesByName = new ConcurrentHashMap<>(); private final ConcurrentHashMap>> listenersByType = new ConcurrentHashMap<>(); @@ -48,8 +54,11 @@ public final class BackendCommandBridgeImpl implements CommandBridgeAPI { private final AtomicBoolean polling = new AtomicBoolean(false); private final AtomicBoolean providerRegistered = new AtomicBoolean(false); - public BackendCommandBridgeImpl(BackendClient client) { + public BackendCommandBridgeImpl(BackendClient client, String localServerId, PlatformAdapter adapter) { this.client = Objects.requireNonNull(client); + this.localServerId = Objects.requireNonNull(localServerId); + this.adapter = Objects.requireNonNull(adapter); + channel(CommandPayload.class).listen((context, payload) -> executeCommand(payload)); } public void bootstrap() { @@ -244,8 +253,24 @@ private PluginMessage requirePluginMessage(Envelope env) { } private String localServerId() { - String id = client.serverId(); - return (id == null || id.isBlank()) ? "backend" : id; + return localServerId; + } + + private void executeCommand(CommandPayload payload) { + if (payload == null || payload.command() == null || payload.command().isBlank() + || payload.command().stripLeading().startsWith("/") || payload.runAs() == null + || (payload.runAs() != RunAs.CONSOLE && payload.player() == null)) { + throw new IllegalArgumentException("Invalid command payload or missing player context"); + } + adapter.getCommandExecutor() + .execute(new ExecuteCommand(payload.command(), payload.runAs(), payload.player(), null)) + .whenComplete((result, error) -> { + if (error != null) { + Log.error(error, "API command execution failed"); + } else if (!result.isSuccess()) { + Log.warn("API command execution failed: {}", result.message()); + } + }); } private void startStatePolling() { diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/net/client/RedisClient.java b/backends/src/main/java/dev/objz/commandbridge/backends/net/client/RedisClient.java index 46c54de7..4a702cff 100644 --- a/backends/src/main/java/dev/objz/commandbridge/backends/net/client/RedisClient.java +++ b/backends/src/main/java/dev/objz/commandbridge/backends/net/client/RedisClient.java @@ -17,6 +17,7 @@ import dev.objz.commandbridge.net.proto.Envelope; import dev.objz.commandbridge.net.redis.RedisChannels; import dev.objz.commandbridge.scripting.model.enums.Location; +import dev.objz.commandbridge.security.RedisSeal; import dev.objz.commandbridge.security.SecretLoader; import redis.clients.jedis.DefaultJedisClientConfig; import redis.clients.jedis.HostAndPort; @@ -47,6 +48,7 @@ public final class RedisClient implements BackendClient { private final BackendCommandBridgeImpl api; private volatile JedisPool pool; + private final RedisSeal seal; private volatile JedisPubSub subscriber; private volatile Thread subscriberThread; private volatile boolean running; @@ -60,20 +62,22 @@ public RedisClient(BackendsConfig cfg, Path dataDir, PlatformAdapter adapter) this.dataDir = Objects.requireNonNull(dataDir); this.adapter = Objects.requireNonNull(adapter); + String secret = resolveSecret(); + this.seal = new RedisSeal(secret); this.reconnectHandler = new ReconnectHandler(cfg, adapter, this::attemptReconnect); - this.authHandler = new AuthHandler(cfg, outNode, stateRef); + this.authHandler = new AuthHandler(cfg, outNode, stateRef, this); this.messageRouter = new RedisMessageRouter( inNode, outNode, awaiter, stateRef, - resolveSecret(), + secret, location); outNode.setClientId(cfg.clientId()); authHandler.onAuthFailed(this::onConnectionLost); messageRouter.onAuthenticationRequired(authHandler::authenticate); - this.api = new BackendCommandBridgeImpl(this); + this.api = new BackendCommandBridgeImpl(this, cfg.clientId(), adapter); } @Override @@ -88,6 +92,7 @@ public synchronized void start() throws Exception { } try { + authHandler.invalidate(); stateRef.set(ConnectionState.CONNECTING); running = true; pool = createPool(); @@ -117,6 +122,7 @@ public synchronized void start() throws Exception { @Override public synchronized void reconnect() throws Exception { Log.warn("Manual reconnection initiated"); + authHandler.invalidate(); reconnectHandler.shutdown(); api.shutdown(); messageRouter.clearTap(); @@ -141,7 +147,9 @@ public SendOperation send(Envelope request) { @Override public synchronized void close() { Log.debug("Closing RedisClient"); + authHandler.invalidate(); reconnectHandler.shutdown(); + api.shutdown(); messageRouter.clearTap(); stopInternal(); stateRef.set(ConnectionState.DISCONNECTED); @@ -229,9 +237,15 @@ public void onSubscribe(String channel, int subscribedChannels) { @Override public void onMessage(String channel, String message) { RedisEndpoint endpoint = proxyEndpoint; - if (endpoint != null) { - messageRouter.onText(endpoint, message); + if (endpoint == null) { + return; + } + String opened = seal.open(channel, message); + if (opened == null) { + Log.debug("Dropped an invalid, expired, or replayed Redis message"); + return; } + messageRouter.onText(endpoint, opened); } }; @@ -251,10 +265,15 @@ public void onMessage(String channel, String message) { } private CompletableFuture publishToProxy(Envelope env) { + final String message; + try { + message = seal.seal(RedisChannels.PROXY_INBOUND, Envelope.MAPPER.writeValueAsString(env)); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } try { - String payload = Envelope.MAPPER.writeValueAsString(env); try (Jedis jedis = pool.getResource()) { - jedis.publish(RedisChannels.PROXY_INBOUND, payload); + jedis.publish(RedisChannels.PROXY_INBOUND, message); } return CompletableFuture.completedFuture(null); } catch (Exception e) { @@ -281,6 +300,7 @@ private JedisPool createPool() { } private void stopInternal() { + authHandler.invalidate(); running = false; JedisPubSub sub = subscriber; diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/net/client/WsClient.java b/backends/src/main/java/dev/objz/commandbridge/backends/net/client/WsClient.java index 2e3393c4..b6c0457f 100644 --- a/backends/src/main/java/dev/objz/commandbridge/backends/net/client/WsClient.java +++ b/backends/src/main/java/dev/objz/commandbridge/backends/net/client/WsClient.java @@ -45,6 +45,8 @@ public final class WsClient implements BackendClient { private volatile WsEndpoint cachedEndpoint; private Location location = Location.BACKEND; private String serverId; + private long connectionGeneration; + private boolean closed; public WsClient(BackendsConfig cfg, Path dataDir, PlatformAdapter adapter) { this.cfg = Objects.requireNonNull(cfg); @@ -52,7 +54,7 @@ public WsClient(BackendsConfig cfg, Path dataDir, PlatformAdapter adapter) { this.resources = new ResourcePool(); this.connectionHandler = new ConnectionHandler(cfg, resources); this.reconnectHandler = new ReconnectHandler(cfg, adapter, this::attemptReconnect); - this.authHandler = new AuthHandler(cfg, outNode, stateRef); + this.authHandler = new AuthHandler(cfg, outNode, stateRef, this); String secret = resolveSecret(); this.messageRouter = new MessageRouter( @@ -66,7 +68,7 @@ public WsClient(BackendsConfig cfg, Path dataDir, PlatformAdapter adapter) { outNode.setClientId(cfg.clientId()); authHandler.onAuthFailed(this::onConnectionLost); - this.api = new BackendCommandBridgeImpl(this); + this.api = new BackendCommandBridgeImpl(this, cfg.clientId(), adapter); } @Override @@ -76,6 +78,10 @@ public synchronized void start() throws Exception { return; } + closed = false; + long generation = ++connectionGeneration; + authHandler.invalidate(); + if (!reconnectHandler.isReconnecting()) { reconnectHandler.stopReconnect(); } @@ -95,7 +101,7 @@ public synchronized void start() throws Exception { WebSocketChannel channel = connectionHandler.connect(reconnectHandler.isReconnecting()); stateRef.set(ConnectionState.CONNECTED); - messageRouter.setupChannel(channel); + messageRouter.setupChannel(channel, () -> onConnectionLost(generation)); api.bootstrap(); authHandler.authenticate(); @@ -105,6 +111,7 @@ public synchronized void start() throws Exception { } } catch (Exception e) { + authHandler.invalidate(); stateRef.set(ConnectionState.DISCONNECTED); if (!reconnectHandler.isReconnecting()) { @@ -118,6 +125,8 @@ public synchronized void start() throws Exception { @Override public synchronized void reconnect() throws Exception { Log.warn("Manual reconnection initiated"); + connectionGeneration++; + authHandler.invalidate(); reconnectHandler.shutdown(); messageRouter.clearTap(); connectionHandler.forceClose(); @@ -154,6 +163,9 @@ public SendOperation send(Envelope request) { @Override public synchronized void close() throws Exception { Log.debug("Closing WsClient"); + closed = true; + connectionGeneration++; + authHandler.invalidate(); reconnectHandler.shutdown(); api.shutdown(); @@ -210,13 +222,27 @@ public void onAuthenticated(Runnable callback) { authHandler.onAuthenticated(callback); } - private void onConnectionLost() { + private synchronized void onConnectionLost() { + onConnectionLost(connectionGeneration); + } + + private synchronized void onConnectionLost(long generation) { + if (closed || generation != connectionGeneration) { + return; + } + connectionGeneration++; + authHandler.invalidate(); + messageRouter.clearTap(); + cachedEndpoint = null; stateRef.set(ConnectionState.RECONNECTING); connectionHandler.forceClose(); reconnectHandler.scheduleReconnect(); } - private void attemptReconnect() { + private synchronized void attemptReconnect() { + if (closed) { + return; + } try { if (connectionHandler.isConnected()) { connectionHandler.forceClose(); diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/net/connection/ReconnectHandler.java b/backends/src/main/java/dev/objz/commandbridge/backends/net/connection/ReconnectHandler.java index 2638e69f..492d75ff 100644 --- a/backends/src/main/java/dev/objz/commandbridge/backends/net/connection/ReconnectHandler.java +++ b/backends/src/main/java/dev/objz/commandbridge/backends/net/connection/ReconnectHandler.java @@ -13,6 +13,7 @@ public final class ReconnectHandler { private final PlatformAdapter adapter; private final AtomicBoolean isReconnecting = new AtomicBoolean(false); private volatile ScheduleHandle reconnectionTask; + private volatile long deadline; private final Runnable reconnectCallback; public ReconnectHandler(BackendsConfig cfg, PlatformAdapter adapter, Runnable reconnectCallback) { @@ -22,6 +23,13 @@ public ReconnectHandler(BackendsConfig cfg, PlatformAdapter adapter, Runnable } public synchronized void scheduleReconnect() { + // a platform task can end itself when its time is up without running the last attempt, + // which would leave this stuck "in progress" for good; past the window, start over + if (reconnectionTask != null && System.currentTimeMillis() >= deadline) { + Log.debug("The last reconnect ran out without a result, starting a new one"); + stopReconnect(); + } + if (isReconnecting.get()) { Log.debug("Reconnect already in progress, skipping"); return; @@ -42,6 +50,7 @@ public synchronized void scheduleReconnect() { interval.getSeconds()); long startTime = System.currentTimeMillis(); + deadline = startTime + totalTimeout.toMillis() + interval.toMillis(); Runnable task = () -> { if (!isReconnecting.get()) { diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/net/connection/ResourcePool.java b/backends/src/main/java/dev/objz/commandbridge/backends/net/connection/ResourcePool.java index 4b3b1fb2..e4ebf9c3 100644 --- a/backends/src/main/java/dev/objz/commandbridge/backends/net/connection/ResourcePool.java +++ b/backends/src/main/java/dev/objz/commandbridge/backends/net/connection/ResourcePool.java @@ -19,6 +19,7 @@ public final class ResourcePool implements AutoCloseable { private XnioWorker worker; private ByteBufferPool bufferPool; private XnioSsl ssl; + private TrustManager sessionTrust; private final ClassLoader classLoader; public ResourcePool() { @@ -58,7 +59,14 @@ public synchronized void initializeSsl(TlsMode mode, String configuredPin, boole TrustManager tm; try { - tm = new TrustManager(mode, configuredPin); + if (mode == TlsMode.TOFU && (configuredPin == null || configuredPin.isBlank())) { + if (sessionTrust == null) { + sessionTrust = new TrustManager(mode, configuredPin); + } + tm = sessionTrust; + } else { + tm = new TrustManager(mode, configuredPin); + } } catch (Exception e) { Log.error(e, "Failed to create TrustManager for TLS mode {}", mode); throw e; diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/net/in/ExecuteCommandHandler.java b/backends/src/main/java/dev/objz/commandbridge/backends/net/in/ExecuteCommandHandler.java index 0b851627..f662847b 100644 --- a/backends/src/main/java/dev/objz/commandbridge/backends/net/in/ExecuteCommandHandler.java +++ b/backends/src/main/java/dev/objz/commandbridge/backends/net/in/ExecuteCommandHandler.java @@ -11,6 +11,7 @@ import dev.objz.commandbridge.api.channel.command.RunAs; import java.util.HashSet; +import java.util.concurrent.CompletableFuture; import java.util.Objects; import java.util.Set; @@ -57,7 +58,14 @@ public void accept(Endpoint endpoint, Envelope env) { final Set finalPermissions = grantedPermissions; - executor.execute(exec, finalPermissions) + CompletableFuture running; + try { + running = executor.execute(exec, finalPermissions); + } catch (Exception ex) { + // if the platform executor throws instead of failing its future, the proxy still gets an answer + running = CompletableFuture.failedFuture(ex); + } + running .thenAccept(result -> { if (result.isSuccess()) { Log.debug("Command '{}' executed successfully", exec.command()); diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/net/in/RegistrationHandler.java b/backends/src/main/java/dev/objz/commandbridge/backends/net/in/RegistrationHandler.java index 5e626efa..14bbd37c 100644 --- a/backends/src/main/java/dev/objz/commandbridge/backends/net/in/RegistrationHandler.java +++ b/backends/src/main/java/dev/objz/commandbridge/backends/net/in/RegistrationHandler.java @@ -3,6 +3,7 @@ import dev.objz.commandbridge.backends.net.client.BackendClient; import dev.objz.commandbridge.backends.platform.cmd.ArgumentMapper; import dev.objz.commandbridge.backends.platform.cmd.CommandRegistry; +import dev.objz.commandbridge.cmd.CommandRegistryInterface; import dev.objz.commandbridge.logging.Log; import dev.objz.commandbridge.logging.Summary; import dev.objz.commandbridge.net.Endpoint; @@ -19,12 +20,26 @@ public final class RegistrationHandler extends InboundHandler { private final BackendClient client; - private final CommandRegistry registry; + private final CommandRegistryInterface registry; private volatile List registeredCommands = List.of(); public RegistrationHandler(BackendClient client) { + this(client, new CommandRegistry(new ArgumentMapper(), client.outboundRouter())); + } + + RegistrationHandler(BackendClient client, CommandRegistryInterface registry) { this.client = Objects.requireNonNull(client); - this.registry = new CommandRegistry(new ArgumentMapper(), client.outboundRouter()); + this.registry = Objects.requireNonNull(registry); + } + + /** Drops the commands this handler registered, before a new client takes over. */ + public void unregisterAll() { + try { + registry.unregisterAll(); + } catch (Exception e) { + Log.warn("Failed to unregister previous commands: {}", e.getMessage()); + } + registeredCommands = List.of(); } @Override @@ -43,7 +58,10 @@ public void accept(Endpoint endpoint, Envelope env) { } if (rc == null || rc.commands() == null || rc.commands().isEmpty()) { - Log.warn("Received empty or malformed REGISTER_COMMANDS. All commands unregistered"); + if (rc == null || rc.commands() == null) + Log.warn("Received malformed REGISTER_COMMANDS. All commands unregistered"); + else + Log.debug("The proxy has no commands for this server"); registeredCommands = List.of(); Feedback f = new Feedback(0, 0, 0, List.of("Received empty registration request"), diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/net/out/AuthRequest.java b/backends/src/main/java/dev/objz/commandbridge/backends/net/out/AuthRequest.java index 42d3b075..fb1d6d88 100644 --- a/backends/src/main/java/dev/objz/commandbridge/backends/net/out/AuthRequest.java +++ b/backends/src/main/java/dev/objz/commandbridge/backends/net/out/AuthRequest.java @@ -2,6 +2,7 @@ import dev.objz.commandbridge.backends.net.connection.ClientStatus; import dev.objz.commandbridge.backends.net.out.ctx.AuthRequestContext; +import dev.objz.commandbridge.backends.platform.PlatformDetector; import dev.objz.commandbridge.logging.Log; import dev.objz.commandbridge.net.OutboundHandler; import dev.objz.commandbridge.net.SendOperation; @@ -30,7 +31,7 @@ public SendOperation accept(AuthRequestContext ctx) { final String clientNonce = UUID.randomUUID().toString(); final String mac = auth.sign(clientId, clientNonce); - var payload = Envelope.MAPPER.valueToTree(new AuthRequestPayload(location, clientNonce, mac)); + var payload = Envelope.MAPPER.valueToTree(new AuthRequestPayload(location, PlatformDetector.detectPlatform(), clientNonce, mac)); Envelope env = Envelope.make(MessageType.AUTH_REQUEST, clientId, "proxy-auth", payload); SendOperation op = send(env) @@ -43,6 +44,10 @@ public SendOperation accept(AuthRequestContext ctx) { if (reply.type() == MessageType.AUTH_FAIL) { ctx.statusSink().accept(ClientStatus.AUTH_FAILED); Log.error("Authentication rejected by server"); + // retry like every other failed login, since over Redis nothing else starts it + if (ctx.onAuthFailed() != null) { + ctx.onAuthFailed().run(); + } return reply; } @@ -53,6 +58,9 @@ public SendOperation accept(AuthRequestContext ctx) { } catch (Exception e) { ctx.statusSink().accept(ClientStatus.AUTH_FAILED); Log.error(e, "Authentication response malformed"); + if (ctx.onAuthFailed() != null) { + ctx.onAuthFailed().run(); + } return reply; } diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/net/out/InvokedCommandEvent.java b/backends/src/main/java/dev/objz/commandbridge/backends/net/out/InvokedCommandEvent.java index be4516ad..ad32af63 100644 --- a/backends/src/main/java/dev/objz/commandbridge/backends/net/out/InvokedCommandEvent.java +++ b/backends/src/main/java/dev/objz/commandbridge/backends/net/out/InvokedCommandEvent.java @@ -19,13 +19,14 @@ import java.util.Collection; import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.UUID; public final class InvokedCommandEvent extends OutboundHandler { @Override public SendOperation accept(InvokedCommandContext ctx) { - SenderContext senderCtx = mapSender(ctx.sender); + SenderContext senderCtx = mapSender(ctx.sender, ctx.stub.name()); List typedArgs = new ArrayList<>(); CommandStub stub = ctx.stub; @@ -54,14 +55,16 @@ public SendOperation accept(InvokedCommandContext ctx) { return op; } - private SenderContext mapSender(Object sender) { + private SenderContext mapSender(Object sender, String commandName) { if (sender == null) { return new SenderContext.Other("null"); } SenderContext.Player player = asPlayerSender(sender); if (player != null) { - return player; + String permission = "commandbridge.command." + commandName; + return new SenderContext.Player(player.name(), player.uuid(), + hasPermission(sender, permission) ? Set.of(permission) : Set.of()); } if (isConsoleSender(sender)) { @@ -99,6 +102,14 @@ private boolean isConsoleSender(Object sender) { || className.endsWith("ConsoleCommandSource"); } + private boolean hasPermission(Object sender, String permission) { + try { + return Boolean.TRUE.equals(sender.getClass().getMethod("hasPermission", String.class).invoke(sender, permission)); + } catch (ReflectiveOperationException e) { + return false; + } + } + private Location3D extractBlockLocation(Object sender) { Object block = invoke(sender, "getBlock"); if (block == null) { @@ -128,12 +139,13 @@ private Object mapArgument(ArgType type, Object raw) { }; } - private int toInt(Object raw) { + // a missing optional argument stays null, which leaves its placeholder empty + private Integer toInt(Object raw) { if (raw instanceof Number number) { return number.intValue(); } if (raw == null) { - return 0; + return null; } try { return Integer.parseInt(raw.toString()); @@ -142,12 +154,12 @@ private int toInt(Object raw) { } } - private double toDouble(Object raw) { + private Double toDouble(Object raw) { if (raw instanceof Number number) { return number.doubleValue(); } if (raw == null) { - return 0.0; + return null; } try { return Double.parseDouble(raw.toString()); @@ -156,14 +168,17 @@ private double toDouble(Object raw) { } } - private boolean toBoolean(Object raw) { + private Boolean toBoolean(Object raw) { if (raw instanceof Boolean value) { return value; } - return raw != null && Boolean.parseBoolean(raw.toString()); + return raw == null ? null : Boolean.parseBoolean(raw.toString()); } private Location3D toLocation3D(Object raw) { + if (raw instanceof Location3D location) { + return location; + } if (raw == null) { return null; } @@ -179,6 +194,9 @@ private Location3D toLocation3D(Object raw) { } private Location2D toLocation2D(Object raw) { + if (raw instanceof Location2D location) { + return location; + } if (raw == null) { return null; } @@ -202,6 +220,9 @@ private String resolveWorldName(Object location) { } private List toEntityRefs(Object raw) { + if (raw instanceof EntityRef ref) { + return List.of(ref); + } if (!(raw instanceof Collection collection)) { return List.of(); } @@ -213,6 +234,9 @@ private List toEntityRefs(Object raw) { } private EntityRef toEntityRef(Object entity) { + if (entity instanceof EntityRef ref) { + return ref; + } if (entity == null) { return null; } diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/net/routing/AuthHandler.java b/backends/src/main/java/dev/objz/commandbridge/backends/net/routing/AuthHandler.java index e55d5c79..a658024a 100644 --- a/backends/src/main/java/dev/objz/commandbridge/backends/net/routing/AuthHandler.java +++ b/backends/src/main/java/dev/objz/commandbridge/backends/net/routing/AuthHandler.java @@ -16,13 +16,17 @@ public final class AuthHandler { private final BackendsConfig cfg; private final OutNode outNode; private final AtomicReference stateRef; + private final Object lifecycleLock; + private long generation; private volatile Runnable onAuthed; private volatile Runnable onAuthFailed; - public AuthHandler(BackendsConfig cfg, OutNode outNode, AtomicReference stateRef) { + public AuthHandler(BackendsConfig cfg, OutNode outNode, AtomicReference stateRef, + Object lifecycleLock) { this.cfg = cfg; this.outNode = outNode; this.stateRef = stateRef; + this.lifecycleLock = lifecycleLock; } public void onAuthenticated(Runnable callback) { @@ -34,35 +38,61 @@ public void onAuthFailed(Runnable callback) { } public boolean authenticate() { + synchronized (lifecycleLock) { + return authenticateLocked(); + } + } + + public void invalidate() { + synchronized (lifecycleLock) { + generation++; + } + } + + private boolean authenticateLocked() { + long attempt = ++generation; Consumer statusUpdater = status -> { - ConnectionState newState = status.toConnectionState(); - stateRef.set(newState); + synchronized (lifecycleLock) { + if (attempt != generation) { + return; + } + ConnectionState newState = status.toConnectionState(); + stateRef.set(newState); - if (newState == ConnectionState.AUTHENTICATED) { - Log.debug("Authentication successful"); - var cb = onAuthed; - if (cb != null) { - try { - cb.run(); - } catch (Exception e) { - Log.error("Authentication callback failed: {}", e.getMessage()); + if (newState == ConnectionState.AUTHENTICATED) { + Log.debug("Authentication successful"); + var cb = onAuthed; + if (cb != null) { + try { + cb.run(); + } catch (Exception e) { + Log.error("Authentication callback failed: {}", e.getMessage()); + } } + } else if (newState == ConnectionState.AUTH_FAILED) { + Log.error("Authentication failed"); } - } else if (newState == ConnectionState.AUTH_FAILED) { - Log.error("Authentication failed"); } }; Duration timeout = Duration.ofSeconds(cfg.timeouts().authTimeout()); - AuthRequestContext context = new AuthRequestContext(timeout, statusUpdater, onAuthFailed); + AuthRequestContext context = new AuthRequestContext(timeout, statusUpdater, () -> { + synchronized (lifecycleLock) { + if (attempt == generation && onAuthFailed != null) { + onAuthFailed.run(); + } + } + }); try { outNode.send(MessageType.AUTH_REQUEST, context); return true; } catch (Exception e) { Log.error(e, "Failed to send authentication request"); - stateRef.set(ConnectionState.AUTH_FAILED); + if (attempt == generation) { + stateRef.set(ConnectionState.AUTH_FAILED); + } return false; } } diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/net/routing/MessageRouter.java b/backends/src/main/java/dev/objz/commandbridge/backends/net/routing/MessageRouter.java index df6d13be..b81576da 100644 --- a/backends/src/main/java/dev/objz/commandbridge/backends/net/routing/MessageRouter.java +++ b/backends/src/main/java/dev/objz/commandbridge/backends/net/routing/MessageRouter.java @@ -12,12 +12,11 @@ import dev.objz.commandbridge.net.ResponseAwaiter; import dev.objz.commandbridge.net.SendOperation; import dev.objz.commandbridge.net.endpoints.WsEndpoint; +import dev.objz.commandbridge.net.endpoints.BoundedReceiveListener; import dev.objz.commandbridge.net.proto.MessageType; import dev.objz.commandbridge.scripting.model.enums.Location; import dev.objz.commandbridge.security.AuthService; -import io.undertow.websockets.core.AbstractReceiveListener; import io.undertow.websockets.core.BufferedBinaryMessage; -import io.undertow.websockets.core.BufferedTextMessage; import io.undertow.websockets.core.StreamSourceFrameChannel; import io.undertow.websockets.core.WebSocketChannel; @@ -32,6 +31,7 @@ public final class MessageRouter { private final Runnable reconnectCallback; private final String secret; private volatile Location location; + private volatile WsEndpoint activeEndpoint; public MessageRouter( InNode inNode, @@ -55,7 +55,12 @@ public void setLocation(Location location) { } public void setupChannel(WebSocketChannel channel) { + setupChannel(channel, reconnectCallback); + } + + public void setupChannel(WebSocketChannel channel, Runnable connectionLost) { var endpoint = new WsEndpoint(channel); + activeEndpoint = endpoint; inNode.setSendOperationFactory((ep, envelope) -> new SendOperation(ep, envelope, awaiter)); outNode.setSendOperationFactory(envelope -> new SendOperation(endpoint, envelope, awaiter)); @@ -82,11 +87,24 @@ public void setupChannel(WebSocketChannel channel) { return matched; }); - channel.getReceiveSetter().set(new AbstractReceiveListener() { + channel.getReceiveSetter().set(new BoundedReceiveListener() { @Override - protected void onFullTextMessage(WebSocketChannel ch, BufferedTextMessage message) { + protected void onError(WebSocketChannel ch, Throwable error) { + super.onError(ch, error); + if (activeEndpoint != endpoint) { + return; + } + Log.warn("WebSocket receive failed: {}", error.getMessage()); + connectionLost.run(); + } + + @Override + protected void onTextMessage(WebSocketChannel ch, String message) { + if (activeEndpoint != endpoint) { + return; + } try { - inNode.onText(endpoint, message.getData()); + inNode.onText(endpoint, message); } catch (Throwable t) { Log.error(t, "Inbound message handling failed"); } @@ -111,9 +129,11 @@ protected void onClose(WebSocketChannel ch, StreamSourceFrameChannel frameChanne } try { + if (activeEndpoint != endpoint) { + return; + } Log.warn("Connection lost - attempting to reconnect"); - stateRef.set(ConnectionState.RECONNECTING); - reconnectCallback.run(); + connectionLost.run(); } catch (Throwable ignore) { } } @@ -131,6 +151,7 @@ protected void onClose(WebSocketChannel ch, StreamSourceFrameChannel frameChanne } public void clearTap() { + activeEndpoint = null; if (inNode != null) { inNode.setInboundTap(null); } diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/platform/PlatformAdapter.java b/backends/src/main/java/dev/objz/commandbridge/backends/platform/PlatformAdapter.java index ec95ffe6..0cc3e5ba 100644 --- a/backends/src/main/java/dev/objz/commandbridge/backends/platform/PlatformAdapter.java +++ b/backends/src/main/java/dev/objz/commandbridge/backends/platform/PlatformAdapter.java @@ -44,4 +44,12 @@ default void load(PlatformEnv env, P plugin) throws Exception { * @return A handle for early cancellation. */ ScheduleHandle runSchedule(Runnable task, Duration timeout, Duration interval); + + /** + * Stops the current client, loads config.yml again and connects with it, after /cbc pair wrote it. + * Commands are not registered a second time. + */ + default void applyNewConfig() { + throw new UnsupportedOperationException("This platform can't switch to a new config without a restart"); + } } diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/platform/PlatformDetector.java b/backends/src/main/java/dev/objz/commandbridge/backends/platform/PlatformDetector.java index 10c40baa..5382c329 100644 --- a/backends/src/main/java/dev/objz/commandbridge/backends/platform/PlatformDetector.java +++ b/backends/src/main/java/dev/objz/commandbridge/backends/platform/PlatformDetector.java @@ -1,29 +1,27 @@ package dev.objz.commandbridge.backends.platform; -public final class PlatformDetector { +import dev.objz.commandbridge.scripting.model.enums.ServerPlatform; - public enum Platform { - FOLIA, PAPER, BUKKIT, FORGE, FABRIC, UNKNOWN - } +public final class PlatformDetector { private PlatformDetector() { } - public static Platform detectPlatform() { - Platform p; + public static ServerPlatform detectPlatform() { + ServerPlatform p; if (classExists("io.papermc.paper.threadedregions.RegionizedServer")) { - p = Platform.FOLIA; + p = ServerPlatform.FOLIA; } else if (classExists("com.destroystokyo.paper.PaperConfig") || classExists("io.papermc.paper.configuration.Configuration")) { - p = Platform.PAPER; + p = ServerPlatform.PAPER; } else if (classExists("org.bukkit.Bukkit")) { - p = Platform.BUKKIT; + p = ServerPlatform.BUKKIT; } else if (classExists("net.minecraftforge.fml.common.Mod")) { - p = Platform.FORGE; + p = ServerPlatform.FORGE; } else if (classExists("net.fabricmc.loader.api.FabricLoader")) { - p = Platform.FABRIC; + p = ServerPlatform.FABRIC; } else { - p = Platform.UNKNOWN; + p = ServerPlatform.UNKNOWN; } return p; } diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/platform/bootstrap/BukkitMain.java b/backends/src/main/java/dev/objz/commandbridge/backends/platform/bootstrap/BukkitMain.java index 59795f68..812abed2 100644 --- a/backends/src/main/java/dev/objz/commandbridge/backends/platform/bootstrap/BukkitMain.java +++ b/backends/src/main/java/dev/objz/commandbridge/backends/platform/bootstrap/BukkitMain.java @@ -2,7 +2,7 @@ import dev.objz.commandbridge.backends.platform.PlatformAdapter; import dev.objz.commandbridge.backends.platform.PlatformDetector; -import dev.objz.commandbridge.backends.platform.PlatformDetector.Platform; +import dev.objz.commandbridge.scripting.model.enums.ServerPlatform; import dev.objz.commandbridge.logging.Log; import org.bstats.bukkit.Metrics; @@ -23,7 +23,7 @@ public void onLoad() { Log.info("Initializing CommandBridge (Bukkit family)"); - Platform platform = PlatformDetector.detectPlatform(); + ServerPlatform platform = PlatformDetector.detectPlatform(); Log.info("Detected platform: {}", platform); try { adapter = switch (platform) { diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/platform/cmd/ClientCommands.java b/backends/src/main/java/dev/objz/commandbridge/backends/platform/cmd/ClientCommands.java index ef15f2fa..28df7ad4 100644 --- a/backends/src/main/java/dev/objz/commandbridge/backends/platform/cmd/ClientCommands.java +++ b/backends/src/main/java/dev/objz/commandbridge/backends/platform/cmd/ClientCommands.java @@ -8,19 +8,31 @@ import net.kyori.adventure.audience.Audience; import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; public final class ClientCommands { private ClientCommands() { } - public static void register(BackendClient client) { + /** {@code client} is null until config.yml is valid; {@code pair} works without it. */ + public static void register(Supplier clients, CommandAPICommand pair) { new CommandAPICommand("commandbridgeclient") .withAliases("cbc") .withPermission("commandbridge.admin") + .withSubcommand(pair) .withSubcommand(new CommandAPICommand("reconnect") .executes((CommandExecutor) (sender, args) -> { Audience src = (Audience) sender; + BackendClient client = clients.get(); + if (client == null) { + MM.msg() + .space() + .line(MM.error("Not connected: config.yml is not valid")) + .line(MM.muted("Run /cbc pair from /cb setup on the proxy")) + .send(src); + return; + } MM.msg() .space() diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/platform/cmd/PairCommand.java b/backends/src/main/java/dev/objz/commandbridge/backends/platform/cmd/PairCommand.java new file mode 100644 index 00000000..6fd01ad1 --- /dev/null +++ b/backends/src/main/java/dev/objz/commandbridge/backends/platform/cmd/PairCommand.java @@ -0,0 +1,108 @@ +package dev.objz.commandbridge.backends.platform.cmd; + +import dev.jorel.commandapi.CommandAPICommand; +import dev.jorel.commandapi.arguments.GreedyStringArgument; +import dev.jorel.commandapi.executors.CommandExecutor; +import dev.objz.commandbridge.backends.platform.PlatformAdapter; +import dev.objz.commandbridge.backends.platform.PlatformDetector; +import dev.objz.commandbridge.backends.setup.PairClient; +import dev.objz.commandbridge.logging.Log; +import dev.objz.commandbridge.util.MM; +import net.kyori.adventure.audience.Audience; + +import net.kyori.adventure.text.Component; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Function; + +/** /cbc pair <code>: takes this server's config from the proxy's setup. */ +public final class PairCommand { + + private PairCommand() { + } + + // one pairing waits for its confirm at a time + private static final AtomicReference WAITING = new AtomicReference<>(); + + public static CommandAPICommand subcommand(PlatformAdapter adapter, Path dataDir, String version) { + return new CommandAPICommand("pair") + .withArguments(new GreedyStringArgument("code")) + .executes((CommandExecutor) (sender, args) -> { + // the code is half the secret: keep it out of chat and out of the server log + if (sender instanceof Player player) { + ((Audience) player).sendMessage(MM.error("Run /cbc pair in the server console")); + return; + } + String input = ((String) args.get("code")).trim(); + if (input.equalsIgnoreCase("confirm")) { + confirm(sender, adapter); + } else if (input.equalsIgnoreCase("cancel")) { + PairClient.Pending pending = WAITING.getAndSet(null); + if (pending == null) { + say(sender, Log::warn, MM::warn, "No pairing is waiting"); + } else { + pending.cancel(); + say(sender, Log::info, MM::muted, "Pairing cancelled, config.yml is unchanged"); + } + } else { + fetch(sender, dataDir, version, input); + } + }); + } + + private static void fetch(CommandSender sender, Path dataDir, String version, String code) { + say(sender, Log::info, MM::muted, "Pairing with the proxy..."); + PairClient.overRelay(dataDir) + .pair(code, PlatformDetector.detectPlatform().name(), version) + .whenComplete((pending, error) -> { + if (error != null) { + Throwable t = error.getCause() != null ? error.getCause() : error; + say(sender, Log::error, MM::error, t.getMessage()); + return; + } + PairClient.Pending before = WAITING.getAndSet(pending); + if (before != null) + before.cancel(); + say(sender, Log::warn, MM::warn, "This server would join proxy " + pending.proxyId() + " at " + pending.where() + + " as " + pending.config().clientId()); + say(sender, Log::warn, MM::warn, "Run /cbc pair confirm to save it, or /cbc pair cancel"); + CompletableFuture.delayedExecutor(5, TimeUnit.MINUTES).execute(() -> { + if (WAITING.compareAndSet(pending, null)) { + pending.cancel(); + say(sender, Log::info, MM::muted, "The pairing was not confirmed and has been cancelled"); + } + }); + }); + } + + private static void confirm(CommandSender sender, PlatformAdapter adapter) { + PairClient.Pending pending = WAITING.getAndSet(null); + if (pending == null) { + say(sender, Log::warn, MM::warn, "No pairing is waiting. Run /cbc pair first"); + return; + } + pending.confirm().whenComplete((config, error) -> { + if (error != null) { + say(sender, Log::error, MM::error, error.getMessage()); + return; + } + say(sender, Log::success, MM::ok, "Paired with " + pending.proxyId()); + adapter.applyNewConfig(); + }); + } + + // the console gets log lines in the logger's colors, a player gets a chat message + private static void say(CommandSender sender, Consumer log, Function chat, String text) { + if (sender instanceof Player player) { + ((Audience) player).sendMessage(chat.apply(text)); + } else { + log.accept(text); + } + } +} diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/setup/PairClient.java b/backends/src/main/java/dev/objz/commandbridge/backends/setup/PairClient.java new file mode 100644 index 00000000..d1727554 --- /dev/null +++ b/backends/src/main/java/dev/objz/commandbridge/backends/setup/PairClient.java @@ -0,0 +1,234 @@ +package dev.objz.commandbridge.backends.setup; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.objz.commandbridge.config.ConfigManager; +import dev.objz.commandbridge.config.model.BackendsConfig; +import dev.objz.commandbridge.config.profile.BackendsConfigProfile; +import dev.objz.commandbridge.logging.Log; +import dev.objz.commandbridge.security.PairCrypto; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.net.http.WebSocketHandshakeException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; + +/** + * The server's side of /cbc pair: joins the relay channel named by the code, takes the config + * the proxy sealed with that code, and saves it as config.yml. + */ +public final class PairClient { + + public static final String RELAY_URL = System.getProperty("commandbridge.relay.url", "wss://cb.objz.dev/"); + static final String NO_SETUP = "No setup is waiting for this code. Check it, or run /cb setup again on the proxy"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final int MAX_FRAME = 64 * 1024; + + public static final String UNPAIRED_HINT = + "CommandBridge isn't paired with a proxy yet. Run /cb setup on the proxy, then /cbc pair here"; + + /** A config nobody changed can't reach a proxy: its secret is the placeholder. */ + public static boolean isUnpaired(BackendsConfig cfg) { + return BackendsConfig.defaults().equals(cfg); + } + + public interface Connector { + CompletableFuture connect(URI uri, WebSocket.Listener listener); + } + + /** + * A config the proxy sent and nothing written yet. Whoever runs /cbc pair sees where it would connect and + * confirms or cancels, so a code from someone else's setup page can't quietly move the server to their proxy. + */ + public final class Pending { + private final String proxyId; + private final BackendsConfig config; + private final WebSocket ws; + private final java.util.concurrent.atomic.AtomicBoolean settled = new java.util.concurrent.atomic.AtomicBoolean(); + + Pending(String proxyId, BackendsConfig config, WebSocket ws) { + this.proxyId = proxyId; + this.config = config; + this.ws = ws; + } + + public String proxyId() { + return proxyId; + } + + public BackendsConfig config() { + return config; + } + + /** Where the server would connect to. */ + public String where() { + if (config.endpointType() == dev.objz.commandbridge.config.model.EndpointType.REDIS) + return "Redis at " + config.endpoints().redis().host() + ":" + config.endpoints().redis().port(); + return config.endpoints().websocket().host() + ":" + config.endpoints().websocket().port(); + } + + /** Saves the config (the old one kept as config.yml.bak) and tells the proxy. */ + public CompletableFuture confirm() { + if (!settled.compareAndSet(false, true)) + return CompletableFuture.failedFuture(new IllegalStateException("This pairing is over")); + try { + Path file = dataDir.resolve("config.yml"); + if (Files.exists(file)) { + Log.warn("Replacing config.yml, the old one is kept as config.yml.bak"); + Files.copy(file, dataDir.resolve("config.yml.bak"), StandardCopyOption.REPLACE_EXISTING); + } + if (!new ConfigManager<>(dataDir, BackendsConfig.class).save(config)) + throw new IllegalStateException("Could not write config.yml"); + ws.sendText(MAPPER.writeValueAsString(Map.of("type", "done")), true); + ws.sendClose(WebSocket.NORMAL_CLOSURE, "paired"); + return CompletableFuture.completedFuture(config); + } catch (Exception e) { + cancelWith("Could not save the config on the server"); + return CompletableFuture.failedFuture(new IllegalStateException("Pairing failed: " + e.getMessage(), e)); + } + } + + public void cancel() { + if (settled.compareAndSet(false, true)) + cancelWith("cancelled on the server"); + } + + private void cancelWith(String reason) { + try { + ws.sendText(MAPPER.writeValueAsString(Map.of("type", "error", "message", reason)), true); + } catch (Exception ignored) { + } + ws.sendClose(WebSocket.NORMAL_CLOSURE, "pairing cancelled"); + } + } + + private final Path dataDir; + private final String relayBase; + private final Connector connector; + + public PairClient(Path dataDir, String relayBase, Connector connector) { + this.dataDir = dataDir; + this.relayBase = relayBase; + this.connector = connector; + } + + public static PairClient overRelay(Path dataDir) { + HttpClient http = HttpClient.newHttpClient(); + return new PairClient(dataDir, RELAY_URL, (uri, listener) -> http.newWebSocketBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .buildAsync(uri, listener)); + } + + public CompletableFuture pair(String typedCode, String platform, String version) { + String code; + try { + code = PairCrypto.normalize(typedCode); + } catch (IllegalArgumentException e) { + return CompletableFuture.failedFuture(e); + } + URI uri = URI.create(relayBase + "api/pair/" + PairCrypto.channelId(code) + "/ws?role=server"); + Session session = new Session(code, platform, version); + connector.connect(uri, session).whenComplete((ws, error) -> { + if (error != null) + session.result.completeExceptionally(explain(error)); + }); + return session.result; + } + + private static Throwable explain(Throwable error) { + Throwable t = error instanceof CompletionException && error.getCause() != null ? error.getCause() : error; + if (t instanceof WebSocketHandshakeException h && h.getResponse().statusCode() == 404) + return new IllegalStateException(NO_SETUP); + return new IllegalStateException("Could not reach the relay: " + t.getMessage(), t); + } + + private final class Session implements WebSocket.Listener { + final CompletableFuture result = new CompletableFuture<>(); + private final String code; + private final String platform; + private final String version; + private final StringBuilder partial = new StringBuilder(); + + Session(String code, String platform, String version) { + this.code = code; + this.platform = platform; + this.version = version; + } + + @Override + public void onOpen(WebSocket ws) { + send(ws, Map.of("type", "hello", "platform", platform, "version", version)); + ws.request(1); + } + + @Override + public CompletionStage onText(WebSocket ws, CharSequence data, boolean last) { + partial.append(data); + if (partial.length() > MAX_FRAME) { + result.completeExceptionally(new IllegalStateException("The proxy sent more than a config")); + ws.abort(); + return null; + } + if (last) { + String frame = partial.toString(); + partial.setLength(0); + handle(ws, frame); + } + ws.request(1); + return null; + } + + private void handle(WebSocket ws, String frame) { + if (result.isDone()) + return; + try { + JsonNode msg = MAPPER.readTree(frame); + if (!"config".equals(msg.path("type").asText(""))) + return; + BackendsConfig config = MAPPER.readValue(PairCrypto.open(code, msg.path("sealed").asText("")), + BackendsConfig.class); + if (config == null || config.endpoints() == null || config.endpoints().websocket() == null + || config.endpoints().redis() == null || config.security() == null || config.timeouts() == null) + throw new IllegalArgumentException("Incomplete config"); + var normalized = new BackendsConfigProfile().normalize(config); + if (!normalized.ok()) + throw new IllegalArgumentException("Invalid config"); + result.complete(new Pending(msg.path("proxy").asText(""), normalized.config(), ws)); + } catch (Exception e) { + // Parser errors can quote decrypted values; never send them back over the unencrypted relay. + Log.warn("Pairing rejected an unreadable or invalid config ({})", e.getClass().getSimpleName()); + String message = "The proxy sent an unreadable or invalid config"; + send(ws, Map.of("type", "error", "message", message)); + ws.sendClose(WebSocket.NORMAL_CLOSURE, "pairing failed"); + result.completeExceptionally(new IllegalStateException(message)); + } + } + + @Override + public CompletionStage onClose(WebSocket ws, int statusCode, String reason) { + result.completeExceptionally(new IllegalStateException("The proxy ended the pairing before sending a config")); + return null; + } + + @Override + public void onError(WebSocket ws, Throwable error) { + result.completeExceptionally(explain(error)); + } + + private void send(WebSocket ws, Map message) { + try { + ws.sendText(MAPPER.writeValueAsString(message), true); + } catch (Exception e) { + result.completeExceptionally(e); + } + } + } +} diff --git a/backends/src/test/java/dev/objz/commandbridge/backends/api/BackendCommandBridgeImplTest.java b/backends/src/test/java/dev/objz/commandbridge/backends/api/BackendCommandBridgeImplTest.java new file mode 100644 index 00000000..fe3740f7 --- /dev/null +++ b/backends/src/test/java/dev/objz/commandbridge/backends/api/BackendCommandBridgeImplTest.java @@ -0,0 +1,137 @@ +package dev.objz.commandbridge.backends.api; + +import dev.objz.commandbridge.api.channel.ChannelPayload; +import dev.objz.commandbridge.api.channel.command.CommandPayload; +import dev.objz.commandbridge.api.channel.command.RunAs; +import dev.objz.commandbridge.api.platform.Platform; +import dev.objz.commandbridge.backends.TestFixtures; +import dev.objz.commandbridge.backends.net.client.BackendClient; +import dev.objz.commandbridge.backends.platform.PlatformAdapter; +import dev.objz.commandbridge.backends.platform.cmd.CommandExecutor; +import dev.objz.commandbridge.net.Endpoint; +import dev.objz.commandbridge.net.ResponseAwaiter; +import dev.objz.commandbridge.net.SendOperation; +import dev.objz.commandbridge.net.payloads.PluginMessage; +import dev.objz.commandbridge.net.payloads.cmd.ExecuteCommand; +import dev.objz.commandbridge.net.proto.Envelope; +import dev.objz.commandbridge.net.proto.MessageType; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +final class BackendCommandBridgeImplTest { + public record Notice(String value) implements ChannelPayload { + } + + private final List sent = new ArrayList<>(); + private final List executed = new ArrayList<>(); + private final ResponseAwaiter awaiter = new ResponseAwaiter(); + private final AtomicReference proxyId = new AtomicReference<>(); + private final Endpoint endpoint = new Endpoint() { + public CompletableFuture send(Envelope env) { + sent.add(env); + awaiter.signal(Envelope.reply(env, MessageType.PLUGIN_MESSAGE_RESPONSE, env.to(), env.payload())); + return CompletableFuture.completedFuture(null); + } + + public boolean isOpen() { + return true; + } + }; + private final BackendClient client = (BackendClient) Proxy.newProxyInstance( + getClass().getClassLoader(), new Class[] { BackendClient.class }, (self, method, args) -> + switch (method.getName()) { + case "serverId" -> proxyId.get(); + case "setServerId" -> { + proxyId.set((String) args[0]); + yield null; + } + case "send" -> new SendOperation(endpoint, (Envelope) args[0], awaiter); + default -> throw new UnsupportedOperationException(method.getName()); + }); + private final CommandExecutor executor = new CommandExecutor() { + public CompletableFuture execute(ExecuteCommand command) { + executed.add(command); + return CompletableFuture.completedFuture(ExecutionResult.success()); + } + + public CompletableFuture execute(ExecuteCommand command, Set permissions) { + return execute(command); + } + }; + private final PlatformAdapter adapter = (PlatformAdapter) Proxy.newProxyInstance( + getClass().getClassLoader(), new Class[] { PlatformAdapter.class }, (self, method, args) -> { + if (method.getName().equals("getCommandExecutor")) { + return executor; + } + throw new UnsupportedOperationException(method.getName()); + }); + private final BackendCommandBridgeImpl api = new BackendCommandBridgeImpl(client, "survival-1", adapter); + + @BeforeAll + static void setup() { + TestFixtures.ensureLog(); + } + + @Test + void configuredIdentityDoesNotBecomeTheLearnedProxyIdentity() { + assertEquals("survival-1", api.server().id()); + client.setServerId("proxy-1"); + assertEquals("survival-1", api.server().id()); + List received = new ArrayList<>(); + api.channel(Notice.class).listen((context, payload) -> received.add(payload)); + + api.channel(Notice.class).to(List.of(Platform.backend("lobby"))).send(new Notice("send")).join(); + api.channel(Notice.class).to(List.of(Platform.backend("lobby"))).request(new Notice("request")).join(); + api.handlePluginMessageRequest(message("survival-1", new Notice("inbound"))); + api.handlePluginMessageRequest(message("proxy-1", new Notice("wrong target"))); + + assertEquals(List.of("survival-1", "survival-1"), sent.stream().map(Envelope::from).toList()); + assertEquals(List.of(new Notice("inbound")), received); + } + + @Test + void builtInCommandsExecuteWithoutUserRegistrationAndKeepUserListeners() { + UUID player = UUID.randomUUID(); + CommandPayload console = new CommandPayload("say hello", RunAs.CONSOLE); + api.handlePluginMessageRequest(message("survival-1", console)); + List received = new ArrayList<>(); + api.channel(CommandPayload.class).listen((context, payload) -> received.add(payload)); + CommandPayload asPlayer = new CommandPayload("home", RunAs.PLAYER, player); + api.handlePluginMessageRequest(message("survival-1", asPlayer)); + api.handlePluginMessageRequest(message("survival-1", new CommandPayload("home", RunAs.OPERATOR, player))); + + assertEquals(List.of(RunAs.CONSOLE, RunAs.PLAYER, RunAs.OPERATOR), + executed.stream().map(ExecuteCommand::runAs).toList()); + assertNull(executed.getFirst().uuid()); + assertEquals(player, executed.get(1).uuid()); + assertEquals(2, received.size()); + } + + @Test + void invalidCommandsNeverReachThePlatformExecutor() { + for (CommandPayload invalid : List.of( + new CommandPayload(null, RunAs.CONSOLE), new CommandPayload(" ", RunAs.CONSOLE), + new CommandPayload("/say hello", RunAs.CONSOLE), new CommandPayload("say hello", null), + new CommandPayload("home", RunAs.PLAYER), new CommandPayload("home", RunAs.OPERATOR))) { + api.handlePluginMessageRequest(message("survival-1", invalid)); + } + assertEquals(List.of(), executed); + } + + private static Envelope message(String target, ChannelPayload payload) { + PluginMessage message = new PluginMessage(payload.getClass().getName(), Envelope.MAPPER.valueToTree(payload), + false, null, null, null); + return Envelope.make(MessageType.PLUGIN_MESSAGE, "proxy-1", target, Envelope.MAPPER.valueToTree(message)); + } +} diff --git a/backends/src/test/java/dev/objz/commandbridge/backends/net/client/RedisClientTest.java b/backends/src/test/java/dev/objz/commandbridge/backends/net/client/RedisClientTest.java new file mode 100644 index 00000000..bf436173 --- /dev/null +++ b/backends/src/test/java/dev/objz/commandbridge/backends/net/client/RedisClientTest.java @@ -0,0 +1,110 @@ +package dev.objz.commandbridge.backends.net.client; + +import dev.objz.commandbridge.backends.TestFixtures; +import dev.objz.commandbridge.api.CommandBridgeProvider; +import dev.objz.commandbridge.backends.api.BackendCommandBridgeImpl; +import dev.objz.commandbridge.backends.platform.PlatformAdapter; +import dev.objz.commandbridge.backends.platform.ScheduleHandle; +import dev.objz.commandbridge.backends.platform.cmd.CommandExecutor; +import dev.objz.commandbridge.config.model.BackendsConfig; +import dev.objz.commandbridge.net.proto.Envelope; +import dev.objz.commandbridge.net.proto.MessageType; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ScheduledExecutorService; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Tests that local message validation does not trigger a Redis reconnect. */ +final class RedisClientTest { + @BeforeAll + static void setup() { + TestFixtures.ensureLog(); + } + + @Test + void oversizedSendFailsWithoutDisconnectingOrReconnecting() throws Exception { + RecordingAdapter adapter = new RecordingAdapter(); + RedisClient client = new RedisClient(BackendsConfig.defaults(), Path.of("."), adapter); + Field running = RedisClient.class.getDeclaredField("running"); + running.setAccessible(true); + running.setBoolean(client, true); + Method publish = RedisClient.class.getDeclaredMethod("publishToProxy", Envelope.class); + publish.setAccessible(true); + Envelope message = Envelope.make(MessageType.PLUGIN_MESSAGE, "lobby", "proxy", + Envelope.MAPPER.getNodeFactory().textNode("x".repeat(1_048_577))); + + CompletableFuture result = (CompletableFuture) publish.invoke(client, message); + + CompletionException failure = assertThrows(CompletionException.class, result::join); + assertInstanceOf(IllegalArgumentException.class, failure.getCause()); + assertTrue(running.getBoolean(client)); + assertEquals(0, adapter.schedules); + } + + @Test + void closeUnregistersApiAndStopsStatePolling() throws Exception { + RedisClient client = new RedisClient(BackendsConfig.defaults(), Path.of("."), new RecordingAdapter()); + Field apiField = RedisClient.class.getDeclaredField("api"); + apiField.setAccessible(true); + BackendCommandBridgeImpl api = (BackendCommandBridgeImpl) apiField.get(client); + try { + api.bootstrap(); + api.onConnectionStateChanged(state -> { }); + Field pollerField = BackendCommandBridgeImpl.class.getDeclaredField("statePoller"); + pollerField.setAccessible(true); + ScheduledExecutorService poller = (ScheduledExecutorService) pollerField.get(api); + assertEquals(api, CommandBridgeProvider.get()); + + client.close(); + client.close(); + + assertTrue(poller.isShutdown()); + assertThrows(IllegalStateException.class, CommandBridgeProvider::get); + } finally { + api.shutdown(); + client.close(); + } + } + + private static final class RecordingAdapter implements PlatformAdapter { + private int schedules; + + @Override + public void start(PlatformEnv env) { + } + + @Override + public void stop() { + } + + @Override + public CommandExecutor getCommandExecutor() { + return null; + } + + @Override + public Set getOnlinePlayerIds() { + return Set.of(); + } + + @Override + public ScheduleHandle runSchedule(Runnable task, Duration timeout, Duration interval) { + schedules++; + return () -> { + }; + } + } +} diff --git a/backends/src/test/java/dev/objz/commandbridge/backends/net/client/WsClientAuthenticationTest.java b/backends/src/test/java/dev/objz/commandbridge/backends/net/client/WsClientAuthenticationTest.java new file mode 100644 index 00000000..9163788d --- /dev/null +++ b/backends/src/test/java/dev/objz/commandbridge/backends/net/client/WsClientAuthenticationTest.java @@ -0,0 +1,202 @@ +package dev.objz.commandbridge.backends.net.client; + +import dev.objz.commandbridge.backends.TestFixtures; +import dev.objz.commandbridge.backends.net.connection.ClientStatus; +import dev.objz.commandbridge.backends.net.out.AuthRequest; +import dev.objz.commandbridge.backends.net.routing.AuthHandler; +import dev.objz.commandbridge.backends.platform.PlatformAdapter; +import dev.objz.commandbridge.backends.platform.ScheduleHandle; +import dev.objz.commandbridge.config.model.BackendsConfig; +import dev.objz.commandbridge.net.Endpoint; +import dev.objz.commandbridge.net.ResponseAwaiter; +import dev.objz.commandbridge.net.SendOperation; +import dev.objz.commandbridge.net.payloads.util.AuthRequestPayload; +import dev.objz.commandbridge.net.payloads.util.AuthResponsePayload; +import dev.objz.commandbridge.net.proto.Envelope; +import dev.objz.commandbridge.net.proto.MessageType; +import dev.objz.commandbridge.scripting.model.enums.Location; +import dev.objz.commandbridge.security.AuthService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class WsClientAuthenticationTest { + private final BackendsConfig config = BackendsConfig.defaults(); + private final AuthService proof = new AuthService(config.security().secret()); + private final ResponseAwaiter awaiter = new ResponseAwaiter(); + private final List requests = new ArrayList<>(); + private final List> sends = new ArrayList<>(); + private final AtomicInteger authenticated = new AtomicInteger(); + private final AtomicInteger reconnects = new AtomicInteger(); + private WsClient client; + private AuthHandler authentication; + + @BeforeAll + static void setupLog() { + TestFixtures.ensureLog(); + } + + @BeforeEach + void setup() throws Exception { + PlatformAdapter adapter = (PlatformAdapter) Proxy.newProxyInstance(getClass().getClassLoader(), + new Class[] { PlatformAdapter.class }, (self, method, args) -> { + if (method.getName().equals("runSchedule")) { + reconnects.incrementAndGet(); + return (ScheduleHandle) () -> { }; + } + throw new UnsupportedOperationException(method.getName()); + }); + client = new WsClient(config, Path.of("."), adapter); + Endpoint endpoint = new Endpoint() { + public CompletableFuture send(Envelope request) { + requests.add(request); + CompletableFuture sent = new CompletableFuture<>(); + sends.add(sent); + return sent; + } + + public boolean isOpen() { + return true; + } + }; + client.outboundRouter().setSendOperationFactory(env -> new SendOperation(endpoint, env, awaiter)); + client.outboundRouter().register(MessageType.AUTH_REQUEST, new AuthRequest(proof, Location.BACKEND)); + client.onAuthenticated(authenticated::incrementAndGet); + Field handler = WsClient.class.getDeclaredField("authHandler"); + handler.setAccessible(true); + authentication = (AuthHandler) handler.get(client); + } + + @AfterEach + void close() throws Exception { + client.close(); + } + + @Test + void successfulProofAfterCloseCannotResurrectTheClient() throws Exception { + authentication.authenticate(); + client.close(); + + succeed(0); + + assertEquals(ClientStatus.DISCONNECTED, client.status()); + assertEquals(0, authenticated.get()); + assertEquals(0, reconnects.get()); + } + + @Test + void rejectionAfterCloseCannotScheduleReconnect() throws Exception { + authentication.authenticate(); + client.close(); + + reply(0, MessageType.AUTH_FAIL, null); + + assertEquals(ClientStatus.DISCONNECTED, client.status()); + assertEquals(0, reconnects.get()); + } + + @Test + void currentRejectionStillSchedulesReconnect() { + authentication.authenticate(); + + reply(0, MessageType.AUTH_FAIL, null); + + assertEquals(ClientStatus.DISCONNECTED, client.status()); + assertEquals(1, reconnects.get()); + } + + @Test + void oldSuccessfulProofCannotAuthenticateAReplacementAttempt() throws Exception { + authentication.authenticate(); + authentication.authenticate(); + + succeed(0); + assertEquals(0, authenticated.get()); + succeed(1); + + assertEquals(ClientStatus.AUTH_OK, client.status()); + assertEquals(1, authenticated.get()); + } + + @Test + void oldRejectionCannotDisconnectAnAuthenticatedReplacement() throws Exception { + authentication.authenticate(); + authentication.authenticate(); + succeed(1); + + reply(0, MessageType.AUTH_FAIL, null); + + assertEquals(ClientStatus.AUTH_OK, client.status()); + assertEquals(1, authenticated.get()); + assertEquals(0, reconnects.get()); + } + + @Test + void oldTimeoutCannotDisconnectAnAuthenticatedReplacement() throws Exception { + authentication.authenticate(); + authentication.authenticate(); + succeed(1); + + sends.getFirst().completeExceptionally(new TimeoutException("old connection")); + + assertEquals(ClientStatus.AUTH_OK, client.status()); + assertEquals(0, reconnects.get()); + } + + @Test + void timeoutAndTransportCloseAfterCloseAreIgnored() throws Exception { + authentication.authenticate(); + client.close(); + + sends.getFirst().completeExceptionally(new TimeoutException("closed connection")); + Method connectionLost = WsClient.class.getDeclaredMethod("onConnectionLost", long.class); + connectionLost.setAccessible(true); + connectionLost.invoke(client, 0L); + + assertEquals(ClientStatus.DISCONNECTED, client.status()); + assertEquals(0, reconnects.get()); + } + + @Test + void transportCloseFromAnOlderGenerationCannotDisconnectTheReplacement() throws Exception { + Field generation = WsClient.class.getDeclaredField("connectionGeneration"); + generation.setAccessible(true); + generation.setLong(client, 2L); + authentication.authenticate(); + succeed(0); + + Method connectionLost = WsClient.class.getDeclaredMethod("onConnectionLost", long.class); + connectionLost.setAccessible(true); + connectionLost.invoke(client, 1L); + + assertEquals(ClientStatus.AUTH_OK, client.status()); + assertEquals(0, reconnects.get()); + } + + private void succeed(int index) throws Exception { + Envelope request = requests.get(index); + String nonce = Envelope.MAPPER.treeToValue(request.payload(), AuthRequestPayload.class).clientNonce(); + reply(index, MessageType.AUTH_OK, new AuthResponsePayload("server-nonce", + proof.signServerProof(config.clientId(), nonce, "server-nonce"))); + } + + private void reply(int index, MessageType type, Object payload) { + Envelope request = requests.get(index); + assertTrue(awaiter.signal(Envelope.reply(request, type, request.to(), + payload == null ? null : Envelope.MAPPER.valueToTree(payload)))); + } +} diff --git a/backends/src/test/java/dev/objz/commandbridge/backends/net/connection/ReconnectHandlerTest.java b/backends/src/test/java/dev/objz/commandbridge/backends/net/connection/ReconnectHandlerTest.java new file mode 100644 index 00000000..54a8688b --- /dev/null +++ b/backends/src/test/java/dev/objz/commandbridge/backends/net/connection/ReconnectHandlerTest.java @@ -0,0 +1,86 @@ +package dev.objz.commandbridge.backends.net.connection; + +import dev.objz.commandbridge.backends.TestFixtures; +import dev.objz.commandbridge.backends.platform.PlatformAdapter; +import dev.objz.commandbridge.backends.platform.ScheduleHandle; +import dev.objz.commandbridge.backends.platform.cmd.CommandExecutor; +import dev.objz.commandbridge.config.model.BackendsConfig; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ReconnectHandlerTest { + + @BeforeAll + static void setUp() { + TestFixtures.ensureLog(); + } + + /** Records tasks without running them, so the reconnect window expires without a callback. */ + static final class SilentScheduler implements PlatformAdapter { + final List scheduled = new ArrayList<>(); + + @Override + public void start(PlatformEnv env) { + } + + @Override + public void stop() { + } + + @Override + public CommandExecutor getCommandExecutor() { + return null; + } + + @Override + public Set getOnlinePlayerIds() { + return Set.of(); + } + + @Override + public ScheduleHandle runSchedule(Runnable task, Duration timeout, Duration interval) { + scheduled.add(task); + return () -> { + }; + } + } + + private static BackendsConfig withReconnectTimeout(int seconds) { + BackendsConfig d = BackendsConfig.defaults(); + return new BackendsConfig(d.clientId(), d.endpointType(), d.endpoints(), d.security(), + new BackendsConfig.Timeouts(d.timeouts().authTimeout(), seconds, 0), d.debug()); + } + + @Test + void aSecondScheduleWhileOneRunsIsIgnored() { + var scheduler = new SilentScheduler(); + var handler = new ReconnectHandler(withReconnectTimeout(60), scheduler, () -> { + }); + + handler.scheduleReconnect(); + handler.scheduleReconnect(); + + assertEquals(1, scheduler.scheduled.size()); + } + + @Test + void afterTheWindowRanOutWithoutAWordANewReconnectStarts() throws Exception { + var scheduler = new SilentScheduler(); + var handler = new ReconnectHandler(withReconnectTimeout(0), scheduler, () -> { + }); + + handler.scheduleReconnect(); + Thread.sleep(20); + handler.scheduleReconnect(); + + assertEquals(2, scheduler.scheduled.size()); + } +} diff --git a/backends/src/test/java/dev/objz/commandbridge/backends/net/connection/ResourcePoolTrustTest.java b/backends/src/test/java/dev/objz/commandbridge/backends/net/connection/ResourcePoolTrustTest.java new file mode 100644 index 00000000..5ec0d35c --- /dev/null +++ b/backends/src/test/java/dev/objz/commandbridge/backends/net/connection/ResourcePoolTrustTest.java @@ -0,0 +1,75 @@ +package dev.objz.commandbridge.backends.net.connection; + +import dev.objz.commandbridge.backends.TestFixtures; +import dev.objz.commandbridge.config.model.TlsMode; +import dev.objz.commandbridge.security.TrustManager; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.InputStream; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +final class ResourcePoolTrustTest { + @TempDir + Path directory; + + @BeforeAll + static void setup() { + TestFixtures.ensureLog(); + } + + @Test + void learnedTofuPinSurvivesResourceRecreation() throws Exception { + X509Certificate[] first = certificate(directory.resolve("first")); + X509Certificate[] replacement = certificate(directory.resolve("replacement")); + Field trust = ResourcePool.class.getDeclaredField("sessionTrust"); + trust.setAccessible(true); + try (ResourcePool pool = new ResourcePool()) { + pool.initialize(); + pool.initializeSsl(TlsMode.TOFU, null, false); + TrustManager learned = (TrustManager) trust.get(pool); + learned.checkServerTrusted(first, "RSA"); + pool.close(); + + pool.initialize(); + pool.initializeSsl(TlsMode.TOFU, null, true); + TrustManager reconnected = (TrustManager) trust.get(pool); + + assertSame(learned, reconnected); + assertDoesNotThrow(() -> reconnected.checkServerTrusted(first, "RSA")); + assertThrows(CertificateException.class, () -> reconnected.checkServerTrusted(replacement, "RSA")); + } + } + + private X509Certificate[] certificate(Path path) throws Exception { + Files.createDirectories(path); + Process keytool = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "keytool").toString(), + "-genkeypair", "-alias", "commandbridge", "-keyalg", "RSA", "-keysize", "2048", + "-storetype", "PKCS12", "-keystore", path.resolve("keystore.p12").toString(), + "-storepass", "test-password", "-dname", "CN=test") + .redirectErrorStream(true).start(); + if (!keytool.waitFor(30, TimeUnit.SECONDS)) { + keytool.destroyForcibly(); + throw new AssertionError("Test certificate generation timed out"); + } + assertEquals(0, keytool.exitValue(), new String(keytool.getInputStream().readAllBytes(), StandardCharsets.UTF_8)); + KeyStore store = KeyStore.getInstance("PKCS12"); + try (InputStream input = Files.newInputStream(path.resolve("keystore.p12"))) { + store.load(input, "test-password".toCharArray()); + } + return new X509Certificate[] { (X509Certificate) store.getCertificate("commandbridge") }; + } +} diff --git a/backends/src/test/java/dev/objz/commandbridge/backends/net/in/ExecuteCommandHandlerTest.java b/backends/src/test/java/dev/objz/commandbridge/backends/net/in/ExecuteCommandHandlerTest.java new file mode 100644 index 00000000..9ab93bfc --- /dev/null +++ b/backends/src/test/java/dev/objz/commandbridge/backends/net/in/ExecuteCommandHandlerTest.java @@ -0,0 +1,178 @@ +package dev.objz.commandbridge.backends.net.in; + +import com.fasterxml.jackson.databind.JsonNode; +import dev.objz.commandbridge.api.channel.command.RunAs; +import dev.objz.commandbridge.backends.TestFixtures; +import dev.objz.commandbridge.backends.platform.cmd.CommandExecutor; +import dev.objz.commandbridge.net.payloads.cmd.ExecuteCommand; +import dev.objz.commandbridge.net.payloads.cmd.ExecuteCommandResult; +import dev.objz.commandbridge.net.proto.Envelope; +import dev.objz.commandbridge.net.proto.MessageType; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class ExecuteCommandHandlerTest { + + private static final UUID STEVE = UUID.randomUUID(); + + private record Call(ExecuteCommand command, Set permissions) { + } + + private final List calls = new ArrayList<>(); + private Supplier> outcome = + () -> CompletableFuture.completedFuture(CommandExecutor.ExecutionResult.success()); + + private final CommandExecutor executor = new CommandExecutor() { + @Override + public CompletableFuture execute(ExecuteCommand command) { + return execute(command, null); + } + + @Override + public CompletableFuture execute(ExecuteCommand command, Set permissions) { + calls.add(new Call(command, permissions)); + return outcome.get(); + } + }; + + private final ExecuteCommandHandler handler = new ExecuteCommandHandler(executor); + private final RecordingEndpoint endpoint = new RecordingEndpoint().wire(handler); + + @BeforeAll + static void setUpLog() { + TestFixtures.ensureLog(); + } + + private void receive(JsonNode payload) { + handler.accept(endpoint, new Envelope(1, UUID.randomUUID(), MessageType.EXECUTE_COMMAND, + "proxy-1", "survival-1", 0L, payload)); + } + + private void receive(ExecuteCommand command) { + receive(Envelope.MAPPER.valueToTree(command)); + } + + private ExecuteCommandResult result() throws Exception { + return endpoint.onlyReply(MessageType.EXECUTE_COMMAND_RESULT, ExecuteCommandResult.class); + } + + @Test + void missingPayloadIsAnsweredWithFailure() throws Exception { + receive((JsonNode) null); + + ExecuteCommandResult result = result(); + assertFalse(result.success()); + assertEquals("Null payload received", result.message()); + assertTrue(calls.isEmpty()); + } + + @Test + void nullCommandIsAnsweredWithFailure() throws Exception { + receive(new ExecuteCommand(null, RunAs.CONSOLE, null, null)); + + assertFalse(result().success()); + assertTrue(calls.isEmpty()); + } + + @Test + void blankCommandIsAnsweredWithFailure() throws Exception { + receive(new ExecuteCommand(" ", RunAs.CONSOLE, null, null)); + + ExecuteCommandResult result = result(); + assertFalse(result.success()); + assertEquals("Empty command received", result.message()); + assertTrue(calls.isEmpty()); + } + + @Test + void unparsablePayloadIsAnsweredWithFailure() throws Exception { + receive(Envelope.MAPPER.valueToTree("say hi")); + + ExecuteCommandResult result = result(); + assertFalse(result.success()); + assertTrue(result.message().startsWith("Failed to parse command"), result.message()); + assertTrue(calls.isEmpty()); + } + + @Test + void operatorPermissionsAreForwarded() { + receive(new ExecuteCommand("gamemode creative", RunAs.OPERATOR, STEVE, Set.of("gamemode", "gamemode.*"))); + + assertEquals(Set.of("gamemode", "gamemode.*"), calls.getFirst().permissions()); + } + + @Test + void permissionsAreDroppedForPlayer() { + receive(new ExecuteCommand("gamemode creative", RunAs.PLAYER, STEVE, Set.of("gamemode"))); + + assertNull(calls.getFirst().permissions()); + } + + @Test + void permissionsAreDroppedForConsole() { + receive(new ExecuteCommand("say hi", RunAs.CONSOLE, null, Set.of("*"))); + + assertNull(calls.getFirst().permissions()); + assertEquals("say hi", calls.getFirst().command().command()); + } + + @Test + void successIsReported() throws Exception { + receive(new ExecuteCommand("heal", RunAs.PLAYER, STEVE, null)); + + ExecuteCommandResult result = result(); + assertTrue(result.success()); + assertEquals("heal", result.command()); + assertEquals(STEVE, result.playerUuid()); + } + + @Test + void executorFailureIsReported() throws Exception { + outcome = () -> CompletableFuture.completedFuture(CommandExecutor.ExecutionResult.playerOffline(STEVE)); + + receive(new ExecuteCommand("heal", RunAs.PLAYER, STEVE, null)); + + ExecuteCommandResult result = result(); + assertFalse(result.success()); + assertEquals("Player is offline: " + STEVE, result.message()); + } + + @Test + void exceptionallyCompletedExecutionIsReported() throws Exception { + outcome = () -> CompletableFuture.failedFuture(new IllegalStateException("boom")); + + receive(new ExecuteCommand("heal", RunAs.CONSOLE, null, null)); + + ExecuteCommandResult result = result(); + assertFalse(result.success()); + assertTrue(result.message().contains("boom"), result.message()); + } + + @Test + void executorThrowingIsReported() throws Exception { + outcome = () -> { + throw new IllegalStateException("boom"); + }; + + try { + receive(new ExecuteCommand("heal", RunAs.CONSOLE, null, null)); + } catch (RuntimeException ignored) { + // the reply below is what matters to the proxy + } + + ExecuteCommandResult result = result(); + assertFalse(result.success()); + } +} diff --git a/backends/src/test/java/dev/objz/commandbridge/backends/net/in/RecordingEndpoint.java b/backends/src/test/java/dev/objz/commandbridge/backends/net/in/RecordingEndpoint.java new file mode 100644 index 00000000..fec43f95 --- /dev/null +++ b/backends/src/test/java/dev/objz/commandbridge/backends/net/in/RecordingEndpoint.java @@ -0,0 +1,42 @@ +package dev.objz.commandbridge.backends.net.in; + +import dev.objz.commandbridge.net.Endpoint; +import dev.objz.commandbridge.net.InboundHandler; +import dev.objz.commandbridge.net.ResponseAwaiter; +import dev.objz.commandbridge.net.SendOperation; +import dev.objz.commandbridge.net.proto.Envelope; +import dev.objz.commandbridge.net.proto.MessageType; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class RecordingEndpoint implements Endpoint { + + final List sent = new CopyOnWriteArrayList<>(); + + RecordingEndpoint wire(InboundHandler handler) { + handler.setSendOperationFactory((ep, env) -> new SendOperation(ep, env, new ResponseAwaiter())); + return this; + } + + @Override + public CompletableFuture send(Envelope env) { + sent.add(env); + return CompletableFuture.completedFuture(null); + } + + @Override + public boolean isOpen() { + return true; + } + + T onlyReply(MessageType type, Class payloadType) throws Exception { + assertEquals(1, sent.size(), "expected exactly one reply"); + Envelope reply = sent.getFirst(); + assertEquals(type, reply.type()); + return Envelope.MAPPER.treeToValue(reply.payload(), payloadType); + } +} diff --git a/backends/src/test/java/dev/objz/commandbridge/backends/net/in/RegistrationHandlerTest.java b/backends/src/test/java/dev/objz/commandbridge/backends/net/in/RegistrationHandlerTest.java new file mode 100644 index 00000000..34c10ed9 --- /dev/null +++ b/backends/src/test/java/dev/objz/commandbridge/backends/net/in/RegistrationHandlerTest.java @@ -0,0 +1,208 @@ +package dev.objz.commandbridge.backends.net.in; + +import com.fasterxml.jackson.databind.JsonNode; +import dev.objz.commandbridge.backends.TestFixtures; +import dev.objz.commandbridge.backends.net.client.BackendClient; +import dev.objz.commandbridge.backends.net.connection.ClientStatus; +import dev.objz.commandbridge.cmd.CommandRegistryInterface; +import dev.objz.commandbridge.net.InNode; +import dev.objz.commandbridge.net.OutNode; +import dev.objz.commandbridge.net.SendOperation; +import dev.objz.commandbridge.net.payloads.cmd.CommandStub; +import dev.objz.commandbridge.net.payloads.cmd.RegisterCommands; +import dev.objz.commandbridge.net.payloads.feedback.Feedback; +import dev.objz.commandbridge.net.proto.Envelope; +import dev.objz.commandbridge.net.proto.MessageType; +import dev.objz.commandbridge.scripting.model.enums.Location; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class RegistrationHandlerTest { + + private final List registryCalls = new ArrayList<>(); + private boolean failUnregister; + + private final CommandRegistryInterface registry = new CommandRegistryInterface() { + @Override + public void register(CommandStub stub) { + registryCalls.add("register:" + stub.name()); + if (stub.name().startsWith("bad")) { + throw new IllegalArgumentException("unsupported argument"); + } + } + + @Override + public void unregisterAll() { + registryCalls.add("unregisterAll"); + if (failUnregister) { + throw new IllegalStateException("commandapi not ready"); + } + } + }; + + private final FakeClient client = new FakeClient(); + private final RegistrationHandler handler = new RegistrationHandler(client, registry); + private final RecordingEndpoint endpoint = new RecordingEndpoint().wire(handler); + + @BeforeAll + static void setUpLog() { + TestFixtures.ensureLog(); + } + + private void receive(JsonNode payload) { + handler.accept(endpoint, new Envelope(1, UUID.randomUUID(), MessageType.REGISTER_COMMANDS, + "proxy-1", "survival-1", 0L, payload)); + } + + private static JsonNode commands(String... names) { + List stubs = new ArrayList<>(); + for (String name : names) { + stubs.add(new CommandStub(name, List.of(), null, List.of())); + } + return Envelope.MAPPER.valueToTree(new RegisterCommands(stubs)); + } + + private Feedback feedback() throws Exception { + return endpoint.onlyReply(MessageType.REGISTER_COMMANDS_RESULT, Feedback.class); + } + + @Test + void validRequestReplacesRegisteredCommands() throws Exception { + receive(commands("heal", "feed")); + + assertEquals(List.of("unregisterAll", "register:heal", "register:feed"), registryCalls); + Feedback feedback = feedback(); + assertEquals(2, feedback.requested()); + assertEquals(2, feedback.succeeded()); + assertEquals(List.of("heal", "feed"), + handler.snapshotRegisteredCommands().stream().map(CommandStub::name).toList()); + } + + @Test + void failingCommandIsReportedAndOthersStillRegister() throws Exception { + receive(commands("bad-one", "heal")); + + Feedback feedback = feedback(); + assertEquals(2, feedback.requested()); + assertEquals(1, feedback.succeeded()); + assertEquals(1, feedback.failed()); + assertEquals(List.of("register 'bad-one': unsupported argument"), feedback.errors()); + } + + @Test + void malformedRequestUnregistersEverything() throws Exception { + receive(commands("heal")); + endpoint.sent.clear(); + registryCalls.clear(); + + receive(Envelope.MAPPER.valueToTree("not a registration")); + + assertEquals(List.of("unregisterAll"), registryCalls); + assertTrue(handler.snapshotRegisteredCommands().isEmpty()); + assertEquals(List.of("Received empty registration request"), feedback().warnings()); + } + + @Test + void emptyRequestUnregistersEverything() throws Exception { + receive(commands()); + + assertEquals(List.of("unregisterAll"), registryCalls); + assertEquals(0, feedback().requested()); + } + + @Test + void unregisterFailureDoesNotBlockRegistration() throws Exception { + failUnregister = true; + + receive(commands("heal")); + + assertEquals(List.of("unregisterAll", "register:heal"), registryCalls); + assertEquals(1, feedback().succeeded()); + } + + @Test + void senderBecomesServerIdWhenUnset() { + receive(commands("heal")); + + assertEquals("proxy-1", client.serverId); + } + + @Test + void existingServerIdIsKept() { + client.serverId = "proxy-main"; + + receive(commands("heal")); + + assertEquals("proxy-main", client.serverId); + } + + private static final class FakeClient implements BackendClient { + String serverId; + + @Override + public String serverId() { + return serverId; + } + + @Override + public void setServerId(String serverId) { + this.serverId = serverId; + } + + @Override + public void start() { + throw new UnsupportedOperationException(); + } + + @Override + public void reconnect() { + throw new UnsupportedOperationException(); + } + + @Override + public void scheduleReconnection() { + throw new UnsupportedOperationException(); + } + + @Override + public SendOperation send(Envelope request) { + throw new UnsupportedOperationException(); + } + + @Override + public ClientStatus status() { + return ClientStatus.AUTH_OK; + } + + @Override + public InNode inboundRouter() { + throw new UnsupportedOperationException(); + } + + @Override + public OutNode outboundRouter() { + throw new UnsupportedOperationException(); + } + + @Override + public void setLocation(Location location) { + throw new UnsupportedOperationException(); + } + + @Override + public void onAuthenticated(Runnable callback) { + throw new UnsupportedOperationException(); + } + + @Override + public void close() { + } + } +} diff --git a/backends/src/test/java/dev/objz/commandbridge/backends/net/out/AuthRequestTest.java b/backends/src/test/java/dev/objz/commandbridge/backends/net/out/AuthRequestTest.java new file mode 100644 index 00000000..10c0e296 --- /dev/null +++ b/backends/src/test/java/dev/objz/commandbridge/backends/net/out/AuthRequestTest.java @@ -0,0 +1,162 @@ +package dev.objz.commandbridge.backends.net.out; + +import dev.objz.commandbridge.backends.TestFixtures; +import dev.objz.commandbridge.backends.net.connection.ClientStatus; +import dev.objz.commandbridge.backends.net.out.ctx.AuthRequestContext; +import dev.objz.commandbridge.net.Endpoint; +import dev.objz.commandbridge.net.OutNode; +import dev.objz.commandbridge.net.ResponseAwaiter; +import dev.objz.commandbridge.net.SendOperation; +import dev.objz.commandbridge.net.payloads.util.AuthRequestPayload; +import dev.objz.commandbridge.net.payloads.util.AuthResponsePayload; +import dev.objz.commandbridge.net.proto.Envelope; +import dev.objz.commandbridge.net.proto.MessageType; +import dev.objz.commandbridge.scripting.model.enums.Location; +import dev.objz.commandbridge.security.AuthService; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class AuthRequestTest { + + private static final String CLIENT_ID = "survival-1"; + + private final AuthService auth = new AuthService("shared-secret"); + private final ResponseAwaiter awaiter = new ResponseAwaiter(); + private final List sent = new CopyOnWriteArrayList<>(); + private final List statuses = new CopyOnWriteArrayList<>(); + private final AtomicInteger authFailedCalls = new AtomicInteger(); + private OutNode outNode; + + @BeforeAll + static void setUpLog() { + TestFixtures.ensureLog(); + } + + @BeforeEach + void setUp() { + Endpoint endpoint = new Endpoint() { + @Override + public CompletableFuture send(Envelope env) { + sent.add(env); + return CompletableFuture.completedFuture(null); + } + + @Override + public boolean isOpen() { + return true; + } + }; + outNode = new OutNode() + .setSendOperationFactory(env -> new SendOperation(endpoint, env, awaiter)) + .setClientId(CLIENT_ID); + outNode.register(MessageType.AUTH_REQUEST, new AuthRequest(auth, Location.BACKEND)); + } + + private Envelope start(Duration timeout) { + outNode.send(MessageType.AUTH_REQUEST, + new AuthRequestContext(timeout, statuses::add, authFailedCalls::incrementAndGet)); + assertEquals(1, sent.size()); + return sent.getFirst(); + } + + private void reply(Envelope request, MessageType type, Object payload) { + Envelope reply = Envelope.reply(request, type, request.to(), + payload == null ? null : Envelope.MAPPER.valueToTree(payload)); + assertTrue(awaiter.signal(reply), "reply should match the pending auth request"); + } + + private static String clientNonce(Envelope request) throws Exception { + return Envelope.MAPPER.treeToValue(request.payload(), AuthRequestPayload.class).clientNonce(); + } + + private void assertFailedAndReconnectTriggered() { + assertEquals(List.of(ClientStatus.AUTH_FAILED), statuses); + assertEquals(1, authFailedCalls.get(), "onAuthFailed should run so the client reconnects"); + } + + @Test + void requestCarriesSignedNonceAndLocation() throws Exception { + Envelope request = start(Duration.ofSeconds(5)); + + assertEquals(MessageType.AUTH_REQUEST, request.type()); + assertEquals(CLIENT_ID, request.from()); + assertEquals("proxy-auth", request.to()); + var payload = Envelope.MAPPER.treeToValue(request.payload(), AuthRequestPayload.class); + assertEquals(Location.BACKEND, payload.location()); + assertTrue(auth.verify(CLIENT_ID, payload.clientNonce(), payload.hmac())); + } + + @Test + void validServerProofAuthenticates() throws Exception { + Envelope request = start(Duration.ofSeconds(5)); + String serverNonce = "server-nonce"; + + reply(request, MessageType.AUTH_OK, new AuthResponsePayload(serverNonce, + auth.signServerProof(CLIENT_ID, clientNonce(request), serverNonce))); + + assertEquals(List.of(ClientStatus.AUTH_OK), statuses); + assertEquals(0, authFailedCalls.get()); + } + + @Test + void badServerProofFails() { + Envelope request = start(Duration.ofSeconds(5)); + + reply(request, MessageType.AUTH_OK, new AuthResponsePayload("server-nonce", + new AuthService("other-secret").signServerProof(CLIENT_ID, "x", "server-nonce"))); + + assertFailedAndReconnectTriggered(); + } + + @Test + void authFailReplyFails() { + Envelope request = start(Duration.ofSeconds(5)); + + reply(request, MessageType.AUTH_FAIL, null); + + assertFailedAndReconnectTriggered(); + } + + @Test + void malformedReplyFails() { + Envelope request = start(Duration.ofSeconds(5)); + + reply(request, MessageType.AUTH_OK, "not an object"); + + assertFailedAndReconnectTriggered(); + } + + @Test + void timeoutFails() throws Exception { + start(Duration.ofMillis(50)); + + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (authFailedCalls.get() == 0 && System.nanoTime() < deadline) { + Thread.sleep(10); + } + + assertFailedAndReconnectTriggered(); + } + + @Test + void unrelatedReplyIsIgnored() { + Envelope request = start(Duration.ofSeconds(5)); + + Envelope other = Envelope.reply(request, MessageType.PONG, request.to(), null); + + assertFalse(awaiter.signal(other)); + assertTrue(statuses.isEmpty()); + } +} diff --git a/backends/src/test/java/dev/objz/commandbridge/backends/net/out/InvokedCommandEventTest.java b/backends/src/test/java/dev/objz/commandbridge/backends/net/out/InvokedCommandEventTest.java new file mode 100644 index 00000000..81637180 --- /dev/null +++ b/backends/src/test/java/dev/objz/commandbridge/backends/net/out/InvokedCommandEventTest.java @@ -0,0 +1,283 @@ +package dev.objz.commandbridge.backends.net.out; + +import com.fasterxml.jackson.databind.JsonNode; +import dev.jorel.commandapi.executors.CommandArguments; +import dev.objz.commandbridge.backends.TestFixtures; +import dev.objz.commandbridge.backends.net.out.ctx.InvokedCommandContext; +import dev.objz.commandbridge.net.Endpoint; +import dev.objz.commandbridge.net.OutNode; +import dev.objz.commandbridge.net.ResponseAwaiter; +import dev.objz.commandbridge.net.SendOperation; +import dev.objz.commandbridge.net.payloads.cmd.CommandStub; +import dev.objz.commandbridge.net.payloads.cmd.InvokedCommand; +import dev.objz.commandbridge.net.payloads.cmd.SenderContext; +import dev.objz.commandbridge.cmd.ref.EntityRef; +import dev.objz.commandbridge.cmd.ref.Location2D; +import dev.objz.commandbridge.cmd.ref.Location3D; +import dev.objz.commandbridge.net.proto.Envelope; +import dev.objz.commandbridge.net.proto.MessageType; +import dev.objz.commandbridge.scripting.model.enums.ArgType; +import dev.objz.commandbridge.scripting.model.records.mapping.ArgMapping; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The event reads senders and argument values by reflection, so plain fake objects with the + * same getter names stand in for Bukkit/Velocity types. + */ +final class InvokedCommandEventTest { + + private static final UUID STEVE = UUID.fromString("00000000-0000-0000-0000-0000000057e5"); + + public static final class FakePlayer { + private final UUID uuid; + private final String name; + + public FakePlayer(UUID uuid, String name) { + this.uuid = uuid; + this.name = name; + } + + public UUID getUniqueId() { + return uuid; + } + + public String getName() { + return name; + } + + public FakeType getType() { + return FakeType.PLAYER; + } + + public boolean hasPermission(String node) { + return node.equals("commandbridge.command.test"); + } + } + + public enum FakeType { + PLAYER + } + + public static final class FakeConsoleCommandSender { + } + + public static final class FakeWorld { + public String getName() { + return "world_nether"; + } + } + + public static final class FakeLocation { + public double getX() { + return 1.5; + } + + public double getY() { + return 64; + } + + public double getZ() { + return -3; + } + + public FakeWorld getWorld() { + return new FakeWorld(); + } + } + + private final List sent = new ArrayList<>(); + private OutNode outNode; + + @BeforeAll + static void setUpLog() { + TestFixtures.ensureLog(); + } + + @BeforeEach + void setUp() { + Endpoint endpoint = new Endpoint() { + @Override + public CompletableFuture send(Envelope env) { + sent.add(env); + return CompletableFuture.completedFuture(null); + } + + @Override + public boolean isOpen() { + return true; + } + }; + outNode = new OutNode() + .setSendOperationFactory(env -> new SendOperation(endpoint, env, new ResponseAwaiter())) + .setClientId("survival-1") + .setServerId("proxy-1"); + outNode.register(MessageType.INVOKED_COMMAND, new InvokedCommandEvent()); + } + + private JsonNode invoke(Object sender, Map values, ArgMapping... mappings) { + sent.clear(); + var args = new CommandArguments(values.values().toArray(), new HashMap<>(values), new String[0], + Map.of(), "/test"); + outNode.send(MessageType.INVOKED_COMMAND, + new InvokedCommandContext("test", sender, args, new CommandStub("test", List.of(), null, List.of(mappings)))); + assertEquals(1, sent.size()); + Envelope env = sent.getFirst(); + assertEquals(MessageType.INVOKED_COMMAND, env.type()); + assertEquals("survival-1", env.from()); + assertEquals("proxy-1", env.to()); + return env.payload(); + } + + private static ArgMapping arg(String name, ArgType type) { + return new ArgMapping(name, false, type, null); + } + + private JsonNode singleValue(Object raw, ArgType type) { + Map values = new HashMap<>(); + if (raw != null) { + values.put("value", raw); + } + JsonNode arg = invoke(new FakeConsoleCommandSender(), values, arg("value", type)).get("args").get(0); + assertEquals(type.name(), arg.get("type").asText()); + return arg.get("value"); + } + + @Test + void playerPermissionAssertionIsScopedToTheRegisteredCommand() throws Exception { + JsonNode payload = invoke(new FakePlayer(STEVE, "Steve"), Map.of()); + var invoked = Envelope.MAPPER.treeToValue(payload, InvokedCommand.class); + assertEquals(Set.of("commandbridge.command.test"), ((SenderContext.Player) invoked.sender()).permissions()); + } + + public static final class PlayerWithoutPermissionMethod { + public UUID getUniqueId() { return STEVE; } + public String getName() { return "Steve"; } + } + + @Test + void missingPermissionMethodFailsClosed() throws Exception { + var payload = invoke(new PlayerWithoutPermissionMethod(), Map.of()); + var invoked = Envelope.MAPPER.treeToValue(payload, InvokedCommand.class); + assertEquals(Set.of(), ((SenderContext.Player) invoked.sender()).permissions()); + } + + @Test + void alreadyMappedReferencesSurviveOutboundAndInboundSerialization() throws Exception { + var entity = new EntityRef("PLAYER", STEVE.toString(), "Steve"); + var location = new Location3D("world", 1, 2, 3); + var location2d = new Location2D("world", 4, 5); + var payload = invoke(new FakePlayer(STEVE, "Steve"), Map.of("player", entity, "loc", location, "loc2", location2d), + arg("player", ArgType.PLAYERS), arg("loc", ArgType.LOCATION), arg("loc2", ArgType.LOCATION_2D)); + var invoked = Envelope.MAPPER.treeToValue(payload, InvokedCommand.class); + + assertEquals(List.of(entity), invoked.args().get(0).value()); + assertEquals(location, invoked.args().get(1).value()); + assertEquals(location2d, invoked.args().get(2).value()); + } + + @Test + void playerSenderIsMappedByReflection() { + JsonNode sender = invoke(new FakePlayer(STEVE, "Steve"), Map.of()).get("sender"); + + assertEquals("PLAYER", sender.get("type").asText()); + assertEquals("Steve", sender.get("name").asText()); + assertEquals(STEVE.toString(), sender.get("uuid").asText()); + } + + @Test + void consoleSenderIsRecognisedByClassName() { + JsonNode sender = invoke(new FakeConsoleCommandSender(), Map.of()).get("sender"); + + assertEquals("CONSOLE", sender.get("type").asText()); + } + + @Test + void unknownSenderIsReportedAsOther() { + JsonNode sender = invoke(new Object(), Map.of()).get("sender"); + + assertEquals("OTHER", sender.get("type").asText()); + assertEquals("Object", sender.get("className").asText()); + } + + @Test + void integerValueIsKept() { + assertEquals(42, singleValue(42, ArgType.INTEGER).asInt()); + assertEquals(7, singleValue("7", ArgType.INTEGER).asInt()); + } + + @Test + void unparsableIntegerBecomesZero() { + assertEquals(0, singleValue("abc", ArgType.INTEGER).asInt()); + } + + @Test + void omittedOptionalIntegerStaysEmpty() { + JsonNode value = singleValue(null, ArgType.INTEGER); + + assertTrue(value.isNull(), "omitted optional INTEGER should not become " + value); + } + + @Test + void booleanIsParsed() { + assertTrue(singleValue(true, ArgType.BOOLEAN).asBoolean()); + assertTrue(singleValue("TRUE", ArgType.BOOLEAN).asBoolean()); + assertEquals(false, singleValue("yes", ArgType.BOOLEAN).asBoolean()); + } + + @Test + void locationIsMappedWithWorldName() { + JsonNode location = singleValue(new FakeLocation(), ArgType.LOCATION); + + assertEquals("world_nether", location.get("world").asText()); + assertEquals(1.5, location.get("x").asDouble()); + assertEquals(64.0, location.get("y").asDouble()); + assertEquals(-3.0, location.get("z").asDouble()); + } + + @Test + void playersBecomeEntityRefs() { + JsonNode refs = singleValue(List.of(new FakePlayer(STEVE, "Steve"), "Alex"), ArgType.PLAYERS); + + assertEquals(2, refs.size()); + assertEquals("PLAYER", refs.get(0).get("type").asText()); + assertEquals(STEVE.toString(), refs.get(0).get("uuid").asText()); + assertEquals("Steve", refs.get(0).get("name").asText()); + assertEquals("Alex", refs.get(1).get("name").asText()); + assertTrue(refs.get(1).get("uuid").isNull()); + } + + @Test + void nonCollectionPlayersBecomeEmptyList() { + JsonNode refs = singleValue("Steve", ArgType.PLAYERS); + + assertTrue(refs.isArray()); + assertEquals(0, refs.size()); + } + + @Test + void textValuesAreStringified() { + assertEquals("hello world", singleValue("hello world", ArgType.GREEDY_STRING).asText()); + } + + @Test + void argumentsKeepStubOrder() { + JsonNode args = invoke(new FakeConsoleCommandSender(), Map.of("b", 2, "a", "x"), + arg("a", ArgType.STRING), arg("b", ArgType.INTEGER)).get("args"); + + assertEquals("x", args.get(0).get("value").asText()); + assertEquals(2, args.get(1).get("value").asInt()); + } +} diff --git a/backends/src/test/java/dev/objz/commandbridge/backends/net/routing/MessageRouterBufferLimitTest.java b/backends/src/test/java/dev/objz/commandbridge/backends/net/routing/MessageRouterBufferLimitTest.java new file mode 100644 index 00000000..c2cfc1dd --- /dev/null +++ b/backends/src/test/java/dev/objz/commandbridge/backends/net/routing/MessageRouterBufferLimitTest.java @@ -0,0 +1,76 @@ +package dev.objz.commandbridge.backends.net.routing; + +import dev.objz.commandbridge.api.platform.ConnectionState; +import dev.objz.commandbridge.backends.TestFixtures; +import dev.objz.commandbridge.net.InNode; +import dev.objz.commandbridge.net.OutNode; +import dev.objz.commandbridge.net.ResponseAwaiter; +import dev.objz.commandbridge.scripting.model.enums.Location; +import io.undertow.Handlers; +import io.undertow.Undertow; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.nio.ByteBuffer; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MessageRouterBufferLimitTest { + @BeforeAll + static void setUp() { + TestFixtures.ensureLog(); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void oversizedMessagesAreClosedBeforeAuthentication(boolean binary) throws Exception { + CompletableFuture reconnect = new CompletableFuture<>(); + MessageRouter router = new MessageRouter(new InNode(), new OutNode(), new ResponseAwaiter(), + new AtomicReference<>(ConnectionState.CONNECTED), () -> { + reconnect.complete(null); + }, "test-secret", Location.BACKEND); + Undertow server = Undertow.builder().addHttpListener(0, "127.0.0.1") + .setHandler(Handlers.websocket((exchange, channel) -> router.setupChannel(channel))).build(); + server.start(); + try (HttpClient client = HttpClient.newHttpClient()) { + int port = ((InetSocketAddress) server.getListenerInfo().getFirst().getAddress()).getPort(); + CompletableFuture closed = new CompletableFuture<>(); + WebSocket socket = client.newWebSocketBuilder().buildAsync(URI.create("ws://127.0.0.1:" + port + "/"), + new WebSocket.Listener() { + @Override + public java.util.concurrent.CompletionStage onClose(WebSocket ws, int status, String reason) { + closed.complete(status); + return null; + } + + @Override + public void onError(WebSocket ws, Throwable error) { + closed.completeExceptionally(error); + } + }).get(5, TimeUnit.SECONDS); + try { + int chunk = 512 * 1024; + for (int i = 0; i < 9; i++) { + if (binary) + socket.sendBinary(ByteBuffer.allocate(chunk), i == 8).get(5, TimeUnit.SECONDS); + else + socket.sendText("x".repeat(chunk), i == 8).get(5, TimeUnit.SECONDS); + } + assertEquals(1009, closed.get(5, TimeUnit.SECONDS)); + reconnect.get(5, TimeUnit.SECONDS); + } finally { + socket.abort(); + } + } finally { + server.stop(); + } + } +} diff --git a/backends/src/test/java/dev/objz/commandbridge/backends/platform/PlatformDetectorTest.java b/backends/src/test/java/dev/objz/commandbridge/backends/platform/PlatformDetectorTest.java index 56747919..760608a6 100644 --- a/backends/src/test/java/dev/objz/commandbridge/backends/platform/PlatformDetectorTest.java +++ b/backends/src/test/java/dev/objz/commandbridge/backends/platform/PlatformDetectorTest.java @@ -1,6 +1,7 @@ package dev.objz.commandbridge.backends.platform; import dev.objz.commandbridge.backends.TestFixtures; +import dev.objz.commandbridge.scripting.model.enums.ServerPlatform; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -10,10 +11,6 @@ import java.util.Set; -/** - * Tests for {@link PlatformDetector}. - * Verifies that platform detection returns valid, consistent results. - */ final class PlatformDetectorTest { @BeforeAll @@ -23,21 +20,21 @@ static void setUp() { @Test void detectReturnsNonNull() { - PlatformDetector.Platform result = PlatformDetector.detectPlatform(); + ServerPlatform result = PlatformDetector.detectPlatform(); assertNotNull(result, "detectPlatform() must never return null"); } @Test void detectReturnsConsistentResult() { - PlatformDetector.Platform first = PlatformDetector.detectPlatform(); - PlatformDetector.Platform second = PlatformDetector.detectPlatform(); + ServerPlatform first = PlatformDetector.detectPlatform(); + ServerPlatform second = PlatformDetector.detectPlatform(); assertEquals(first, second, "consecutive calls must return the same platform"); } @Test void detectResultIsValidPlatform() { - PlatformDetector.Platform result = PlatformDetector.detectPlatform(); - Set valid = Set.of(PlatformDetector.Platform.values()); + ServerPlatform result = PlatformDetector.detectPlatform(); + Set valid = Set.of(ServerPlatform.values()); assertTrue(valid.contains(result), "result must be one of the defined Platform enum values"); } diff --git a/backends/src/test/java/dev/objz/commandbridge/backends/setup/PairClientTest.java b/backends/src/test/java/dev/objz/commandbridge/backends/setup/PairClientTest.java new file mode 100644 index 00000000..222bcd42 --- /dev/null +++ b/backends/src/test/java/dev/objz/commandbridge/backends/setup/PairClientTest.java @@ -0,0 +1,292 @@ +package dev.objz.commandbridge.backends.setup; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.objz.commandbridge.backends.TestFixtures; +import dev.objz.commandbridge.config.ConfigManager; +import dev.objz.commandbridge.config.model.BackendsConfig; +import dev.objz.commandbridge.config.model.EndpointType; +import dev.objz.commandbridge.config.model.TlsMode; +import dev.objz.commandbridge.security.PairCrypto; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.net.ssl.SSLSession; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.WebSocket; +import java.net.http.WebSocketHandshakeException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PairClientTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @BeforeAll + static void setUp() { + TestFixtures.ensureLog(); + } + + private static BackendsConfig paired() { + BackendsConfig d = BackendsConfig.defaults(); + return new BackendsConfig("survival", EndpointType.WEBSOCKET, + new BackendsConfig.Endpoints(new BackendsConfig.Endpoints.WebSocket("play.example.net", 8765), + d.endpoints().redis()), + new BackendsConfig.Security(TlsMode.TOFU, "", "s3cr3t"), d.timeouts(), false); + } + + /** Plays the proxy's side of the pair channel: answers hello with the sealed config. */ + static final class FakeProxy implements WebSocket { + final String code; + final List received = new ArrayList<>(); + URI uri; + Listener listener; + String configJson; + + FakeProxy(String code) { + this.code = code; + } + + CompletableFuture connect(URI uri, Listener listener) { + this.uri = uri; + this.listener = listener; + listener.onOpen(this); + return CompletableFuture.completedFuture(this); + } + + @Override + public CompletableFuture sendText(CharSequence data, boolean last) { + try { + JsonNode msg = MAPPER.readTree(data.toString()); + received.add(msg); + if (msg.path("type").asText().equals("hello")) { + String sealed = PairCrypto.seal(code, configJson == null ? MAPPER.writeValueAsBytes(paired()) + : configJson.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String reply = MAPPER.writeValueAsString(Map.of("type", "config", "proxy", "main-proxy", "sealed", sealed)); + CompletableFuture.runAsync(() -> listener.onText(this, reply, true)); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + return CompletableFuture.completedFuture(this); + } + + @Override + public CompletableFuture sendBinary(ByteBuffer data, boolean last) { + return CompletableFuture.completedFuture(this); + } + + @Override + public CompletableFuture sendPing(ByteBuffer message) { + return CompletableFuture.completedFuture(this); + } + + @Override + public CompletableFuture sendPong(ByteBuffer message) { + return CompletableFuture.completedFuture(this); + } + + @Override + public CompletableFuture sendClose(int statusCode, String reason) { + return CompletableFuture.completedFuture(this); + } + + @Override + public void request(long n) { + } + + @Override + public String getSubprotocol() { + return ""; + } + + @Override + public boolean isOutputClosed() { + return false; + } + + @Override + public boolean isInputClosed() { + return false; + } + + @Override + public void abort() { + } + } + + @Test + void pairWritesTheSealedConfigAndKeepsABackup(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("config.yml"), "client-id: old\n"); + String code = PairCrypto.newCode(); + FakeProxy proxy = new FakeProxy(code); + var client = new PairClient(dir, "ws://relay.test/", proxy::connect); + + PairClient.Pending pending = client.pair(code.toLowerCase().replace("-", " "), "PAPER", "1.21.4") + .get(5, TimeUnit.SECONDS); + + assertEquals("main-proxy", pending.proxyId()); + assertEquals("play.example.net:8765", pending.where()); + assertEquals("client-id: old\n", Files.readString(dir.resolve("config.yml")), "nothing is written before the confirm"); + pending.confirm().get(5, TimeUnit.SECONDS); + + assertEquals(URI.create("ws://relay.test/api/pair/" + PairCrypto.channelId(code) + "/ws?role=server"), proxy.uri); + assertEquals("hello", proxy.received.get(0).path("type").asText()); + assertEquals("PAPER", proxy.received.get(0).path("platform").asText()); + assertEquals("done", proxy.received.get(proxy.received.size() - 1).path("type").asText()); + assertEquals("client-id: old\n", Files.readString(dir.resolve("config.yml.bak"))); + var manager = new ConfigManager<>(dir, BackendsConfig.class); + assertTrue(manager.load()); + assertEquals("survival", manager.current().clientId()); + assertEquals("s3cr3t", manager.current().security().secret()); + } + + @Test + void wrongCodeExplains(@TempDir Path dir) { + var client = new PairClient(dir, "ws://relay.test/", + (uri, listener) -> CompletableFuture.failedFuture(new WebSocketHandshakeException(new Status(404)))); + + var e = assertThrows(ExecutionException.class, + () -> client.pair("7KQM-4XR2-9W", "PAPER", "1.21.4").get(5, TimeUnit.SECONDS)); + assertEquals("No setup is waiting for this code. Check it, or run /cb setup again on the proxy", + e.getCause().getMessage()); + assertFalse(Files.exists(dir.resolve("config.yml.bak"))); + } + + @Test + void secretIsNotLogged(@TempDir Path dir) throws Exception { + List lines = new ArrayList<>(); + Handler capture = new Handler() { + @Override + public void publish(LogRecord record) { + lines.add(String.valueOf(record.getMessage())); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + Logger.getLogger("test").addHandler(capture); + try { + String code = PairCrypto.newCode(); + FakeProxy proxy = new FakeProxy(code); + new PairClient(dir, "ws://relay.test/", proxy::connect).pair(code, "PAPER", "1.21.4").get(5, TimeUnit.SECONDS) + .confirm().get(5, TimeUnit.SECONDS); + } finally { + Logger.getLogger("test").removeHandler(capture); + } + assertTrue(lines.stream().noneMatch(l -> l.contains("s3cr3t")), String.join("\n", lines)); + } + + /** The relay's refusal as java.net.http reports it. */ + record Status(int statusCode) implements HttpResponse { + @Override + public HttpRequest request() { + return null; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(Map.of(), (a, b) -> true); + } + + @Override + public String body() { + return ""; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return null; + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_1_1; + } + } + + @Test + void aFreshConfigIsUnpaired(@TempDir Path dir) { + var manager = new ConfigManager<>(dir, BackendsConfig.class); + manager.load(); + + assertTrue(PairClient.isUnpaired(manager.current())); + assertFalse(PairClient.isUnpaired(paired())); + } + + @Test + void cancellingWritesNothingAndTellsTheProxy(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("config.yml"), "client-id: old\n"); + String code = PairCrypto.newCode(); + FakeProxy proxy = new FakeProxy(code); + PairClient.Pending pending = new PairClient(dir, "ws://relay.test/", proxy::connect).pair(code, "PAPER", "1.21.4") + .get(5, TimeUnit.SECONDS); + + pending.cancel(); + + assertEquals("client-id: old\n", Files.readString(dir.resolve("config.yml"))); + assertFalse(Files.exists(dir.resolve("config.yml.bak"))); + assertEquals("error", proxy.received.get(proxy.received.size() - 1).path("type").asText()); + } + + @Test + void rejectsIncompleteConfigsBeforeOfferingAConfirm(@TempDir Path dir) { + String code = PairCrypto.newCode(); + FakeProxy proxy = new FakeProxy(code); + proxy.configJson = "{}"; + var client = new PairClient(dir, "ws://relay.test/", proxy::connect); + + assertThrows(ExecutionException.class, () -> client.pair(code, "PAPER", "1.21.4").get(5, TimeUnit.SECONDS)); + assertFalse(Files.exists(dir.resolve("config.yml"))); + } + + @Test + void configParseErrorsDoNotSendDecryptedValuesToTheRelay(@TempDir Path dir) { + String code = PairCrypto.newCode(); + FakeProxy proxy = new FakeProxy(code); + proxy.configJson = "{\"endpointType\":\"private-value-must-stay-encrypted\"}"; + var client = new PairClient(dir, "ws://relay.test/", proxy::connect); + + var error = assertThrows(ExecutionException.class, + () -> client.pair(code, "PAPER", "1.21.4").get(5, TimeUnit.SECONDS)); + + assertFalse(proxy.received.toString().contains("private-value-must-stay-encrypted")); + assertFalse(error.getCause().getMessage().contains("private-value-must-stay-encrypted")); + } +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 5ef3c546..9d4f9239 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -29,13 +29,15 @@ dependencies { testImplementation("org.spongepowered:configurate-yaml:4.2.0") testImplementation("org.snakeyaml:snakeyaml-engine:2.10") api("org.slf4j:slf4j-api:2.0.17") - api("io.undertow:undertow-core:2.3.12.Final") - api("io.undertow:undertow-websockets-jsr:2.3.12.Final") + api("io.undertow:undertow-core:2.3.21.Final") + api("io.undertow:undertow-websockets-jsr:2.3.21.Final") compileOnly("net.kyori:adventure-text-minimessage:4.17.0") compileOnly("org.bouncycastle:bcprov-jdk18on:1.82") compileOnly("org.bouncycastle:bcpkix-jdk18on:1.82") + testImplementation("org.bouncycastle:bcprov-jdk18on:1.82") + testImplementation("org.bouncycastle:bcpkix-jdk18on:1.82") compileOnly("com.google.code.gson:gson:2.13.2") diff --git a/core/src/main/java/dev/objz/commandbridge/config/ConfigManager.java b/core/src/main/java/dev/objz/commandbridge/config/ConfigManager.java index 96d048bf..266d5f2d 100644 --- a/core/src/main/java/dev/objz/commandbridge/config/ConfigManager.java +++ b/core/src/main/java/dev/objz/commandbridge/config/ConfigManager.java @@ -12,12 +12,15 @@ import org.spongepowered.configurate.yaml.NodeStyle; import org.spongepowered.configurate.yaml.YamlConfigurationLoader; +import java.io.BufferedWriter; import java.io.IOException; +import java.io.StringWriter; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.lang.reflect.RecordComponent; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.Comparator; import java.util.HashSet; import java.util.Locale; @@ -43,13 +46,31 @@ public ConfigManager(Path dataDir, String name, Class modelClass) { } private YamlConfigurationLoader loader() { + return loader(filePath); + } + + private static YamlConfigurationLoader loader(Path path) { return YamlConfigurationLoader.builder() - .path(filePath) + .path(path) .indent(2) .nodeStyle(NodeStyle.BLOCK) .build(); } + /** The YAML {@link #save} would write for {@code config}. */ + public static String render(Class modelClass, T config) throws IOException { + StringWriter out = new StringWriter(); + var loader = YamlConfigurationLoader.builder() + .sink(() -> new BufferedWriter(out)) + .indent(2) + .nodeStyle(NodeStyle.BLOCK) + .build(); + ConfigurationNode root = loader.createNode(); + root.set(modelClass, config); + loader.save(root); + return out.toString(); + } + public boolean load() { ensureDir(); @@ -80,29 +101,39 @@ public boolean load() { boolean enumOk = validateEnumValues(root, modelClass, ""); if (!enumOk) { - this.current = defaults; + if (current == null) + this.current = defaults; Log.error("Invalid config.yml"); return false; } + // keys the file leaves out take their defaults; without this a missing section maps to 0/null + ConfigurationNode defaultsNode = loader.createNode(); + defaultsNode.set(modelClass, defaults); + root.mergeFrom(defaultsNode); T loaded = root.get(modelClass, defaults); var result = profile.normalize(loaded); - this.current = result.config(); Log.debug("Config loaded from '{}' for model {} (valid={})", filePath, modelClass.getSimpleName(), result.ok()); if (!result.ok()) { + if (current == null) + this.current = defaults; Log.error("Invalid config.yml"); + return false; } - return result.ok(); + this.current = result.config(); + return true; } catch (SerializationException e) { Log.error("Invalid config.yml: {}", e.getMessage()); - this.current = profileOf(modelClass).defaults(); + if (current == null) + this.current = profileOf(modelClass).defaults(); return false; } catch (Exception e) { Log.error(e, "Error loading config.yml: {}", e.getMessage()); - this.current = profileOf(modelClass).defaults(); + if (current == null) + this.current = profileOf(modelClass).defaults(); return false; } } @@ -115,6 +146,27 @@ public T current() { return current; } + public boolean save(T config) { + ensureDir(); + Path tmp = filePath.resolveSibling(filePath.getFileName() + ".tmp"); + try { + var loader = loader(tmp); + ConfigurationNode root = loader.createNode(); + root.set(modelClass, config); + loader.save(root); + Files.move(tmp, filePath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + this.current = config; + return true; + } catch (Exception e) { + Log.error(e, "Failed to save {}: {}", filePath.getFileName(), e.getMessage()); + try { + Files.deleteIfExists(tmp); + } catch (IOException ignored) { + } + return false; + } + } + private void ensureDir() { try { Files.createDirectories(filePath.getParent()); diff --git a/core/src/main/java/dev/objz/commandbridge/config/profile/VelocityConfigProfile.java b/core/src/main/java/dev/objz/commandbridge/config/profile/VelocityConfigProfile.java index 5fc638da..67dd7d96 100644 --- a/core/src/main/java/dev/objz/commandbridge/config/profile/VelocityConfigProfile.java +++ b/core/src/main/java/dev/objz/commandbridge/config/profile/VelocityConfigProfile.java @@ -158,7 +158,9 @@ public Result normalize(VelocityConfig in) { } } - VelocityConfig.Security secOut = new VelocityConfig.Security(tlsMode, keystorePath, keystorePassword, keystoreType); + // unset stays "", matching the defaults + VelocityConfig.Security secOut = new VelocityConfig.Security(tlsMode, keystorePath == null ? "" : keystorePath, + keystorePassword == null ? "" : keystorePassword, keystoreType); VelocityConfig out = new VelocityConfig( in.actAsClient(), diff --git a/core/src/main/java/dev/objz/commandbridge/net/InNode.java b/core/src/main/java/dev/objz/commandbridge/net/InNode.java index 7f542eea..9eddbe11 100644 --- a/core/src/main/java/dev/objz/commandbridge/net/InNode.java +++ b/core/src/main/java/dev/objz/commandbridge/net/InNode.java @@ -9,12 +9,14 @@ import java.util.Map; import java.util.Objects; import java.util.function.BiFunction; +import java.util.function.BiPredicate; import java.util.function.Predicate; public final class InNode { private final Map handlers; private Predicate inboundTap; + private volatile BiPredicate gate; private BiFunction sendOperationFactory; public InNode() { @@ -25,16 +27,26 @@ public void setInboundTap(Predicate tap) { this.inboundTap = tap; } + /** Decides per message whether it may be handled at all, before the tap; e.g. nothing but auth until authenticated. */ + public void setGate(BiPredicate gate) { + this.gate = gate; + } + public InNode setSendOperationFactory(BiFunction factory) { - this.sendOperationFactory = factory; + synchronized (handlers) { + this.sendOperationFactory = factory; + handlers.values().forEach(handler -> handler.setSendOperationFactory(factory)); + } return this; } public InNode register(MessageType type, InboundHandler handler) { Objects.requireNonNull(type); Objects.requireNonNull(handler); - handler.setSendOperationFactory(sendOperationFactory); - handlers.put(type, handler); + synchronized (handlers) { + handler.setSendOperationFactory(sendOperationFactory); + handlers.put(type, handler); + } return this; } @@ -48,8 +60,18 @@ public void onText(Endpoint endpoint, String text) { return; } + if (env == null || env.type() == null) { + Log.warn("Dropped an envelope without a message type from {}", endpoint != null ? endpoint.describe() : "unknown"); + return; + } + Log.debug("Inbound message type={} id={} from={}", env.type(), env.id(), env.from()); + if (gate != null && !gate.test(endpoint, env)) { + Log.debug("Dropped {} from {}: not authenticated", env.type(), endpoint != null ? endpoint.describe() : "unknown"); + return; + } + if (inboundTap != null) { try { if (inboundTap.test(env)) { diff --git a/core/src/main/java/dev/objz/commandbridge/net/InboundHandler.java b/core/src/main/java/dev/objz/commandbridge/net/InboundHandler.java index 716793df..e6e93048 100644 --- a/core/src/main/java/dev/objz/commandbridge/net/InboundHandler.java +++ b/core/src/main/java/dev/objz/commandbridge/net/InboundHandler.java @@ -7,7 +7,7 @@ public abstract class InboundHandler { - private BiFunction sendOperationFactory; + private volatile BiFunction sendOperationFactory; public void setSendOperationFactory(BiFunction factory) { this.sendOperationFactory = factory; diff --git a/core/src/main/java/dev/objz/commandbridge/net/endpoints/BoundedReceiveListener.java b/core/src/main/java/dev/objz/commandbridge/net/endpoints/BoundedReceiveListener.java new file mode 100644 index 00000000..2ae67df7 --- /dev/null +++ b/core/src/main/java/dev/objz/commandbridge/net/endpoints/BoundedReceiveListener.java @@ -0,0 +1,50 @@ +package dev.objz.commandbridge.net.endpoints; + +import io.undertow.websockets.core.AbstractReceiveListener; +import io.undertow.websockets.core.BufferedBinaryMessage; +import io.undertow.websockets.core.StreamSourceFrameChannel; +import io.undertow.websockets.core.UTF8Output; +import io.undertow.websockets.core.WebSocketCallback; +import io.undertow.websockets.core.WebSocketChannel; + +import java.io.IOException; + +public abstract class BoundedReceiveListener extends AbstractReceiveListener { + private static final int MAX_MESSAGE_BYTES = 4 * 1024 * 1024; + + @Override + protected final long getMaxTextBufferSize() { + return MAX_MESSAGE_BYTES; + } + + @Override + protected final long getMaxBinaryBufferSize() { + return MAX_MESSAGE_BYTES; + } + + @Override + protected final void onText(WebSocketChannel channel, StreamSourceFrameChannel frame) { + // Undertow's async text reader misses size accounting; bound raw bytes before decoding. + new BufferedBinaryMessage(MAX_MESSAGE_BYTES, true).read(frame, new WebSocketCallback() { + @Override + public void complete(WebSocketChannel ch, BufferedBinaryMessage message) { + try (var data = message.getData()) { + onTextMessage(ch, new UTF8Output(data.getResource()).extract()); + } catch (IOException e) { + BoundedReceiveListener.this.onError(ch, e); + } + } + + @Override + public void onError(WebSocketChannel ch, BufferedBinaryMessage message, Throwable error) { + try { + message.getData().close(); + } finally { + BoundedReceiveListener.this.onError(ch, error); + } + } + }); + } + + protected abstract void onTextMessage(WebSocketChannel channel, String message) throws IOException; +} diff --git a/core/src/main/java/dev/objz/commandbridge/net/payloads/cmd/InvokedCommand.java b/core/src/main/java/dev/objz/commandbridge/net/payloads/cmd/InvokedCommand.java index feeba21f..c0401952 100644 --- a/core/src/main/java/dev/objz/commandbridge/net/payloads/cmd/InvokedCommand.java +++ b/core/src/main/java/dev/objz/commandbridge/net/payloads/cmd/InvokedCommand.java @@ -1,7 +1,13 @@ package dev.objz.commandbridge.net.payloads.cmd; +import java.util.Collection; import java.util.List; +import java.util.Map; +import dev.objz.commandbridge.cmd.ref.EntityRef; +import dev.objz.commandbridge.cmd.ref.Location2D; +import dev.objz.commandbridge.cmd.ref.Location3D; +import dev.objz.commandbridge.net.proto.Envelope; import dev.objz.commandbridge.scripting.model.enums.ArgType; public record InvokedCommand( @@ -12,5 +18,30 @@ public record InvokedCommand( public record TypedArgument( ArgType type, Object value) { + public TypedArgument { + value = normalizeValue(type, value); + } + } + + /** Only reconstruct the known argument records, never Java types supplied by a peer. */ + public static Object normalizeValue(ArgType type, Object value) { + if (value == null || type == null) { + return value; + } + return switch (type) { + case LOCATION -> value instanceof Map ? Envelope.MAPPER.convertValue(value, Location3D.class) : value; + case LOCATION_2D -> value instanceof Map ? Envelope.MAPPER.convertValue(value, Location2D.class) : value; + case PLAYERS, ENTITIES -> { + if (value instanceof Collection values) { + yield values.stream().map(InvokedCommand::normalizeEntity).toList(); + } + yield normalizeEntity(value); + } + default -> value; + }; + } + + private static Object normalizeEntity(Object value) { + return value instanceof Map ? Envelope.MAPPER.convertValue(value, EntityRef.class) : value; } } diff --git a/core/src/main/java/dev/objz/commandbridge/net/payloads/cmd/SenderContext.java b/core/src/main/java/dev/objz/commandbridge/net/payloads/cmd/SenderContext.java index 5e5daf1a..a3c10cb5 100644 --- a/core/src/main/java/dev/objz/commandbridge/net/payloads/cmd/SenderContext.java +++ b/core/src/main/java/dev/objz/commandbridge/net/payloads/cmd/SenderContext.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonTypeName; import dev.objz.commandbridge.cmd.ref.Location3D; +import java.util.Set; @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ @@ -17,7 +18,14 @@ public sealed interface SenderContext permits SenderContext.Player, SenderContext.Console, SenderContext.Block, SenderContext.Other { @JsonTypeName("PLAYER") - record Player(String name, String uuid) implements SenderContext { + record Player(String name, String uuid, Set permissions) implements SenderContext { + public Player { + permissions = permissions == null ? Set.of() : Set.copyOf(permissions); + } + + public Player(String name, String uuid) { + this(name, uuid, Set.of()); + } } @JsonTypeName("CONSOLE") diff --git a/core/src/main/java/dev/objz/commandbridge/net/payloads/util/AuthRequestPayload.java b/core/src/main/java/dev/objz/commandbridge/net/payloads/util/AuthRequestPayload.java index a7588365..ddf9c09a 100644 --- a/core/src/main/java/dev/objz/commandbridge/net/payloads/util/AuthRequestPayload.java +++ b/core/src/main/java/dev/objz/commandbridge/net/payloads/util/AuthRequestPayload.java @@ -1,9 +1,11 @@ package dev.objz.commandbridge.net.payloads.util; import dev.objz.commandbridge.scripting.model.enums.Location; +import dev.objz.commandbridge.scripting.model.enums.ServerPlatform; public record AuthRequestPayload( Location location, + ServerPlatform platform, String clientNonce, String hmac) { } diff --git a/core/src/main/java/dev/objz/commandbridge/scripting/bind/adapters/ListAdapter.java b/core/src/main/java/dev/objz/commandbridge/scripting/bind/adapters/ListAdapter.java index b40ead97..1b6f530d 100644 --- a/core/src/main/java/dev/objz/commandbridge/scripting/bind/adapters/ListAdapter.java +++ b/core/src/main/java/dev/objz/commandbridge/scripting/bind/adapters/ListAdapter.java @@ -3,6 +3,7 @@ import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import dev.objz.commandbridge.scripting.bind.ConvertContext; @@ -49,7 +50,8 @@ public List fromYaml(YamlNode node, Type targetType, ConvertContext ctx) { } idx++; } - return List.copyOf(out); + // a failed element stays as null next to its recorded problem; List.copyOf would throw on it + return Collections.unmodifiableList(out); } @Override diff --git a/core/src/main/java/dev/objz/commandbridge/scripting/migration/YamlMigrator.java b/core/src/main/java/dev/objz/commandbridge/scripting/migration/YamlMigrator.java index 1f679184..061b8712 100644 --- a/core/src/main/java/dev/objz/commandbridge/scripting/migration/YamlMigrator.java +++ b/core/src/main/java/dev/objz/commandbridge/scripting/migration/YamlMigrator.java @@ -82,7 +82,9 @@ public MigrationResult migrate(String yaml) { */ public MigrationResult migrate(String yaml, int from, int to) { List merged = mergeRules(from, to); - List lines = new ArrayList<>(List.of(yaml.split("\n", -1))); + // keep the file's own line endings: split on either, join with the one it uses + String nl = yaml.contains("\r\n") ? "\r\n" : "\n"; + List lines = new ArrayList<>(List.of(yaml.split("\r?\n", -1))); List modifyActions = new ArrayList<>(); for (MigrationRule rule : merged) { @@ -134,6 +136,10 @@ public MigrationResult migrate(String yaml, int from, int to) { int childrenStart = parent[0] >= 0 ? parent[0] + 1 : 0; int childrenEnd = parent[1]; + // a key the block has already (written by hand, or by a newer plugin) stays as it is + if (hasChildKey(lines, childrenStart, childrenEnd, leafKey)) + continue; + int childIndent = detectChildKeyIndent(lines, childrenStart, childrenEnd); if (childIndent < 0) { childIndent = (parent[0] >= 0 ? leadingSpaces(lines.get(parent[0])) : 0) + 2; @@ -151,7 +157,7 @@ public MigrationResult migrate(String yaml, int from, int to) { } updateVersionLine(lines, to); - return MigrationResult.success(from, to, String.join("\n", lines), merged.size()); + return MigrationResult.success(from, to, String.join(nl, lines), merged.size()); } /** @@ -294,6 +300,23 @@ private int detectChildKeyIndent(List lines, int start, int end) { * block. * Otherwise returns the end of the children range (appends as the last child). */ + private boolean hasChildKey(List lines, int start, int end, String key) { + int childIndent = detectChildKeyIndent(lines, start, end); + if (childIndent < 0) + return false; + for (int i = start; i < end; i++) { + String line = lines.get(i); + if (isBlankOrComment(line)) + continue; + int indent = keyIndent(line); + if (indent < childIndent) + break; + if (indent == childIndent && key.equals(extractKey(line))) + return true; + } + return false; + } + private int findInsertionPoint(List lines, int childrenStart, int childrenEnd, String afterKey) { if (afterKey != null) { diff --git a/core/src/main/java/dev/objz/commandbridge/scripting/model/enums/ServerPlatform.java b/core/src/main/java/dev/objz/commandbridge/scripting/model/enums/ServerPlatform.java new file mode 100644 index 00000000..d098f7bf --- /dev/null +++ b/core/src/main/java/dev/objz/commandbridge/scripting/model/enums/ServerPlatform.java @@ -0,0 +1,6 @@ +package dev.objz.commandbridge.scripting.model.enums; + +/** The server software a client runs, as it reports itself to the proxy when it authenticates. */ +public enum ServerPlatform { + FOLIA, PAPER, BUKKIT, FORGE, FABRIC, UNKNOWN +} diff --git a/core/src/main/java/dev/objz/commandbridge/security/PairCrypto.java b/core/src/main/java/dev/objz/commandbridge/security/PairCrypto.java new file mode 100644 index 00000000..01f98fbc --- /dev/null +++ b/core/src/main/java/dev/objz/commandbridge/security/PairCrypto.java @@ -0,0 +1,97 @@ +package dev.objz.commandbridge.security; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HexFormat; +import java.util.Locale; + +// Pairing codes for /cbc pair: the code names the relay channel and is the only key to the sealed config. +public final class PairCrypto { + private static final String ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"; + private static final int LENGTH = 10; + private static final byte[] SALT = "commandbridge-pair-v1".getBytes(StandardCharsets.UTF_8); + private static final byte[] INFO = "config".getBytes(StandardCharsets.UTF_8); + private static final int NONCE = 12; + private static final SecureRandom RANDOM = new SecureRandom(); + + private PairCrypto() { + } + + public static String newCode() { + StringBuilder sb = new StringBuilder(LENGTH + 2); + for (int i = 0; i < LENGTH; i++) { + if (i == 4 || i == 8) + sb.append('-'); + sb.append(ALPHABET.charAt(RANDOM.nextInt(ALPHABET.length()))); + } + return sb.toString(); + } + + public static String normalize(String typed) { + StringBuilder sb = new StringBuilder(LENGTH); + for (char c : typed.toUpperCase(Locale.ROOT).toCharArray()) { + if (ALPHABET.indexOf(c) >= 0) + sb.append(c); + } + if (sb.length() != LENGTH) + throw new IllegalArgumentException("A pairing code has 10 characters"); + return sb.toString(); + } + + public static String channelId(String code) { + try { + byte[] hash = MessageDigest.getInstance("SHA-256") + .digest(("cb-pair\n" + normalize(code)).getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + public static String seal(String code, byte[] plaintext) throws GeneralSecurityException { + byte[] nonce = new byte[NONCE]; + RANDOM.nextBytes(nonce); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, key(code), new GCMParameterSpec(128, nonce)); + byte[] ct = cipher.doFinal(plaintext); + return Base64.getEncoder().encodeToString(ByteBuffer.allocate(NONCE + ct.length).put(nonce).put(ct).array()); + } + + public static byte[] open(String code, String sealed) throws GeneralSecurityException { + byte[] all; + try { + all = Base64.getDecoder().decode(sealed); + } catch (IllegalArgumentException e) { + throw new GeneralSecurityException("Sealed data is not base64", e); + } + if (all.length <= NONCE) + throw new GeneralSecurityException("Sealed data is too short"); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, key(code), new GCMParameterSpec(128, all, 0, NONCE)); + return cipher.doFinal(all, NONCE, all.length - NONCE); + } + + private static SecretKeySpec key(String code) throws GeneralSecurityException { + return new SecretKeySpec(hkdf32(normalize(code).getBytes(StandardCharsets.UTF_8), SALT, INFO), "AES"); + } + + // HKDF-SHA256 (RFC 5869), one expand block is enough for a 32-byte key. + static byte[] hkdf32(byte[] ikm, byte[] salt, byte[] info) throws GeneralSecurityException { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(salt, "HmacSHA256")); + byte[] prk = mac.doFinal(ikm); + mac.init(new SecretKeySpec(prk, "HmacSHA256")); + mac.update(info); + mac.update((byte) 1); + return mac.doFinal(); + } +} diff --git a/core/src/main/java/dev/objz/commandbridge/security/RedisSeal.java b/core/src/main/java/dev/objz/commandbridge/security/RedisSeal.java new file mode 100644 index 00000000..ffb92efb --- /dev/null +++ b/core/src/main/java/dev/objz/commandbridge/security/RedisSeal.java @@ -0,0 +1,123 @@ +package dev.objz.commandbridge.security; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.Map; +import java.util.function.LongSupplier; + +/** Authenticates Redis messages and their destination, with a bounded replay window. */ +public final class RedisSeal { + // Allow clock skew between the proxy and backends. + static final long WINDOW_MS = 120_000; + static final int MAX_MESSAGE_CHARS = 1_048_576; + static final int MAX_SEEN = 65_536; + private static final byte[] SALT = "commandbridge-redis-v1".getBytes(StandardCharsets.UTF_8); + private static final byte[] INFO = "hmac-sha256".getBytes(StandardCharsets.UTF_8); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final SecureRandom RANDOM = new SecureRandom(); + + private final SecretKeySpec key; + private final LongSupplier clock; + private final Map seen = new HashMap<>(); + private long nextCleanup; + + public RedisSeal(String secret) { + this(secret, System::currentTimeMillis); + } + + RedisSeal(String secret, LongSupplier clock) { + try { + this.key = new SecretKeySpec(PairCrypto.hkdf32(secret.getBytes(StandardCharsets.UTF_8), SALT, INFO), "HmacSHA256"); + } catch (GeneralSecurityException e) { + throw new IllegalStateException(e); + } + this.clock = clock; + } + + public String seal(String channel, String message) { + long t = clock.getAsLong(); + byte[] nonce = new byte[16]; + RANDOM.nextBytes(nonce); + String n = HexFormat.of().formatHex(nonce); + ObjectNode out = MAPPER.createObjectNode(); + out.put("t", t); + out.put("n", n); + out.put("m", message); + out.put("s", sign(channel, t, n, message)); + String sealed = out.toString(); + if (sealed.length() > MAX_MESSAGE_CHARS) { + throw new IllegalArgumentException("Redis message exceeds the size limit"); + } + return sealed; + } + + /** Returns null for invalid, expired, replayed, or misrouted messages. */ + public synchronized String open(String channel, String text) { + if (text == null || text.length() > MAX_MESSAGE_CHARS) { + return null; + } + JsonNode in; + try { + in = MAPPER.readTree(text); + } catch (Exception e) { + return null; + } + if (in == null || !in.path("t").isIntegralNumber() || !in.path("t").canConvertToLong() + || !in.path("n").isTextual() || !in.path("m").isTextual() + || !in.path("s").isTextual()) + return null; + long t = in.path("t").asLong(); + String n = in.path("n").asText(); + String m = in.path("m").asText(); + long now = clock.getAsLong(); + if (t < now - WINDOW_MS || t > now + WINDOW_MS || !n.matches("[0-9a-f]{32}") + || !in.path("s").asText().matches("[A-Za-z0-9_-]{43}")) + return null; + final String expected; + try { + expected = sign(channel, t, n, m); + } catch (IllegalArgumentException e) { + return null; + } + if (!MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII), + in.path("s").asText().getBytes(StandardCharsets.US_ASCII))) + return null; + if (now >= nextCleanup) { + seen.values().removeIf(at -> at < now - WINDOW_MS); + nextCleanup = now + 1000; + } + // Refuse new messages rather than evicting live nonces and permitting replays. + if (seen.size() >= MAX_SEEN || seen.putIfAbsent(n, t) != null) + return null; + return m; + } + + private String sign(String channel, long t, String n, String m) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(key); + // String.getBytes replaces malformed surrogates, which would allow signed payloads to change. + mac.update(StandardCharsets.UTF_8.newEncoder().encode( + CharBuffer.wrap(channel.length() + ":" + channel + "\n" + t + "\n" + n + "\n" + m))); + byte[] out = mac.doFinal(); + return Base64.getUrlEncoder().withoutPadding().encodeToString(out); + } catch (CharacterCodingException e) { + throw new IllegalArgumentException("Redis messages must contain valid Unicode", e); + } catch (GeneralSecurityException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/core/src/main/java/dev/objz/commandbridge/security/RelayCrypto.java b/core/src/main/java/dev/objz/commandbridge/security/RelayCrypto.java new file mode 100644 index 00000000..73f8081f --- /dev/null +++ b/core/src/main/java/dev/objz/commandbridge/security/RelayCrypto.java @@ -0,0 +1,67 @@ +package dev.objz.commandbridge.security; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * Messages between the proxy and a page on cb.objz.dev. The key comes from the secret in the link's #fragment, + * which the relay never sees, so it can neither read nor change them. The sender's role, the page's connection + * id and the sequence number are bound in, so a message can't be reflected, moved to another tab or replayed. + * The page does the same with WebCrypto (relay.js). + */ +public final class RelayCrypto { + private static final byte[] SALT = "commandbridge-relay-v1".getBytes(StandardCharsets.UTF_8); + private static final byte[] INFO = "aes-256-gcm".getBytes(StandardCharsets.UTF_8); + private static final SecureRandom RANDOM = new SecureRandom(); + + public record Sealed(String iv, String box) { + } + + private RelayCrypto() { + } + + public static SecretKeySpec key(byte[] secret) throws GeneralSecurityException { + return new SecretKeySpec(PairCrypto.hkdf32(secret, SALT, INFO), "AES"); + } + + public static Sealed seal(SecretKeySpec key, String role, String conn, long seq, String body) throws GeneralSecurityException { + byte[] iv = new byte[12]; + RANDOM.nextBytes(iv); + return seal(key, role, conn, seq, iv, body); + } + + static Sealed seal(SecretKeySpec key, String role, String conn, long seq, byte[] iv, String body) throws GeneralSecurityException { + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv)); + c.updateAAD(aad(role, conn, seq)); + byte[] box = c.doFinal(body.getBytes(StandardCharsets.UTF_8)); + Base64.Encoder b64 = Base64.getUrlEncoder().withoutPadding(); + return new Sealed(b64.encodeToString(iv), b64.encodeToString(box)); + } + + public static String open(SecretKeySpec key, String role, String conn, long seq, String iv, String box) throws GeneralSecurityException { + byte[] ivBytes; + byte[] boxBytes; + try { + ivBytes = Base64.getUrlDecoder().decode(iv); + boxBytes = Base64.getUrlDecoder().decode(box); + } catch (IllegalArgumentException e) { + throw new GeneralSecurityException("Not base64", e); + } + if (ivBytes.length != 12) + throw new GeneralSecurityException("Bad nonce length"); + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, ivBytes)); + c.updateAAD(aad(role, conn, seq)); + return new String(c.doFinal(boxBytes), StandardCharsets.UTF_8); + } + + private static byte[] aad(String role, String conn, long seq) { + return (role + "\n" + conn + "\n" + seq).getBytes(StandardCharsets.UTF_8); + } +} diff --git a/core/src/main/java/dev/objz/commandbridge/security/TLS.java b/core/src/main/java/dev/objz/commandbridge/security/TLS.java index 34dd2b27..5008b1e1 100644 --- a/core/src/main/java/dev/objz/commandbridge/security/TLS.java +++ b/core/src/main/java/dev/objz/commandbridge/security/TLS.java @@ -203,6 +203,24 @@ public static javax.net.ssl.SSLContext fromKeystore(String path, String type, St } } + public static String keystoreSpkiPin(String path, String type, String password) { + try { + KeyStore ks = KeyStore.getInstance((type == null || type.isBlank()) ? STORE_TYPE : type); + try (InputStream in = Files.newInputStream(Path.of(path))) { + ks.load(in, password.toCharArray()); + } + var aliases = ks.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (ks.isKeyEntry(alias) && ks.getCertificate(alias) instanceof X509Certificate x) + return spkiPin(x); + } + throw new IllegalStateException("no key entry"); + } catch (Exception e) { + throw new IllegalStateException("Could not read the keystore: " + e.getMessage(), e); + } + } + private static String spkiPinFromKeystore(KeyStore ks, String password) throws Exception { Certificate c = ks.getCertificate(ALIAS); if (c instanceof X509Certificate x) { diff --git a/core/src/main/java/dev/objz/commandbridge/security/TrustManager.java b/core/src/main/java/dev/objz/commandbridge/security/TrustManager.java index fe3be7de..c3104dd2 100644 --- a/core/src/main/java/dev/objz/commandbridge/security/TrustManager.java +++ b/core/src/main/java/dev/objz/commandbridge/security/TrustManager.java @@ -58,7 +58,7 @@ private void handleStrict(String spki) throws CertificateException { Log.debug("TLS STRICT validation succeeded with pin '{}'", expectedPin); } - private void handleTofu(String spki) throws CertificateException { + private synchronized void handleTofu(String spki) throws CertificateException { if (configuredPin != null && !configuredPin.isBlank()) { if (!configuredPin.equals(spki)) { Log.error("TLS pin mismatch (TOFU configured): expected '{}' but got '{}'", diff --git a/core/src/test/java/dev/objz/commandbridge/config/ConfigManagerLoadTest.java b/core/src/test/java/dev/objz/commandbridge/config/ConfigManagerLoadTest.java new file mode 100644 index 00000000..af4f372b --- /dev/null +++ b/core/src/test/java/dev/objz/commandbridge/config/ConfigManagerLoadTest.java @@ -0,0 +1,233 @@ +package dev.objz.commandbridge.config; + +import dev.objz.commandbridge.TestFixtures; +import dev.objz.commandbridge.config.model.BackendsConfig; +import dev.objz.commandbridge.config.model.EndpointType; +import dev.objz.commandbridge.config.model.TlsMode; +import dev.objz.commandbridge.config.model.VelocityConfig; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class ConfigManagerLoadTest { + + @TempDir + Path dir; + + @BeforeAll + static void setUp() { + TestFixtures.ensureLog(); + } + + private ConfigManager velocity(String yaml) throws IOException { + Files.writeString(dir.resolve("config.yml"), yaml); + return new ConfigManager<>(dir, VelocityConfig.class); + } + + private String defaultYaml() throws IOException { + Path seed = dir.resolve("seed"); + assertTrue(new ConfigManager<>(seed, VelocityConfig.class).load()); + return Files.readString(seed.resolve("config.yml")); + } + + private static void assertDefaultsLoaded(VelocityConfig cfg) { + VelocityConfig d = VelocityConfig.defaults(); + assertEquals(d.serverId(), cfg.serverId()); + assertEquals(d.endpointType(), cfg.endpointType()); + assertEquals(d.endpoints(), cfg.endpoints()); + assertEquals(d.security().tlsMode(), cfg.security().tlsMode()); + assertEquals(d.timeouts(), cfg.timeouts()); + assertEquals(d.tasks(), cfg.tasks()); + } + + @Test + void missingFileWritesDefaultsAndLoadsThem() { + var manager = new ConfigManager<>(dir.resolve("nested"), VelocityConfig.class); + + assertTrue(manager.load()); + assertTrue(Files.exists(dir.resolve("nested").resolve("config.yml"))); + assertDefaultsLoaded(manager.current()); + } + + @Test + void missingBackendsFileWritesDefaultsAndLoadsThem() { + var manager = new ConfigManager<>(dir, "backend.yml", BackendsConfig.class); + + assertTrue(manager.load()); + assertTrue(Files.exists(dir.resolve("backend.yml"))); + assertEquals(BackendsConfig.defaults(), manager.current()); + } + + @Test + void writtenDefaultsLoadAgainUnchanged() { + var first = new ConfigManager<>(dir, VelocityConfig.class); + assertTrue(first.load()); + + var second = new ConfigManager<>(dir, VelocityConfig.class); + assertTrue(second.load()); + assertEquals(first.current(), second.current()); + } + + @Test + void enumValuesAreCaseInsensitive() throws IOException { + String yaml = defaultYaml(); + assertTrue(yaml.contains("tls-mode: TOFU") && yaml.contains("endpoint-type: WEBSOCKET"), yaml); + var manager = velocity(yaml.replace("tls-mode: TOFU", "tls-mode: tofu") + .replace("endpoint-type: WEBSOCKET", "endpoint-type: redis")); + + assertTrue(manager.load()); + assertEquals(TlsMode.TOFU, manager.current().security().tlsMode()); + assertEquals(EndpointType.REDIS, manager.current().endpointType()); + } + + @Test + void misspelledEnumFailsAndKeepsDefaults() throws IOException { + var manager = velocity(defaultYaml().replace("server-id: proxy-1", "server-id: custom") + .replace("tls-mode: TOFU", "tls-mode: tofuu")); + + assertFalse(manager.load()); + assertDefaultsLoaded(manager.current()); + } + + @Test + void blankEnumValueFails() throws IOException { + var manager = velocity(defaultYaml().replace("endpoint-type: WEBSOCKET", "endpoint-type: \"\"")); + + assertFalse(manager.load()); + assertDefaultsLoaded(manager.current()); + } + + @Test + void unknownTopLevelKeyIsIgnored() throws IOException { + var manager = velocity(defaultYaml() + "server-idd: typo\nsomething-else: 1\n"); + + assertTrue(manager.load()); + assertDefaultsLoaded(manager.current()); + } + + @Test + void partialFileIsFilledFromDefaults() throws IOException { + var manager = velocity(""" + server-id: lobby-proxy + endpoints: + websocket: + bind-host: 10.0.0.1 + """); + + assertTrue(manager.load()); + VelocityConfig cfg = manager.current(); + VelocityConfig d = VelocityConfig.defaults(); + assertEquals("lobby-proxy", cfg.serverId()); + assertEquals("10.0.0.1", cfg.endpoints().webSocket().bindHost()); + assertEquals(d.endpoints().webSocket().bindPort(), cfg.endpoints().webSocket().bindPort()); + assertEquals(d.endpoints().redis(), cfg.endpoints().redis()); + assertEquals(d.security(), cfg.security()); + assertEquals(d.timeouts(), cfg.timeouts()); + assertEquals(d.tasks(), cfg.tasks()); + assertEquals(d.endpointType(), cfg.endpointType()); + } + + @Test + void missingSectionKeepsItsDefaults() throws IOException { + String yaml = defaultYaml(); + int start = yaml.indexOf("tasks:"); + int end = yaml.indexOf("\n", yaml.indexOf("expire-after:", start)) + 1; + var manager = velocity(yaml.substring(0, start) + yaml.substring(end)); + + assertTrue(manager.load()); + assertEquals(86400, manager.current().tasks().expireAfter()); + } + + @Test + void invalidValueIsNormalizedButReported() throws IOException { + var manager = velocity(defaultYaml().replace("bind-port: 8765", "bind-port: 70000")); + + assertFalse(manager.load()); + assertEquals(8765, manager.current().endpoints().webSocket().bindPort()); + } + + @Test + void failedReloadsKeepTheLastGoodConfig() throws IOException { + var manager = velocity("server-id: live-proxy\nendpoints:\n websocket:\n bind-port: 9001\n"); + assertTrue(manager.load()); + VelocityConfig good = manager.current(); + + for (String invalid : List.of( + "server-id: candidate\nendpoints:\n websocket:\n bind-port: 70000\n", + "server-id: candidate\nendpoint-type: typo\n", + "timeouts:\n register-timeout: soon\n", + "server-id: [unclosed\n")) { + Files.writeString(dir.resolve("config.yml"), invalid); + assertFalse(manager.reload(), invalid); + assertSame(good, manager.current(), invalid); + } + + Files.delete(dir.resolve("config.yml")); + Files.createDirectory(dir.resolve("config.yml")); + assertFalse(manager.reload()); + assertSame(good, manager.current()); + } + + @Test + void successfulReloadPublishesTheNormalizedCandidate() throws IOException { + var manager = velocity("server-id: live-proxy\n"); + assertTrue(manager.load()); + Files.writeString(dir.resolve("config.yml"), "server-id: ' next-proxy '\n"); + + assertTrue(manager.reload()); + assertEquals("next-proxy", manager.current().serverId()); + } + + @Test + void backendLoadsAlsoKeepTheLastGoodConfigOnFailure() throws IOException { + Files.writeString(dir.resolve("backend.yml"), "client-id: live-backend\n"); + var manager = new ConfigManager<>(dir, "backend.yml", BackendsConfig.class); + assertTrue(manager.load()); + BackendsConfig good = manager.current(); + Files.writeString(dir.resolve("backend.yml"), "client-id: candidate\nendpoints:\n websocket:\n port: 70000\n"); + + assertFalse(manager.load()); + assertSame(good, manager.current()); + } + + @Test + void invalidInitialCandidateFallsBackToDefaultsWithoutPublishingItsOtherChanges() throws IOException { + var manager = velocity("server-id: candidate\nendpoints:\n websocket:\n bind-port: 70000\n"); + + assertFalse(manager.load()); + assertDefaultsLoaded(manager.current()); + } + + @Test + void malformedYamlFailsAndKeepsDefaults() throws IOException { + var manager = velocity(""" + server-id: [unclosed + security: + tls-mode: TOFU + """); + + assertFalse(manager.load()); + assertDefaultsLoaded(manager.current()); + } + + @Test + void wrongValueTypeFails() throws IOException { + var manager = velocity(""" + timeouts: + register-timeout: soon + """); + + assertFalse(manager.load()); + assertDefaultsLoaded(manager.current()); + } +} diff --git a/core/src/test/java/dev/objz/commandbridge/config/ConfigManagerSaveTest.java b/core/src/test/java/dev/objz/commandbridge/config/ConfigManagerSaveTest.java new file mode 100644 index 00000000..bd90587e --- /dev/null +++ b/core/src/test/java/dev/objz/commandbridge/config/ConfigManagerSaveTest.java @@ -0,0 +1,70 @@ +package dev.objz.commandbridge.config; + +import dev.objz.commandbridge.TestFixtures; +import dev.objz.commandbridge.config.model.BackendsConfig; +import dev.objz.commandbridge.config.model.EndpointType; +import dev.objz.commandbridge.config.model.TlsMode; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConfigManagerSaveTest { + + @BeforeAll + static void setUp() { + TestFixtures.ensureLog(); + } + + private static BackendsConfig sample() { + BackendsConfig d = BackendsConfig.defaults(); + return new BackendsConfig( + "survival", + EndpointType.WEBSOCKET, + new BackendsConfig.Endpoints( + new BackendsConfig.Endpoints.WebSocket("play.example.net", 8765), + d.endpoints().redis()), + new BackendsConfig.Security(TlsMode.TOFU, "", "s3cr3t"), + d.timeouts(), + false); + } + + @Test + void savedConfigLoadsBackEqual(@TempDir Path dir) { + var manager = new ConfigManager<>(dir, BackendsConfig.class); + manager.load(); + BackendsConfig cfg = sample(); + + assertTrue(manager.save(cfg)); + assertEquals(cfg, manager.current()); + + var other = new ConfigManager<>(dir, BackendsConfig.class); + assertTrue(other.load()); + assertEquals(cfg, other.current()); + } + + @Test + void saveReplacesFileAtomically(@TempDir Path dir) { + var manager = new ConfigManager<>(dir, BackendsConfig.class); + manager.load(); + + assertTrue(manager.save(sample())); + + assertFalse(Files.exists(dir.resolve("config.yml.tmp"))); + assertTrue(Files.exists(dir.resolve("config.yml"))); + } + + @Test + void renderMatchesWhatSaveWrites(@TempDir Path dir) throws Exception { + var manager = new ConfigManager<>(dir, BackendsConfig.class); + manager.save(sample()); + + assertEquals(Files.readString(dir.resolve("config.yml")), ConfigManager.render(BackendsConfig.class, sample())); + } +} diff --git a/core/src/test/java/dev/objz/commandbridge/config/profile/BackendsConfigProfileTest.java b/core/src/test/java/dev/objz/commandbridge/config/profile/BackendsConfigProfileTest.java new file mode 100644 index 00000000..7df6aacc --- /dev/null +++ b/core/src/test/java/dev/objz/commandbridge/config/profile/BackendsConfigProfileTest.java @@ -0,0 +1,166 @@ +package dev.objz.commandbridge.config.profile; + +import dev.objz.commandbridge.TestFixtures; +import dev.objz.commandbridge.config.model.BackendsConfig; +import dev.objz.commandbridge.config.model.EndpointType; +import dev.objz.commandbridge.config.model.TlsMode; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class BackendsConfigProfileTest { + + private static final BackendsConfigProfile PROFILE = new BackendsConfigProfile(); + private static final BackendsConfig D = BackendsConfig.defaults(); + + @BeforeAll + static void setUp() { + TestFixtures.ensureLog(); + } + + private static BackendsConfig with(String clientId, EndpointType type, BackendsConfig.Endpoints endpoints, + BackendsConfig.Security security, BackendsConfig.Timeouts timeouts) { + return new BackendsConfig(clientId, type, endpoints, security, timeouts, false); + } + + private static BackendsConfig withEndpoints(String wsHost, int wsPort, String redisHost, int redisPort) { + return with(D.clientId(), D.endpointType(), new BackendsConfig.Endpoints( + new BackendsConfig.Endpoints.WebSocket(wsHost, wsPort), + new BackendsConfig.Endpoints.Redis(redisHost, redisPort, "", "")), + D.security(), D.timeouts()); + } + + private static BackendsConfig withSecurity(BackendsConfig.Security security) { + return with(D.clientId(), D.endpointType(), D.endpoints(), security, D.timeouts()); + } + + private static BackendsConfig withTimeouts(int auth, int reconnectTimeout, int reconnectInterval) { + return with(D.clientId(), D.endpointType(), D.endpoints(), D.security(), + new BackendsConfig.Timeouts(auth, reconnectTimeout, reconnectInterval)); + } + + @Test + void defaultsAreValidAndUnchanged() { + var result = PROFILE.normalize(D); + assertTrue(result.ok()); + assertEquals(D, result.config()); + } + + @Test + void blankClientIdIsInvalidAndFallsBackToDefault() { + var result = PROFILE.normalize(with(" ", D.endpointType(), D.endpoints(), D.security(), D.timeouts())); + assertFalse(result.ok()); + assertEquals("survival-1", result.config().clientId()); + } + + @Test + void nullEndpointTypeIsInvalid() { + var result = PROFILE.normalize(with(D.clientId(), null, D.endpoints(), D.security(), D.timeouts())); + assertFalse(result.ok()); + assertEquals(EndpointType.WEBSOCKET, result.config().endpointType()); + } + + @Test + void websocketSchemeIsStrippedFromHost() { + var result = PROFILE.normalize(withEndpoints("wss://0.0.0.0", 8765, "127.0.0.1", 6379)); + assertTrue(result.ok()); + assertEquals("0.0.0.0", result.config().endpoints().websocket().host()); + } + + @Test + void redisSchemeIsStrippedFromHost() { + var result = PROFILE.normalize(withEndpoints("127.0.0.1", 8765, "rediss://h", 6379)); + assertTrue(result.ok()); + assertEquals("h", result.config().endpoints().redis().host()); + } + + @Test + void portZeroFallsBackToDefaultAndIsInvalid() { + var result = PROFILE.normalize(withEndpoints("127.0.0.1", 0, "127.0.0.1", 6379)); + assertFalse(result.ok()); + assertEquals(8765, result.config().endpoints().websocket().port()); + } + + @Test + void portAboveRangeFallsBackToDefaultAndIsInvalid() { + var result = PROFILE.normalize(withEndpoints("127.0.0.1", 8765, "127.0.0.1", 65536)); + assertFalse(result.ok()); + assertEquals(6379, result.config().endpoints().redis().port()); + } + + @Test + void highestPortIsAccepted() { + var result = PROFILE.normalize(withEndpoints("127.0.0.1", 65535, "127.0.0.1", 65535)); + assertTrue(result.ok()); + assertEquals(65535, result.config().endpoints().websocket().port()); + } + + @Test + void zeroAuthTimeoutIsInvalid() { + var result = PROFILE.normalize(withTimeouts(0, 60, 5)); + assertFalse(result.ok()); + assertEquals(5, result.config().timeouts().authTimeout()); + } + + @Test + void negativeReconnectTimeoutIsInvalid() { + var result = PROFILE.normalize(withTimeouts(5, -3, 5)); + assertFalse(result.ok()); + assertEquals(60, result.config().timeouts().reconnectTimeout()); + } + + @Test + void zeroReconnectIntervalIsInvalid() { + var result = PROFILE.normalize(withTimeouts(5, 60, 0)); + assertFalse(result.ok()); + assertEquals(5, result.config().timeouts().reconnectInterval()); + } + + @Test + void blankSecretIsInvalid() { + assertFalse(PROFILE.normalize(withSecurity(new BackendsConfig.Security(TlsMode.TOFU, "", " "))).ok()); + } + + @Test + void placeholderSecretOnlyWarns() { + var result = PROFILE.normalize(withSecurity(new BackendsConfig.Security(TlsMode.TOFU, "", "change-me"))); + assertTrue(result.ok()); + assertEquals("change-me", result.config().security().secret()); + } + + @Test + void nullTlsModeIsInvalidAndFallsBackToTofu() { + var result = PROFILE.normalize(withSecurity(new BackendsConfig.Security(null, "", "s3cret"))); + assertFalse(result.ok()); + assertEquals(TlsMode.TOFU, result.config().security().tlsMode()); + } + + @Test + void tlsPinIsTrimmed() { + var result = PROFILE.normalize(withSecurity(new BackendsConfig.Security(TlsMode.STRICT, " sha256/abc ", "s3cret"))); + assertTrue(result.ok()); + assertEquals("sha256/abc", result.config().security().tlsPin()); + } + + @Test + void nullSectionsFallBackToDefaultsWithoutNpe() { + var result = PROFILE.normalize(new BackendsConfig("lobby", EndpointType.REDIS, null, null, null, true)); + assertTrue(result.ok()); + BackendsConfig out = result.config(); + assertEquals(D.endpoints(), out.endpoints()); + assertEquals(D.security(), out.security()); + assertEquals(D.timeouts(), out.timeouts()); + assertTrue(out.debug()); + } + + @Test + void nullNestedEndpointSectionsFallBackToDefaults() { + var result = PROFILE.normalize(with(D.clientId(), D.endpointType(), + new BackendsConfig.Endpoints(null, null), D.security(), D.timeouts())); + assertTrue(result.ok()); + assertEquals(D.endpoints(), result.config().endpoints()); + } +} diff --git a/core/src/test/java/dev/objz/commandbridge/config/profile/VelocityConfigProfileTest.java b/core/src/test/java/dev/objz/commandbridge/config/profile/VelocityConfigProfileTest.java new file mode 100644 index 00000000..8eeeb868 --- /dev/null +++ b/core/src/test/java/dev/objz/commandbridge/config/profile/VelocityConfigProfileTest.java @@ -0,0 +1,236 @@ +package dev.objz.commandbridge.config.profile; + +import dev.objz.commandbridge.TestFixtures; +import dev.objz.commandbridge.config.model.EndpointType; +import dev.objz.commandbridge.config.model.TlsMode; +import dev.objz.commandbridge.config.model.VelocityConfig; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class VelocityConfigProfileTest { + + private static final VelocityConfigProfile PROFILE = new VelocityConfigProfile(); + private static final VelocityConfig D = VelocityConfig.defaults(); + + @BeforeAll + static void setUp() { + TestFixtures.ensureLog(); + } + + private static VelocityConfig with(String serverId, EndpointType type, VelocityConfig.Endpoints endpoints, + VelocityConfig.Security security, VelocityConfig.Timeouts timeouts, VelocityConfig.Tasks tasks) { + return new VelocityConfig(false, serverId, type, endpoints, security, timeouts, tasks, false); + } + + private static VelocityConfig withServerId(String serverId) { + return with(serverId, D.endpointType(), D.endpoints(), D.security(), D.timeouts(), D.tasks()); + } + + private static VelocityConfig withEndpoints(String bindHost, int bindPort, String redisHost, int redisPort) { + return with(D.serverId(), D.endpointType(), new VelocityConfig.Endpoints( + new VelocityConfig.Endpoints.WebSocket(bindHost, bindPort), + new VelocityConfig.Endpoints.Redis(redisHost, redisPort, "", "")), + D.security(), D.timeouts(), D.tasks()); + } + + private static VelocityConfig withSecurity(EndpointType type, VelocityConfig.Security security) { + return with(D.serverId(), type, D.endpoints(), security, D.timeouts(), D.tasks()); + } + + private static VelocityConfig withTimeouts(int register, int ping) { + return with(D.serverId(), D.endpointType(), D.endpoints(), D.security(), + new VelocityConfig.Timeouts(register, ping), D.tasks()); + } + + private static VelocityConfig withExpireAfter(int expireAfter) { + return with(D.serverId(), D.endpointType(), D.endpoints(), D.security(), D.timeouts(), + new VelocityConfig.Tasks(expireAfter)); + } + + @Test + void defaultsAreValid() { + var result = PROFILE.normalize(D); + assertTrue(result.ok()); + assertEquals(D.endpoints(), result.config().endpoints()); + assertEquals(D.timeouts(), result.config().timeouts()); + assertEquals(D.security().tlsMode(), result.config().security().tlsMode()); + } + + @Test + void blankServerIdFallsBackToDefaultWithoutFailing() { + var result = PROFILE.normalize(withServerId(" ")); + assertTrue(result.ok()); + assertEquals("proxy-1", result.config().serverId()); + } + + @Test + void serverIdIsTrimmed() { + assertEquals("proxy-2", PROFILE.normalize(withServerId(" proxy-2 ")).config().serverId()); + } + + @Test + void nullEndpointTypeIsInvalidAndFallsBackToWebsocket() { + var result = PROFILE.normalize(with(D.serverId(), null, D.endpoints(), D.security(), D.timeouts(), D.tasks())); + assertFalse(result.ok()); + assertEquals(EndpointType.WEBSOCKET, result.config().endpointType()); + } + + @Test + void websocketSchemeIsStrippedFromBindHost() { + var result = PROFILE.normalize(withEndpoints("wss://0.0.0.0", 8765, "127.0.0.1", 6379)); + assertTrue(result.ok()); + assertEquals("0.0.0.0", result.config().endpoints().webSocket().bindHost()); + } + + @Test + void redisSchemeIsStrippedFromHost() { + var result = PROFILE.normalize(withEndpoints("0.0.0.0", 8765, "rediss://h", 6379)); + assertTrue(result.ok()); + assertEquals("h", result.config().endpoints().redis().host()); + } + + @Test + void blankBindHostIsInvalid() { + var result = PROFILE.normalize(withEndpoints(" ", 8765, "127.0.0.1", 6379)); + assertFalse(result.ok()); + assertEquals("0.0.0.0", result.config().endpoints().webSocket().bindHost()); + } + + @Test + void bindPortZeroFallsBackToDefaultAndIsInvalid() { + var result = PROFILE.normalize(withEndpoints("0.0.0.0", 0, "127.0.0.1", 6379)); + assertFalse(result.ok()); + assertEquals(8765, result.config().endpoints().webSocket().bindPort()); + } + + @Test + void bindPortAboveRangeFallsBackToDefaultAndIsInvalid() { + var result = PROFILE.normalize(withEndpoints("0.0.0.0", 65536, "127.0.0.1", 6379)); + assertFalse(result.ok()); + assertEquals(8765, result.config().endpoints().webSocket().bindPort()); + } + + @Test + void redisPortAboveRangeFallsBackToDefaultAndIsInvalid() { + var result = PROFILE.normalize(withEndpoints("0.0.0.0", 8765, "127.0.0.1", 65536)); + assertFalse(result.ok()); + assertEquals(6379, result.config().endpoints().redis().port()); + } + + @Test + void highestPortIsAccepted() { + var result = PROFILE.normalize(withEndpoints("0.0.0.0", 65535, "127.0.0.1", 65535)); + assertTrue(result.ok()); + assertEquals(65535, result.config().endpoints().webSocket().bindPort()); + assertEquals(65535, result.config().endpoints().redis().port()); + } + + @Test + void zeroRegisterTimeoutIsInvalid() { + var result = PROFILE.normalize(withTimeouts(0, 5)); + assertFalse(result.ok()); + assertEquals(5, result.config().timeouts().registerTimeout()); + } + + @Test + void negativePingTimeoutIsInvalid() { + var result = PROFILE.normalize(withTimeouts(5, -1)); + assertFalse(result.ok()); + assertEquals(5, result.config().timeouts().pingTimeout()); + } + + @Test + void negativeExpireAfterIsInvalid() { + var result = PROFILE.normalize(withExpireAfter(-1)); + assertFalse(result.ok()); + assertEquals(86400, result.config().tasks().expireAfter()); + } + + @Test + void zeroExpireAfterDisablesExpiryAndIsValid() { + var result = PROFILE.normalize(withExpireAfter(0)); + assertTrue(result.ok()); + assertEquals(0, result.config().tasks().expireAfter()); + } + + @Test + void strictWebsocketWithoutKeystorePathIsInvalid() { + var result = PROFILE.normalize(withSecurity(EndpointType.WEBSOCKET, + new VelocityConfig.Security(TlsMode.STRICT, "", "secret", "PKCS12"))); + assertFalse(result.ok()); + } + + @Test + void strictWebsocketWithoutKeystorePasswordIsInvalid() { + var result = PROFILE.normalize(withSecurity(EndpointType.WEBSOCKET, + new VelocityConfig.Security(TlsMode.STRICT, "/keys/ks.p12", " ", "PKCS12"))); + assertFalse(result.ok()); + } + + @Test + void strictWebsocketWithKeystoreIsValid() { + var result = PROFILE.normalize(withSecurity(EndpointType.WEBSOCKET, + new VelocityConfig.Security(TlsMode.STRICT, " /keys/ks.p12 ", "secret", "JKS"))); + assertTrue(result.ok()); + assertEquals("/keys/ks.p12", result.config().security().keystorePath()); + assertEquals("JKS", result.config().security().keystoreType()); + } + + @Test + void strictWithRedisDoesNotNeedKeystore() { + var result = PROFILE.normalize(withSecurity(EndpointType.REDIS, + new VelocityConfig.Security(TlsMode.STRICT, "", "", "PKCS12"))); + assertTrue(result.ok()); + } + + @Test + void nullTlsModeIsInvalidAndFallsBackToTofu() { + var result = PROFILE.normalize(withSecurity(EndpointType.WEBSOCKET, + new VelocityConfig.Security(null, "", "", "PKCS12"))); + assertFalse(result.ok()); + assertEquals(TlsMode.TOFU, result.config().security().tlsMode()); + } + + @Test + void blankKeystoreTypeFallsBackToDefault() { + var result = PROFILE.normalize(withSecurity(EndpointType.WEBSOCKET, + new VelocityConfig.Security(TlsMode.TOFU, "", "", " "))); + assertTrue(result.ok()); + assertEquals("PKCS12", result.config().security().keystoreType()); + } + + @Test + void nullSectionsFallBackToDefaultsWithoutNpe() { + var result = PROFILE.normalize(new VelocityConfig(true, null, EndpointType.REDIS, null, null, null, null, true)); + assertTrue(result.ok()); + VelocityConfig out = result.config(); + assertEquals(D.endpoints(), out.endpoints()); + assertEquals(D.timeouts(), out.timeouts()); + assertEquals(D.tasks(), out.tasks()); + assertEquals(D.security().tlsMode(), out.security().tlsMode()); + assertEquals(D.security().keystoreType(), out.security().keystoreType()); + assertTrue(out.actAsClient()); + assertTrue(out.debug()); + } + + @Test + void nullNestedEndpointSectionsFallBackToDefaults() { + var result = PROFILE.normalize(with(D.serverId(), D.endpointType(), + new VelocityConfig.Endpoints(null, null), D.security(), D.timeouts(), D.tasks())); + assertTrue(result.ok()); + assertEquals(D.endpoints(), result.config().endpoints()); + } + + @Test + void nullRedisCredentialsBecomeEmpty() { + var result = PROFILE.normalize(with(D.serverId(), D.endpointType(), new VelocityConfig.Endpoints( + D.endpoints().webSocket(), new VelocityConfig.Endpoints.Redis("h", 6379, null, null)), + D.security(), D.timeouts(), D.tasks())); + assertEquals("", result.config().endpoints().redis().username()); + assertEquals("", result.config().endpoints().redis().password()); + } +} diff --git a/core/src/test/java/dev/objz/commandbridge/net/InNodeGateTest.java b/core/src/test/java/dev/objz/commandbridge/net/InNodeGateTest.java new file mode 100644 index 00000000..cdf42a4b --- /dev/null +++ b/core/src/test/java/dev/objz/commandbridge/net/InNodeGateTest.java @@ -0,0 +1,92 @@ +package dev.objz.commandbridge.net; + +import dev.objz.commandbridge.TestFixtures; +import dev.objz.commandbridge.net.proto.Envelope; +import dev.objz.commandbridge.net.proto.MessageType; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InNodeGateTest { + + @BeforeAll + static void setUp() { + TestFixtures.ensureLog(); + } + + private static final Endpoint STRANGER = new Endpoint() { + @Override + public CompletableFuture send(Envelope env) { + return CompletableFuture.completedFuture(null); + } + + @Override + public boolean isOpen() { + return true; + } + }; + + private static String text(MessageType type) throws Exception { + return Envelope.MAPPER.writeValueAsString(Envelope.make(type, "lobby", "proxy", null)); + } + + @Test + void aClosedGateDropsTheMessageBeforeTheTapAndTheHandler() throws Exception { + List handled = new ArrayList<>(); + List tapped = new ArrayList<>(); + InNode node = new InNode(); + node.register(MessageType.PLUGIN_MESSAGE, new InboundHandler() { + @Override + public void accept(Endpoint endpoint, Envelope env) { + handled.add(env.type()); + } + }); + node.setInboundTap(env -> { + tapped.add(env.type()); + return false; + }); + node.setGate((endpoint, env) -> env.type() == MessageType.AUTH_REQUEST); + + node.onText(STRANGER, text(MessageType.PLUGIN_MESSAGE)); + + assertTrue(handled.isEmpty()); + assertTrue(tapped.isEmpty()); + } + + @Test + void anOpenGateLetsTheMessageThrough() throws Exception { + List handled = new ArrayList<>(); + InNode node = new InNode(); + node.register(MessageType.AUTH_REQUEST, new InboundHandler() { + @Override + public void accept(Endpoint endpoint, Envelope env) { + handled.add(env.type()); + } + }); + node.setGate((endpoint, env) -> env.type() == MessageType.AUTH_REQUEST); + + node.onText(STRANGER, text(MessageType.AUTH_REQUEST)); + + assertEquals(List.of(MessageType.AUTH_REQUEST), handled); + } + + @Test + void nullAndIncompleteEnvelopesAreRejectedBeforeTheGateOrTap() { + InNode node = new InNode(); + node.setGate((endpoint, env) -> { + throw new AssertionError("Malformed envelope reached the gate"); + }); + node.setInboundTap(env -> { + throw new AssertionError("Malformed envelope reached the tap"); + }); + + for (String json : List.of("null", "{}", "{\"type\":null}")) + node.onText(STRANGER, json); + } +} diff --git a/core/src/test/java/dev/objz/commandbridge/net/InNodeSendFactoryTest.java b/core/src/test/java/dev/objz/commandbridge/net/InNodeSendFactoryTest.java new file mode 100644 index 00000000..7ffe9a02 --- /dev/null +++ b/core/src/test/java/dev/objz/commandbridge/net/InNodeSendFactoryTest.java @@ -0,0 +1,55 @@ +package dev.objz.commandbridge.net; + +import dev.objz.commandbridge.TestFixtures; +import dev.objz.commandbridge.net.proto.Envelope; +import dev.objz.commandbridge.net.proto.MessageType; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class InNodeSendFactoryTest { + @BeforeAll + static void setup() { + TestFixtures.ensureLog(); + } + + @Test + void registeredHandlersReceiveLateAndReplacementFactories() throws Exception { + List factories = new ArrayList<>(); + List replies = new ArrayList<>(); + Endpoint endpoint = new Endpoint() { + public CompletableFuture send(Envelope env) { + replies.add(env); + return CompletableFuture.completedFuture(null); + } + + public boolean isOpen() { + return true; + } + }; + InNode node = new InNode().register(MessageType.PING, new InboundHandler() { + public void accept(Endpoint ep, Envelope env) { + reply(ep, env, MessageType.PONG, null).dispatch().join(); + } + }); + String request = Envelope.MAPPER.writeValueAsString( + Envelope.make(MessageType.PING, "proxy", "backend", null)); + + for (String factory : List.of("initial", "replacement")) { + node.setSendOperationFactory((ep, env) -> { + factories.add(factory); + return new SendOperation(ep, env, new ResponseAwaiter()); + }); + node.onText(endpoint, request); + } + + assertEquals(List.of("initial", "replacement"), factories); + assertEquals(2, replies.size()); + assertEquals(MessageType.PONG, replies.getFirst().type()); + } +} diff --git a/core/src/test/java/dev/objz/commandbridge/net/ResponseAwaiterTest.java b/core/src/test/java/dev/objz/commandbridge/net/ResponseAwaiterTest.java index a477f6c6..3c6d3a90 100644 --- a/core/src/test/java/dev/objz/commandbridge/net/ResponseAwaiterTest.java +++ b/core/src/test/java/dev/objz/commandbridge/net/ResponseAwaiterTest.java @@ -9,17 +9,16 @@ import java.time.Duration; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -/** - * Tests for {@link ResponseAwaiter}. - * Verifies signal matching by client ID, wildcard null-clientId fallback, - * and safe behavior when no awaiter is pending. - */ final class ResponseAwaiterTest { @BeforeAll @@ -91,4 +90,34 @@ void signalNoMatchingAwaiterDoesNotThrow() { boolean handled = awaiter.signal(envelope(UUID.randomUUID(), "server1")); assertFalse(handled, "Signal with no pending awaiter should return false"); } + + @Test + void timeoutFailsFutureAndRemovesWaiter() throws Exception { + ResponseAwaiter awaiter = new ResponseAwaiter(); + UUID id = UUID.randomUUID(); + CompletableFuture future = awaiter.await( + "server1", id, e -> true, Duration.ofMillis(50)); + + var ex = assertThrows(ExecutionException.class, () -> future.get(2, TimeUnit.SECONDS)); + assertInstanceOf(TimeoutException.class, ex.getCause()); + + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1); + boolean handled = true; + while (handled && System.nanoTime() < deadline) { + handled = awaiter.signal(envelope(id, "server1")); + } + assertFalse(handled, "Timed-out waiter should be removed"); + } + + @Test + void rejectedMatchKeepsWaiterPending() throws Exception { + ResponseAwaiter awaiter = new ResponseAwaiter(); + UUID id = UUID.randomUUID(); + CompletableFuture future = awaiter.await( + null, id, e -> "server2".equals(e.from()), Duration.ofSeconds(5)); + + assertFalse(awaiter.signal(envelope(id, "server1"))); + assertTrue(awaiter.signal(envelope(id, "server2"))); + assertEquals("server2", future.get(1, TimeUnit.SECONDS).from()); + } } diff --git a/core/src/test/java/dev/objz/commandbridge/net/channel/PluginMessageChannelTest.java b/core/src/test/java/dev/objz/commandbridge/net/channel/PluginMessageChannelTest.java new file mode 100644 index 00000000..dd1cc569 --- /dev/null +++ b/core/src/test/java/dev/objz/commandbridge/net/channel/PluginMessageChannelTest.java @@ -0,0 +1,230 @@ +package dev.objz.commandbridge.net.channel; + +import dev.objz.commandbridge.api.channel.ChannelPayload; +import dev.objz.commandbridge.api.channel.command.CommandPayload; +import dev.objz.commandbridge.api.channel.command.RunAs; +import dev.objz.commandbridge.api.message.MessageListener; +import dev.objz.commandbridge.api.message.Subscription; +import dev.objz.commandbridge.api.platform.Platform; +import dev.objz.commandbridge.net.payloads.PluginMessage; +import dev.objz.commandbridge.net.proto.Envelope; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class PluginMessageChannelTest { + + public record Greeting(String text) implements ChannelPayload { + } + + private record Sent(Platform.ServerTarget target, PluginMessage message, Duration timeout) { + } + + private static final Platform.ServerTarget SURVIVAL = Platform.backend("survival-1"); + private static final Platform.ServerTarget LOBBY = Platform.backend("lobby"); + + private final List sent = new ArrayList<>(); + private CompletableFuture nextResponse = new CompletableFuture<>(); + + private

PluginMessageChannel

channel(Class

type) { + return new PluginMessageChannel<>(type, + (target, msg) -> { + sent.add(new Sent(target, msg, null)); + return CompletableFuture.completedFuture(null); + }, + (target, msg, timeout) -> { + sent.add(new Sent(target, msg, timeout)); + return nextResponse; + }, + listener -> () -> { + }); + } + + @Test + void sendWrapsPayloadForSingleTarget() { + channel(Greeting.class).to(List.of(SURVIVAL)).send(new Greeting("hi")).join(); + + assertEquals(1, sent.size()); + Sent s = sent.getFirst(); + assertEquals(SURVIVAL, s.target()); + assertEquals(Greeting.class.getName(), s.message().channelType()); + assertEquals("hi", s.message().data().get("text").asText()); + assertFalse(s.message().expectsResponse()); + assertNull(s.message().requirePlayer()); + assertNull(s.message().whenOnline()); + } + + @Test + void explicitRequirePlayerIsForwarded() { + UUID player = UUID.randomUUID(); + channel(Greeting.class).to(List.of(SURVIVAL)).requirePlayer(player).send(new Greeting("hi")); + + assertEquals(player, sent.getFirst().message().requirePlayer()); + assertNull(sent.getFirst().message().whenOnline()); + } + + @Test + void requirePlayerWithoutArgumentUsesCommandPayloadPlayer() { + UUID player = UUID.randomUUID(); + channel(CommandPayload.class).to(List.of(SURVIVAL)).requirePlayer() + .send(new CommandPayload("spawn", RunAs.PLAYER, player)); + + assertEquals(player, sent.getFirst().message().requirePlayer()); + } + + @Test + void whenOnlineWithoutArgumentUsesCommandPayloadPlayer() { + UUID player = UUID.randomUUID(); + channel(CommandPayload.class).to(List.of(SURVIVAL)).whenOnline() + .send(new CommandPayload("spawn", RunAs.PLAYER, player)); + + assertEquals(player, sent.getFirst().message().whenOnline()); + assertNull(sent.getFirst().message().requirePlayer()); + } + + @Test + void payloadDerivedConditionFailsWithoutPlayer() { + var sender = channel(CommandPayload.class).to(List.of(SURVIVAL)).requirePlayer(); + + assertThrows(IllegalStateException.class, + () -> sender.send(new CommandPayload("say hi", RunAs.CONSOLE))); + assertTrue(sent.isEmpty()); + } + + @Test + void payloadDerivedConditionFailsForNonCommandPayload() { + var sender = channel(Greeting.class).to(List.of(SURVIVAL)).whenOnline(); + + assertThrows(IllegalStateException.class, () -> sender.send(new Greeting("hi"))); + } + + @Test + void requirePlayerAndWhenOnlineCannotBeCombined() { + UUID player = UUID.randomUUID(); + var single = channel(Greeting.class).to(List.of(SURVIVAL)).requirePlayer(player); + assertThrows(IllegalStateException.class, () -> single.whenOnline(player)); + + var multi = channel(Greeting.class).to(List.of(SURVIVAL, LOBBY)).whenOnline(player); + assertThrows(IllegalStateException.class, multi::requirePlayer); + } + + @Test + void requestUsesDefaultTimeoutAndConvertsResponse() throws Exception { + nextResponse = CompletableFuture.completedFuture(new PluginMessage(Greeting.class.getName(), + Envelope.MAPPER.valueToTree(new Greeting("pong")), false)); + + Greeting reply = channel(Greeting.class).to(List.of(SURVIVAL)).request(new Greeting("ping")) + .get(1, TimeUnit.SECONDS); + + assertEquals(new Greeting("pong"), reply); + Sent s = sent.getFirst(); + assertTrue(s.message().expectsResponse()); + assertEquals(Duration.ofSeconds(15), s.timeout()); + } + + @Test + void requestForwardsCustomTimeoutAndRequirePlayer() { + UUID player = UUID.randomUUID(); + channel(Greeting.class).to(List.of(SURVIVAL)).requirePlayer(player) + .request(new Greeting("ping"), Duration.ofMillis(250)); + + assertEquals(Duration.ofMillis(250), sent.getFirst().timeout()); + assertEquals(player, sent.getFirst().message().requirePlayer()); + } + + @Test + void requestTimeoutPropagatesToCaller() { + var future = channel(Greeting.class).to(List.of(SURVIVAL)).request(new Greeting("ping"), Duration.ofMillis(10)); + nextResponse.completeExceptionally(new TimeoutException()); + + var ex = assertThrows(ExecutionException.class, () -> future.get(1, TimeUnit.SECONDS)); + assertInstanceOf(TimeoutException.class, ex.getCause()); + } + + @Test + void unconvertibleResponseFailsFuture() { + nextResponse = CompletableFuture.completedFuture(new PluginMessage(Greeting.class.getName(), + Envelope.MAPPER.createArrayNode().add(1), false)); + + var future = channel(Greeting.class).to(List.of(SURVIVAL)).request(new Greeting("ping")); + + assertThrows(ExecutionException.class, () -> future.get(1, TimeUnit.SECONDS)); + } + + @Test + void requestWithWhenOnlineIsUnsupported() { + var sender = channel(Greeting.class).to(List.of(SURVIVAL)).whenOnline(UUID.randomUUID()); + + assertThrows(UnsupportedOperationException.class, () -> sender.request(new Greeting("ping"))); + assertTrue(sent.isEmpty()); + } + + @Test + void multiTargetSendReachesEveryTargetOnce() { + channel(Greeting.class).to(List.of(SURVIVAL, LOBBY, SURVIVAL)).send(new Greeting("hi")).join(); + + assertEquals(List.of(SURVIVAL, LOBBY), sent.stream().map(Sent::target).toList()); + assertSame(sent.get(0).message(), sent.get(1).message()); + } + + @Test + void multiTargetRequestIsUnsupported() { + var sender = channel(Greeting.class).to(List.of(SURVIVAL, LOBBY)); + + assertThrows(UnsupportedOperationException.class, () -> sender.request(new Greeting("x"))); + } + + @Test + void broadcastTargetsAllBackends() { + channel(Greeting.class).toAll().send(new Greeting("hi")); + + assertEquals(Platform.backend("*"), sent.getFirst().target()); + } + + @Test + void broadcastRejectsDeliveryConditions() { + var sender = channel(Greeting.class).toAll(); + + assertThrows(UnsupportedOperationException.class, sender::requirePlayer); + assertThrows(UnsupportedOperationException.class, () -> sender.whenOnline(UUID.randomUUID())); + } + + @Test + void emptyTargetListIsRejected() { + assertThrows(IllegalArgumentException.class, () -> channel(Greeting.class).to(List.of())); + } + + @Test + void listenDelegatesToRegistrar() { + List> registered = new ArrayList<>(); + Subscription subscription = () -> { + }; + var channel = new PluginMessageChannel<>(Greeting.class, + (t, m) -> CompletableFuture.completedFuture(null), + (t, m, d) -> new CompletableFuture<>(), + listener -> { + registered.add(listener); + return subscription; + }); + MessageListener listener = (ctx, payload) -> { + }; + + assertSame(subscription, channel.listen(listener)); + assertEquals(List.of(listener), registered); + } +} diff --git a/core/src/test/java/dev/objz/commandbridge/net/payloads/cmd/InvokedCommandTest.java b/core/src/test/java/dev/objz/commandbridge/net/payloads/cmd/InvokedCommandTest.java new file mode 100644 index 00000000..01b6502e --- /dev/null +++ b/core/src/test/java/dev/objz/commandbridge/net/payloads/cmd/InvokedCommandTest.java @@ -0,0 +1,42 @@ +package dev.objz.commandbridge.net.payloads.cmd; + +import dev.objz.commandbridge.cmd.ref.EntityRef; +import dev.objz.commandbridge.cmd.ref.Location2D; +import dev.objz.commandbridge.cmd.ref.Location3D; +import dev.objz.commandbridge.net.proto.Envelope; +import dev.objz.commandbridge.scripting.model.enums.ArgType; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +final class InvokedCommandTest { + @Test + void complexArgumentsKeepTheirTypesAcrossTheWire() throws Exception { + var entity = new EntityRef("PLAYER", "00000000-0000-0000-0000-000000000001", "Alex"); + var invoked = new InvokedCommand("test", List.of( + new InvokedCommand.TypedArgument(ArgType.PLAYERS, List.of(entity)), + new InvokedCommand.TypedArgument(ArgType.ENTITIES, entity), + new InvokedCommand.TypedArgument(ArgType.LOCATION, new Location3D("world", 1, 2, 3)), + new InvokedCommand.TypedArgument(ArgType.LOCATION_2D, new Location2D("world", 4, 5)), + new InvokedCommand.TypedArgument(ArgType.PLAYERS, null)), + new SenderContext.Player("Alex", entity.uuid(), Set.of("commandbridge.command.test"))); + + assertEquals(invoked, Envelope.MAPPER.readValue(Envelope.MAPPER.writeValueAsBytes(invoked), InvokedCommand.class)); + } + + @Test + void legacySenderHasNoAssertedPermissions() throws Exception { + var sender = Envelope.MAPPER.readValue("{\"type\":\"PLAYER\",\"name\":\"Alex\",\"uuid\":\"uuid\"}", SenderContext.class); + assertEquals(new SenderContext.Player("Alex", "uuid", Set.of()), sender); + } + + @Test + void aPeerCannotSelectAnArbitraryJavaType() { + assertThrows(Exception.class, () -> Envelope.MAPPER.readValue( + "{\"type\":\"PLAYERS\",\"value\":{\"@class\":\"java.lang.Runtime\"}}", InvokedCommand.TypedArgument.class)); + } +} diff --git a/core/src/test/java/dev/objz/commandbridge/scripting/ScriptLoaderTest.java b/core/src/test/java/dev/objz/commandbridge/scripting/ScriptLoaderTest.java new file mode 100644 index 00000000..8b60fb4e --- /dev/null +++ b/core/src/test/java/dev/objz/commandbridge/scripting/ScriptLoaderTest.java @@ -0,0 +1,292 @@ +package dev.objz.commandbridge.scripting; + +import dev.objz.commandbridge.TestFixtures; +import dev.objz.commandbridge.api.channel.command.RunAs; +import dev.objz.commandbridge.scripting.model.Script; +import dev.objz.commandbridge.scripting.model.enums.ArgType; +import dev.objz.commandbridge.scripting.model.enums.Location; +import dev.objz.commandbridge.scripting.model.records.mapping.CmdMapping; +import dev.objz.commandbridge.scripting.platform.PlatformFeatureKeys; +import dev.objz.commandbridge.scripting.platform.PlatformFeatureSet; +import dev.objz.commandbridge.scripting.platform.PlatformFeatures; +import dev.objz.commandbridge.scripting.validation.ProblemSink; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +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.assertTrue; + +final class ScriptLoaderTest { + + private static final PlatformFeatures PACKET_EVENTS_ON_VELOCITY = PlatformFeatureSet.builder() + .add(Location.VELOCITY, PlatformFeatureKeys.PACKET_EVENTS) + .build(); + + @BeforeAll + static void setUp() { + TestFixtures.ensureLog(); + } + + private static final String VALID = """ + version: 4 + name: give-item + permissions: + enabled: true + silent: false + register: + - id: proxy-1 + location: VELOCITY + defaults: + run-as: CONSOLE + execute: + - id: survival-1 + location: BACKEND + server: + target-required: true + player-arg: player + delay: 2s + args: + - name: player + required: true + type: STRING + - name: amount + type: INTEGER + commands: + - command: "give ${player} diamond ${amount}" + """; + + private static ScriptLoader.LoadResult