Merge remote-tracking branch 'origin/main' into feat/logging

# Conflicts:
#	proxy/core/src/test/java/dev/moonbridge/app/ProxyConfigurationTest.java
This commit is contained in:
layue13 committed 2026-10-22 16:57:55 +08:00
commit e890645ba5
17 files changed
+316 -62

No files matched your search

@@ -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");
}
}
@@ -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());
@@ -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"));
}
}
@@ -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() { }
}
@@ -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<String, String> 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<String, String> 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<String> 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<String, String> settings(String plugin, Map<String, String> 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);
}
}
}
@@ -172,13 +172,9 @@ public final class ProxyMain {
private static Map<String, String> sessionBindingSecrets(ProxyConfiguration configuration) {
if (configuration.backendChannel() == null) return Map.of();
Map<String, String> 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);
}
@@ -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()));
}
@@ -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);
@@ -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<Boolean> executeCommand(PluginContextImpl caller, CommandSource source, String input) {
synchronized (lock) { return executeCommandLocked(caller, source, input); }
}
private CompletionStage<Boolean> 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<PlayerView> requestedPlayer;
try {
requestedPlayer = Objects.requireNonNull(source.player(), "command source player");
@@ -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());
@@ -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);
@@ -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());
@@ -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);
}
@@ -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));
}
}
@@ -160,6 +160,41 @@ class PermissionCommandTest {
}
}
@Test
void slowPluginSuppliedSourceDoesNotHoldTheHostLock() throws Exception {
var commands = new AtomicReference<Commands>();
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<PlayerView> 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<Commands>();
@@ -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());
}
}
@@ -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");