mirror of
https://github.com/layue13/MoonBridge.git
synced 2026-10-25 18:46:18 +00:00
Merge pull request #13 from layue13/feat/admin-console
feat: 管理控制台(JLine)与内置命令
This commit is contained in:
27 files changed
+1044
-69
No files matched your search
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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`,不要依赖注册顺序。
|
||||
|
||||
@@ -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" }
|
||||
@@ -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:<instance>" 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user