feat: configure authenticated player entry routes

This commit is contained in:
Lemon-miaow committed 2026-10-07 17:19:20 +08:00
1 parent 07973a4bbd
commit 20994be996
24 files changed
+1044 -17

No files matched your search

@@ -137,6 +137,10 @@ final class LoginFlow {
disconnect(id, BLACKLISTED);
return;
}
if (!api.entryPolicy().requireAccountLink()) {
// The proxy owns routing and startup progress; this space is only a waiting room.
return;
}
// Check registration before minting: an already-linked player needs no
// bind code, so send them straight to the lobby instead of flashing a
// useless code. Only unlinked players get one. The on-demand /link
@@ -65,6 +65,7 @@ public final class LoginFlowTest {
QUIET.setLevel(Level.OFF);
Stub stub = new Stub();
try {
automaticWaitDoesNotRequireWebSignIn(stub);
linkedPlayerGoesStraightToTheLobby(stub);
unlinkedPlayerGetsTheCodeAndIsReleasedOnceLinked(stub);
codeWithoutPanelUrlPointsAtTheConsole(stub);
@@ -91,6 +92,18 @@ public final class LoginFlowTest {
// ---- the flow ----
private static void automaticWaitDoesNotRequireWebSignIn(Stub stub) {
stub.requireAccountLink = false;
Rig rig = new Rig(stub);
Seat seat = rig.join(stub.player(false, false), "Automatic");
rig.gate.advance(20 * 1000);
assertEq("automatic wait: no code", 0, stub.mints.get());
assertEq("automatic wait: no web release", 0, seat.releases.size());
assertEq("automatic wait: no login timeout", null, seat.disconnected);
assertEq("automatic wait: no web timers", 0, rig.gate.pending());
stub.requireAccountLink = true;
}
private static void linkedPlayerGoesStraightToTheLobby(Stub stub) {
Rig rig = new Rig(stub);
UUID id = rig.stub.player(false, true);
@@ -595,6 +608,7 @@ public final class LoginFlowTest {
final Map<String, AtomicInteger> hits = new ConcurrentHashMap<>();
final AtomicInteger mints = new AtomicInteger();
volatile int failWith;
volatile boolean requireAccountLink = true;
volatile int mintStatus = 201;
volatile String panelUrl;
@@ -632,6 +646,10 @@ public final class LoginFlowTest {
reply(ex, 401, "{\"error\":{\"code\":\"unauthorized\",\"message\":\"unauthorized\"}}");
return;
}
if (path.endsWith("/settings/entry-policy")) {
reply(ex, 200, "{\"mode\":\"domain\",\"requireAccountLink\":" + requireAccountLink + ",\"offlineAction\":\"wake\",\"waitingSpace\":\"lobby\"}");
return;
}
String kind = path.contains("/link/status/") ? "/link/status/"
: path.contains("/blacklist/") ? "/blacklist/"
: path.endsWith("/link/code") ? "/link/code" : path;
@@ -0,0 +1,29 @@
package best.lolicon.felis.link;
import java.util.Map;
/** Owner-selected entry routing; authentication remains the proxy's responsibility. */
public record EntryPolicy(String mode, String defaultServer, boolean requireAccountLink,
String offlineAction, String waitingSpace, String fallbackServer) {
public static EntryPolicy legacy() {
return new EntryPolicy("domain", "", true, "wake", "lobby", "");
}
public static EntryPolicy fromJson(Map<?, ?> data) throws LinkException {
String mode = text(data, "mode");
String action = text(data, "offlineAction");
String space = text(data, "waitingSpace");
if (!(mode.equals("lobby") || mode.equals("direct") || mode.equals("domain"))
|| !(action.equals("wake") || action.equals("fallback") || action.equals("disconnect"))
|| !(space.equals("login") || space.equals("lobby"))
|| !(data.get("requireAccountLink") instanceof Boolean)) {
throw new LinkException(503, "entry_policy_unavailable", "Invalid player entry policy");
}
return new EntryPolicy(mode, text(data, "defaultServer"), (boolean) data.get("requireAccountLink"),
action, space, text(data, "fallbackServer"));
}
private static String text(Map<?, ?> data, String key) {
return data.get(key) instanceof String value ? value : "";
}
}
@@ -69,6 +69,10 @@ public final class FelisApiClient {
return stats;
}
public EntryPolicy entryPolicy() throws LinkException {
return EntryPolicy.fromJson(getObject("/api/v1/internal/settings/entry-policy", 200));
}
/** listServers returns the lifecycle view of every MinecraftServer (GET /servers). */
public List<ServerView> listServers() throws LinkException {
return ServerView.listFrom(getObject("/api/v1/servers", 200));
@@ -324,6 +324,11 @@ public final class FelisVelocityPlugin {
private void refreshRegistrations() {
if (router != null) {
router.pruneLinks();
try {
router.setEntryPolicy(apiClient.entryPolicy());
} catch (LinkException error) {
logger.warn("Felis: entry policy refresh failed; retaining the last policy: {}", error.getMessage());
}
}
ServerListSource.Result r = serverList.next();
if (r.servers != null) {
@@ -1,6 +1,7 @@
package best.lolicon.felis.velocity;
import best.lolicon.felis.link.FelisApiClient;
import best.lolicon.felis.link.EntryPolicy;
import best.lolicon.felis.link.LinkException;
import best.lolicon.felis.link.ServerView;
@@ -93,6 +94,11 @@ public final class WaitingRouter {
private final String loginServer;
private final String lobbyServer;
private volatile EntryPolicy entryPolicy = EntryPolicy.legacy();
private final Map<UUID, EntryPolicy> entrySessions = new ConcurrentHashMap<>();
void setEntryPolicy(EntryPolicy policy) { this.entryPolicy = policy; }
private final LinkGate links;
private final Map<UUID, Waiter> waiting = new ConcurrentHashMap<>();
private final Map<UUID, String> pendingTargets = new ConcurrentHashMap<>();
@@ -195,6 +201,10 @@ public final class WaitingRouter {
// linked runs the link check through the gate and notes when the answer came from
// the outage fallback rather than felis-api.
private boolean linked(UUID id) throws LinkException {
EntryPolicy policy = entrySessions.get(id);
if (policy != null && !policy.requireAccountLink()) {
return !api.isBlacklisted(id);
}
LinkGate.Result r = links.check(id);
if (r.degraded) {
log.warn("Felis: felis-api unreachable; admitting {} on a link confirmation from the last {} min",
@@ -281,17 +291,20 @@ public final class WaitingRouter {
}
@Subscribe
public void onChooseInitialServer(PlayerChooseInitialServerEvent event) {
public EventTask onChooseInitialServer(PlayerChooseInitialServerEvent event) {
Player player = event.getPlayer();
UUID id = player.getUniqueId();
pendingTargets.remove(id); // a reconnect must never inherit an earlier host
Optional<String> host = virtualHost(player);
if (host.isEmpty()) {
return; // velocity.toml's only fallback is login
entrySessions.remove(id);
waiting.remove(id);
EntryPolicy policy = entryPolicy;
if (!policy.requireAccountLink()) {
return EventTask.async(() -> chooseAuthenticated(event, policy));
}
Optional<ServerView> targetOpt = registry.resolveByHost(host.get());
entrySessions.put(id, policy);
pendingTargets.remove(id); // a reconnect must never inherit an earlier host
Optional<ServerView> targetOpt = policyTarget(player, policy);
if (targetOpt.isEmpty()) {
return; // unknown host also falls through to login
return null; // unknown host also falls through to login
}
ServerView target = targetOpt.get();
Optional<RegisteredServer> login = login();
@@ -302,7 +315,7 @@ public final class WaitingRouter {
? "Felis 登录网关不可用,请稍后重连。"
: "The Felis login gate is unavailable. Please reconnect shortly.",
NamedTextColor.RED));
return;
return null;
}
// login.<root-domain> is a valid system hostname, but it is the gate rather
// than a post-auth destination. Remembering it would redirect the successful
@@ -311,6 +324,77 @@ public final class WaitingRouter {
pendingTargets.put(id, target.name());
}
event.setInitialServer(login.get());
return null;
}
private Optional<ServerView> policyTarget(Player player, EntryPolicy policy) {
if (policy.mode().equals("lobby")) return Optional.ofNullable(registry.view(lobbyServer));
if (policy.mode().equals("domain")) {
Optional<ServerView> host = virtualHost(player).flatMap(registry::resolveByHost)
.filter(server -> !server.name().equalsIgnoreCase(loginServer));
if (host.isPresent()) return host;
}
return Optional.ofNullable(registry.view(policy.defaultServer()));
}
private void chooseAuthenticated(PlayerChooseInitialServerEvent event, EntryPolicy policy) {
Player player = event.getPlayer();
event.setInitialServer(null);
pendingTargets.remove(player.getUniqueId());
try {
if (api.isBlacklisted(player.getUniqueId())) {
entryDisconnect(player, "此游戏身份已被禁止登录。", "This game identity is barred.");
return;
}
entrySessions.put(player.getUniqueId(), policy);
ServerView target = policyTarget(player, policy).orElse(null);
if (target == null) {
entryDisconnect(player, "未配置此入口的目标服务器,请联系管理员。", "No destination is configured for this entry. Contact the administrator.");
return;
}
// Refresh readiness instead of routing on the registration cache alone.
target = api.serverStatus(target.name());
registry.observe(target);
if (target.ready() && registry.registered(target.name()).isPresent()) {
event.setInitialServer(registry.registered(target.name()).get());
return;
}
if (policy.offlineAction().equals("fallback")) {
ServerView fallback = api.serverStatus(policy.fallbackServer());
registry.observe(fallback);
if (fallback.ready() && registry.registered(fallback.name()).isPresent()) {
event.setInitialServer(registry.registered(fallback.name()).get());
return;
}
entryDisconnect(player, "目标服务器与备用服务器均不可用。", "The destination and fallback server are unavailable.");
return;
}
if (policy.offlineAction().equals("disconnect")) {
entryDisconnect(player, "目标服务器当前未运行,请稍后重连。", "The destination is offline. Reconnect later.");
return;
}
String space = policy.waitingSpace().equals("login") ? loginServer : lobbyServer;
ServerView spaceStatus = api.serverStatus(space);
registry.observe(spaceStatus);
Optional<RegisteredServer> waitingSpace = proxy.getServer(space);
if (!spaceStatus.ready() || waitingSpace.isEmpty()) {
entryDisconnect(player, "等待空间不可用,请联系管理员。", "The waiting space is unavailable. Contact the administrator.");
return;
}
wakeAndWaitLinked(player, target.name(), false);
if (!waiting.containsKey(player.getUniqueId())) {
entryDisconnect(player, "服务器无法自动启动,请联系管理员检查启动权限、维护状态及资源。", "Automatic startup was refused. Contact the administrator to check permissions, maintenance and capacity.");
return;
}
event.setInitialServer(waitingSpace.get());
} catch (LinkException error) {
log.warn("Felis: entry routing failed for {}: {}", player.getUniqueId(), error.getMessage());
entryDisconnect(player, "身份或路由服务暂不可用,请稍后重连。", "Identity or routing service is unavailable. Reconnect later.");
}
}
private static void entryDisconnect(Player player, String zh, String en) {
player.disconnect(Component.text(FelisVelocityPlugin.zh(player) ? zh : en, NamedTextColor.RED));
}
/**
@@ -320,6 +404,8 @@ public final class WaitingRouter {
*/
@Subscribe
public EventTask onServerPreConnect(ServerPreConnectEvent event) {
EntryPolicy session = entrySessions.get(event.getPlayer().getUniqueId());
if (session != null && !session.requireAccountLink()) return null;
RegisteredServer previous = event.getPreviousServer();
if (previous == null || !serverNamed(previous, loginServer)) {
return null;
@@ -398,6 +484,27 @@ public final class WaitingRouter {
return;
}
EntryPolicy policy = entrySessions.getOrDefault(id, EntryPolicy.legacy());
if (policy.offlineAction().equals("disconnect")) {
entryDisconnect(player, "目标服务器当前未运行,请稍后重连。", "The destination is offline. Reconnect later.");
return;
}
if (policy.offlineAction().equals("fallback")) {
try {
ServerView fallback = api.serverStatus(policy.fallbackServer());
registry.observe(fallback);
Optional<RegisteredServer> destination = registry.registered(fallback.name());
if (fallback.ready() && destination.isPresent()) {
pendingTargets.remove(id);
event.setResult(ServerPreConnectEvent.ServerResult.allowed(destination.get()));
return;
}
} catch (LinkException error) {
log.warn("Felis: fallback lookup failed: {}", error.getMessage());
}
entryDisconnect(player, "备用服务器不可用,请稍后重连。", "The fallback server is unavailable. Reconnect later.");
return;
}
pendingTargets.remove(id, targetName);
event.setResult(ServerPreConnectEvent.ServerResult.allowed(event.getOriginalServer()));
wakeAndWaitLinked(player, targetName, false);
@@ -407,6 +514,7 @@ public final class WaitingRouter {
public void onDisconnect(DisconnectEvent event) {
UUID id = event.getPlayer().getUniqueId();
pendingTargets.remove(id);
entrySessions.remove(id);
waiting.remove(id);
lastGateNotice.remove(id);
}
@@ -428,6 +536,15 @@ public final class WaitingRouter {
String name = event.getServer().getServerInfo().getName();
UUID id = event.getPlayer().getUniqueId();
pendingTargets.remove(id, name);
EntryPolicy session = entrySessions.get(id);
Waiter waiter = waiting.get(id);
if (session != null && !session.requireAccountLink() && waiter != null) {
event.getPlayer().sendMessage(Component.text(
FelisVelocityPlugin.zh(event.getPlayer())
? "正在等待「" + waiter.serverName + "」启动。服务器就绪后将自动连接。"
: "Waiting for « " + waiter.serverName + " » to start. Connection proceeds automatically when ready.",
NamedTextColor.GRAY));
}
if (!registry.isManaged(name)
|| name.equalsIgnoreCase(loginServer)
|| name.equalsIgnoreCase(lobbyServer)) {
@@ -479,6 +596,8 @@ public final class WaitingRouter {
continue;
}
Player player = po.get();
EntryPolicy session = entrySessions.get(id);
if (session != null && !session.requireAccountLink() && player.getCurrentServer().isEmpty()) continue;
boolean zh = FelisVelocityPlugin.zh(player);
Optional<ServerView> poll = polled.get(w.serverName);
if (poll == null) {
@@ -486,11 +605,19 @@ public final class WaitingRouter {
polled.put(w.serverName, poll);
}
ServerView status = poll.orElse(null);
if (status != null && status.ready() && now - w.sinceMillis > MAX_WAIT_MILLIS) {
waiting.remove(id);
disconnectAutomaticWait(player);
player.sendMessage(Component.text("服务器等待超时 / Server waiting timed out", NamedTextColor.RED));
continue;
}
if (status == null || !status.ready()) {
if (status != null && !stillComing(player, zh, w, status, now)) {
waiting.remove(id);
disconnectAutomaticWait(player);
} else if (now > w.deadlineMillis || now - w.sinceMillis > MAX_WAIT_MILLIS) {
waiting.remove(id);
disconnectAutomaticWait(player);
player.sendMessage(Component.text(
zh ? "「" + w.serverName + "」启动耗时超出预期。你可以稍后在大厅重试。"
: "« " + w.serverName + " » is taking longer than expected to start. "
@@ -505,6 +632,10 @@ public final class WaitingRouter {
try {
if (!linked(id)) {
waiting.remove(id);
if (session != null && !session.requireAccountLink()) {
entryDisconnect(player, "此游戏身份已被禁止登录。", "This game identity is barred.");
continue;
}
player.sendMessage(Component.text(
zh ? "你的账户已不再绑定。请重连以重新登录。"
: "Your account is no longer linked. Reconnect to sign in again.",
@@ -530,6 +661,21 @@ public final class WaitingRouter {
}
}
private void disconnectAutomaticWait(Player player) {
disconnectAutomaticWait(player, Component.text(FelisVelocityPlugin.zh(player)
? "服务器启动失败或等待超时,请联系管理员检查启动日志。"
: "Startup failed or waiting timed out. Contact the administrator to inspect the startup log.",
NamedTextColor.RED));
}
private void disconnectAutomaticWait(Player player, Component reason) {
EntryPolicy policy = entrySessions.get(player.getUniqueId());
if (policy != null && !policy.requireAccountLink() && player.getCurrentServer()
.map(server -> serverNamed(server.getServer(), loginServer)).orElse(false)) {
player.disconnect(reason);
}
}
// poll asks felis-api how one waited-on server is doing; empty when it does not
// answer, which the waiters ride out until their window closes.
private Optional<ServerView> poll(String serverName) {
@@ -851,6 +997,7 @@ public final class WaitingRouter {
.append(reason.get())
: line.append(Component.text(zh ? "请重试。" : " Please try again.", NamedTextColor.RED));
player.sendMessage(line);
disconnectAutomaticWait(player, line);
});
}
@@ -459,6 +459,7 @@ final class Fakes {
static final class Api implements AutoCloseable {
final Set<UUID> linked = ConcurrentHashMap.newKeySet();
volatile boolean linkDown;
final Set<UUID> barred = ConcurrentHashMap.newKeySet();
final Map<String, Boolean> ready = new ConcurrentHashMap<>();
/** address is the direct endpoint a server reports while it is ready. */
final Map<String, String> address = new ConcurrentHashMap<>();
@@ -536,6 +537,12 @@ final class Fakes {
}
}
}
String blacklist = "/api/v1/internal/player/blacklist/";
if (path.startsWith(blacklist)) {
if (linkDown) reply(ex, 500, "{}");
else reply(ex, 200, "{\"blacklisted\":" + barred.contains(UUID.fromString(path.substring(blacklist.length()))) + "}");
return;
}
String status = "/api/v1/internal/account/link/status/";
String servers = "/api/v1/internal/servers/";
String menuAccess = "/api/v1/internal/player/menu-access/";
@@ -1,6 +1,7 @@
package best.lolicon.felis.velocity;
import best.lolicon.felis.link.FelisApiClient;
import best.lolicon.felis.link.EntryPolicy;
import best.lolicon.felis.link.LinkConfig;
import best.lolicon.felis.link.ServerView;
@@ -81,12 +82,81 @@ public final class WaitingRouterTest {
slowApi();
backToLobby();
disconnectAndRelease();
automaticEntry();
} finally {
api.close();
}
System.out.println("WaitingRouterTest OK (" + checks + " checks)");
}
private static void automaticEntry() {
reg.refresh(servers(true));
api.ready.put("beta", true);
router.setEntryPolicy(new EntryPolicy("direct", "beta", false, "disconnect", "login", ""));
Fakes.FakePlayer direct = player("alpha.mc.test", false);
assertEq("automatic direct ignores hostname and web linking", "beta", choose(direct));
assertEq("automatic does not query link status", 0, api.count(LINK + direct.id));
Fakes.FakePlayer barred = player(null, false);
api.barred.add(barred.id);
assertEq("barred automatic connection has no destination", null, choose(barred));
assertEq("barred automatic connection explained", true, barred.disconnectedWith != null && barred.disconnectedWith.contains("barred"));
api.linkDown = true;
assertEq("identity service failure fails closed", null, choose(player(null, false)));
api.linkDown = false;
router.setEntryPolicy(new EntryPolicy("direct", "alpha", false, "fallback", "login", "beta"));
api.ready.put("alpha", false);
assertEq("offline destination uses configured fallback", "beta", choose(player(null, false)));
router.setEntryPolicy(new EntryPolicy("direct", "alpha", false, "disconnect", "login", ""));
assertEq("offline disconnect has no destination", null, choose(player(null, false)));
router.setEntryPolicy(new EntryPolicy("domain", "beta", false, "disconnect", "login", ""));
assertEq("unknown hostname uses default", "beta", choose(player("unknown.mc.test", false)));
router.setEntryPolicy(new EntryPolicy("direct", "alpha", false, "wake", "login", ""));
api.ready.put("login", true);
Fakes.FakePlayer waiting = player(null, false);
int before = api.count("POST " + SERVERS + "alpha/wake");
assertEq("automatic startup waits in Limbo", "login", choose(waiting));
assertEq("automatic startup requests wake", before + 1, api.count("POST " + SERVERS + "alpha/wake"));
waiting.current = login;
router.onServerConnected(new ServerConnectedEvent(waiting.player, login, null));
assertEq("Limbo waiting immediately shows destination progress", true, waiting.said("Waiting for « alpha »"));
api.ready.put("alpha", true);
router.tick();
assertEq("automatic wait transfers without panel account", List.of("alpha"), List.copyOf(waiting.connects));
api.ready.put("alpha", false);
api.ready.put("login", false);
int wakes = api.count("POST " + SERVERS + "alpha/wake");
Fakes.FakePlayer noSpace = player(null, false);
assertEq("stopped waiting space rejects connection", null, choose(noSpace));
assertEq("stopped waiting space does not initiate startup", wakes, api.count("POST " + SERVERS + "alpha/wake"));
api.ready.put("login", true);
Fakes.FakePlayer failed = player(null, false);
assertEq("failed startup starts with a wait", "login", choose(failed));
failed.current = login;
api.gaveUp.add("alpha");
router.tick();
assertEq("failed startup disconnects Limbo wait", true, failed.disconnectedWith != null);
api.gaveUp.remove("alpha");
Fakes.FakePlayer revoked = player(null, false);
assertEq("barred wait initially enters Limbo", "login", choose(revoked));
revoked.current = login;
api.barred.add(revoked.id);
api.ready.put("alpha", true);
router.tick();
assertEq("blacklist revocation disconnects the wait with its reason", true, revoked.disconnectedWith != null && revoked.disconnectedWith.contains("barred"));
api.ready.put("alpha", false);
api.policy.put("alpha", "ownerOnly");
assertEq("automatic wake cannot bypass owner-only startup", null, choose(player(null, false)));
api.policy.remove("alpha");
api.ready.put("lobby", true);
router.setEntryPolicy(new EntryPolicy("lobby", "", false, "disconnect", "login", ""));
assertEq("lobby mode ignores a server hostname", "lobby", choose(player("beta.mc.test", false)));
net.proxy.unregisterServer(net.proxy.getServer("login").orElseThrow().getServerInfo());
net.proxy.unregisterServer(net.proxy.getServer("lobby").orElseThrow().getServerInfo());
router.setEntryPolicy(new EntryPolicy("direct", "beta", false, "disconnect", "login", ""));
assertEq("direct entry works without login or lobby registrations", "beta", choose(player(null, false)));
router.setEntryPolicy(EntryPolicy.legacy());
}
// The server list the stub control plane serves. "gone" is dropped by a later
// refresh; "fresh" has no backend address yet.
private static List<ServerView> servers(boolean withGone) {
@@ -858,7 +928,7 @@ public final class WaitingRouterTest {
net.remove("login");
Fakes.FakePlayer stranded = player("beta.mc.test", true);
PlayerChooseInitialServerEvent e = new PlayerChooseInitialServerEvent(stranded.player, null);
router.onChooseInitialServer(e);
chooseEvent(e);
assertEq("no login: no initial server", false, e.getInitialServer().isPresent());
assertEq("no login: disconnected with a reason", "The Felis login gate is unavailable. Please reconnect shortly.",
stranded.disconnectedWith);
@@ -880,10 +950,18 @@ public final class WaitingRouterTest {
// try server, login, preset) and returns the server the player will land on.
private static String choose(Fakes.FakePlayer p) {
PlayerChooseInitialServerEvent e = new PlayerChooseInitialServerEvent(p.player, login);
router.onChooseInitialServer(e);
chooseEvent(e);
return e.getInitialServer().map(s -> s.getServerInfo().getName()).orElse(null);
}
private static void chooseEvent(PlayerChooseInitialServerEvent event) {
EventTask task = router.onChooseInitialServer(event);
if (task != null) task.execute(new Continuation() {
public void resume() { }
public void resumeWithException(Throwable error) { throw new AssertionError(error); }
});
}
// release is the login gate asking to move the player to the lobby, with the
// router's async part run to completion the way Velocity's event manager would.
private static ServerPreConnectEvent release(Fakes.FakePlayer p) {