Merge pull request #13 from layue13/feat/admin-console

feat: 管理控制台(JLine)与内置命令
This commit is contained in:
layue13 authored and GitHub committed 2026-10-23 15:50:18 +08:00
commit 6224d3deb4
27 files changed
+1044 -69

No files matched your search

+2
View File
@@ -61,6 +61,8 @@ testing/smoke/ smoke scripts and dated records; testing/benchmarks/ likewis
**Operator logs.** docs/logging.md is the policy: INFO is one line per event an operator asks about, WARN is something to act on, protocol steps stay at DEBUG. Player and server events go through `core.log.ConnectionLog` (loggers `moonbridge.players` / `moonbridge.servers`), which cleans peer text with `LogText` and honours `logging.playerAddresses`; floods use `ThrottledWarning`. Sessions record why they ended with `endBecause` (first reason wins) and log it in `closePair`.
**Admin console.** `app.console.Console` reads the console with JLine in a terminal (prompt kept at the bottom by `ConsoleLogAppender`, which logback.xml uses for console output) or plain stdin otherwise, and sends each line to `BuiltinCommands` before plugin console commands. Commands read `core.admin.AdminView`, immutable snapshots built from `ProxySessionListener.onlinePlayers()`, the catalog and `ConnectionLog.activity()` (ten one-minute `RecentCounter` buckets), and act through `AdminActions`; never read session state from the console thread directly.
**Backends and plugins.** Backends come from static config, plugin registration (`Servers.register`, owner-scoped handles with generations) or the optional control channel (`BackendControlService`: separate authenticated blocking sockets on virtual threads, leases that outlive short disconnects). Initial routing is fail-closed: with no placement plugin it uses only `initialRouting.servers`. Permissions are a provider interface (`PermissionService`) with per-connection subjects that are prepared before login admission and released on any exit.
## Testing notes
+3 -1
View File
@@ -66,9 +66,11 @@ INFO Stopped
- 过载(连接数上限、插件命令、命令补全、插件事件、初次选服的有界队列已满)用合并的 WARN:第一次立即写,之后每分钟最多一行,带上这段时间里被拒绝的次数。合并在下一次发生时输出,所以安静下来以后不会再补一行。
控制台的 `send`、`kick`、`loglevel` 和 `stop` 以 INFO 写入 logger `moonbridge.admin`,例如 `Console kicks Tester: spamming chat`。
## DEBUG 写什么
协议细节保留在 DEBUG:后端拨号与 TCP 建立、登录帧刷写、加密请求、意外的登录包、帧处理失败的异常栈、WebSocket 升级被拒、控制通道连接结束。排查某一块时只打开那一块,例如在 `logback.xml` 中:
协议细节保留在 DEBUG:后端拨号与 TCP 建立、登录帧刷写、加密请求、意外的登录包、帧处理失败的异常栈、WebSocket 升级被拒、控制通道连接结束。排查某一块时只打开那一块:运行中可以在控制台输入 `loglevel transfer debug`(重启后恢复),或长期在 `logback.xml` 中:
```xml
<logger name="dev.moonbridge.core.session.transfer" level="DEBUG" />
+18
View File
@@ -19,6 +19,24 @@ java -jar moonbridge.jar --config config\moonbridge.yml
采用统一事件模型后,已有配置中的 `plugins.accessTimeoutSeconds` 需要改为 `plugins.eventTimeoutSeconds`;旧键已删除,启动时会被当作未知配置拒绝。
## 控制台命令
在交互式终端中启动时,控制台使用 JLine:提示符固定在底部,日志在上方滚动(警告黄色、错误红色),支持历史(↑↓)和 Tab 补全命令、玩家名、服务器名和日志区域。没有终端时(Docker 日志、systemd、面板控制台)按行读取标准输入,输出为纯文本。控制台输入先交给内置命令,再交给插件注册的控制台命令;插件命令与内置命令同名时,启动日志会警告,该名字在控制台上执行内置命令,玩家仍可使用插件的 `/名字`。
| 命令 | 作用 |
| --- | --- |
| `list` | 按服务器列出在线玩家 |
| `servers` | 每个服务器的地址、在线人数和来源(静态配置、插件或控制通道实例) |
| `player <名字>` | 玩家的 UUID、地址、TCP/WebSocket、所在服务器和在线时长 |
| `status` | 版本、运行时长、网络传输层、监听地址、人数、堆内存,以及最近 10 分钟的加入、离开、拒绝、转服和过载次数 |
| `send <玩家> <服务器>` | 通过正常转服流程把玩家移到另一个服务器 |
| `kick <玩家> [原因]` | 断开玩家,原因省略时为 `Disconnected by an operator.` |
| `loglevel [<区域> <级别>]` | 不带参数时列出区域和当前级别;`loglevel transfer debug` 等运行时修改,重启后恢复 `logback.xml` 的设置 |
| `stop` | 正常关闭,与 Ctrl+C 相同 |
| `help` | 列出以上命令 |
`player` 和 `list` 中的地址不受 `logging.playerAddresses` 影响:该开关只决定日志文件里是否写 IP。`send`、`kick`、`loglevel` 和 `stop` 会以 INFO 写入 `moonbridge.admin`。Ctrl+D 只关闭控制台输入,代理继续运行。
## 从 StrataProxy 迁移
MoonBridge 是破坏性改名,旧包名/API、启动程序和配置文件没有兼容别名。升级时将 `strataproxy` 启动程序改为 `moonbridge`、将 `strataproxy.yml` 迁移为 `moonbridge.yml`,并检查 `dev.strataproxy` Java 导入改为 `dev.moonbridge`。旧配置中的 `plugins.initialPlacementTimeoutSeconds` 改为 `initialRouting.timeoutSeconds`;首次登录入口应显式写入 `initialRouting.servers`,不要依赖注册顺序。
+4
View File
@@ -5,6 +5,7 @@ jackson = "2.20.0"
slf4j = "2.0.18"
logback = "1.6.3"
adventure = "5.2.0"
jline = "3.30.17"
[libraries]
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
@@ -27,3 +28,6 @@ adventure-api = { module = "net.kyori:adventure-api", version.ref = "adventure"
adventure-minimessage = { module = "net.kyori:adventure-text-minimessage", version.ref = "adventure" }
adventure-gson = { module = "net.kyori:adventure-text-serializer-gson", version.ref = "adventure" }
adventure-plain = { module = "net.kyori:adventure-text-serializer-plain", version.ref = "adventure" }
jline-reader = { module = "org.jline:jline-reader", version.ref = "jline" }
jline-terminal = { module = "org.jline:jline-terminal", version.ref = "jline" }
jline-terminal-ffm = { module = "org.jline:jline-terminal-ffm", version.ref = "jline" }
+7 -1
View File
@@ -34,7 +34,13 @@ dependencies {
implementation(libs.findLibrary("jackson-yaml").get())
implementation(libs.findLibrary("adventure-gson").get())
implementation(libs.findLibrary("adventure-plain").get())
runtimeOnly(libs.findLibrary("logback-classic").get())
// Compile-time: the console log appender and the loglevel command use Logback directly.
implementation(libs.findLibrary("logback-classic").get())
// The admin console: line editing, history and completion. The FFM provider talks to the terminal through
// Java's foreign function API, allowed by the jar manifest's Enable-Native-Access.
implementation(libs.findLibrary("jline-reader").get())
implementation(libs.findLibrary("jline-terminal").get())
runtimeOnly(libs.findLibrary("jline-terminal-ffm").get())
testImplementation(project(":backend"))
testImplementation(libs.findLibrary("logback-classic").get())
testImplementation(libs.findLibrary("netty-pkitesting").get())
@@ -1,55 +0,0 @@
package dev.moonbridge.app;
import dev.moonbridge.core.plugin.PluginHost;
import net.kyori.adventure.text.Component;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
/** Reads trusted local stdin without closing it or holding up proxy shutdown. */
final class ConsoleCommands implements AutoCloseable {
private final AtomicBoolean closed = new AtomicBoolean();
private final Thread reader;
ConsoleCommands(PluginHost plugins, InputStream input, Consumer<Component> reply) {
reader = Thread.ofPlatform().daemon().name("moonbridge-console").start(() -> {
var source = new InputStreamReader(input, StandardCharsets.UTF_8);
var line = new StringBuilder();
boolean tooLong = false;
try {
int character;
while (!closed.get() && (character = source.read()) != -1) {
if (closed.get()) break;
if (character == '\n') {
if (tooLong) reply.accept(Component.text("Console command exceeds 8192 characters."));
else dispatch(plugins, line.toString(), reply);
line.setLength(0);
tooLong = false;
} else if (character != '\r') {
if (line.length() < 8192) line.append((char) character);
else tooLong = true;
}
}
if (!closed.get() && !tooLong && !line.isEmpty()) dispatch(plugins, line.toString(), reply);
} catch (IOException failure) {
if (!closed.get()) reply.accept(Component.text("Console input is unavailable: " + failure.getMessage()));
}
});
}
private static void dispatch(PluginHost plugins, String line, Consumer<Component> reply) {
String command = line.trim();
if (!command.isEmpty() && !plugins.dispatchConsoleCommand(command, reply)) {
reply.accept(Component.text("Unknown console command."));
}
}
@Override public void close() {
closed.set(true);
reader.interrupt();
}
}
@@ -4,6 +4,10 @@ import dev.moonbridge.core.session.channel.NetworkTransport;
import dev.moonbridge.core.backend.InMemoryBackendCatalog;
import dev.moonbridge.core.control.BackendControlService;
import dev.moonbridge.core.auth.MojangSessionVerifier;
import dev.moonbridge.app.console.BuiltinCommands;
import dev.moonbridge.app.console.Console;
import dev.moonbridge.app.console.LiveAdminActions;
import dev.moonbridge.core.admin.AdminView;
import dev.moonbridge.core.log.ConnectionLog;
import dev.moonbridge.core.plugin.PluginHost;
import dev.moonbridge.core.session.ProxySessionListener;
@@ -79,11 +83,13 @@ public final class ProxyMain {
}
private static void runProxy(Path configPath) throws Exception {
var started = java.time.Instant.now();
var loader = new ProxyConfigurationLoader();
var configuration = loader.load(configPath);
String version = ProxyMain.class.getPackage().getImplementationVersion();
LOGGER.info("Starting MoonBridge {} on Java {} with {} authentication", version == null ? "development" : version,
Runtime.version(), configuration.authentication());
String implementationVersion = ProxyMain.class.getPackage().getImplementationVersion();
String version = implementationVersion == null ? "development" : implementationVersion;
LOGGER.info("Starting MoonBridge {} on Java {} with {} authentication", version, Runtime.version(),
configuration.authentication());
UUID proxyEpoch = UUID.randomUUID();
var serverListStatus = loader.loadServerListStatus(configuration, configPath);
var webSocket = loader.loadWebSocket(configuration, configPath);
@@ -107,7 +113,8 @@ public final class ProxyMain {
listener.setSessionBinding(proxyEpoch, sessionBindingSecrets(configuration));
listener.setServerListStatus(serverListStatus);
listener.setInitialServers(configuration.initialRouting().servers());
listener.setConnectionLog(new ConnectionLog(configuration.logging().playerAddresses()));
var connectionLog = new ConnectionLog(configuration.logging().playerAddresses());
listener.setConnectionLog(connectionLog);
if (webSocket != null) listener.setWebSocket(configuration.websocket().listenAddress(), webSocket);
try (var plugins = new PluginHost(catalog, listener, placementTimeout, eventTimeout, proxyEpoch);
var control = configuration.backendChannel() == null ? null
@@ -130,9 +137,22 @@ public final class ProxyMain {
LOGGER.info("Ready: {} server(s) in the catalog, {} plugin(s) enabled, backend control channel {}",
catalog.snapshot().size(), configuration.plugins().enabled().size(),
control == null ? "off" : "on " + configuration.backendChannel().listen());
var listeners = new java.util.ArrayList<String>();
listeners.add("TCP " + serverChannel.localAddress());
if (webSocket != null) {
LOGGER.info("Listening for WebSocket{} on {}{}", webSocket.tls() == null ? "" : " (TLS)",
listener.webSocketChannel().localAddress(), webSocket.path());
listeners.add((webSocket.tls() == null ? "ws " : "wss ") + listener.webSocketChannel().localAddress()
+ webSocket.path());
}
var admin = new AdminView(listener::onlinePlayers, catalog, connectionLog::activity,
new AdminView.Identity(version, started, transport.name(), listeners), java.time.Clock.systemUTC());
var builtins = new BuiltinCommands(admin, new LiveAdminActions(listener), Console::print);
for (String name : plugins.consoleCommandNames()) {
if (BuiltinCommands.NAMES.contains(name)) {
LOGGER.warn("Plugin console command {} is hidden on the console by the built-in command of the "
+ "same name; players can still run /{}", name, name);
}
}
var shutdown = new Thread(() -> {
LOGGER.info("Shutting down with {} player(s) online", listener.online().size());
@@ -151,9 +171,7 @@ public final class ProxyMain {
}
}, "moonbridge-shutdown");
Runtime.getRuntime().addShutdownHook(shutdown);
try (var console = new ConsoleCommands(plugins, System.in,
component -> LOGGER.info("{}", net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer
.plainText().serialize(component)))) {
try (var console = Console.start(builtins, plugins)) {
serverChannel.closeFuture().sync();
} finally {
listener.close().toCompletableFuture().join();
@@ -0,0 +1,23 @@
package dev.moonbridge.app.console;
import dev.moonbridge.api.DisconnectResult;
import dev.moonbridge.api.PlayerIdentity;
import dev.moonbridge.api.TransferResult;
import java.util.concurrent.CompletionStage;
/** What the built-in console commands can change on the running proxy. */
public interface AdminActions {
CompletionStage<DisconnectResult> kick(PlayerIdentity player, String reason);
CompletionStage<TransferResult> send(PlayerIdentity player, String server);
/** The configured level of a logger, or the level it inherits, in upper case. */
String logLevel(String logger);
/** Sets a logger's level at runtime; nothing is written to logback.xml. */
void setLogLevel(String logger, String level);
/** Stops the proxy the same way as Ctrl+C. */
void stop();
}
@@ -0,0 +1,264 @@
package dev.moonbridge.app.console;
import dev.moonbridge.api.DisconnectResult;
import dev.moonbridge.api.TransferStatus;
import dev.moonbridge.core.admin.AdminView;
import dev.moonbridge.core.log.ConnectionLog;
import dev.moonbridge.core.log.LogText;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.TreeMap;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* The proxy's own console commands. They run before plugin console commands, and print to the console only; actions
* that change something (kick, send, loglevel, stop) are also logged to {@code moonbridge.admin}.
*/
public final class BuiltinCommands {
public static final List<String> NAMES =
List.of("help", "list", "servers", "player", "status", "send", "kick", "loglevel", "stop");
/** Short names for the loggers an operator usually wants to adjust; any full logger name works too. */
static final Map<String, String> LOG_AREAS = areas();
static final List<String> LEVELS = List.of("TRACE", "DEBUG", "INFO", "WARN", "ERROR", "OFF");
private static final Logger ADMIN = LoggerFactory.getLogger("moonbridge.admin");
private static final String DEFAULT_KICK_REASON = "Disconnected by an operator.";
private final AdminView view;
private final AdminActions actions;
private final Consumer<String> out;
public BuiltinCommands(AdminView view, AdminActions actions, Consumer<String> out) {
this.view = Objects.requireNonNull(view, "view");
this.actions = Objects.requireNonNull(actions, "actions");
this.out = Objects.requireNonNull(out, "out");
}
/** Runs a built-in command; false when the line names none, so plugin commands get it. */
public boolean run(String line) {
List<String> words = words(line);
if (words.isEmpty()) return false;
List<String> args = words.subList(1, words.size());
switch (words.getFirst().toLowerCase(Locale.ROOT)) {
case "help" -> help();
case "list" -> list();
case "servers" -> servers();
case "player" -> player(args);
case "status" -> status();
case "send" -> send(args);
case "kick" -> kick(args);
case "loglevel" -> logLevel(args);
case "stop" -> stop();
default -> { return false; }
}
return true;
}
/** Candidates for the word at {@code index} of {@code words} (the word being typed may be empty). */
public List<String> complete(List<String> words, int index) {
if (index == 0) return NAMES;
String command = words.getFirst().toLowerCase(Locale.ROOT);
return switch (command) {
case "player", "kick" -> index == 1 ? playerNames() : List.of();
case "send" -> index == 1 ? playerNames()
: index == 2 ? view.servers().stream().map(AdminView.ServerRow::name).toList() : List.of();
case "loglevel" -> index == 1 ? List.copyOf(LOG_AREAS.keySet())
: index == 2 ? LEVELS : List.of();
default -> List.of();
};
}
private void help() {
out.accept("Built-in commands:");
out.accept(" list players online, by server");
out.accept(" servers servers, their players and who registered them");
out.accept(" player <name> one player's connection");
out.accept(" status version, uptime, load and the last 10 minutes");
out.accept(" send <player> <server> move a player to another server");
out.accept(" kick <player> [reason] disconnect a player");
out.accept(" loglevel [<area> <level>] show or change log detail at runtime");
out.accept(" stop shut down, same as Ctrl+C");
out.accept("Other words are passed to plugin console commands.");
}
private void list() {
List<AdminView.PlayerRow> players = view.players();
out.accept(plural(players.size(), "player") + " online");
Map<String, List<String>> byServer = new TreeMap<>();
for (AdminView.PlayerRow player : players) {
byServer.computeIfAbsent(player.server().orElse("(moving)"), server -> new ArrayList<>()).add(player.name());
}
byServer.forEach((server, names) ->
out.accept(" " + LogText.clean(server) + " (" + names.size() + "): "
+ names.stream().map(LogText::clean).collect(Collectors.joining(", "))));
}
private void servers() {
List<AdminView.ServerRow> servers = view.servers();
if (servers.isEmpty()) {
out.accept("No servers are registered.");
return;
}
List<String[]> rows = new ArrayList<>();
rows.add(new String[]{"NAME", "ADDRESS", "PLAYERS", "SOURCE"});
for (AdminView.ServerRow server : servers) {
rows.add(new String[]{LogText.clean(server.name()), LogText.clean(server.address()),
Integer.toString(server.players()), server.source()});
}
table(rows);
}
private void player(List<String> args) {
if (args.size() != 1) {
out.accept("Usage: player <name>");
return;
}
Optional<AdminView.PlayerRow> found = view.player(args.getFirst());
if (found.isEmpty()) {
out.accept("No online player named " + LogText.clean(args.getFirst()) + ".");
return;
}
AdminView.PlayerRow player = found.get();
out.accept(LogText.clean(player.name()) + " (" + player.identity().playerId() + ") via " + player.via()
+ " from " + player.address());
out.accept(" " + player.server().map(server -> "on " + LogText.clean(server)).orElse("between servers")
+ ", online " + ConnectionLog.duration(player.online()));
}
private void status() {
AdminView.Status status = view.status();
out.accept("MoonBridge " + status.version() + " · up " + ConnectionLog.duration(status.uptime()) + " · "
+ status.transport() + " · " + String.join(", ", status.listeners()));
out.accept(plural(status.players(), "player") + " · " + plural(status.servers(), "server") + " · heap "
+ status.heapUsedBytes() / (1024 * 1024) + "/" + status.heapMaxBytes() / (1024 * 1024) + " MB");
var recent = status.activity();
out.accept("Last 10 min: " + recent.joins() + " joins · " + recent.leaves() + " leaves · " + recent.refused()
+ " refused · " + recent.moves() + " moves, " + recent.failedMoves() + " failed · "
+ recent.overloads() + " overload rejections");
}
private void send(List<String> args) {
if (args.size() != 2) {
out.accept("Usage: send <player> <server>");
return;
}
Optional<AdminView.PlayerRow> player = view.player(args.get(0));
if (player.isEmpty()) {
out.accept("No online player named " + LogText.clean(args.get(0)) + ".");
return;
}
String server = args.get(1);
if (view.servers().stream().noneMatch(row -> row.name().equals(server))) {
out.accept("No server named " + LogText.clean(server) + ". Type servers to list them.");
return;
}
String name = LogText.clean(player.get().name());
ADMIN.info("Console sends {} to {}", name, LogText.clean(server));
actions.send(player.get().identity(), server).whenComplete((result, failure) -> {
if (failure != null) out.accept("Could not send " + name + ": " + LogText.clean(failure.getMessage()));
else if (result.status() == TransferStatus.NETWORK_READY) out.accept(name + " is on " + LogText.clean(server) + ".");
else out.accept("Could not send " + name + ": " + LogText.clean(result.detail()
.orElse(result.status().name().toLowerCase(Locale.ROOT).replace('_', ' '))));
});
}
private void kick(List<String> args) {
if (args.isEmpty()) {
out.accept("Usage: kick <player> [reason]");
return;
}
Optional<AdminView.PlayerRow> player = view.player(args.getFirst());
if (player.isEmpty()) {
out.accept("No online player named " + LogText.clean(args.getFirst()) + ".");
return;
}
String reason = args.size() == 1 ? DEFAULT_KICK_REASON : String.join(" ", args.subList(1, args.size()));
String name = LogText.clean(player.get().name());
ADMIN.info("Console kicks {}: {}", name, LogText.clean(reason));
actions.kick(player.get().identity(), reason).whenComplete((result, failure) -> {
if (failure != null) out.accept("Could not kick " + name + ": " + LogText.clean(failure.getMessage()));
else if (result != DisconnectResult.DISCONNECTED) out.accept(name + " is no longer online.");
});
}
private void logLevel(List<String> args) {
if (args.isEmpty()) {
out.accept("Log areas (or any logger name) and their levels:");
LOG_AREAS.forEach((area, logger) ->
out.accept(" " + pad(area, 10) + pad(actions.logLevel(logger), 7) + logger));
out.accept("Usage: loglevel <area> <" + String.join("|", LEVELS) + ">");
return;
}
if (args.size() != 2) {
out.accept("Usage: loglevel <area> <level>");
return;
}
String logger = LOG_AREAS.getOrDefault(args.get(0).toLowerCase(Locale.ROOT), args.get(0));
String level = args.get(1).toUpperCase(Locale.ROOT);
if (!LEVELS.contains(level)) {
out.accept("Unknown level " + LogText.clean(args.get(1)) + "; use one of " + String.join(", ", LEVELS) + ".");
return;
}
actions.setLogLevel(logger, level);
ADMIN.info("Console set the log level of {} to {}", LogText.clean(logger), level);
out.accept(LogText.clean(logger) + " now logs at " + level + " until the proxy restarts.");
}
private void stop() {
ADMIN.info("Stop requested from the console");
actions.stop();
}
private List<String> playerNames() {
return view.players().stream().map(AdminView.PlayerRow::name).toList();
}
private void table(List<String[]> rows) {
int[] widths = new int[rows.getFirst().length];
for (String[] row : rows) for (int i = 0; i < row.length; i++) widths[i] = Math.max(widths[i], row[i].length());
for (String[] row : rows) {
StringBuilder line = new StringBuilder();
for (int i = 0; i < row.length; i++) {
line.append(i == row.length - 1 ? row[i] : pad(row[i], widths[i] + 2));
}
out.accept(line.toString());
}
}
private static String plural(long count, String noun) { return count + " " + noun + (count == 1 ? "" : "s"); }
private static String pad(String text, int width) {
return text.length() >= width ? text + " " : text + " ".repeat(width - text.length());
}
static List<String> words(String line) {
String trimmed = line.strip();
return trimmed.isEmpty() ? List.of() : Arrays.asList(trimmed.split("\\s+"));
}
private static Map<String, String> areas() {
Map<String, String> areas = new LinkedHashMap<>();
areas.put("players", ConnectionLog.PLAYERS);
areas.put("servers", ConnectionLog.SERVERS);
areas.put("plugins", "moonbridge.plugins");
areas.put("admin", "moonbridge.admin");
areas.put("session", "dev.moonbridge.core.session");
areas.put("transfer", "dev.moonbridge.core.session.transfer");
areas.put("websocket", "dev.moonbridge.core.websocket");
areas.put("control", "dev.moonbridge.core.control");
areas.put("netty", "io.netty");
areas.put("root", "ROOT");
return java.util.Collections.unmodifiableMap(areas);
}
}
@@ -0,0 +1,147 @@
package dev.moonbridge.app.console;
import dev.moonbridge.core.plugin.PluginHost;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.jline.reader.Candidate;
import org.jline.reader.EndOfFileException;
import org.jline.reader.LineReader;
import org.jline.reader.LineReaderBuilder;
import org.jline.reader.UserInterruptException;
import org.jline.terminal.Terminal;
import org.jline.terminal.TerminalBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* The proxy's console. In an interactive terminal it uses JLine: the prompt stays at the bottom while logs scroll above
* it, with history and Tab completion of commands, players, servers and log areas. Without a terminal (Docker logs,
* systemd, hosting panels) it reads plain lines from standard input. Either way a line goes to the built-in commands
* first and then to plugin console commands. The reader thread is a daemon and never holds up shutdown.
*/
public final class Console implements AutoCloseable {
private static final Logger LOGGER = LoggerFactory.getLogger(Console.class);
private static final int MAX_LINE = 8192;
private final BuiltinCommands builtins;
private final PluginHost plugins;
private final AtomicBoolean closed = new AtomicBoolean();
private final Thread reader;
private volatile Terminal terminal;
private Console(BuiltinCommands builtins, PluginHost plugins) {
this.builtins = builtins;
this.plugins = plugins;
boolean interactive = System.console() != null && System.console().isTerminal();
reader = Thread.ofPlatform().daemon().name("moonbridge-console")
.unstarted(interactive ? this::readInteractive : this::readPlain);
}
public static Console start(BuiltinCommands builtins, PluginHost plugins) {
Console console = new Console(builtins, plugins);
console.reader.start();
return console;
}
/** Output for command results, printed above the prompt when there is one. */
public static void print(String line) { ConsoleLogAppender.printLine(line); }
void dispatch(String line) {
String command = line.strip();
if (command.isEmpty() || builtins.run(command)) return;
if (!plugins.dispatchConsoleCommand(command,
component -> print(PlainTextComponentSerializer.plainText().serialize(component)))) {
print("Unknown command. Type help for the built-in commands.");
}
}
private void readInteractive() {
Terminal opened;
LineReader lineReader;
try {
opened = TerminalBuilder.builder().system(true).name("MoonBridge").build();
lineReader = LineReaderBuilder.builder()
.terminal(opened)
.appName("MoonBridge")
.completer((ignored, parsed, candidates) -> complete(parsed.words(), parsed.wordIndex(), candidates))
.option(LineReader.Option.DISABLE_EVENT_EXPANSION, true)
.build();
} catch (IOException | RuntimeException unavailable) {
LOGGER.warn("Interactive console unavailable ({}); reading plain lines instead", unavailable.toString());
readPlain();
return;
}
terminal = opened;
ConsoleLogAppender.attach(lineReader);
try (opened) {
while (!closed.get()) {
String line;
try {
line = lineReader.readLine("> ");
} catch (UserInterruptException interrupt) {
// The terminal is in raw mode, so Ctrl+C arrives here instead of as a signal.
dispatch("stop");
return;
} catch (EndOfFileException endOfInput) {
if (!closed.get()) LOGGER.info("Console input closed; the proxy keeps running");
return;
}
dispatch(line);
}
} catch (IOException | RuntimeException closedUnderneath) {
// During shutdown JLine closes the terminal while a read is waiting; that only ends the console.
LOGGER.debug("Console reader stopped", closedUnderneath);
} finally {
ConsoleLogAppender.detach();
}
}
private void complete(List<String> words, int index, List<Candidate> candidates) {
List<String> options = new ArrayList<>(builtins.complete(words, index));
if (index == 0) options.addAll(plugins.consoleCommandNames());
for (String option : options) candidates.add(new Candidate(option));
}
private void readPlain() {
var source = new InputStreamReader(System.in, StandardCharsets.UTF_8);
var line = new StringBuilder();
boolean tooLong = false;
try {
int character;
while (!closed.get() && (character = source.read()) != -1) {
if (character == '\n') {
if (tooLong) print("Console command exceeds " + MAX_LINE + " characters.");
else dispatch(line.toString());
line.setLength(0);
tooLong = false;
} else if (character != '\r') {
if (line.length() < MAX_LINE) line.append((char) character);
else tooLong = true;
}
}
if (!closed.get() && !tooLong && !line.isEmpty()) dispatch(line.toString());
} catch (IOException failure) {
if (!closed.get()) print("Console input is unavailable: " + failure.getMessage());
}
}
@Override public void close() {
if (!closed.compareAndSet(false, true)) return;
ConsoleLogAppender.detach();
reader.interrupt();
Terminal current = terminal;
if (current != null) {
try {
current.close();
} catch (IOException ignored) {
// The JVM is stopping; the terminal is restored by JLine's own shutdown handling.
}
}
}
}
@@ -0,0 +1,63 @@
package dev.moonbridge.app.console;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.AppenderBase;
import ch.qos.logback.core.encoder.Encoder;
import org.jline.reader.LineReader;
import org.jline.utils.AttributedString;
import org.jline.utils.AttributedStyle;
import java.nio.charset.StandardCharsets;
/**
* Console log output that keeps the interactive prompt at the bottom: while a JLine reader is attached, each line is
* printed above the prompt, with warnings and errors coloured; otherwise lines go to standard output unchanged, so
* Docker, systemd and panel consoles see plain text.
*/
public final class ConsoleLogAppender extends AppenderBase<ILoggingEvent> {
private static volatile LineReader reader;
private Encoder<ILoggingEvent> encoder;
public void setEncoder(Encoder<ILoggingEvent> encoder) { this.encoder = encoder; }
@Override public void start() {
if (encoder == null) {
addError("No encoder set for the console appender " + name);
return;
}
super.start();
}
@Override protected void append(ILoggingEvent event) {
String text = new String(encoder.encode(event), StandardCharsets.UTF_8);
if (text.endsWith("\n")) text = text.substring(0, text.length() - (text.endsWith("\r\n") ? 2 : 1));
print(text, event.getLevel());
}
/** Prints command output the same way as log lines, without a level. */
static void printLine(String line) { print(line, null); }
static void attach(LineReader lineReader) { reader = lineReader; }
static void detach() { reader = null; }
private static void print(String text, Level level) {
LineReader current = reader;
if (current == null) {
System.out.println(text);
return;
}
AttributedStyle style = level == Level.ERROR ? AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)
: level == Level.WARN ? AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)
: AttributedStyle.DEFAULT;
try {
current.printAbove(new AttributedString(text, style));
} catch (RuntimeException terminalClosed) {
// JLine closes the terminal from its own shutdown hook while the proxy is still logging its shutdown.
reader = null;
System.out.println(text);
}
}
}
@@ -0,0 +1,44 @@
package dev.moonbridge.app.console;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.LoggerContext;
import dev.moonbridge.api.DisconnectResult;
import dev.moonbridge.api.PlayerIdentity;
import dev.moonbridge.api.Players;
import dev.moonbridge.api.TransferResult;
import net.kyori.adventure.text.Component;
import org.slf4j.LoggerFactory;
import java.util.Objects;
import java.util.concurrent.CompletionStage;
/** Console actions on the running proxy: the same player operations plugins use, plus Logback and shutdown. */
public final class LiveAdminActions implements AdminActions {
private final Players players;
public LiveAdminActions(Players players) { this.players = Objects.requireNonNull(players, "players"); }
@Override public CompletionStage<DisconnectResult> kick(PlayerIdentity player, String reason) {
return players.disconnect(player, Component.text(reason));
}
@Override public CompletionStage<TransferResult> send(PlayerIdentity player, String server) {
return players.transfer(player, server);
}
@Override public String logLevel(String logger) {
return context().getLogger(logger).getEffectiveLevel().toString();
}
@Override public void setLogLevel(String logger, String level) {
context().getLogger(logger).setLevel(Level.toLevel(level));
}
@Override public void stop() {
// System.exit runs the proxy's shutdown hook, which stops it exactly as Ctrl+C does. It is called from a fresh
// thread because the hook closes the console, which waits for nothing but must not be the exiting thread.
Thread.ofPlatform().name("moonbridge-stop").start(() -> System.exit(0));
}
private static LoggerContext context() { return (LoggerContext) LoggerFactory.getILoggerFactory(); }
}
@@ -0,0 +1,108 @@
package dev.moonbridge.core.admin;
import dev.moonbridge.api.PlayerIdentity;
import dev.moonbridge.core.backend.BackendCatalog;
import dev.moonbridge.core.backend.BackendView;
import dev.moonbridge.core.log.ConnectionLog;
import dev.moonbridge.core.log.RecentActivity;
import dev.moonbridge.core.session.OnlinePlayer;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Supplier;
/**
* Read-only snapshots of the proxy for the admin console: who is online, which servers exist and how busy the proxy
* has been. Every method builds a fresh immutable answer from thread-safe sources, so it can run on the console thread
* without touching session state on the network threads.
*/
public final class AdminView {
/** {@code identity} names this connection, so actions on it cannot reach a later reconnect. */
public record PlayerRow(String name, PlayerIdentity identity, Optional<String> server, Duration online,
String address, String via) { }
public record ServerRow(String name, String address, int players, String source) { }
public record Status(String version, Duration uptime, String transport, List<String> listeners, int players,
int servers, long heapUsedBytes, long heapMaxBytes, RecentActivity activity) { }
/** What does not change while the proxy runs. */
public record Identity(String version, Instant started, String transport, List<String> listeners) {
public Identity {
Objects.requireNonNull(version, "version");
Objects.requireNonNull(started, "started");
Objects.requireNonNull(transport, "transport");
listeners = List.copyOf(listeners);
}
}
private static final Comparator<PlayerRow> BY_NAME = Comparator.comparing(row -> row.name().toLowerCase(Locale.ROOT));
private final Supplier<List<OnlinePlayer>> players;
private final BackendCatalog catalog;
private final Supplier<RecentActivity> activity;
private final Identity identity;
private final Clock clock;
public AdminView(Supplier<List<OnlinePlayer>> players, BackendCatalog catalog, Supplier<RecentActivity> activity,
Identity identity, Clock clock) {
this.players = Objects.requireNonNull(players, "players");
this.catalog = Objects.requireNonNull(catalog, "catalog");
this.activity = Objects.requireNonNull(activity, "activity");
this.identity = Objects.requireNonNull(identity, "identity");
this.clock = Objects.requireNonNull(clock, "clock");
}
/** Online players sorted by name. */
public List<PlayerRow> players() {
Instant now = clock.instant();
return players.get().stream().map(player -> row(player, now)).sorted(BY_NAME).toList();
}
/** The online player with this name, ignoring case. */
public Optional<PlayerRow> player(String name) {
Objects.requireNonNull(name, "name");
return players().stream().filter(row -> row.name().equalsIgnoreCase(name)).findFirst();
}
/** Catalog servers sorted by name, each with the number of online players on it. */
public List<ServerRow> servers() {
Map<String, Integer> counts = new HashMap<>();
for (OnlinePlayer player : players.get()) {
player.view().currentServer().ifPresent(server -> counts.merge(server, 1, Integer::sum));
}
return catalog.snapshot().stream()
.sorted(Comparator.comparing(view -> view.handle().id().value()))
.map(view -> new ServerRow(view.handle().id().value(), hostPort(view),
counts.getOrDefault(view.handle().id().value(), 0), ConnectionLog.owner(view.owner().id())))
.toList();
}
public Status status() {
Runtime runtime = Runtime.getRuntime();
return new Status(identity.version(), Duration.between(identity.started(), clock.instant()),
identity.transport(), identity.listeners(), players.get().size(), catalog.snapshot().size(),
runtime.totalMemory() - runtime.freeMemory(), runtime.maxMemory(), activity.get());
}
private static PlayerRow row(OnlinePlayer player, Instant now) {
var view = player.view();
String address = player.address() == null ? "<unknown>"
: player.address().getAddress() == null ? player.address().getHostString()
: player.address().getAddress().getHostAddress();
return new PlayerRow(view.username(), view.identity(), view.currentServer(),
Duration.between(player.connectedAt(), now), address, player.webSocket() ? "WebSocket" : "TCP");
}
private static String hostPort(BackendView view) {
return view.address().getHost() + ":" + view.address().getPort();
}
}
@@ -30,37 +30,61 @@ public final class ConnectionLog {
"Connection limit reached: refused {} connection(s); raise maxConnections if this is expected load",
1, TimeUnit.MINUTES);
/** Refusals of any kind (connection limit, full plugin pools), shared by every ConnectionLog in the process. */
private static final RecentCounter OVERLOADS = new RecentCounter();
private final boolean playerAddresses;
private final RecentCounter joins = new RecentCounter();
private final RecentCounter leaves = new RecentCounter();
private final RecentCounter refused = new RecentCounter();
private final RecentCounter moves = new RecentCounter();
private final RecentCounter failedMoves = new RecentCounter();
public ConnectionLog(boolean playerAddresses) { this.playerAddresses = playerAddresses; }
/** Counts of the events this log wrote in the last ten minutes. */
public RecentActivity activity() {
return new RecentActivity(joins.lastTenMinutes(), leaves.lastTenMinutes(), refused.lastTenMinutes(),
moves.lastTenMinutes(), failedMoves.lastTenMinutes(), OVERLOADS.lastTenMinutes());
}
static void overloaded() { OVERLOADS.increment(); }
public void connected(String name, UUID playerId, InetSocketAddress from, boolean webSocket, String server) {
joins.increment();
PLAYER_LOG.info("{} ({}) connected from {} via {} -> {}", LogText.clean(name), playerId, address(from),
webSocket ? "WebSocket" : "TCP", LogText.clean(server));
}
public void moved(String name, String from, String to) {
moves.increment();
PLAYER_LOG.info("{} moved {} -> {}", LogText.clean(name), from == null ? "(no server)" : LogText.clean(from),
LogText.clean(to));
}
/** A transfer someone asked for did not happen; the player is still connected. */
public void moveFailed(String name, String target, String reason, String current) {
failedMoves.increment();
PLAYER_LOG.warn("{} could not move to {}: {} (stays on {})", LogText.clean(name), LogText.clean(target),
LogText.clean(reason), current == null ? "no server" : LogText.clean(current));
}
public void left(String name, String server, Duration online, String reason) {
leaves.increment();
PLAYER_LOG.info("{} left {} after {}: {}", LogText.clean(name),
server == null ? "(no server)" : LogText.clean(server), duration(online), LogText.clean(reason));
}
/** The player sent Login Start but never reached a server: refused, timed out or gave up. */
public void loginFailed(String name, InetSocketAddress from, String reason) {
refused.increment();
PLAYER_LOG.info("{} from {} could not join: {}", LogText.clean(name), address(from), LogText.clean(reason));
}
public static void connectionLimitReached() { CONNECTION_LIMIT.record(); }
public static void connectionLimitReached() {
OVERLOADS.increment();
CONNECTION_LIMIT.record();
}
public static void serverRegistered(String name, URI address, String ownerId) {
SERVER_LOG.info("Server {} registered by {} at {}", LogText.clean(name), owner(ownerId), hostPort(address));
@@ -96,7 +120,7 @@ public final class ConnectionLog {
}
/** Owner ids are "static-config", "backend-control:&lt;instance&gt;" or a plugin id. */
static String owner(String ownerId) {
public static String owner(String ownerId) {
if ("static-config".equals(ownerId)) return "static config";
if (ownerId.startsWith("backend-control:")) {
return "control channel (" + LogText.clean(ownerId.substring("backend-control:".length())) + ")";
@@ -104,7 +128,7 @@ public final class ConnectionLog {
return "plugin " + LogText.clean(ownerId);
}
static String duration(Duration duration) {
public static String duration(Duration duration) {
long seconds = Math.max(0, duration.toSeconds());
if (seconds < 60) return seconds + "s";
long minutes = seconds / 60;
@@ -16,6 +16,7 @@ public final class Overload {
/** {@code area} is a fixed description such as "plugin commands"; never pass peer or plugin text. */
public static void rejected(String area) {
ConnectionLog.overloaded();
WARNINGS.computeIfAbsent(area, name -> new ThrottledWarning(LOGGER,
"Overloaded: rejected {} " + name + " request(s); a plugin may be blocking its worker threads",
1, TimeUnit.MINUTES)).record();
@@ -0,0 +1,4 @@
package dev.moonbridge.core.log;
/** What happened on the proxy in the last ten minutes, for the admin console. */
public record RecentActivity(long joins, long leaves, long refused, long moves, long failedMoves, long overloads) { }
@@ -0,0 +1,51 @@
package dev.moonbridge.core.log;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLongArray;
import java.util.function.LongSupplier;
/**
* Counts events over the last ten minutes in one-minute buckets, for the admin console's activity line. Recording is
* two atomic operations and never blocks, so it can sit on the network threads.
*/
public final class RecentCounter {
static final int MINUTES = 10;
private static final long MINUTE_NANOS = TimeUnit.MINUTES.toNanos(1);
private final LongSupplier nanoTime;
private final long origin;
/** Bucket i holds the count for the minute number stored in minutes[i]. */
private final AtomicLongArray counts = new AtomicLongArray(MINUTES);
private final AtomicLongArray minutes = new AtomicLongArray(MINUTES);
public RecentCounter() { this(System::nanoTime); }
RecentCounter(LongSupplier nanoTime) {
this.nanoTime = nanoTime;
this.origin = nanoTime.getAsLong();
for (int i = 0; i < MINUTES; i++) minutes.set(i, -1);
}
public void increment() {
long minute = minute();
int slot = (int) (minute % MINUTES);
long owner = minutes.get(slot);
// The first event of a new minute reclaims the slot; a racing increment of the old minute may be lost or
// land in the new one, which is acceptable for an operator's approximate count.
if (owner != minute && minutes.compareAndSet(slot, owner, minute)) counts.set(slot, 0);
counts.incrementAndGet(slot);
}
/** Events in the current minute and the nine before it. */
public long lastTenMinutes() {
long now = minute();
long sum = 0;
for (int i = 0; i < MINUTES; i++) {
long owner = minutes.get(i);
if (owner >= 0 && now - owner < MINUTES) sum += counts.get(i);
}
return sum;
}
private long minute() { return (nanoTime.getAsLong() - origin) / MINUTE_NANOS; }
}
@@ -129,6 +129,16 @@ final class CommandService {
}
/** Returns up to 100 registered roots matching a case-insensitive prefix without a leading slash. */
/** Names of the active plugin commands the console may run, without a slash, sorted. */
List<String> consoleCommandNames() {
if (!lifecycle.enabled()) return List.of();
return commands.values().stream()
.filter(command -> command.context.active && command.options.allowConsole())
.map(command -> command.name)
.sorted()
.toList();
}
List<String> commandNames(String prefix) {
Objects.requireNonNull(prefix, "prefix");
if (!lifecycle.enabled()) return List.of();
@@ -385,6 +385,9 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
/** Returns up to 100 registered roots matching a case-insensitive prefix without a leading slash. */
public List<String> commandNames(String prefix) { return commandService.commandNames(prefix); }
/** Plugin commands the console may run, for console completion and built-in name checks. */
public List<String> consoleCommandNames() { return commandService.consoleCommandNames(); }
/** Returns roots visible to this player; unavailable, undefined, and denied nodes stay hidden. */
public List<String> commandNames(PlayerView player, String prefix) {
return commandService.commandNames(player, prefix);
@@ -0,0 +1,12 @@
package dev.moonbridge.core.session;
import dev.moonbridge.api.PlayerView;
import java.net.InetSocketAddress;
import java.time.Instant;
/**
* An online player as the admin console shows it: the plugin-visible view plus how and since when the player is
* connected. {@code address} is the player's address as {@code SessionChannels.clientAddress} reports it.
*/
public record OnlinePlayer(PlayerView view, InetSocketAddress address, boolean webSocket, Instant connectedAt) { }
@@ -464,6 +464,11 @@ public final class ProxySessionListener implements Players {
return sessionsByPlayerId.values().stream().flatMap(session -> session.onlineView().stream()).toList();
}
/** Online players with their connection details, for the admin console. */
public List<OnlinePlayer> onlinePlayers() {
return sessionsByPlayerId.values().stream().flatMap(session -> session.onlinePlayer().stream()).toList();
}
@Override public CompletionStage<TransferResult> transfer(PlayerIdentity identity, String backendName) {
Objects.requireNonNull(identity, "identity");
Objects.requireNonNull(backendName, "backendName");
@@ -85,6 +85,10 @@ final class Session extends ChannelInboundHandlerAdapter {
/** Why the session ended, for the operator log; the first reason recorded wins. */
private final AtomicReference<String> endReason = new AtomicReference<>();
private long publishedAtNanos;
/** Set once when the player is published; read by the admin console from any thread. */
private volatile Connection connection;
private record Connection(InetSocketAddress address, boolean webSocket, java.time.Instant since) { }
private boolean frontendPlayPhase;
private volatile boolean disconnecting;
private TabCompletionBridge tabCompletion;
@@ -356,6 +360,9 @@ final class Session extends ChannelInboundHandlerAdapter {
});
});
view = new PlayerView(identity, view.username(), selected.handle().id().value());
// Details first: published is volatile, so readers that see it also see the connection.
connection = new Connection(SessionChannels.clientAddress(frontend), SessionChannels.isWebSocket(frontend),
java.time.Instant.now());
published = true;
publishedAtNanos = System.nanoTime();
owner.sessionPublished();
@@ -648,6 +655,11 @@ final class Session extends ChannelInboundHandlerAdapter {
else closeBecause(ctx.channel() == frontend ? "client connection error" : "server connection error");
}
Optional<OnlinePlayer> onlinePlayer() {
Connection current = connection;
return onlineView().map(view -> new OnlinePlayer(view, current.address(), current.webSocket(), current.since()));
}
Optional<PlayerView> onlineView() {
if (!published || closed.get()) return Optional.empty();
return Optional.of(view);
+3 -2
View File
@@ -4,8 +4,9 @@
<property name="LOG_DIR" value="${moonbridge.logDir:-logs}" />
<property name="PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n" />
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<!-- Keeps the interactive prompt at the bottom of the terminal; plain standard output otherwise. -->
<appender name="CONSOLE" class="dev.moonbridge.app.console.ConsoleLogAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>${PATTERN}</pattern>
</encoder>
</appender>
@@ -0,0 +1,159 @@
package dev.moonbridge.app.console;
import dev.moonbridge.api.DisconnectResult;
import dev.moonbridge.api.PlayerIdentity;
import dev.moonbridge.api.PlayerView;
import dev.moonbridge.api.TransferResult;
import dev.moonbridge.api.TransferStatus;
import dev.moonbridge.core.admin.AdminView;
import dev.moonbridge.core.backend.BackendId;
import dev.moonbridge.core.backend.BackendOwner;
import dev.moonbridge.core.backend.BackendRegistration;
import dev.moonbridge.core.backend.InMemoryBackendCatalog;
import dev.moonbridge.core.log.RecentActivity;
import dev.moonbridge.core.session.OnlinePlayer;
import org.junit.jupiter.api.Test;
import java.net.InetSocketAddress;
import java.net.URI;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
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 BuiltinCommandsTest {
private static final Instant NOW = Instant.parse("2026-10-22T12:00:00Z");
private static final PlayerIdentity TESTER = new PlayerIdentity(UUID.fromString("3f2a0000-0000-0000-0000-0000000000e1"), 7);
private static final PlayerIdentity STEVE = new PlayerIdentity(UUID.randomUUID(), 8);
private final List<String> out = new ArrayList<>();
private final FakeActions actions = new FakeActions();
private final BuiltinCommands commands = commands();
@Test
void listGroupsPlayersByServer() {
assertTrue(commands.run("list"));
assertEquals(List.of("2 players online", " lobby (1): Tester", " survival (1): Steve"), out);
}
@Test
void serversShowsAddressPlayersAndSource() {
commands.run("servers");
assertEquals(List.of(
"NAME ADDRESS PLAYERS SOURCE",
"lobby 127.0.0.1:25565 1 static config",
"survival 10.0.0.5:25565 1 control channel (island-a-1)"), out);
}
@Test
void playerShowsTheConnectionAndIgnoresNameCase() {
commands.run("player tester");
assertEquals(List.of("Tester (" + TESTER.playerId() + ") via WebSocket from 203.0.113.7",
" on lobby, online 18m"), out);
out.clear();
commands.run("player nobody");
assertEquals(List.of("No online player named nobody."), out);
}
@Test
void statusSummarisesTheProxyAndRecentActivity() {
commands.run("status");
assertEquals("MoonBridge 0.1.0 · up 3h 20m · kqueue · TCP /127.0.0.1:25577", out.get(0));
assertTrue(out.get(1).startsWith("2 players · 2 servers · heap "), out.get(1));
assertEquals("Last 10 min: 14 joins · 11 leaves · 2 refused · 9 moves, 1 failed · 0 overload rejections",
out.get(2));
}
@Test
void sendChecksBothNamesAndReportsTheResult() {
commands.run("send Tester nowhere");
assertEquals(List.of("No server named nowhere. Type servers to list them."), out);
assertTrue(actions.sent.isEmpty());
out.clear();
commands.run("send Tester survival");
assertEquals(List.of(TESTER + " -> survival"), actions.sent);
assertEquals(List.of("Tester is on survival."), out);
}
@Test
void kickUsesTheGivenReasonOrADefault() {
commands.run("kick Steve spamming chat");
commands.run("kick Tester");
assertEquals(List.of(STEVE + ": spamming chat", TESTER + ": Disconnected by an operator."), actions.kicked);
}
@Test
void loglevelAcceptsAreasAndRejectsUnknownLevels() {
commands.run("loglevel transfer debug");
assertEquals(List.of("dev.moonbridge.core.session.transfer=DEBUG"), actions.levels);
out.clear();
commands.run("loglevel transfer loud");
assertEquals(List.of("Unknown level loud; use one of TRACE, DEBUG, INFO, WARN, ERROR, OFF."), out);
}
@Test
void stopAndUnknownCommands() {
assertTrue(commands.run("stop"));
assertTrue(actions.stopped);
assertFalse(commands.run("someplugincommand arg"), "unknown words go on to plugin commands");
assertFalse(commands.run(" "));
}
@Test
void completionOffersCommandsPlayersServersAndLevels() {
assertEquals(BuiltinCommands.NAMES, commands.complete(List.of(""), 0));
assertEquals(List.of("Steve", "Tester"), commands.complete(List.of("kick", ""), 1));
assertEquals(List.of("lobby", "survival"), commands.complete(List.of("send", "Tester", ""), 2));
assertTrue(commands.complete(List.of("loglevel", "transfer", ""), 2).contains("DEBUG"));
}
private BuiltinCommands commands() {
var catalog = new InMemoryBackendCatalog();
catalog.register(new BackendRegistration(new BackendId("lobby"), new BackendOwner("static-config", 0),
URI.create("tcp://127.0.0.1:25565"), Map.of(), Map.of()));
catalog.register(new BackendRegistration(new BackendId("survival"),
new BackendOwner("backend-control:island-a-1", 1), URI.create("tcp://10.0.0.5:25565"), Map.of(), Map.of()));
List<OnlinePlayer> online = List.of(
new OnlinePlayer(new PlayerView(TESTER, "Tester", "lobby"),
new InetSocketAddress("203.0.113.7", 50000), true, NOW.minus(Duration.ofMinutes(18))),
new OnlinePlayer(new PlayerView(STEVE, "Steve", "survival"),
new InetSocketAddress("198.51.100.2", 50001), false, NOW.minus(Duration.ofMinutes(4))));
var view = new AdminView(() -> online, catalog, () -> new RecentActivity(14, 11, 2, 9, 1, 0),
new AdminView.Identity("0.1.0", NOW.minus(Duration.ofMinutes(200)), "kqueue",
List.of("TCP /127.0.0.1:25577")), Clock.fixed(NOW, ZoneOffset.UTC));
return new BuiltinCommands(view, actions, out::add);
}
private static final class FakeActions implements AdminActions {
final List<String> kicked = new ArrayList<>();
final List<String> sent = new ArrayList<>();
final List<String> levels = new ArrayList<>();
boolean stopped;
@Override public CompletionStage<DisconnectResult> kick(PlayerIdentity player, String reason) {
kicked.add(player + ": " + reason);
return CompletableFuture.completedFuture(DisconnectResult.DISCONNECTED);
}
@Override public CompletionStage<TransferResult> send(PlayerIdentity player, String server) {
sent.add(player + " -> " + server);
return CompletableFuture.completedFuture(TransferResult.of(TransferStatus.NETWORK_READY));
}
@Override public String logLevel(String logger) { return "INFO"; }
@Override public void setLogLevel(String logger, String level) { levels.add(logger + "=" + level); }
@Override public void stop() { stopped = true; }
}
}
@@ -70,4 +70,22 @@ class ConnectionLogTest {
assertEquals("1h 12m", ConnectionLog.duration(Duration.ofMinutes(72)));
assertEquals("2d 3h", ConnectionLog.duration(Duration.ofHours(51)));
}
@Test
void activityCountsWhatThisLogRecorded() {
try (var ignored = new LogCapture(ConnectionLog.PLAYERS)) {
var players = new ConnectionLog(true);
players.connected("Tester", PLAYER, FROM, false, "lobby");
players.moved("Tester", "lobby", "survival");
players.moveFailed("Tester", "creative", "timed out", "survival");
players.left("Tester", "survival", Duration.ofMinutes(1), "client disconnected");
players.loginFailed("Steve", FROM, "refused");
var activity = players.activity();
assertEquals(1, activity.joins());
assertEquals(1, activity.leaves());
assertEquals(1, activity.refused());
assertEquals(1, activity.moves());
assertEquals(1, activity.failedMoves());
}
}
}
@@ -0,0 +1,26 @@
package dev.moonbridge.core.log;
import org.junit.jupiter.api.Test;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.assertEquals;
class RecentCounterTest {
@Test
void countsTheLastTenMinutesAndForgetsOlderOnes() {
var clock = new AtomicLong();
var counter = new RecentCounter(clock::get);
counter.increment();
clock.addAndGet(TimeUnit.MINUTES.toNanos(5));
counter.increment();
counter.increment();
assertEquals(3, counter.lastTenMinutes());
clock.addAndGet(TimeUnit.MINUTES.toNanos(6));
assertEquals(2, counter.lastTenMinutes(), "the first minute's event is now older than ten minutes");
clock.addAndGet(TimeUnit.MINUTES.toNanos(10));
counter.increment();
assertEquals(1, counter.lastTenMinutes(), "a reused bucket starts from zero");
}
}
@@ -644,6 +644,11 @@ final class ProxySessionListenerTest {
sendLogin(client, username);
assertEquals(2, readVarInt(readFrame(new DataInputStream(client.getInputStream()))));
awaitOnlinePlayer(listener);
var details = listener.onlinePlayers();
assertEquals(1, details.size());
assertEquals("127.0.0.1", details.get(0).address().getAddress().getHostAddress());
assertFalse(details.get(0).webSocket());
assertEquals(Optional.of("logged-lobby"), details.get(0).view().currentServer());
}
awaitCondition(() -> log.lines().stream().anyMatch(line -> line.contains(" left ")));
backendDone.get(5, TimeUnit.SECONDS);