feat(auth)!: go fully passwordless and fix cross-check review findings

Remove password authentication everywhere; the only session doors are
passkey (WebAuthn), email OTP, in-game bind codes, QR scan-login, and
op-login vouching. Remediates the 33-finding cross-check review across
backend, CLI, panel, plugins, and docs.

Backend/CLI:
- Drop password routes and fields from account/user/onboard/auth
  handlers; align tests (new account subtests, naming reserves
  "console", op-login/onboard/qr-login test updates).
- Add migrations 0016_op_login.sql and 0017_drop_password.sql.
- Thread panel/admin hostnames from hostcfg through api.go,
  setup_panel.go, tui_root.go and tui_preflight.go instead of
  hardcoding; bootstrap.sh writes panel-hostname/admin-hostname
  into felis.toml.
- Reword breakglass and TUI copy for passwordless flows.

Panel:
- Delete the ChangePassword page and all password UI; align
  login/auth/api/types with the passwordless contract; add the
  migration and op-login approval flows.
- i18n: convert ImageBuildPage durations/status badges and
  ServerLuckPerms strings to translation keys; drop 72 orphan keys
  per locale; unify the title as "Felis - Console".

Plugins (all six rebuilt):
- Velocity waiting router returns 503 at_capacity during wake;
  MOTD/control-channel copy and config comments.
- Paper zh menu title; Limbo bind-code TTL 600s with panel_url
  preference; unified /link lines in fabric/forge/neoforge; shared
  link-client javadoc contract fixes.

Docs: openapi.yaml, sequence-diagrams.md, deploy/limbo/README.md and
plugins/README.md aligned with the implementation.

