mirror of
https://github.com/layue13/MoonBridge.git
synced 2026-10-25 18:46:18 +00:00
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:
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
-7
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user