diff --git a/backend/src/main/java/dev/moonbridge/backendchannel/BackendChannelClient.java b/backend/src/main/java/dev/moonbridge/backendchannel/BackendChannelClient.java index 729fb3a..b4ea23a 100644 --- a/backend/src/main/java/dev/moonbridge/backendchannel/BackendChannelClient.java +++ b/backend/src/main/java/dev/moonbridge/backendchannel/BackendChannelClient.java @@ -8,6 +8,7 @@ import dev.moonbridge.messaging.MessagingException; import dev.moonbridge.messaging.PublishResult; import dev.moonbridge.messaging.SendResult; import dev.moonbridge.messaging.internal.LocalMessaging; +import dev.moonbridge.messaging.protocol.ControlHandshakeLimits; import dev.moonbridge.messaging.protocol.MessageCodec; import static dev.moonbridge.backendchannel.Failures.*; @@ -35,6 +36,8 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import java.util.function.Supplier; +import java.util.logging.Level; +import java.util.logging.Logger; /** Java 8-compatible reconnecting transport with process-local messaging scopes. */ public final class BackendChannelClient implements AutoCloseable, LocalMessaging.Outbound { @@ -43,6 +46,7 @@ public final class BackendChannelClient implements AutoCloseable, LocalMessaging public static final int MAX_WRITER_QUEUE = 128; public static final long MAX_WRITER_QUEUE_BYTES = 1024L * 1024L; + private static final Logger LOGGER = Logger.getLogger(BackendChannelClient.class.getName()); private static final long DEFAULT_MESSAGE_TIMEOUT_MILLIS = 5000L; private static final long CLOSE_FLUSH_MILLIS = 100L; private static final long WRITE_COMPLETION_TIMEOUT_MILLIS = 5000L; @@ -64,6 +68,19 @@ public final class BackendChannelClient implements AutoCloseable, LocalMessaging private volatile long epoch; private volatile UUID proxyEpoch = new UUID(0L, 0L); private final Thread connectionThread; + /** Where the last reported reconnect failure happened; only the connection thread touches it. */ + private ConnectStage lastReportedFailure; + + /** Fixed failure categories: the log never carries peer-controlled protocol details. */ + private enum ConnectStage { + CONNECT("Cannot reach the MoonBridge proxy control port; retrying"), + REGISTER("MoonBridge proxy did not accept this backend's registration; check that instanceId, backendName, " + + "keyId and secret match backendChannel.clients on the proxy and that both run the same version"), + CONNECTED("Lost the connection to the MoonBridge proxy; reconnecting"); + + final String message; + ConnectStage(String message) { this.message = message; } + } public BackendChannelClient(String host, int port, String instanceId, String backendName, String gameAddress, String generation, String keyId, byte[] secret) { @@ -80,11 +97,11 @@ public final class BackendChannelClient implements AutoCloseable, LocalMessaging if (heartbeatMillis < 1000L || reconnectMinMillis < 1L || reconnectMaxMillis < reconnectMinMillis) { throw new IllegalArgumentException("invalid timing configuration"); } - validateIdentity(instanceId, "instanceId"); - validateIdentity(backendName, "backendName"); - validateIdentity(gameAddress, "gameAddress"); - validateIdentity(generation, "generation"); - validateIdentity(keyId, "keyId"); + validateIdentity(instanceId, "instanceId", ControlHandshakeLimits.INSTANCE_ID_BYTES); + validateIdentity(backendName, "backendName", ControlHandshakeLimits.BACKEND_NAME_BYTES); + validateIdentity(gameAddress, "gameAddress", ControlHandshakeLimits.GAME_ADDRESS_BYTES); + validateIdentity(generation, "generation", ControlHandshakeLimits.GENERATION_BYTES); + validateIdentity(keyId, "keyId", ControlHandshakeLimits.KEY_ID_BYTES); this.host = host; this.port = port; this.instanceId = instanceId; @@ -283,6 +300,7 @@ public final class BackendChannelClient implements AutoCloseable, LocalMessaging long pause = reconnectMinMillis; while (!closed.get()) { ClientSession active = null; + ConnectStage stage = ConnectStage.CONNECT; try { Socket socket = new Socket(); active = new ClientSession(socket, timer, instanceId); @@ -292,14 +310,17 @@ public final class BackendChannelClient implements AutoCloseable, LocalMessaging } if (closed.get()) { active.close(new IOException("client closed")); break; } socket.connect(new InetSocketAddress(host, port), 5000); + stage = ConnectStage.REGISTER; active.initialize(); runSession(active); pause = reconnectMinMillis; + reportFailure(ConnectStage.CONNECTED); } catch (InterruptedException interrupted) { if (closed.get()) break; Thread.currentThread().interrupt(); - } catch (Exception ignored) { - // Avoid logging peer-controlled protocol details from this transport layer. + } catch (Exception failure) { + // Report only the stage: the exception may carry peer-controlled protocol details. + if (!closed.get()) reportFailure(registered ? ConnectStage.CONNECTED : stage); } finally { if (active != null) { session.compareAndSet(active, null); @@ -317,6 +338,13 @@ public final class BackendChannelClient implements AutoCloseable, LocalMessaging } } + /** Logs each failure category once until the next successful registration, so retries do not flood the log. */ + private void reportFailure(ConnectStage stage) { + if (closed.get() || stage == lastReportedFailure) return; + lastReportedFailure = stage; + LOGGER.log(stage == ConnectStage.CONNECTED ? Level.INFO : Level.WARNING, stage.message); + } + private void runSession(final ClientSession active) throws Exception { active.socket.setSoTimeout(5000); Wire.Hello hello = Wire.decodeHello(FrameCodec.read(active.in)); @@ -359,6 +387,8 @@ public final class BackendChannelClient implements AutoCloseable, LocalMessaging registered = true; stateMonitor.notifyAll(); } + lastReportedFailure = null; + LOGGER.info("Registered with the MoonBridge proxy as backend " + backendName); while (!active.isClosed()) { byte[] frame = FrameCodec.read(active.in); inbound.handleFrame(active, frame); @@ -384,9 +414,9 @@ public final class BackendChannelClient implements AutoCloseable, LocalMessaging return Math.max(15_000L, triple); } - private static void validateIdentity(String value, String label) { - if (value == null || value.trim().isEmpty() || value.getBytes(StandardCharsets.UTF_8).length > Wire.MAX_STRING_BYTES) { - throw new IllegalArgumentException(label + " is empty or too long"); + private static void validateIdentity(String value, String label, int maxBytes) { + if (value == null || value.trim().isEmpty() || value.getBytes(StandardCharsets.UTF_8).length > maxBytes) { + throw new IllegalArgumentException(label + " is empty or longer than " + maxBytes + " UTF-8 bytes"); } } diff --git a/backend/src/main/java/dev/moonbridge/bukkit/MoonBridgeBackendPlugin.java b/backend/src/main/java/dev/moonbridge/bukkit/MoonBridgeBackendPlugin.java index bfebaac..d61fec3 100644 --- a/backend/src/main/java/dev/moonbridge/bukkit/MoonBridgeBackendPlugin.java +++ b/backend/src/main/java/dev/moonbridge/bukkit/MoonBridgeBackendPlugin.java @@ -44,6 +44,10 @@ public final class MoonBridgeBackendPlugin extends JavaPlugin implements Listene getServer().getPluginManager().registerEvents(this, this); getServer().getPluginManager().registerEvents(sessions, this); getLogger().info("Shared MoonBridge messaging service started; registration runs asynchronously"); + } catch (IllegalArgumentException invalid) { + // The configuration checks above and in BackendChannelClient name the key, never its value. + getLogger().severe("Cannot start MoonBridge messaging: " + invalid.getMessage()); + getServer().getPluginManager().disablePlugin(this); } catch (RuntimeException failure) { // Do not include configuration values or authentication peer data. getLogger().severe("Cannot start MoonBridge messaging: " + failure.getClass().getSimpleName()); diff --git a/backend/src/test/java/dev/moonbridge/backendchannel/BackendChannelClientTest.java b/backend/src/test/java/dev/moonbridge/backendchannel/BackendChannelClientTest.java index 38f9afb..91f95b0 100644 --- a/backend/src/test/java/dev/moonbridge/backendchannel/BackendChannelClientTest.java +++ b/backend/src/test/java/dev/moonbridge/backendchannel/BackendChannelClientTest.java @@ -4,6 +4,7 @@ import dev.moonbridge.messaging.Endpoint; import dev.moonbridge.messaging.Message; import dev.moonbridge.messaging.MessageKind; import dev.moonbridge.messaging.MessageChannel; +import dev.moonbridge.messaging.protocol.ControlHandshakeLimits; import dev.moonbridge.messaging.protocol.MessageCodec; import org.junit.jupiter.api.Test; @@ -297,4 +298,15 @@ class BackendChannelClientTest { } peer.get(10, TimeUnit.SECONDS); } + + @Test + void identityLongerThanTheProxyAcceptsIsRejectedAtConstruction() { + byte[] secret = new byte[32]; + char[] longId = new char[ControlHandshakeLimits.INSTANCE_ID_BYTES + 1]; + Arrays.fill(longId, 'a'); + IllegalArgumentException rejected = assertThrows(IllegalArgumentException.class, + () -> new BackendChannelClient("127.0.0.1", 28081, new String(longId), "island-a", + "tcp://127.0.0.1:25565", UUID.randomUUID().toString(), "key-1", secret)); + assertTrue(rejected.getMessage().startsWith("instanceId")); + } } diff --git a/messaging/src/main/java/dev/moonbridge/messaging/protocol/ControlHandshakeLimits.java b/messaging/src/main/java/dev/moonbridge/messaging/protocol/ControlHandshakeLimits.java new file mode 100644 index 0000000..81d72ed --- /dev/null +++ b/messaging/src/main/java/dev/moonbridge/messaging/protocol/ControlHandshakeLimits.java @@ -0,0 +1,16 @@ +package dev.moonbridge.messaging.protocol; + +/** + * UTF-8 byte limits for the identity fields of a backend control-channel registration. The backend validates its + * configuration against the same limits the proxy enforces when it reads the frame, so a registration the proxy + * would reject is refused at backend start-up instead of failing on every reconnect. + */ +public final class ControlHandshakeLimits { + public static final int INSTANCE_ID_BYTES = 64; + public static final int BACKEND_NAME_BYTES = 128; + public static final int GAME_ADDRESS_BYTES = 1024; + public static final int GENERATION_BYTES = 64; + public static final int KEY_ID_BYTES = 64; + + private ControlHandshakeLimits() { } +} diff --git a/proxy/core/src/main/java/dev/moonbridge/app/ProxyConfiguration.java b/proxy/core/src/main/java/dev/moonbridge/app/ProxyConfiguration.java index 428828b..db82ed5 100644 --- a/proxy/core/src/main/java/dev/moonbridge/app/ProxyConfiguration.java +++ b/proxy/core/src/main/java/dev/moonbridge/app/ProxyConfiguration.java @@ -3,6 +3,7 @@ package dev.moonbridge.app; import io.netty.util.NetUtil; import java.net.InetAddress; import java.net.InetSocketAddress; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; @@ -148,7 +149,7 @@ public record ProxyConfiguration(String listen, Authentication authentication, L } } - public InetSocketAddress listenAddress() { return parseAddress(listen); } + public InetSocketAddress listenAddress() { return resolve(parseAddress(listen)); } } /** PEM certificate chain and unencrypted PKCS#8 private key, relative to the configuration file. */ @@ -178,7 +179,7 @@ public record ProxyConfiguration(String listen, Authentication authentication, L } public InetSocketAddress listenAddress() { - return parseAddress(listen); + return resolve(parseAddress(listen)); } private static boolean isLiteralLoopback(String listen) { @@ -194,6 +195,10 @@ public record ProxyConfiguration(String listen, Authentication authentication, L } } + /** + * Parses {@code host:port} without resolving the host: loading or validating a configuration never blocks on DNS. + * Listeners resolve their address with {@link #resolve} when they bind. + */ public static InetSocketAddress parseAddress(String text) { if (text == null || text.isBlank()) { throw new IllegalArgumentException("address is required"); @@ -218,7 +223,11 @@ public record ProxyConfiguration(String listen, Authentication authentication, L if (host.isBlank() || port < 0 || port > 65535) { throw new IllegalArgumentException("invalid address: " + text); } - return new InetSocketAddress(host, port); + return InetSocketAddress.createUnresolved(host, port); + } + + private static InetSocketAddress resolve(InetSocketAddress address) { + return new InetSocketAddress(address.getHostString(), address.getPort()); } public record Backend(String name, String address, Map tags) { @@ -230,11 +239,22 @@ public record ProxyConfiguration(String listen, Authentication authentication, L if (parseAddress(address).getPort() == 0) { throw new IllegalArgumentException("backend port must be positive"); } + uri(address); tags = tags == null ? Map.of() : Map.copyOf(tags); } - public InetSocketAddress socketAddress() { - return parseAddress(address); + /** The catalog address, checked here so that config validation rejects what registration would reject. */ + public URI uri() { return uri(address); } + + private static URI uri(String address) { + URI uri; + try { + uri = new URI("tcp://" + address.trim()); + } catch (java.net.URISyntaxException invalid) { + throw new IllegalArgumentException("invalid backend address: " + address, invalid); + } + if (uri.getHost() == null) throw new IllegalArgumentException("invalid backend host: " + address); + return uri; } } @@ -243,14 +263,19 @@ public record ProxyConfiguration(String listen, Authentication authentication, L Integer maxConnections) { public BackendChannel { if (listen == null || listen.isBlank()) throw new IllegalArgumentException("backendChannel.listen is required"); - parseAddress(listen); if (parseAddress(listen).getPort() == 0) throw new IllegalArgumentException("backendChannel.listen port must be positive"); clients = clients == null ? Map.of() : Map.copyOf(clients); if (clients.isEmpty()) throw new IllegalArgumentException("backendChannel.clients must not be empty"); - for (String instanceId : clients.keySet()) { - if (!instanceId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}")) - throw new IllegalArgumentException("invalid backendChannel client id: " + instanceId); + Map secretByBackend = new java.util.HashMap<>(); + for (var client : clients.entrySet()) { + if (!client.getKey().matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}")) + throw new IllegalArgumentException("invalid backendChannel client id: " + client.getKey()); + // Players' session bindings are signed per backend name, so every instance of it needs one secret. + String previous = secretByBackend.putIfAbsent(client.getValue().backendName(), client.getValue().secret()); + if (previous != null && !previous.equals(client.getValue().secret())) + throw new IllegalArgumentException("backendChannel has conflicting secrets for backend " + + client.getValue().backendName()); } leaseSeconds = leaseSeconds == null ? 30 : leaseSeconds; if (leaseSeconds < 5 || leaseSeconds > 3600) @@ -260,7 +285,7 @@ public record ProxyConfiguration(String listen, Authentication authentication, L throw new IllegalArgumentException("backendChannel.maxConnections must be between 1 and 4096"); } - public InetSocketAddress listenAddress() { return parseAddress(listen); } + public InetSocketAddress listenAddress() { return resolve(parseAddress(listen)); } } public record Client(String backendName, String keyId, String secret, Set allowedHosts, @@ -310,7 +335,17 @@ public record ProxyConfiguration(String listen, Authentication authentication, L } return entry.getKey(); }, - entry -> Map.copyOf(entry.getValue()))); + entry -> settings(entry.getKey(), entry.getValue()))); + } + + /** A plugin listed without settings ({@code com.example.Plugin:}) is enabled with none. */ + private static Map settings(String plugin, Map settings) { + if (settings == null) return Map.of(); + settings.forEach((key, value) -> { + if (key == null || value == null) + throw new IllegalArgumentException("plugin " + plugin + " has a setting without a value"); + }); + return Map.copyOf(settings); } } } diff --git a/proxy/core/src/main/java/dev/moonbridge/app/ProxyMain.java b/proxy/core/src/main/java/dev/moonbridge/app/ProxyMain.java index e02f70e..b39cffe 100644 --- a/proxy/core/src/main/java/dev/moonbridge/app/ProxyMain.java +++ b/proxy/core/src/main/java/dev/moonbridge/app/ProxyMain.java @@ -172,13 +172,9 @@ public final class ProxyMain { private static Map sessionBindingSecrets(ProxyConfiguration configuration) { if (configuration.backendChannel() == null) return Map.of(); Map secrets = new HashMap<>(); - configuration.backendChannel().clients().values().forEach(client -> { - String previous = secrets.putIfAbsent(client.backendName(), client.secret()); - if (previous != null && !previous.equals(client.secret())) { - throw new IllegalArgumentException("backendChannel has conflicting secrets for backend " - + client.backendName()); - } - }); + // BackendChannel already rejects two secrets for one backend name. + configuration.backendChannel().clients().values() + .forEach(client -> secrets.putIfAbsent(client.backendName(), client.secret())); return Map.copyOf(secrets); } diff --git a/proxy/core/src/main/java/dev/moonbridge/app/StaticBackends.java b/proxy/core/src/main/java/dev/moonbridge/app/StaticBackends.java index 92daf59..5fbc690 100644 --- a/proxy/core/src/main/java/dev/moonbridge/app/StaticBackends.java +++ b/proxy/core/src/main/java/dev/moonbridge/app/StaticBackends.java @@ -5,7 +5,6 @@ import dev.moonbridge.core.backend.BackendId; import dev.moonbridge.core.backend.BackendOwner; import dev.moonbridge.core.backend.BackendRegistration; -import java.net.URI; import java.util.Objects; /** Loads configured servers through the same catalog used by registration plugins. */ @@ -22,7 +21,7 @@ public final class StaticBackends { catalog.register(new BackendRegistration( new BackendId(backend.name()), OWNER, - URI.create("tcp://" + backend.address().trim()), + backend.uri(), backend.tags(), java.util.Map.of())); } diff --git a/proxy/core/src/main/java/dev/moonbridge/core/control/RegistrationVerifier.java b/proxy/core/src/main/java/dev/moonbridge/core/control/RegistrationVerifier.java index 5abb462..dd41226 100644 --- a/proxy/core/src/main/java/dev/moonbridge/core/control/RegistrationVerifier.java +++ b/proxy/core/src/main/java/dev/moonbridge/core/control/RegistrationVerifier.java @@ -4,6 +4,7 @@ import dev.moonbridge.app.ProxyConfiguration; import dev.moonbridge.core.backend.BackendId; import dev.moonbridge.core.backend.BackendOwner; import dev.moonbridge.core.backend.BackendRegistration; +import dev.moonbridge.messaging.protocol.ControlHandshakeLimits; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; @@ -30,11 +31,11 @@ final class RegistrationVerifier { RegistrationVerifier(ProxyConfiguration.BackendChannel configuration) { this.configuration = configuration; } Registration parse(DataInputStream input, byte[] nonce) throws Exception { - String instanceId = readString(input, 64); - String name = readString(input, 128); - String address = readString(input, 1024); - String generation = readString(input, 64); - String keyId = readString(input, 64); + String instanceId = readString(input, ControlHandshakeLimits.INSTANCE_ID_BYTES); + String name = readString(input, ControlHandshakeLimits.BACKEND_NAME_BYTES); + String address = readString(input, ControlHandshakeLimits.GAME_ADDRESS_BYTES); + String generation = readString(input, ControlHandshakeLimits.GENERATION_BYTES); + String keyId = readString(input, ControlHandshakeLimits.KEY_ID_BYTES); byte[] signature = input.readNBytes(32); if (signature.length != 32) throw new EOFException(); requireEmpty(input); diff --git a/proxy/core/src/main/java/dev/moonbridge/core/plugin/CommandService.java b/proxy/core/src/main/java/dev/moonbridge/core/plugin/CommandService.java index ad7279a..f5dcba5 100644 --- a/proxy/core/src/main/java/dev/moonbridge/core/plugin/CommandService.java +++ b/proxy/core/src/main/java/dev/moonbridge/core/plugin/CommandService.java @@ -430,26 +430,28 @@ final class CommandService { permissionService.check(player.identity(), node) == PermissionResult.ALLOW).orElse(true); } + /** + * Runs a command on behalf of a plugin. {@code source} is plugin-supplied, so its {@code player()} and + * {@code reply()} run without the host lock: a slow implementation must not stall host shutdown or other + * commands. Only the registration lookup is locked; the worker re-checks it before invoking the handler. + */ CompletionStage executeCommand(PluginContextImpl caller, CommandSource source, String input) { - synchronized (lock) { return executeCommandLocked(caller, source, input); } - } - - private CompletionStage executeCommandLocked(PluginContextImpl caller, CommandSource source, - String input) { Objects.requireNonNull(source, "source"); Objects.requireNonNull(input, "command"); - synchronized (caller) { caller.requireActive(); } - if (!lifecycle.enabled()) { - return CompletableFuture.failedFuture(new IllegalStateException("Plugin host is not enabled")); - } String message = input.startsWith("/") ? input.substring(1) : input; - if (message.isEmpty()) return CompletableFuture.completedFuture(false); int end = 0; while (end < message.length() && !Character.isWhitespace(message.charAt(end))) end++; - if (end == 0) return CompletableFuture.completedFuture(false); String name = message.substring(0, end).toLowerCase(Locale.ROOT); - RegisteredCommand registered = commands.get(name); - if (registered == null || !registered.context.active) return CompletableFuture.completedFuture(false); + RegisteredCommand registered; + synchronized (lock) { + synchronized (caller) { caller.requireActive(); } + if (!lifecycle.enabled()) { + return CompletableFuture.failedFuture(new IllegalStateException("Plugin host is not enabled")); + } + if (end == 0) return CompletableFuture.completedFuture(false); + registered = commands.get(name); + if (registered == null || !registered.context.active) return CompletableFuture.completedFuture(false); + } final Optional requestedPlayer; try { requestedPlayer = Objects.requireNonNull(source.player(), "command source player"); diff --git a/proxy/core/src/main/java/dev/moonbridge/core/session/Session.java b/proxy/core/src/main/java/dev/moonbridge/core/session/Session.java index 79f5fba..74e3c4b 100644 --- a/proxy/core/src/main/java/dev/moonbridge/core/session/Session.java +++ b/proxy/core/src/main/java/dev/moonbridge/core/session/Session.java @@ -21,7 +21,6 @@ import dev.moonbridge.api.MessageResult; import dev.moonbridge.api.DisconnectResult; import dev.moonbridge.api.TransferResult; import dev.moonbridge.core.backend.BackendView; -import dev.moonbridge.core.protocol.LoginStart; import dev.moonbridge.core.protocol.MinecraftLoginSuccess; import dev.moonbridge.core.protocol.ProtocolProfile; import dev.moonbridge.core.protocol.ProtocolVarInt; @@ -255,19 +254,15 @@ final class Session extends ChannelInboundHandlerAdapter { backend.config().setAutoRead(true); LOGGER.debug("Initial backend TCP connection established for player {} to {}", view.username(), selected.address()); - ByteBuf handshakeBody = null; - ByteBuf loginBody; + ByteBuf handshakeBody; try { handshakeBody = encodeBackendHandshake( selected.handle().id().value(), selected.owner().instanceGeneration()); - loginBody = new LoginStart(view.username()).encode(frontend.alloc(), ProtocolProfile.minecraft1710()); } catch (RuntimeException failure) { - ReferenceCountUtil.release(handshakeBody); disconnectLogin("Could not prepare backend login."); return; } - backend.write(handshakeBody); - backend.writeAndFlush(loginBody).addListener(write -> { + SessionChannels.writeBackendLogin(backend, handshakeBody, view.username()).addListener(write -> { if (write.isSuccess()) { LOGGER.debug("Initial backend login frames flushed for player {} to {}", view.username(), selected.address()); diff --git a/proxy/core/src/main/java/dev/moonbridge/core/session/channel/SessionChannels.java b/proxy/core/src/main/java/dev/moonbridge/core/session/channel/SessionChannels.java index a47be5f..cc534d3 100644 --- a/proxy/core/src/main/java/dev/moonbridge/core/session/channel/SessionChannels.java +++ b/proxy/core/src/main/java/dev/moonbridge/core/session/channel/SessionChannels.java @@ -1,9 +1,12 @@ package dev.moonbridge.core.session.channel; +import dev.moonbridge.core.protocol.LoginStart; import dev.moonbridge.core.protocol.MinecraftFrameDecoder; import dev.moonbridge.core.protocol.ProtocolProfile; import io.netty.bootstrap.Bootstrap; +import io.netty.buffer.ByteBuf; import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelOption; import io.netty.channel.ChannelPipeline; @@ -27,6 +30,22 @@ public final class SessionChannels { private SessionChannels() { } + /** + * Writes the backend handshake and Login Start, which takes ownership of {@code handshake}. The handshake is + * queued first, so if Login Start cannot be encoded, closing the channel releases it and the future fails. + */ + public static ChannelFuture writeBackendLogin(Channel backend, ByteBuf handshake, String username) { + backend.write(handshake); + ByteBuf login; + try { + login = new LoginStart(username).encode(backend.alloc(), ProtocolProfile.minecraft1710()); + } catch (RuntimeException failure) { + backend.close(); + return backend.newFailedFuture(failure); + } + return backend.writeAndFlush(login); + } + /** Records the player address a front proxy reported, for a frontend that is not a direct TCP peer. */ public static void setClientAddress(Channel frontend, InetSocketAddress address) { frontend.attr(CLIENT_ADDRESS).set(address); diff --git a/proxy/core/src/main/java/dev/moonbridge/core/session/transfer/TransferCoordinator.java b/proxy/core/src/main/java/dev/moonbridge/core/session/transfer/TransferCoordinator.java index 560a27c..1d21056 100644 --- a/proxy/core/src/main/java/dev/moonbridge/core/session/transfer/TransferCoordinator.java +++ b/proxy/core/src/main/java/dev/moonbridge/core/session/transfer/TransferCoordinator.java @@ -9,8 +9,6 @@ import dev.moonbridge.core.backend.BackendId; import dev.moonbridge.core.backend.BackendView; import dev.moonbridge.core.event.PreparedTransfer; import dev.moonbridge.core.protocol.ByteBufs; -import dev.moonbridge.core.protocol.LoginStart; -import dev.moonbridge.core.protocol.ProtocolProfile; import dev.moonbridge.core.protocol.Minecraft1710PlayPackets; import dev.moonbridge.core.session.play.RawRelay; import dev.moonbridge.core.session.channel.SessionChannels; @@ -285,11 +283,15 @@ public final class TransferCoordinator { private void writeBackendLogin(Channel channel) { transfer.candidate.loginStarted(); - ByteBuf handshakeBody = host.backendHandshake( - transfer.target.handle().id().value(), transfer.target.owner().instanceGeneration()); - ByteBuf loginBody = new LoginStart(host.view().username()).encode(frontend.alloc(), ProtocolProfile.minecraft1710()); - channel.write(handshakeBody); - channel.writeAndFlush(loginBody).addListener(write -> { + ByteBuf handshakeBody; + try { + handshakeBody = host.backendHandshake( + transfer.target.handle().id().value(), transfer.target.owner().instanceGeneration()); + } catch (RuntimeException failure) { + failTransfer(transfer, "could not prepare replacement backend login"); + return; + } + SessionChannels.writeBackendLogin(channel, handshakeBody, host.view().username()).addListener(write -> { if (write.isSuccess()) { LOGGER.debug("Replacement backend login frames flushed for player {} to {}", host.view().username(), channel.remoteAddress()); diff --git a/proxy/core/src/main/java/dev/moonbridge/core/websocket/ClientAddressPolicy.java b/proxy/core/src/main/java/dev/moonbridge/core/websocket/ClientAddressPolicy.java index fa6cc41..5225343 100644 --- a/proxy/core/src/main/java/dev/moonbridge/core/websocket/ClientAddressPolicy.java +++ b/proxy/core/src/main/java/dev/moonbridge/core/websocket/ClientAddressPolicy.java @@ -95,6 +95,12 @@ public final class ClientAddressPolicy { throw new IllegalArgumentException("invalid trusted proxy: " + text, invalid); } if (prefix < 0 || prefix > network.length * 8) throw new IllegalArgumentException("invalid trusted proxy: " + text); + byte[] unmapped = unmapped(network); + if (unmapped != network) { + // contains() compares mapped peers as IPv4, so an IPv4-mapped block has to be stored as IPv4 too. + if (prefix < 96) throw new IllegalArgumentException("invalid trusted proxy: " + text); + return new Cidr(unmapped, prefix - 96); + } return new Cidr(network, prefix); } diff --git a/proxy/core/src/test/java/dev/moonbridge/app/ProxyConfigurationTest.java b/proxy/core/src/test/java/dev/moonbridge/app/ProxyConfigurationTest.java index ce822ec..ae64458 100644 --- a/proxy/core/src/test/java/dev/moonbridge/app/ProxyConfigurationTest.java +++ b/proxy/core/src/test/java/dev/moonbridge/app/ProxyConfigurationTest.java @@ -383,6 +383,26 @@ final class ProxyConfigurationTest { ImageIO.write(image, "PNG", destination.toFile()); } + @Test + void pluginListedWithoutSettingsIsEnabledWithNone() throws Exception { + var config = Files.createTempFile("moonbridge-plugin-no-settings", ".yml"); + try { + Files.writeString(config, """ + listen: "127.0.0.1:25577" + authentication: OFFLINE + backends: [] + plugins: + directory: "extensions" + enabled: + dev.example.PlainPlugin: + """); + var enabled = new ProxyConfigurationLoader().load(config).plugins().enabled(); + assertEquals(java.util.Map.of(), enabled.get("dev.example.PlainPlugin")); + } finally { + Files.deleteIfExists(config); + } + } + @Test void playerAddressesAreLoggedUnlessTurnedOff() throws Exception { var config = Files.createTempFile("moonbridge-logging", ".yml"); @@ -396,4 +416,40 @@ final class ProxyConfigurationTest { Files.deleteIfExists(config); } } + + @Test + void validateConfigRejectsConflictingSecretsForOneBackendName() throws Exception { + var config = Files.createTempFile("moonbridge-conflicting-secrets", ".yml"); + try { + String client = """ + %s: + backendName: island-a + keyId: primary + secret: "%s" + allowedHosts: ["127.0.0.1"] + """; + Files.writeString(config, """ + listen: "127.0.0.1:25577" + authentication: OFFLINE + backends: [] + backendChannel: + listen: "127.0.0.1:28081" + clients: + """ + client.formatted("island-a-1", "first-secret-at-least-thirty-two-bytes") + + client.formatted("island-a-2", "other-secret-at-least-thirty-two-bytes")); + assertEquals(1, ProxyMain.run(new String[]{"--validate-config", config.toString()})); + } finally { + Files.deleteIfExists(config); + } + } + + @Test + void backendAddressesAreCheckedWithoutDnsAndAsTheCatalogWillReadThem() { + assertTrue(ProxyConfiguration.parseAddress("lobby.example.invalid:25565").isUnresolved()); + var backend = new ProxyConfiguration.Backend("lobby", "lobby.example.invalid:25565", null); + assertEquals("lobby.example.invalid", backend.uri().getHost()); + // URI cannot carry an underscore host, so the catalog would reject it: config validation has to as well. + assertThrows(IllegalArgumentException.class, + () -> new ProxyConfiguration.Backend("lobby", "lobby_a:25565", null)); + } } diff --git a/proxy/core/src/test/java/dev/moonbridge/core/plugin/PermissionCommandTest.java b/proxy/core/src/test/java/dev/moonbridge/core/plugin/PermissionCommandTest.java index 7c47596..6e6c14c 100644 --- a/proxy/core/src/test/java/dev/moonbridge/core/plugin/PermissionCommandTest.java +++ b/proxy/core/src/test/java/dev/moonbridge/core/plugin/PermissionCommandTest.java @@ -160,6 +160,41 @@ class PermissionCommandTest { } } + @Test + void slowPluginSuppliedSourceDoesNotHoldTheHostLock() throws Exception { + var commands = new AtomicReference(); + var plugin = new PermissionPlugin(context -> { + commands.set(context.commands()); + context.commands().register("staff", "moonbridge.staff", invocation -> { }); + }, node -> PermissionDecision.ALLOW); + try (var host = new PluginHost(new InMemoryBackendCatalog(), players(), Duration.ofSeconds(1))) { + host.load(List.of(plugin)); + host.enable(); + var inPlayer = new CountDownLatch(1); + var release = new CountDownLatch(1); + CommandSource slow = new CommandSource() { + @Override public Optional player() { + inPlayer.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + return Optional.empty(); + } + @Override public void reply(Component message) { } + }; + var blocked = CompletableFuture.supplyAsync(() -> commands.get().execute(slow, "staff")); + assertTrue(inPlayer.await(2, TimeUnit.SECONDS)); + // Every command lookup takes the host lock, so this would wait for the slow source if it held it. + var other = CompletableFuture.supplyAsync(() -> commands.get() + .execute(CommandSource.console(ignored -> { }), "missing").toCompletableFuture().join()); + assertFalse(other.get(2, TimeUnit.SECONDS)); + release.countDown(); + blocked.get(2, TimeUnit.SECONDS).toCompletableFuture().get(2, TimeUnit.SECONDS); + } + } + @Test void apiDispatchResolvesExactLivePlayerAndExpiresWithPluginContext() throws Exception { var commands = new AtomicReference(); diff --git a/proxy/core/src/test/java/dev/moonbridge/core/session/channel/SessionChannelsTest.java b/proxy/core/src/test/java/dev/moonbridge/core/session/channel/SessionChannelsTest.java new file mode 100644 index 0000000..497f3fd --- /dev/null +++ b/proxy/core/src/test/java/dev/moonbridge/core/session/channel/SessionChannelsTest.java @@ -0,0 +1,37 @@ +package dev.moonbridge.core.session.channel; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +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; + +class SessionChannelsTest { + @Test + void writesHandshakeThenLoginStart() { + var backend = new EmbeddedChannel(); + ByteBuf handshake = Unpooled.buffer().writeByte(7); + assertTrue(SessionChannels.writeBackendLogin(backend, handshake, "Tester").isSuccess()); + ByteBuf first = backend.readOutbound(); + ByteBuf second = backend.readOutbound(); + assertEquals(7, first.getByte(0)); + assertEquals(0, second.getByte(0), "Login Start packet id"); + first.release(); + second.release(); + backend.finishAndReleaseAll(); + } + + @Test + void unencodableLoginStartReleasesTheQueuedHandshake() { + var backend = new EmbeddedChannel(); + ByteBuf handshake = Unpooled.buffer().writeByte(7); + // Longer than the 1.7.10 login name limit, so Login Start cannot be encoded. + var write = SessionChannels.writeBackendLogin(backend, handshake, "x".repeat(64)); + assertFalse(write.isSuccess()); + assertFalse(backend.isOpen()); + assertEquals(0, handshake.refCnt()); + } +} diff --git a/proxy/core/src/test/java/dev/moonbridge/core/websocket/ClientAddressPolicyTest.java b/proxy/core/src/test/java/dev/moonbridge/core/websocket/ClientAddressPolicyTest.java index 93a9353..077bf2b 100644 --- a/proxy/core/src/test/java/dev/moonbridge/core/websocket/ClientAddressPolicyTest.java +++ b/proxy/core/src/test/java/dev/moonbridge/core/websocket/ClientAddressPolicyTest.java @@ -59,6 +59,15 @@ final class ClientAddressPolicyTest { assertEquals("198.51.100.2", host(policy.resolve(mapped, headers("X-Real-IP", "198.51.100.2")))); } + @Test + void ipv4MappedTrustedProxiesMatchIpv4Peers() { + var single = new ClientAddressPolicy(List.of("::ffff:173.245.48.10"), "X-Real-IP"); + assertEquals("198.51.100.3", host(single.resolve(EDGE, headers("X-Real-IP", "198.51.100.3")))); + var block = new ClientAddressPolicy(List.of("::ffff:173.245.48.0/116"), "X-Real-IP"); + assertEquals("198.51.100.4", host(block.resolve(EDGE, headers("X-Real-IP", "198.51.100.4")))); + assertThrows(IllegalArgumentException.class, () -> new ClientAddressPolicy(List.of("::ffff:0:0/80"), "X-Real-IP")); + } + @Test void malformedValuesFromATrustedPeerAreRejected() { var policy = new ClientAddressPolicy(List.of("173.245.48.0/20"), "CF-Connecting-IP");