BREAKING CHANGE: migration 0017 irreversibly drops
users.password_hash and users.must_change_password; password login
cannot be restored after migrating.
This commit is contained in:
flyemoji committed 2026-07-20 04:47:32 +09:00
1 parent c96b36a41f
commit 7860152f57
97 files changed
+1923 -1444

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ These are the in-cluster and edge plugins for Felis. Every module **except the
lobby** ships the in-game first leg of the §10 account-link flow: a player who is already online
(so Mojang has verified their UUID) runs `/link`; the plugin asks felis-api to
mint a one-time code for that UUID and shows it in chat. The player then enters
the code on the web panel → **Account** page (the second leg), which binds the
the code on the web console → **Account** page (the second leg), which binds the
code to their logged-in account. The web side is already built.
The **Velocity** module additionally carries the §11 domain-autostart routing
@@ -28,7 +28,7 @@ import java.util.concurrent.Executors;
* FelisFabricMod is the Fabric (dedicated-server) leg of the §10 account-link
* flow. A server-side {@code /link} command takes the player's already-verified
* UUID, asks felis-api for a one-time code, and shows it in chat; the player then
* redeems it on the web panel. The HTTP call is pushed onto a daemon I/O thread
* redeems it on the web console. The HTTP call is pushed onto a daemon I/O thread
* and the reply is hopped back onto the server thread, so a slow felis-api never
* stalls the tick loop. Failures collapse to a generic chat line with details
* confined to the server log.
@@ -65,7 +65,7 @@ public final class FelisFabricMod implements DedicatedServerModInitializer {
try {
player = source.getPlayerOrException();
} catch (CommandSyntaxException e) {
source.sendFailure(Component.literal("/link can only be run by a player."));
source.sendFailure(Component.literal("/link 只能由玩家执行 / /link can only be run by a player."));
return 0;
}
requestAndReply(source.getServer(), player);
@@ -75,18 +75,22 @@ public final class FelisFabricMod implements DedicatedServerModInitializer {
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
UUID uuid = player.getUUID();
player.sendSystemMessage(Component.literal("Requesting a link code…"));
player.sendSystemMessage(Component.literal("正在获取绑定码… / Requesting a link code…"));
io.submit(() -> {
try {
LinkCode code = linkClient.requestCode(uuid);
server.execute(() -> player.sendSystemMessage(Component.literal(
"Your link code: " + code.code()
+ " — enter it on the web panel → Account (valid a few minutes).")));
server.execute(() -> {
player.sendSystemMessage(Component.literal(
"绑定码 / Link code: " + code.code() + "(几分钟内有效 / valid a few minutes)"));
player.sendSystemMessage(Component.literal(code.panelUrl() != null
? "在此完成绑定 / Finish linking at: " + code.panelUrl()
: "在网页控制台 → 账户 中输入 / Enter it on the web console → Account."));
});
} catch (LinkException e) {
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
uuid, e.statusCode(), e.errorCode(), e.getMessage());
server.execute(() -> player.sendSystemMessage(Component.literal(
"Couldn't get a link code right now. Please try again in a moment.")));
"现在无法获取绑定码,请稍后再试 / Couldn't get a link code right now. Please try again in a moment.")));
}
});
}
@@ -70,7 +70,7 @@ public final class FelisForgeMod {
try {
player = source.getPlayerOrException();
} catch (CommandSyntaxException e) {
source.sendFailure(Component.literal("/link can only be run by a player."));
source.sendFailure(Component.literal("/link 只能由玩家执行 / /link can only be run by a player."));
return 0;
}
requestAndReply(source.getServer(), player);
@@ -80,18 +80,22 @@ public final class FelisForgeMod {
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
UUID uuid = player.getUUID();
player.sendSystemMessage(Component.literal("Requesting a link code…"));
player.sendSystemMessage(Component.literal("正在获取绑定码… / Requesting a link code…"));
io.submit(() -> {
try {
LinkCode code = linkClient.requestCode(uuid);
server.execute(() -> player.sendSystemMessage(Component.literal(
"Your link code: " + code.code()
+ " — enter it on the web panel → Account (valid a few minutes).")));
server.execute(() -> {
player.sendSystemMessage(Component.literal(
"绑定码 / Link code: " + code.code() + "(几分钟内有效 / valid a few minutes)"));
player.sendSystemMessage(Component.literal(code.panelUrl() != null
? "在此完成绑定 / Finish linking at: " + code.panelUrl()
: "在网页控制台 → 账户 中输入 / Enter it on the web console → Account."));
});
} catch (LinkException e) {
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
uuid, e.statusCode(), e.errorCode(), e.getMessage());
server.execute(() -> player.sendSystemMessage(Component.literal(
"Couldn't get a link code right now. Please try again in a moment.")));
"现在无法获取绑定码,请稍后再试 / Couldn't get a link code right now. Please try again in a moment.")));
}
});
}
@@ -72,7 +72,7 @@ import java.util.logging.Logger;
* (env wins, else a {@code felis-link.properties} template in the plugin data dir) via
* the shared {@link LinkConfigLoader}. {@code FELIS_ROOT_DOMAIN} builds the console
* link; {@code FELIS_LOBBY_SERVER} (default {@code lobby}) is the transfer target;
* {@code FELIS_LOGIN_TIMEOUT_SECONDS} (default 300) bounds the login window. If the
* {@code FELIS_LOGIN_TIMEOUT_SECONDS} (default 600) bounds the login window. If the
* link config or the root domain is absent the login flow stays OFF and the plugin
* runs readiness-only — the same "load un-crippled" fail-safe the other Felis plugins
* use — so a bare image still boots and serves readiness; production must supply the
@@ -88,10 +88,11 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
private static final int DEFAULT_PORT = 8080;
// Poll cadence and window. 20 ticks ≈ 1s at Limbo's tick rate; polling once a
// second is responsive without hammering felis-api. The default window (5 min)
// matches the Bind Code TTL — no point holding a player past code expiry.
// second is responsive without hammering felis-api. The default window (10 min)
// matches the Bind Code TTL (linkCodeTTL in internal/api) — no point holding a
// player past code expiry, and no point cutting them off while it is still valid.
private static final long POLL_PERIOD_TICKS = 20L;
private static final long DEFAULT_TIMEOUT_SECONDS = 300L;
private static final long DEFAULT_TIMEOUT_SECONDS = 600L;
private static final long MIN_TIMEOUT_SECONDS = 30L;
private static final long MAX_TIMEOUT_SECONDS = 3600L;
@@ -261,17 +262,21 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
return; // player left during the async mint
}
// Prefer the panel URL the server minted with the code (it is the same
// single source of truth felis-api holds); the env-built consoleUrl is the
// fallback for an older API that does not emit panel_url yet.
String url = code.panelUrl() != null ? code.panelUrl() : consoleUrl;
try {
player.openBook(loginBook(code));
player.openBook(loginBook(code, url));
} catch (RuntimeException e) {
// A client that refuses the book (rare) still gets the chat instructions
// below, so a book failure is not fatal to the flow.
LOG.fine("FelisLimbo: openBook failed for " + id + " — " + e.getMessage());
}
player.sendMessage("§e[Felis] 绑定码 / Code: §6" + code.code());
player.sendMessage("§e[Felis] 用系统浏览器打开 §b" + consoleUrl
player.sendMessage("§e[Felis] 用系统浏览器打开 §b" + url
+ " §e完成登录(勿用微信/QQ内置浏览器)。");
player.sendMessage("§7Open " + consoleUrl + " in your system browser (not WeChat/QQ) to finish.");
player.sendMessage("§7Open " + url + " in your system browser (not WeChat/QQ) to finish.");
long deadline = System.currentTimeMillis() + timeoutMillis;
int taskId = getServer().getScheduler().runTaskTimerAsync(
@@ -341,13 +346,13 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
// ---- rendering / wire ----
private Book loginBook(LinkCode code) {
private Book loginBook(LinkCode code, String url) {
Component page = Component.text("Felis 登录 / Login\n\n")
.append(Component.text("绑定码 / Code:\n"))
.append(Component.text(code.code() + "\n\n").color(NamedTextColor.GOLD))
.append(Component.text("▶ 点此打开登录页\n▶ Open login page\n")
.color(NamedTextColor.AQUA)
.clickEvent(ClickEvent.openUrl(consoleUrl)))
.clickEvent(ClickEvent.openUrl(url)))
.append(Component.text("\n在系统浏览器中完成。\nUse your SYSTEM browser —\nnot WeChat / QQ (passkey\nwon't work there).")
.color(NamedTextColor.GRAY));
return Book.book(
@@ -74,7 +74,7 @@ public final class FelisNeoForgeMod {
try {
player = source.getPlayerOrException();
} catch (CommandSyntaxException e) {
source.sendFailure(Component.literal("/link can only be run by a player."));
source.sendFailure(Component.literal("/link 只能由玩家执行 / /link can only be run by a player."));
return 0;
}
requestAndReply(source.getServer(), player);
@@ -84,18 +84,22 @@ public final class FelisNeoForgeMod {
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
UUID uuid = player.getUUID();
player.sendSystemMessage(Component.literal("Requesting a link code…"));
player.sendSystemMessage(Component.literal("正在获取绑定码… / Requesting a link code…"));
io.submit(() -> {
try {
LinkCode code = linkClient.requestCode(uuid);
server.execute(() -> player.sendSystemMessage(Component.literal(
"Your link code: " + code.code()
+ " — enter it on the web panel → Account (valid a few minutes).")));
server.execute(() -> {
player.sendSystemMessage(Component.literal(
"绑定码 / Link code: " + code.code() + "(几分钟内有效 / valid a few minutes)"));
player.sendSystemMessage(Component.literal(code.panelUrl() != null
? "在此完成绑定 / Finish linking at: " + code.panelUrl()
: "在网页控制台 → 账户 中输入 / Enter it on the web console → Account."));
});
} catch (LinkException e) {
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
uuid, e.statusCode(), e.errorCode(), e.getMessage());
server.execute(() -> player.sendSystemMessage(Component.literal(
"Couldn't get a link code right now. Please try again in a moment.")));
"现在无法获取绑定码,请稍后再试 / Couldn't get a link code right now. Please try again in a moment.")));
}
});
}
@@ -48,14 +48,13 @@ import java.util.List;
* {@code ClaimRequest} when it is claimable (ownerless + stopped → "Claim &
* Start") or a {@code WakeRequest} otherwise (the single frame behind both the "Join"
* of a running owned server and the "Wake" of a stopped owned one), then closes the
* menu. A refusal comes back as an {@code Error} frame and is shown to the player —
* the only place claim/quota/policy failures surface — and readiness arrives as
* {@code TransferReady} just before the proxy Connects them.
* menu. A claim refusal comes back as an {@code Error} frame and is shown to the
* player here; wake-path refusals (policy gate, capacity) are chat messages the
* proxy's waiting queue sends directly. Readiness arrives as {@code TransferReady}
* just before the proxy Connects them.
*/
public final class FelisPaperPlugin extends JavaPlugin implements Listener, PluginMessageListener {
private static final Component MENU_TITLE =
Component.text("Felis Servers", NamedTextColor.AQUA).decoration(TextDecoration.ITALIC, false);
private static final int MAX_TILES = 54; // a double chest, the GUI ceiling
/** Server names to show as tiles, in display order; loaded from config. */
@@ -90,19 +89,21 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
}
private void openMenu(Player player) {
boolean zh = zh(player);
if (servers.isEmpty()) {
player.sendMessage(Component.text(
"No servers are configured yet — ask an operator to set up felis-paper.",
zh ? "还没有配置任何服务器——请管理员先配置 felis-paper。"
: "No servers are configured yet — ask an operator to set up felis-paper.",
NamedTextColor.YELLOW));
return;
}
int shown = Math.min(servers.size(), MAX_TILES);
List<String> view = new ArrayList<>(servers.subList(0, shown));
MenuHolder holder = new MenuHolder(view);
Inventory inv = Bukkit.createInventory(holder, invSize(shown), MENU_TITLE);
Inventory inv = Bukkit.createInventory(holder, invSize(shown), menuTitle(zh));
holder.setInventory(inv);
for (int i = 0; i < shown; i++) {
inv.setItem(i, loadingTile(view.get(i)));
inv.setItem(i, loadingTile(view.get(i), zh));
}
player.openInventory(inv);
// Ask the proxy for live status of every tile; answers repaint them.
@@ -180,7 +181,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
case ControlFrame.ERROR:
// The proxy already sanitizes transport faults; this is the only place
// a claim/quota/policy refusal becomes visible to the player.
player.sendMessage(Component.text("⚠ " + errorText(frame), NamedTextColor.RED));
player.sendMessage(Component.text("⚠ " + errorText(frame, zh(player)), NamedTextColor.RED));
break;
case ControlFrame.TRANSFER_READY:
// The proxy performs the actual Connect; just make sure a stale menu is
@@ -203,7 +204,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
return; // a server we are not showing
}
holder.put(frame.server(), frame);
top.setItem(slot, tile(frame));
top.setItem(slot, tile(frame, zh(player)));
}
private void closeIfMenu(Player player) {
@@ -214,21 +215,26 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
// ---- rendering ----
private ItemStack tile(ControlFrame f) {
private static Component menuTitle(boolean zh) {
return Component.text(zh ? "Felis 服务器" : "Felis Servers", NamedTextColor.AQUA)
.decoration(TextDecoration.ITALIC, false);
}
private ItemStack tile(ControlFrame f, boolean zh) {
Material material;
String action;
NamedTextColor color;
if (f.claimable()) {
material = Material.GOLD_BLOCK;
action = "Claim & Start";
action = zh ? "认领并启动" : "Claim & Start";
color = NamedTextColor.GOLD;
} else if (f.ready()) {
material = Material.LIME_CONCRETE;
action = "Join";
action = zh ? "加入" : "Join";
color = NamedTextColor.GREEN;
} else {
material = Material.RED_CONCRETE;
action = "Wake";
action = zh ? "唤醒" : "Wake";
color = NamedTextColor.RED;
}
ItemStack item = new ItemStack(material);
@@ -236,18 +242,18 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
meta.displayName(Component.text(action + " · " + f.server(), color)
.decoration(TextDecoration.ITALIC, false));
List<Component> lore = new ArrayList<>();
lore.add(line("Status", f.phase() == null || f.phase().isEmpty() ? "?" : f.phase()));
lore.add(line("Players", f.playersOnline() + "/" + f.playersMax()));
lore.add(line(zh ? "状态" : "Status", f.phase() == null || f.phase().isEmpty() ? "?" : f.phase()));
lore.add(line(zh ? "在线" : "Players", f.playersOnline() + "/" + f.playersMax()));
meta.lore(lore);
item.setItemMeta(meta);
return item;
}
private ItemStack loadingTile(String server) {
private ItemStack loadingTile(String server, boolean zh) {
ItemStack item = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
ItemMeta meta = item.getItemMeta();
meta.displayName(Component.text(server, NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false));
meta.lore(List.of(Component.text("Loading…", NamedTextColor.DARK_GRAY)
meta.lore(List.of(Component.text(zh ? "加载中…" : "Loading…", NamedTextColor.DARK_GRAY)
.decoration(TextDecoration.ITALIC, false)));
item.setItemMeta(meta);
return item;
@@ -259,27 +265,35 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
.decoration(TextDecoration.ITALIC, false);
}
private static String errorText(ControlFrame f) {
private static String errorText(ControlFrame f, boolean zh) {
String code = f.code();
if (code != null) {
switch (code) {
case "not_linked":
return "Link your account first — run /link, then finish on the web panel.";
return zh ? "请先绑定账号——运行 /link,然后在网页控制台完成绑定。"
: "Link your account first — run /link, then finish on the web console.";
case "quota_exceeded":
return "You've reached your server quota.";
return zh ? "你已达到服务器配额上限。"
: "You've reached your server quota.";
case "already_claimed":
return "That server was just claimed by someone else.";
return zh ? "该服务器已被认领。"
: "That server is already claimed.";
default:
break;
}
}
return f.message() != null && !f.message().isEmpty()
? f.message()
: (code != null ? code : "Request failed — please try again.");
: (code != null ? code : (zh ? "请求失败,请重试。" : "Request failed — please try again."));
}
// ---- helpers ----
/** zh mirrors the Velocity rule: render Chinese when the client locale is zh-*. */
private static boolean zh(Player player) {
return "zh".equalsIgnoreCase(player.locale().getLanguage());
}
private void sendUpstream(Player player, ControlFrame frame) {
player.sendPluginMessage(this, Control.CHANNEL, Control.encode(frame));
}
@@ -14,22 +14,19 @@ import java.util.UUID;
/**
* FelisApiClient is the proxy's read/drive client for the felis-api internal face
* (spec §7, §9). Where {@link LinkClient} mints account-link codes, this client
* drives domain-autostart routing: it lists the registrable servers, resolves a
* connecting virtual host to its server, polls a server's lifecycle status, pulls
* the wake lever, and reports real player joins. It shares the {@link LinkConfig}
* drives domain-autostart routing: it lists the registrable servers, polls a
* server's lifecycle status, pulls the wake lever, and reports real player
* joins. It shares the {@link LinkConfig}
* (same internal base URL + service token) and the same zero-dependency JDK HTTP
* stack, so it compiles straight into each loader jar with nothing to shade.
*
* <p>Every call authenticates with {@code Authorization: Bearer <serviceToken>}
* and surfaces a non-success status as a {@link LinkException} carrying the HTTP
* status, so the proxy can branch on it without parsing human text. The two that
* matter for routing:
* <ul>
* <li>{@code wake} → 403 means the autostartPolicy gate refused this UUID (do
* not enqueue the player); 429 means a wake is already cooling down
* ("already waking, keep waiting"), not a failure.</li>
* <li>{@code serverByHost} → 404 means the host maps to no server.</li>
* </ul>
* status, so the proxy can branch on it without parsing human text. The one that
* matters most for routing: {@code wake} → 403 means the autostartPolicy gate
* refused this UUID (do not enqueue the player); 429 means a wake is already
* cooling down ("already waking, keep waiting"); 503 means the cluster is at
* capacity (tell the player to try later — nothing is coming up).
*/
public final class FelisApiClient {
private final LinkConfig config;
@@ -57,16 +54,6 @@ public final class FelisApiClient {
return out;
}
/**
* serverByHost resolves {@code subdomain.<root_domain>} to its server view
* (GET /servers/by-host/{host}). A 404 surfaces as a LinkException with
* statusCode 404 so the caller can distinguish "unknown host" from a transport
* fault.
*/
public ServerView serverByHost(String host) throws LinkException {
return ServerView.fromJson(getObject("/api/v1/servers/by-host/" + Objects.requireNonNull(host, "host"), 200));
}
/** serverStatus reads one server's current lifecycle view (internal status). */
public ServerView serverStatus(String name) throws LinkException {
return ServerView.fromJson(getObject("/api/v1/internal/servers/" + Objects.requireNonNull(name, "name") + "/status", 200));
@@ -75,8 +62,9 @@ public final class FelisApiClient {
/**
* wake pulls the domain-autostart lever for {@code name} on behalf of the
* joining player (spec §9.1, §14). The reply (202) carries the current phase
* and ready flag so the caller can decide whether to wait. A 403 (policy gate)
* or 429 (cooldown) arrives as a LinkException the caller branches on.
* and ready flag so the caller can decide whether to wait. A 403 (policy gate),
* 429 (cooldown), or 503 {@code at_capacity} (running cap) arrives as a
* LinkException the caller branches on.
*/
public ServerView wake(String name, UUID mcUuid) throws LinkException {
Objects.requireNonNull(name, "name");
@@ -142,7 +130,7 @@ public final class FelisApiClient {
* completion leg of the in-game login flow (spec §B3). After the player redeems
* the Bind Code on {@code console.<root_domain>} the login limbo polls this until
* it flips true, then admits/transfers the player. {@code GET
* /api/v1/internal/account/link/status/{mc_uuid}} → {@code {"linked":bool,...}};
* /api/v1/internal/account/link/status/{mc_uuid}} → {@code {"linked":bool}};
* read-only and keyed by the verified UUID, so it consumes nothing and is safe to
* poll repeatedly. Anything but {@code linked:true} (including a missing field) is
* reported as not-yet-linked — the caller keeps waiting rather than admitting on
@@ -22,7 +22,8 @@ import java.util.UUID;
* <li>header {@code Authorization: Bearer <serviceToken>} (constant-time
* compared server-side; an empty token fails closed)</li>
* <li>request body {@code {"mc_uuid":"<uuid>"}}</li>
* <li>success: HTTP 201 with {@code {"code","expires_at"}}</li>
* <li>success: HTTP 201 with {@code {"code","expires_at","panel_url"?}}
* ({@code panel_url} present only when a panel hostname is configured)</li>
* <li>failure: the {@code {"error":{"code","message"}}} envelope</li>
* </ul>
*
@@ -94,7 +95,11 @@ public final class LinkClient {
"success body missing 'code'");
}
Object exp = obj.get("expires_at");
return new LinkCode((String) code, exp instanceof String ? (String) exp : null);
Object panelUrl = obj.get("panel_url");
return new LinkCode((String) code,
exp instanceof String ? (String) exp : null,
panelUrl instanceof String && !((String) panelUrl).isEmpty()
? (String) panelUrl : null);
}
private LinkException parseError(int status, String text) {
@@ -13,10 +13,12 @@ import java.util.Objects;
public final class LinkCode {
private final String code;
private final String expiresAt;
private final String panelUrl;
public LinkCode(String code, String expiresAt) {
public LinkCode(String code, String expiresAt, String panelUrl) {
this.code = Objects.requireNonNull(code, "code");
this.expiresAt = expiresAt;
this.panelUrl = panelUrl;
}
public String code() {
@@ -27,4 +29,12 @@ public final class LinkCode {
public String expiresAt() {
return expiresAt;
}
/**
* panelUrl is the ready-to-open web-panel URL the server minted alongside the
* code, or null when no panel hostname is configured server-side.
*/
public String panelUrl() {
return panelUrl;
}
}
@@ -5,8 +5,7 @@ import java.util.Map;
/**
* ServerView is the proxy-side mirror of the felis-api lifecycle view of one
* MinecraftServer (the {@code ServerInfo} the internal face emits for
* {@code GET /servers}, {@code GET /servers/by-host/{host}} and the internal
* status/wake replies). It is an immutable, dependency-free value object so the
* {@code GET /servers} and the internal status/wake replies). It is an immutable, dependency-free value object so the
* shared link core stays zero-dependency and source-shareable across all four
* loaders.
*
@@ -193,10 +193,12 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener
// errors carry user-safe text (the same {code,message} the external API returns),
// but a transport failure (statusCode 0) carries internal IO detail — host names,
// refused ports — that must not reach a player's screen, so it is generalized.
// The lobby localizes known codes itself; this fallback message may reach the
// screen raw, so it carries both languages in one line (the no-locale pattern).
private static ControlFrame errorFrame(LinkException e, String server) {
String code = e.errorCode() != null ? e.errorCode() : "error";
String message = e.statusCode() == 0
? "felis is temporarily unavailable — please try again."
? "Felis 暂时不可用,请稍后再试 / Felis is temporarily unavailable — please try again."
: e.getMessage();
return ControlFrame.error(code, message, server);
}
@@ -19,7 +19,8 @@ import java.util.Properties;
* URL + service token (and its first-run template), so {@code /link} keeps working
* exactly as before; these extra keys are read from the same properties file (or
* {@code FELIS_ROOT_DOMAIN} / {@code FELIS_LOGIN_SERVER} /
* {@code FELIS_LOBBY_SERVER}).
* {@code FELIS_LOBBY_SERVER} / {@code FELIS_PANEL_HOSTNAME} /
* {@code FELIS_ADMIN_HOSTNAME}).
*
* <p>The routing extras are optional at load time and the routing layer degrades rather
* than crashing: a missing {@code root-domain} disables routing (with a clear log
@@ -33,9 +34,13 @@ final class FelisVelocityConfig {
static final String ENV_ROOT_DOMAIN = "FELIS_ROOT_DOMAIN";
static final String ENV_LOGIN = "FELIS_LOGIN_SERVER";
static final String ENV_LOBBY = "FELIS_LOBBY_SERVER";
static final String ENV_PANEL_HOSTNAME = "FELIS_PANEL_HOSTNAME";
static final String ENV_ADMIN_HOSTNAME = "FELIS_ADMIN_HOSTNAME";
private static final String KEY_ROOT_DOMAIN = "root-domain";
private static final String KEY_LOGIN = "login-server";
private static final String KEY_LOBBY = "lobby-server";
private static final String KEY_PANEL_HOSTNAME = "panel-hostname";
private static final String KEY_ADMIN_HOSTNAME = "admin-hostname";
private static final String DEFAULT_LOGIN = "login";
private static final String DEFAULT_LOBBY = "lobby";
@@ -43,13 +48,18 @@ final class FelisVelocityConfig {
private final String rootDomain; // null → routing disabled
private final String loginServer;
private final String lobbyServer;
private final String panelHostname; // null → fall back to console.<root>
private final String adminHostname; // null → fall back to op.console.<root>
private FelisVelocityConfig(LinkConfig linkConfig, String rootDomain,
String loginServer, String lobbyServer) {
String loginServer, String lobbyServer,
String panelHostname, String adminHostname) {
this.linkConfig = linkConfig;
this.rootDomain = rootDomain;
this.loginServer = loginServer;
this.lobbyServer = lobbyServer;
this.panelHostname = panelHostname;
this.adminHostname = adminHostname;
}
static FelisVelocityConfig load(Path file) throws IOException {
@@ -63,13 +73,17 @@ final class FelisVelocityConfig {
String root = trimToNull(firstNonBlank(System.getenv(ENV_ROOT_DOMAIN), props.getProperty(KEY_ROOT_DOMAIN)));
String login = trimToNull(firstNonBlank(System.getenv(ENV_LOGIN), props.getProperty(KEY_LOGIN)));
String lobby = trimToNull(firstNonBlank(System.getenv(ENV_LOBBY), props.getProperty(KEY_LOBBY)));
String panel = trimToNull(firstNonBlank(System.getenv(ENV_PANEL_HOSTNAME), props.getProperty(KEY_PANEL_HOSTNAME)));
String admin = trimToNull(firstNonBlank(System.getenv(ENV_ADMIN_HOSTNAME), props.getProperty(KEY_ADMIN_HOSTNAME)));
login = login == null ? DEFAULT_LOGIN : login;
lobby = lobby == null ? DEFAULT_LOBBY : lobby;
if (login.equalsIgnoreCase(lobby)) {
throw new IOException("login-server and lobby-server must be different");
}
return new FelisVelocityConfig(
link, root == null ? null : root.toLowerCase(Locale.ROOT), login, lobby);
link, root == null ? null : root.toLowerCase(Locale.ROOT), login, lobby,
panel == null ? null : panel.toLowerCase(Locale.ROOT),
admin == null ? null : admin.toLowerCase(Locale.ROOT));
}
LinkConfig linkConfig() {
@@ -95,6 +109,28 @@ final class FelisVelocityConfig {
return lobbyServer;
}
/**
* panelHostname is the player web-panel host, falling back to
* {@code console.<root-domain>}; null when neither is configured.
*/
String panelHostname() {
if (panelHostname != null) {
return panelHostname;
}
return rootDomain == null ? null : "console." + rootDomain;
}
/**
* adminHostname is the staff op.console host, falling back to
* {@code op.console.<root-domain>}; null when neither is configured.
*/
String adminHostname() {
if (adminHostname != null) {
return adminHostname;
}
return rootDomain == null ? null : "op.console." + rootDomain;
}
private static String firstNonBlank(String a, String b) {
if (a != null && !a.trim().isEmpty()) {
return a;
@@ -28,6 +28,7 @@ import org.slf4j.Logger;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
import java.util.regex.Pattern;
@@ -180,20 +181,34 @@ public final class FelisVelocityPlugin {
}
private void requestAndReply(Player player) {
player.sendMessage(Component.text("Requesting a link code…", NamedTextColor.GRAY));
boolean zh = zh(player);
player.sendMessage(Component.text(
zh ? "正在获取绑定码……" : "Requesting a link code…", NamedTextColor.GRAY));
async(() -> {
try {
LinkCode code = linkClient.requestCode(player.getUniqueId());
player.sendMessage(Component.text("Your link code: ", NamedTextColor.GREEN)
.append(Component.text(code.code(), NamedTextColor.YELLOW)));
player.sendMessage(Component.text(
"Enter it on the web panel → Account to finish linking (valid a few minutes).",
NamedTextColor.GRAY));
zh ? "你的绑定码:" : "Your link code: ", NamedTextColor.GREEN)
.append(Component.text(code.code(), NamedTextColor.YELLOW)));
String panelUrl = code.panelUrl();
if (panelUrl != null) {
player.sendMessage(Component.text(
zh ? "在这里输入它完成绑定(几分钟内有效):"
: "Enter it here to finish linking (valid a few minutes):",
NamedTextColor.GRAY));
player.sendMessage(Component.text(" " + panelUrl, NamedTextColor.WHITE));
} else {
player.sendMessage(Component.text(
zh ? "在网页控制台 → 账户 中输入它完成绑定(几分钟内有效)。"
: "Enter it on the web console → Account to finish linking (valid a few minutes).",
NamedTextColor.GRAY));
}
} catch (LinkException e) {
logger.warn("link code request failed for {} (status={}, code={}): {}",
player.getUniqueId(), e.statusCode(), e.errorCode(), e.getMessage());
player.sendMessage(Component.text(
"Couldn't get a link code right now. Please try again in a moment.",
zh ? "现在无法获取绑定码,请稍后再试。"
: "Couldn't get a link code right now. Please try again in a moment.",
NamedTextColor.RED));
}
});
@@ -210,6 +225,7 @@ public final class FelisVelocityPlugin {
// /felis server the felis servers this proxy knows
// /felis go <server> wake a server and move me in when it's ready
// /felis claim take ownership of the server I'm on
// /felis migrate open a migration of my servers to another account
// /felis web where the web consoles live
// /felis web op approve <code> vouch for a pending op.console staff login (§B)
//
@@ -301,15 +317,18 @@ public final class FelisVelocityPlugin {
// login limbo (or not yet on any backend): they have not passed the front door.
// Fails closed on an unknown position.
private boolean ensureOutOfLimbo(Player player) {
boolean zh = zh(player);
Optional<ServerConnection> current = player.getCurrentServer();
if (current.isEmpty()) {
player.sendMessage(Component.text(
"Hold on — finish connecting before using /felis.", NamedTextColor.YELLOW));
zh ? "请稍候——完成连接后再使用 /felis。"
: "Hold on — finish connecting before using /felis.", NamedTextColor.YELLOW));
return false;
}
if (config.loginServer().equalsIgnoreCase(current.get().getServerInfo().getName())) {
player.sendMessage(Component.text(
"Finish signing in first — /felis isn't available from the login area.",
zh ? "请先完成登录——登录区内无法使用 /felis。"
: "Finish signing in first — /felis isn't available from the login area.",
NamedTextColor.YELLOW));
return false;
}
@@ -328,53 +347,69 @@ public final class FelisVelocityPlugin {
if (!gateInfo(source)) {
return;
}
boolean zh = zh(source);
source.sendMessage(Component.text("Felis proxy", NamedTextColor.AQUA));
source.sendMessage(field("online-mode", String.valueOf(onlineMode)));
if (!routingActive) {
source.sendMessage(Component.text(
" routing: disabled" + (onlineMode ? " (no root-domain set)" : " (offline mode)"),
zh ? " routing: 已禁用" + (onlineMode ? "(未设置 root-domain)" : "(离线模式)")
: " routing: disabled" + (onlineMode ? " (no root-domain set)" : " (offline mode)"),
NamedTextColor.YELLOW));
source.sendMessage(Component.text(" /felis help for commands", NamedTextColor.GRAY));
source.sendMessage(Component.text(
zh ? " /felis help 查看命令" : " /felis help for commands", NamedTextColor.GRAY));
return;
}
source.sendMessage(field("root-domain", config.rootDomain()));
source.sendMessage(field("login", config.loginServer()));
source.sendMessage(field("lobby", config.lobbyServer()));
source.sendMessage(field("servers", String.valueOf(registry.all().size())));
source.sendMessage(Component.text(" /felis help for commands", NamedTextColor.GRAY));
source.sendMessage(Component.text(
zh ? " /felis help 查看命令" : " /felis help for commands", NamedTextColor.GRAY));
}
private void sendHelp(CommandSource source) {
source.sendMessage(Component.text("Felis commands", NamedTextColor.AQUA));
helpLine(source, "/felis", "proxy and routing status");
helpLine(source, "/felis server", "the felis servers this proxy knows");
helpLine(source, "/felis go <server>", "start a server and move you in when it's ready");
helpLine(source, "/felis claim", "take ownership of the server you're on");
helpLine(source, "/felis migrate", "move your servers to another account");
helpLine(source, "/felis web", "where the web consoles live");
helpLine(source, "/felis web op approve <code>", "approve a pending operator sign-in");
boolean zh = zh(source);
source.sendMessage(Component.text(zh ? "Felis 命令" : "Felis commands", NamedTextColor.AQUA));
helpLine(source, "/felis",
zh ? "代理与路由状态" : "proxy and routing status");
helpLine(source, "/felis server",
zh ? "此代理已知的 felis 服务器" : "the felis servers this proxy knows");
helpLine(source, "/felis go <server>",
zh ? "启动服务器并在就绪后把你传送过去" : "start a server and move you in when it's ready");
helpLine(source, "/felis claim",
zh ? "认领你所在的服务器" : "take ownership of the server you're on");
helpLine(source, "/felis migrate",
zh ? "把你的服务器迁移到另一个账户" : "move your servers to another account");
helpLine(source, "/felis web",
zh ? "网页控制台地址" : "where the web consoles live");
helpLine(source, "/felis web op approve <code>",
zh ? "批准待处理的管理员登录" : "approve a pending operator sign-in");
}
private void sendServerList(CommandSource source) {
if (!gateInfo(source)) {
return;
}
boolean zh = zh(source);
if (!routingActive) {
source.sendMessage(Component.text("Felis routing is disabled.", NamedTextColor.YELLOW));
source.sendMessage(Component.text(
zh ? "Felis 路由已禁用。" : "Felis routing is disabled.", NamedTextColor.YELLOW));
return;
}
List<ServerView> servers = registry.all().stream()
.filter(v -> !isSystemServer(v.name()))
.toList();
if (servers.isEmpty()) {
source.sendMessage(Component.text("No felis servers known yet.", NamedTextColor.GRAY));
source.sendMessage(Component.text(
zh ? "暂无已知的 felis 服务器。" : "No felis servers known yet.", NamedTextColor.GRAY));
return;
}
source.sendMessage(Component.text("Felis servers:", NamedTextColor.AQUA));
source.sendMessage(Component.text(zh ? "Felis 服务器:" : "Felis servers:", NamedTextColor.AQUA));
for (ServerView v : servers) {
String phase = v.phase() == null ? "?" : v.phase();
String ready = v.ready() ? (zh ? ",就绪" : ", ready") : "";
source.sendMessage(Component.text(" " + v.name() + " ", NamedTextColor.WHITE)
.append(Component.text("[" + phase + (v.ready() ? ", ready" : "") + "]",
.append(Component.text("[" + phase + ready + "]",
v.ready() ? NamedTextColor.GREEN : NamedTextColor.GRAY)));
}
}
@@ -384,8 +419,9 @@ public final class FelisVelocityPlugin {
if (player == null || !ensureOutOfLimbo(player)) {
return;
}
boolean zh = zh(player);
if (!routingActive) {
player.sendMessage(routingDisabled());
player.sendMessage(routingDisabled(zh));
return;
}
String target = serverArg.trim();
@@ -398,12 +434,15 @@ public final class FelisVelocityPlugin {
}
if (match == null) {
player.sendMessage(Component.text(
"No felis server named « " + target + " ». Try /felis server.", NamedTextColor.YELLOW));
zh ? "没有名为「" + target + "」的 felis 服务器。试试 /felis server。"
: "No felis server named « " + target + " ». Try /felis server.", NamedTextColor.YELLOW));
return;
}
Optional<ServerConnection> current = player.getCurrentServer();
if (current.isPresent() && current.get().getServerInfo().getName().equalsIgnoreCase(match.name())) {
player.sendMessage(Component.text("You're already on « " + match.name() + " ».", NamedTextColor.GRAY));
player.sendMessage(Component.text(
zh ? "你已经在「" + match.name() + "」上了。"
: "You're already on « " + match.name() + " ».", NamedTextColor.GRAY));
return;
}
// Wake + park + transfer through the shared waiting queue; it reports its own
@@ -416,29 +455,36 @@ public final class FelisVelocityPlugin {
if (player == null || !ensureOutOfLimbo(player)) {
return;
}
boolean zh = zh(player);
if (!routingActive) {
player.sendMessage(routingDisabled());
player.sendMessage(routingDisabled(zh));
return;
}
Optional<ServerConnection> current = player.getCurrentServer();
if (current.isEmpty()) {
player.sendMessage(Component.text("Join a server before claiming it.", NamedTextColor.YELLOW));
player.sendMessage(Component.text(
zh ? "请先加入一个服务器再认领。" : "Join a server before claiming it.",
NamedTextColor.YELLOW));
return;
}
String name = current.get().getServerInfo().getName();
if (!registry.isManaged(name)) {
player.sendMessage(Component.text(
"« " + name + " » isn't a claimable felis server.", NamedTextColor.YELLOW));
zh ? "「" + name + "」不是可认领的 felis 服务器。"
: "« " + name + " » isn't a claimable felis server.", NamedTextColor.YELLOW));
return;
}
UUID uuid = player.getUniqueId();
player.sendMessage(Component.text("Claiming « " + name + " »…", NamedTextColor.GRAY));
player.sendMessage(Component.text(
zh ? "正在认领「" + name + "」……" : "Claiming « " + name + " »…", NamedTextColor.GRAY));
async(() -> {
try {
apiClient.claim(name, uuid);
player.sendMessage(Component.text("You now own « " + name + " ».", NamedTextColor.GREEN));
player.sendMessage(Component.text(
zh ? "你现在拥有「" + name + "」了。" : "You now own « " + name + " ».",
NamedTextColor.GREEN));
} catch (LinkException e) {
player.sendMessage(Component.text(claimError(e, name), NamedTextColor.RED));
player.sendMessage(Component.text(claimError(e, name, zh), NamedTextColor.RED));
}
});
}
@@ -455,28 +501,34 @@ public final class FelisVelocityPlugin {
if (player == null || !ensureOutOfLimbo(player)) {
return;
}
boolean zh = zh(player);
if (!routingActive) {
player.sendMessage(routingDisabled());
player.sendMessage(routingDisabled(zh));
return;
}
UUID uuid = player.getUniqueId();
String who = player.getUsername();
player.sendMessage(Component.text("Starting account migration…", NamedTextColor.GRAY));
player.sendMessage(Component.text(
zh ? "正在发起账户迁移……" : "Starting account migration…", NamedTextColor.GRAY));
async(() -> {
try {
apiClient.migrateStart(uuid);
String root = config.rootDomain();
String panelHost = config.panelHostname();
player.sendMessage(Component.text(
"Migration started — finish it on the web console:", NamedTextColor.GREEN));
zh ? "迁移已发起——请在网页控制台完成:"
: "Migration started — finish it on the web console:", NamedTextColor.GREEN));
player.sendMessage(Component.text(
" " + (root == null ? "the players' web console" : "https://console." + root),
" " + (panelHost == null
? (zh ? "玩家网页控制台" : "the players' web console")
: "https://" + panelHost + "/account"),
NamedTextColor.WHITE));
player.sendMessage(Component.text(
"You'll confirm it's you, name the account to receive your servers, then get a code.",
zh ? "你需要确认身份、指定接收服务器的账户,然后获得一个迁移码。"
: "You'll confirm it's you, name the account to receive your servers, then get a code.",
NamedTextColor.GRAY));
logger.info("Felis: account migration started in-game by {} ({})", who, uuid);
} catch (LinkException e) {
player.sendMessage(Component.text(migrateError(e), NamedTextColor.RED));
player.sendMessage(Component.text(migrateError(e, zh), NamedTextColor.RED));
}
});
}
@@ -485,17 +537,26 @@ public final class FelisVelocityPlugin {
if (!gateInfo(source)) {
return;
}
String root = config.rootDomain();
if (root == null) {
boolean zh = zh(source);
String panelHost = config.panelHostname();
String adminHost = config.adminHostname();
if (panelHost == null && adminHost == null) {
source.sendMessage(Component.text(
"The web console isn't configured on this proxy.", NamedTextColor.YELLOW));
zh ? "此代理未配置网页控制台。"
: "The web console isn't configured on this proxy.", NamedTextColor.YELLOW));
return;
}
source.sendMessage(Component.text("Felis web consoles", NamedTextColor.AQUA));
source.sendMessage(field("players", "https://console." + root));
source.sendMessage(field("operators", "https://op.console." + root));
source.sendMessage(Component.text(
" operators: /felis web op approve <code> vouches for a pending sign-in",
zh ? "Felis 网页控制台" : "Felis web consoles", NamedTextColor.AQUA));
if (panelHost != null) {
source.sendMessage(field(zh ? "玩家" : "players", "https://" + panelHost));
}
if (adminHost != null) {
source.sendMessage(field(zh ? "管理员" : "operators", "https://" + adminHost));
}
source.sendMessage(Component.text(
zh ? " 管理员:/felis web op approve <code> 用于为待处理登录作担保"
: " operators: /felis web op approve <code> vouches for a pending sign-in",
NamedTextColor.GRAY));
}
@@ -503,12 +564,20 @@ public final class FelisVelocityPlugin {
if (!gateInfo(source)) {
return;
}
source.sendMessage(Component.text("Operator sign-in", NamedTextColor.AQUA));
boolean zh = zh(source);
String adminHost = config.adminHostname();
String site = adminHost != null ? adminHost : (zh ? "管理员控制台" : "the operator console");
source.sendMessage(Component.text(
"An operator signing in at op.console shows an approval code. Run", NamedTextColor.GRAY));
zh ? "管理员登录" : "Operator sign-in", NamedTextColor.AQUA));
source.sendMessage(Component.text(
zh ? "管理员在 " + site + " 登录时会显示一个批准码。运行"
: "An operator signing in at " + site + " shows an approval code. Run",
NamedTextColor.GRAY));
source.sendMessage(Component.text(" /felis web op approve <code>", NamedTextColor.WHITE));
source.sendMessage(Component.text(
"to vouch for it — you must be an online, linked administrator.", NamedTextColor.GRAY));
zh ? "即可为其担保——你必须是已绑定并在线的管理员。"
: "to vouch for it — you must be an online, linked administrator.",
NamedTextColor.GRAY));
}
private void doOpApprove(CommandSource source, String codeArg) {
@@ -516,36 +585,56 @@ public final class FelisVelocityPlugin {
if (player == null || !ensureOutOfLimbo(player)) {
return;
}
boolean zh = zh(player);
if (!routingActive) {
player.sendMessage(routingDisabled());
player.sendMessage(routingDisabled(zh));
return;
}
String code = codeArg.trim();
if (!OP_LOGIN_CODE.matcher(code).matches()) {
player.sendMessage(Component.text(
"That doesn't look like a valid approval code.", NamedTextColor.RED));
zh ? "这不像一个有效的批准码。" : "That doesn't look like a valid approval code.",
NamedTextColor.RED));
return;
}
UUID approver = player.getUniqueId();
String who = player.getUsername();
player.sendMessage(Component.text("Approving operator sign-in…", NamedTextColor.GRAY));
player.sendMessage(Component.text(
zh ? "正在批准管理员登录……" : "Approving operator sign-in…", NamedTextColor.GRAY));
async(() -> {
try {
apiClient.opLoginApprove(code, approver);
player.sendMessage(Component.text(
"Approved — the operator can finish signing in now.", NamedTextColor.GREEN));
zh ? "已批准——对方现在可以完成登录了。"
: "Approved — the operator can finish signing in now.", NamedTextColor.GREEN));
logger.info("Felis: op-login {} approved in-game by {} ({})", code, who, approver);
} catch (LinkException e) {
player.sendMessage(Component.text(opApproveError(e), NamedTextColor.RED));
player.sendMessage(Component.text(opApproveError(e, zh), NamedTextColor.RED));
}
});
}
// ---- helpers ----
private Component routingDisabled() {
/**
* zh reports whether the caller's client locale is Chinese, so player-facing
* text can follow the client language. The console (and any non-player source)
* always reads English, and a client that has not yet sent its settings falls
* back to English too. Package-private so {@link WaitingRouter} and the other
* proxy faces share the one locale rule.
*/
static boolean zh(CommandSource source) {
if (!(source instanceof Player)) {
return false;
}
Locale locale = ((Player) source).getPlayerSettings().getLocale();
return locale != null && "zh".equalsIgnoreCase(locale.getLanguage());
}
private Component routingDisabled(boolean zh) {
return Component.text(
"Felis routing is disabled on this proxy" + (onlineMode ? " (no root-domain set)." : " (offline mode)."),
zh ? "此代理已禁用 Felis 路由" + (onlineMode ? "(未设置 root-domain)。" : "(离线模式)。")
: "Felis routing is disabled on this proxy" + (onlineMode ? " (no root-domain set)." : " (offline mode)."),
NamedTextColor.YELLOW);
}
@@ -555,52 +644,66 @@ public final class FelisVelocityPlugin {
}
// claimError maps the felis-api claim refusals (spec §9.3) to player-safe text.
private static String claimError(LinkException e, String server) {
private static String claimError(LinkException e, String server, boolean zh) {
switch (e.statusCode()) {
case 412:
return "Link your account on the web console before claiming a server.";
return zh ? "请先在网页控制台绑定账户,再认领服务器。"
: "Link your account on the web console before claiming a server.";
case 403:
return "You've reached your server limit — you can't claim another.";
return zh ? "你已达到服务器数量上限——无法再认领。"
: "You've reached your server limit — you can't claim another.";
case 409:
return "« " + server + " » is already owned.";
return zh ? "「" + server + "」已有主人。"
: "« " + server + " » is already owned.";
case 404:
return "« " + server + " » is no longer available.";
return zh ? "「" + server + "」已不可用。"
: "« " + server + " » is no longer available.";
case 0:
return "Felis is temporarily unavailable — please try again.";
return zh ? "Felis 暂时不可用——请稍后再试。"
: "Felis is temporarily unavailable — please try again.";
default:
return "Couldn't claim « " + server + " » right now. Please try again.";
return zh ? "现在无法认领「" + server + "」。请稍后再试。"
: "Couldn't claim « " + server + " » right now. Please try again.";
}
}
// migrateError maps the felis-api migrate-start refusals (spec §B3) to player-safe
// text. A 404 means the caller's UUID isn't linked to any account to migrate; a 409
// means the linked account can't start one (already migrated, or retired).
private static String migrateError(LinkException e) {
private static String migrateError(LinkException e, boolean zh) {
switch (e.statusCode()) {
case 404:
return "Link your account on the web console before migrating.";
return zh ? "请先在网页控制台绑定账户,再进行迁移。"
: "Link your account on the web console before migrating.";
case 409:
return "This account can't start a migration (already migrated or retired).";
return zh ? "此账户无法发起迁移(已迁移或已停用)。"
: "This account can't start a migration (already migrated or retired).";
case 0:
return "Felis is temporarily unavailable — please try again.";
return zh ? "Felis 暂时不可用——请稍后再试。"
: "Felis is temporarily unavailable — please try again.";
default:
return "Couldn't start the migration right now. Please try again.";
return zh ? "现在无法发起迁移。请稍后再试。"
: "Couldn't start the migration right now. Please try again.";
}
}
// opApproveError maps the internal approve refusals to player-safe text. A 403 is
// the API's own admin re-check (defence in depth over the in-game gate); a 404
// means no live pending request carries that code.
private static String opApproveError(LinkException e) {
private static String opApproveError(LinkException e, boolean zh) {
switch (e.statusCode()) {
case 403:
return "Only a linked administrator may approve an operator sign-in.";
return zh ? "只有已绑定的管理员才能批准管理员登录。"
: "Only a linked administrator may approve an operator sign-in.";
case 404:
return "No pending operator sign-in with that code (it may have expired).";
return zh ? "没有携带该码的待处理管理员登录(可能已过期)。"
: "No pending operator sign-in with that code (it may have expired).";
case 0:
return "Felis is temporarily unavailable — please try again.";
return zh ? "Felis 暂时不可用——请稍后再试。"
: "Felis is temporarily unavailable — please try again.";
default:
return "Couldn't approve that sign-in right now. Please try again.";
return zh ? "现在无法批准该登录。请稍后再试。"
: "Couldn't approve that sign-in right now. Please try again.";
}
}
@@ -54,14 +54,16 @@ public final class MotdResponder {
event.setPing(b.build());
}
// The server-list ping carries no client locale, so the MOTD status uses the
// both-languages-in-one-line pattern the modded /link clients share.
private static String statusLine(ServerView v) {
if (v.ready()) {
return "online";
return "在线 / online";
}
if ("Running".equals(v.desiredState())) {
return "starting…";
return "启动中… / starting…";
}
return "sleeping — join to wake";
return "休眠中,加入即唤醒 / sleeping — join to wake";
}
private static NamedTextColor statusColor(ServerView v) {
@@ -47,8 +47,10 @@ import java.util.concurrent.ConcurrentHashMap;
* command/menu queue entries are checked the same way. The wake is then gated
* server-side by autostartPolicy keyed on the player's online-mode UUID: a 403 means
* this player may not start the server (we tell them and stop), a 429 means a wake is
* already in flight (we keep waiting). Real user-backend joins are reported back so
* the reaper sees activity and the player is auto-added to the allowlist.
* already in flight (we keep waiting), and a 503 means the cluster is at capacity
* (we tell them to try later — nothing is coming up, so we do not enqueue). Real
* user-backend joins are reported back so the reaper sees activity and the player is
* auto-added to the allowlist.
*/
public final class WaitingRouter {
private static final long WAIT_TIMEOUT_MILLIS = 120_000L;
@@ -132,7 +134,9 @@ public final class WaitingRouter {
if (login.isEmpty()) {
event.setInitialServer(null);
player.disconnect(Component.text(
"The Felis login gate is unavailable. Please reconnect shortly.",
FelisVelocityPlugin.zh(player)
? "Felis 登录网关不可用,请稍后重连。"
: "The Felis login gate is unavailable. Please reconnect shortly.",
NamedTextColor.RED));
return;
}
@@ -161,7 +165,10 @@ public final class WaitingRouter {
event.setResult(ServerPreConnectEvent.ServerResult.denied());
if (!serverNamed(event.getOriginalServer(), lobbyServer)) {
player.sendMessage(Component.text(
"The login gate may only release players to the lobby.", NamedTextColor.RED));
FelisVelocityPlugin.zh(player)
? "登录网关只能把玩家放行到大厅。"
: "The login gate may only release players to the lobby.",
NamedTextColor.RED));
log.warn("Felis: denied login-gate transfer for {} to {}",
player.getUniqueId(), event.getOriginalServer().getServerInfo().getName());
return null;
@@ -173,17 +180,20 @@ public final class WaitingRouter {
private void authorizeLoginRelease(ServerPreConnectEvent event) {
Player player = event.getPlayer();
UUID id = player.getUniqueId();
boolean zh = FelisVelocityPlugin.zh(player);
try {
if (!api.linkStatus(id)) {
player.sendMessage(Component.text(
"Finish signing in before leaving the login area.", NamedTextColor.YELLOW));
zh ? "请先完成登录,再离开登录区。"
: "Finish signing in before leaving the login area.", NamedTextColor.YELLOW));
return;
}
} catch (LinkException e) {
log.warn("Felis: could not verify login release for {} (status={}): {}",
id, e.statusCode(), e.getMessage());
player.sendMessage(Component.text(
"Login verification is temporarily unavailable. Please wait and try again.",
zh ? "登录验证暂时不可用,请稍候重试。"
: "Login verification is temporarily unavailable. Please wait and try again.",
NamedTextColor.RED));
return;
}
@@ -202,7 +212,8 @@ public final class WaitingRouter {
pendingTargets.remove(id, targetName);
event.setResult(ServerPreConnectEvent.ServerResult.allowed(event.getOriginalServer()));
player.sendMessage(Component.text(
"« " + targetName + " » is no longer available.", NamedTextColor.YELLOW));
zh ? "「" + targetName + "」已不可用。"
: "« " + targetName + " » is no longer available.", NamedTextColor.YELLOW));
return;
}
Optional<RegisteredServer> backend = registry.registered(targetName);
@@ -260,11 +271,13 @@ public final class WaitingRouter {
continue;
}
Player player = po.get();
boolean zh = FelisVelocityPlugin.zh(player);
if (now > w.deadlineMillis) {
waiting.remove(id);
player.sendMessage(Component.text(
"« " + w.serverName + " » is taking longer than expected to start. "
+ "You can try again from the lobby later.", NamedTextColor.YELLOW));
zh ? "「" + w.serverName + "」启动耗时超出预期。你可以稍后在大厅重试。"
: "« " + w.serverName + " » is taking longer than expected to start. "
+ "You can try again from the lobby later.", NamedTextColor.YELLOW));
continue;
}
Boolean ready = readyCache.get(w.serverName);
@@ -287,7 +300,8 @@ public final class WaitingRouter {
if (!api.linkStatus(id)) {
waiting.remove(id);
player.sendMessage(Component.text(
"Your account is no longer linked. Reconnect to sign in again.",
zh ? "你的账户已不再绑定。请重连以重新登录。"
: "Your account is no longer linked. Reconnect to sign in again.",
NamedTextColor.RED));
continue;
}
@@ -298,7 +312,8 @@ public final class WaitingRouter {
}
waiting.remove(id);
player.sendMessage(Component.text(
"« " + w.serverName + " » is ready — moving you in…", NamedTextColor.GREEN));
zh ? "「" + w.serverName + "」已就绪——正在把你传送过去……"
: "« " + w.serverName + " » is ready — moving you in…", NamedTextColor.GREEN));
// Tell a menu-driven lobby its tile is live before we pull the player off
// it; the proxy still performs the actual Connect just below.
MenuTransferListener listener = menuListener;
@@ -311,18 +326,21 @@ public final class WaitingRouter {
private void authorizeAndWait(Player player, String serverName, boolean fromMenu) {
UUID id = player.getUniqueId();
boolean zh = FelisVelocityPlugin.zh(player);
plugin.async(() -> {
try {
if (!api.linkStatus(id)) {
player.sendMessage(Component.text(
"Finish signing in before joining a server.", NamedTextColor.YELLOW));
zh ? "请先完成登录,再加入服务器。"
: "Finish signing in before joining a server.", NamedTextColor.YELLOW));
return;
}
} catch (LinkException e) {
log.warn("Felis: could not verify queue entry for {} (status={}): {}",
id, e.statusCode(), e.getMessage());
player.sendMessage(Component.text(
"Login verification is temporarily unavailable. Please try again shortly.",
zh ? "登录验证暂时不可用,请稍后重试。"
: "Login verification is temporarily unavailable. Please try again shortly.",
NamedTextColor.RED));
return;
}
@@ -335,26 +353,44 @@ public final class WaitingRouter {
// both the account gate and the server-side autostart policy.
private void wakeAndWaitLinked(Player player, String serverName, boolean fromMenu) {
UUID id = player.getUniqueId();
boolean zh = FelisVelocityPlugin.zh(player);
try {
api.wake(serverName, id);
} catch (LinkException e) {
switch (e.statusCode()) {
case 403:
player.sendMessage(Component.text(
"You're not allowed to start « " + serverName + " ».", NamedTextColor.RED));
zh ? "你无权启动「" + serverName + "」。"
: "You're not allowed to start « " + serverName + " ».", NamedTextColor.RED));
return;
case 429:
break; // a wake is already in flight → join the existing wait
case 503:
if ("at_capacity".equals(e.errorCode())) {
// at_capacity: nothing is coming up, so enqueueing would only strand
// the player until the timeout. Be honest and let them retry later.
player.sendMessage(Component.text(
zh ? "集群当前已满——「" + serverName + "」暂时无法启动。请稍后再试。"
: "The cluster is at capacity right now — « " + serverName
+ " » can't start. Please try again later.",
NamedTextColor.YELLOW));
return;
}
// A 503 without the at_capacity code is a plain outage, not a
// capacity verdict — report it like any other failure.
// fall through
default:
log.warn("Felis: wake {} failed (status={}): {}", serverName, e.statusCode(), e.getMessage());
player.sendMessage(Component.text(
"Couldn't start « " + serverName + " » right now. Try again shortly.",
zh ? "现在无法启动「" + serverName + "」。请稍后再试。"
: "Couldn't start « " + serverName + " » right now. Try again shortly.",
NamedTextColor.RED));
return;
}
}
player.sendMessage(Component.text(
"Starting « " + serverName + " » — you'll be moved in automatically.",
zh ? "正在启动「" + serverName + "」——就绪后会自动把你传送过去。"
: "Starting « " + serverName + " » — you'll be moved in automatically.",
NamedTextColor.GRAY));
waiting.put(id, new Waiter(
serverName, System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS, fromMenu));
@@ -364,7 +400,9 @@ public final class WaitingRouter {
player.createConnectionRequest(backend).connect().whenComplete((result, err) -> {
if (err != null || (result != null && !result.isSuccessful())) {
player.sendMessage(Component.text(
"Couldn't connect you to « " + serverName + " ». Please try again.",
FelisVelocityPlugin.zh(player)
? "无法把你连接到「" + serverName + "」。请重试。"
: "Couldn't connect you to « " + serverName + " ». Please try again.",
NamedTextColor.RED));
}
});