feat(proxy): enforce login-first routing

This commit is contained in:
flyemoji committed 2026-07-14 02:55:11 +09:00
1 parent 16b7ad2756
commit 688c86da18
6 files changed
+272 -113

No files matched your search

+4 -3
View File
@@ -87,8 +87,8 @@ What it does when routing is active:
| Surface | Behavior |
| ------- | -------- |
| Backend registry | Polls `GET /api/v1/servers` every 15 s and reconciles Velocity's dynamic registry. A failed poll **keeps existing registrations** — a control-plane blip never deregisters live backends. Addresses are registered *unresolved* (a sleeping backend's Service DNS may not resolve yet). |
| Join (`PlayerChooseInitialServerEvent`) | Resolves `subdomain.<root-domain>` → server. **Ready** → send straight in. **Not ready + lobby** → park in the lobby, wake, and transfer when ready. **Not ready + no lobby** → disconnect with a "reconnect shortly" message, still firing the wake so the reconnect lands faster. |
| Backend registry | Polls `GET /api/v1/servers` every 15 s and reconciles Velocity's dynamic registry. A failed poll **keeps existing registrations** — a control-plane blip never deregisters live backends. The API advertises each backend Service's host-routable ClusterIP, avoiding cluster-DNS names on the host-run proxy. |
| Join (`PlayerChooseInitialServerEvent`) | Resolves `subdomain.<root-domain>` and remembers the target, but every fresh connection still enters `login`. When the login gate requests its post-auth lobby transfer, Velocity re-checks link status: a ready remembered target is selected immediately; an asleep target is woken and queued from the lobby. |
| Waiting queue | One scheduled drain every 2 s polls status once per distinct waited-on server; a waiter drops out on transfer, on the player leaving, or after a 120 s timeout. |
| Wake gate | The wake is `POST /api/v1/internal/servers/{name}/wake` keyed on the player's online-mode UUID. **403** (policy refused) tells the player and stops; **429** (wake already in flight) keeps waiting. |
| Server-list ping (`ProxyPingEvent`) | Answers from the cached lifecycle view with a phase-aware MOTD (online / starting / sleeping) — **read-only, never wakes** anything. Mirroring each backend's own MOTD by background-pinging ready servers is a later slice. |
@@ -101,7 +101,8 @@ Velocity-only config keys (read from the same `felis-link.properties` / env as
| Key | Env | Meaning |
| --- | --- | ------- |
| `root-domain` | `FELIS_ROOT_DOMAIN` | Routing zone, e.g. `mc.example.net`. Unset → routing off. |
| `lobby-server` | `FELIS_LOBBY_SERVER` | A `velocity.toml` static server to park players in while a backend wakes. Unset → players are asked to reconnect instead. Its name must not collide with a felis server name. |
| `login-server` | `FELIS_LOGIN_SERVER` | The system auth gate every fresh connection must pass. Defaults to `login`. |
| `lobby-server` | `FELIS_LOBBY_SERVER` | The distinct post-auth holding server used while a backend wakes. Defaults to `lobby`; it must not equal `login-server`. |
## Lobby menu (§12)
+29 -7
View File
@@ -5,12 +5,34 @@ plugins {
group = 'best.lolicon.felis'
version = '0.1.0'
// JDK 17 is the ceiling the whole plugin suite targets (Velocity 3.3.0 is a
// Java-17 line); we run Gradle on JDK 17 and compile to 17 bytecode rather than
// provisioning a separate toolchain.
// The proxy API this jar compiles against. Default = the newest RELEASED velocity-api,
// which is what deploy/bootstrap.sh installs. velocity-api is compileOnly, so this
// selects the surface we are CHECKED against, not one that ships in the jar.
//
// Do NOT drift this back to a -SNAPSHOT of the 3.x line: 3.3.0-SNAPSHOT (the previous
// value) froze in 2024 and tops out at MINECRAFT_1_21, so it cannot even name the
// protocol the rest of the stack speaks.
def velocityApi = findProperty('velocityApi') ?: '3.5.1'
// 21 is the floor of the proxy we ship against: velocity-api 3.5.1's Gradle module
// metadata declares `org.gradle.jvm.version = 21`, so 17 does not buy backward reach —
// it makes resolution fail outright. 21 bytecode also loads on Velocity 4, which runs a
// newer JVM still, so ONE jar serves both lines.
//
// The knob exists only for the Velocity-4 compile check: velocity-api 4.0.0-SNAPSHOT
// demands `jvm.version = 25`, and Gradle refuses to put a 25 library on a 21 consumer's
// classpath. To prove these sources also compile against the 4 API (4.0.0 itself is
// unreleased — zero published builds; only the snapshot exists), build with a JDK 25
// toolchain and both knobs turned up:
//
// gradle build -PvelocityApi=4.0.0-SNAPSHOT -PjavaTarget=25
//
// That build is a CHECK, not an artifact — the jar we deploy is the default 21 one.
def javaTarget = JavaVersion.toVersion(findProperty('javaTarget') ?: '21')
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = javaTarget
targetCompatibility = javaTarget
}
repositories {
@@ -24,8 +46,8 @@ repositories {
dependencies {
// velocity-api is compile-only (the proxy provides it at runtime); the
// annotation processor turns @Plugin into the generated velocity-plugin.json.
compileOnly 'com.velocitypowered:velocity-api:3.3.0-SNAPSHOT'
annotationProcessor 'com.velocitypowered:velocity-api:3.3.0-SNAPSHOT'
compileOnly "com.velocitypowered:velocity-api:${velocityApi}"
annotationProcessor "com.velocitypowered:velocity-api:${velocityApi}"
}
// The platform-agnostic link core lives in ../shared and is compiled straight
@@ -11,35 +11,44 @@ import java.util.Locale;
import java.util.Properties;
/**
* FelisVelocityConfig extends the shared link config with the two inputs only the
* full proxy needs: the {@code root-domain} the deployment serves (the zone
* subdomains are carved from) and the {@code lobby-server} waiters are parked in
* while their backend wakes. It reuses {@link LinkConfigLoader} for the API base
* FelisVelocityConfig extends the shared link config with the inputs only the full
* proxy needs: the {@code root-domain} the deployment serves (the zone subdomains
* are carved from), the {@code login-server} every fresh connection must pass
* through, and the post-auth {@code lobby-server} waiters are parked in while their
* backend wakes. It reuses {@link LinkConfigLoader} for the API base
* 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_LOBBY_SERVER}).
* {@code FELIS_ROOT_DOMAIN} / {@code FELIS_LOGIN_SERVER} /
* {@code FELIS_LOBBY_SERVER}).
*
* <p>Both extras are optional at load time and the routing layer degrades rather
* <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
* line) while {@code /link} still runs, and a missing {@code lobby-server} means
* the proxy has nowhere to hold waiters, so it refuses the join with a "reconnect
* shortly" message instead of dropping the player onto a not-yet-ready backend.
* The root domain is the only place the deployment zone enters the proxy — it is
* never compiled in (CI red line).
* line) while {@code /link} still runs. The two server names default to the system
* names ({@code login}/{@code lobby}) but must remain distinct: collapsing them
* would put the waiting area on the unauthenticated side of the gate. The root
* domain is the only place the deployment zone enters the proxy — it is never
* compiled in (CI red line).
*/
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";
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 DEFAULT_LOGIN = "login";
private static final String DEFAULT_LOBBY = "lobby";
private final LinkConfig linkConfig;
private final String rootDomain; // null → routing disabled
private final String lobbyServer; // null → no lobby to park waiters in
private final String loginServer;
private final String lobbyServer;
private FelisVelocityConfig(LinkConfig linkConfig, String rootDomain, String lobbyServer) {
private FelisVelocityConfig(LinkConfig linkConfig, String rootDomain,
String loginServer, String lobbyServer) {
this.linkConfig = linkConfig;
this.rootDomain = rootDomain;
this.loginServer = loginServer;
this.lobbyServer = lobbyServer;
}
@@ -52,8 +61,15 @@ 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)));
return new FelisVelocityConfig(link, root == null ? null : root.toLowerCase(Locale.ROOT), lobby);
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);
}
LinkConfig linkConfig() {
@@ -69,7 +85,12 @@ final class FelisVelocityConfig {
return rootDomain != null;
}
/** lobbyServer is the velocity.toml server name waiters are parked in, or null. */
/** loginServer is the only server a fresh connection may enter. */
String loginServer() {
return loginServer;
}
/** lobbyServer is the post-auth server name waiters are parked in. */
String lobbyServer() {
return lobbyServer;
}
@@ -27,7 +27,6 @@ import org.slf4j.Logger;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -116,7 +115,8 @@ public final class FelisVelocityPlugin {
this.apiClient = new FelisApiClient(config.linkConfig());
this.registry = new ServerRegistry(proxy, logger, config.rootDomain());
this.router = new WaitingRouter(proxy, logger, apiClient, registry, this, config.lobbyServer());
this.router = new WaitingRouter(proxy, logger, apiClient, registry, this,
config.loginServer(), config.lobbyServer());
MotdResponder motd = new MotdResponder(registry);
proxy.getEventManager().register(this, router);
proxy.getEventManager().register(this, motd);
@@ -135,12 +135,8 @@ public final class FelisVelocityPlugin {
repeating(WAIT_POLL, router::tick);
this.routingActive = true;
if (config.lobbyServer() == null) {
logger.warn("Felis routing active without a lobby-server: a player whose target is asleep will be "
+ "asked to reconnect rather than parked. Set 'lobby-server=' to enable the waiting queue.");
}
logger.info("Felis routing ready: rootDomain={}, lobby={}. /link and /felis registered.",
config.rootDomain(), config.lobbyServer() == null ? "<none>" : config.lobbyServer());
logger.info("Felis routing ready: rootDomain={}, login={}, lobby={}. /link and /felis registered.",
config.rootDomain(), config.loginServer(), config.lobbyServer());
}
/** async runs a task on Velocity's scheduler so felis-api I/O never blocks the proxy thread. */
@@ -235,11 +231,6 @@ public final class FelisVelocityPlugin {
* (slash, dot, whitespace) is refused client-side rather than sent. */
private static final Pattern OP_LOGIN_CODE = Pattern.compile("^[A-Za-z0-9_-]{1,128}$");
/** The always-on login limbo (LOOHP/Limbo) — the reserved system name
* {@code naming.SystemLoginServer}, which users can never claim, so gating on the
* server name is stable. */
private static final String LOGIN_LIMBO = "login";
private void registerFelisCommand() {
CommandManager commands = proxy.getCommandManager();
LiteralCommandNode<CommandSource> node = BrigadierCommand.literalArgumentBuilder("felis")
@@ -316,7 +307,7 @@ public final class FelisVelocityPlugin {
"Hold on — finish connecting before using /felis.", NamedTextColor.YELLOW));
return false;
}
if (LOGIN_LIMBO.equalsIgnoreCase(current.get().getServerInfo().getName())) {
if (config.loginServer().equalsIgnoreCase(current.get().getServerInfo().getName())) {
player.sendMessage(Component.text(
"Finish signing in first — /felis isn't available from the login area.",
NamedTextColor.YELLOW));
@@ -347,7 +338,8 @@ public final class FelisVelocityPlugin {
return;
}
source.sendMessage(field("root-domain", config.rootDomain()));
source.sendMessage(field("lobby", config.lobbyServer() == null ? "<none>" : config.lobbyServer()));
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));
}
@@ -371,7 +363,9 @@ public final class FelisVelocityPlugin {
source.sendMessage(Component.text("Felis routing is disabled.", NamedTextColor.YELLOW));
return;
}
Collection<ServerView> servers = registry.all();
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));
return;
@@ -397,7 +391,7 @@ public final class FelisVelocityPlugin {
String target = serverArg.trim();
ServerView match = null;
for (ServerView v : registry.all()) {
if (v.name().equalsIgnoreCase(target)) {
if (!isSystemServer(v.name()) && v.name().equalsIgnoreCase(target)) {
match = v;
break;
}
@@ -555,6 +549,11 @@ public final class FelisVelocityPlugin {
NamedTextColor.YELLOW);
}
private boolean isSystemServer(String name) {
return config.loginServer().equalsIgnoreCase(name)
|| config.lobbyServer().equalsIgnoreCase(name);
}
// claimError maps the felis-api claim refusals (spec §9.3) to player-safe text.
private static String claimError(LinkException e, String server) {
switch (e.statusCode()) {
@@ -29,10 +29,10 @@ import java.util.concurrent.ConcurrentHashMap;
* untouched (spec §11 keep-old-on-failure) — a transient control-plane blip must
* never deregister live backends out from under connected players.
*
* <p>Only felis-managed servers live in this registry; servers defined statically
* in {@code velocity.toml} (notably the lobby) are never added here and so are
* never deregistered by a refresh. Static server names must therefore not collide
* with felis server names.
* <p>Every API-reported backend, including the system login and lobby, lives in
* this registry. The generated {@code velocity.toml} contains a deliberately dead
* login placeholder only so Velocity can validate {@code try = ["login"]}; the
* first successful refresh replaces that placeholder with the live ClusterIP.
*/
final class ServerRegistry {
private static final int DEFAULT_PORT = 25565;
@@ -138,8 +138,9 @@ final class ServerRegistry {
if (idx > 0 && idx < addr.length() - 1) {
try {
int port = Integer.parseInt(addr.substring(idx + 1));
// Unresolved: the backend's DNS (a K8s Service) may not resolve yet
// while the server is asleep; Velocity resolves at connect time.
// Keep address parsing side-effect-free; Velocity resolves hostnames
// at connect time. Bootstrap deployments normally advertise a
// host-routable Service ClusterIP here.
return InetSocketAddress.createUnresolved(addr.substring(0, idx), port);
} catch (NumberFormatException ignored) {
// not host:port → fall through to the default Minecraft port
@@ -4,9 +4,12 @@ import best.lolicon.felis.link.FelisApiClient;
import best.lolicon.felis.link.LinkException;
import best.lolicon.felis.link.ServerView;
import com.velocitypowered.api.event.EventTask;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.DisconnectEvent;
import com.velocitypowered.api.event.player.PlayerChooseInitialServerEvent;
import com.velocitypowered.api.event.player.ServerConnectedEvent;
import com.velocitypowered.api.event.player.ServerPreConnectEvent;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.server.RegisteredServer;
@@ -26,13 +29,13 @@ import java.util.concurrent.ConcurrentHashMap;
/**
* WaitingRouter implements the §11 domain-autostart routing loop and its waiting
* queue. It resolves the virtual host a player connected with to a felis server
* and decides what happens next:
* and remembers the requested backend while the player passes the login gate:
*
* <pre>
* host has no felis subdomain → leave Velocity's default routing alone
* server ready + registered → set it as the initial server (straight in)
* server not ready, lobby configured → park in lobby, wake it, enqueue a transfer
* server not ready, no lobby → refuse cleanly ("reconnect shortly"), wake
* fresh connection → login (always; never a user backend)
* login says linked + target ready → requested backend
* login says linked + target asleep → post-auth lobby, wake, queued transfer
* login requests any other target → deny (fail closed)
* </pre>
*
* <p>The queue is drained by {@link #tick()}, scheduled by the plugin on the async
@@ -40,11 +43,12 @@ import java.util.concurrent.ConcurrentHashMap;
* reports ready, transfers everyone waiting on it. A waiter drops out when it times
* out, when the player leaves the proxy, or on a successful transfer.
*
* <p>The wake is 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 joins
* to a felis backend are reported back so the reaper sees activity and the player
* is auto-added to the allowlist.
* <p>Every transition out of login is checked against felis-api's link status, and
* 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.
*/
public final class WaitingRouter {
private static final long WAIT_TIMEOUT_MILLIS = 120_000L;
@@ -54,9 +58,11 @@ public final class WaitingRouter {
private final FelisApiClient api;
private final ServerRegistry registry;
private final FelisVelocityPlugin plugin;
private final String lobbyServer; // may be null → no lobby
private final String loginServer;
private final String lobbyServer;
private final Map<UUID, Waiter> waiting = new ConcurrentHashMap<>();
private final Map<UUID, String> pendingTargets = new ConcurrentHashMap<>();
// Notified just before a menu-originated waiter is transferred, so the lobby's
// felis:control face can tell the player's GUI the backend is ready. Null until
@@ -64,12 +70,13 @@ public final class WaitingRouter {
private volatile MenuTransferListener menuListener;
WaitingRouter(ProxyServer proxy, Logger log, FelisApiClient api, ServerRegistry registry,
FelisVelocityPlugin plugin, String lobbyServer) {
FelisVelocityPlugin plugin, String loginServer, String lobbyServer) {
this.proxy = proxy;
this.log = log;
this.api = api;
this.registry = registry;
this.plugin = plugin;
this.loginServer = loginServer;
this.lobbyServer = lobbyServer;
}
@@ -91,7 +98,7 @@ public final class WaitingRouter {
* fire {@link MenuTransferListener} on transfer.
*/
void enqueueFromMenu(Player player, String serverName) {
wakeAndWait(player, serverName, true);
authorizeAndWait(player, serverName, true);
}
/**
@@ -104,50 +111,130 @@ public final class WaitingRouter {
* exactly as the other origins, so this adds a new entry point, not a new authority.
*/
void enqueueFromCommand(Player player, String serverName) {
wakeAndWait(player, serverName, false);
authorizeAndWait(player, serverName, false);
}
@Subscribe
public void 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; // direct connect / no SRV host → leave default routing
return; // velocity.toml's only fallback is login
}
Optional<ServerView> targetOpt = registry.resolveByHost(host.get());
if (targetOpt.isEmpty()) {
return; // host is not a felis subdomain → leave default routing
return; // unknown host also falls through to login
}
ServerView target = targetOpt.get();
Optional<RegisteredServer> backend = registry.registered(target.name());
if (target.ready() && backend.isPresent()) {
event.setInitialServer(backend.get()); // ready → straight in
Optional<RegisteredServer> login = login();
if (login.isEmpty()) {
event.setInitialServer(null);
player.disconnect(Component.text(
"The Felis login gate is unavailable. Please reconnect shortly.",
NamedTextColor.RED));
return;
}
// 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
// lobby release straight back into login and loop forever.
if (!target.name().equalsIgnoreCase(loginServer)) {
pendingTargets.put(id, target.name());
}
event.setInitialServer(login.get());
}
/**
* The login backend requests the configured lobby only after its own link poll
* succeeds. Re-check that state on the trusted proxy boundary, then either route
* the remembered virtual-host target or admit the player to the post-auth lobby.
*/
@Subscribe
public EventTask onServerPreConnect(ServerPreConnectEvent event) {
RegisteredServer previous = event.getPreviousServer();
if (previous == null || !serverNamed(previous, loginServer)) {
return null;
}
Player player = event.getPlayer();
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));
log.warn("Felis: denied login-gate transfer for {} to {}",
player.getUniqueId(), event.getOriginalServer().getServerInfo().getName());
return null;
}
return EventTask.async(() -> authorizeLoginRelease(event));
}
private void authorizeLoginRelease(ServerPreConnectEvent event) {
Player player = event.getPlayer();
UUID id = player.getUniqueId();
try {
if (!api.linkStatus(id)) {
player.sendMessage(Component.text(
"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.",
NamedTextColor.RED));
return;
}
Optional<RegisteredServer> lobby = lobby();
if (lobby.isEmpty()) {
// Nowhere to hold the player while the backend wakes: refuse cleanly so
// they reconnect onto a ready server, rather than dropping them onto a
// backend that is still starting. Still fire the wake so the reconnect
// lands faster.
player.disconnect(Component.text(
"« " + target.name() + " » is starting up — please reconnect in a moment.",
NamedTextColor.YELLOW));
fireWake(player.getUniqueId(), target.name());
String targetName = pendingTargets.get(id);
if (targetName == null
|| targetName.equalsIgnoreCase(loginServer)
|| targetName.equalsIgnoreCase(lobbyServer)) {
event.setResult(ServerPreConnectEvent.ServerResult.allowed(event.getOriginalServer()));
pendingTargets.remove(id);
return;
}
event.setInitialServer(lobby.get()); // park in lobby
wakeAndWait(player, target.name(), false);
ServerView target = registry.view(targetName);
if (target == null) {
pendingTargets.remove(id, targetName);
event.setResult(ServerPreConnectEvent.ServerResult.allowed(event.getOriginalServer()));
player.sendMessage(Component.text(
"« " + targetName + " » is no longer available.", NamedTextColor.YELLOW));
return;
}
Optional<RegisteredServer> backend = registry.registered(targetName);
if (target.ready() && backend.isPresent()) {
// Keep pendingTargets until ServerConnectedEvent confirms the redirect.
// If the connect fails, Limbo retries its lobby release and we retry too.
event.setResult(ServerPreConnectEvent.ServerResult.allowed(backend.get()));
return;
}
pendingTargets.remove(id, targetName);
event.setResult(ServerPreConnectEvent.ServerResult.allowed(event.getOriginalServer()));
wakeAndWaitLinked(player, targetName, false);
}
@Subscribe
public void onDisconnect(DisconnectEvent event) {
UUID id = event.getPlayer().getUniqueId();
pendingTargets.remove(id);
waiting.remove(id);
}
@Subscribe
public void onServerConnected(ServerConnectedEvent event) {
String name = event.getServer().getServerInfo().getName();
if (!registry.isManaged(name)) {
return; // lobby / static server → not a felis backend, nothing to report
}
UUID id = event.getPlayer().getUniqueId();
pendingTargets.remove(id, name);
if (!registry.isManaged(name)
|| name.equalsIgnoreCase(loginServer)
|| name.equalsIgnoreCase(lobbyServer)) {
return; // system/static servers do not affect user-server activity
}
plugin.async(() -> {
try {
api.reportJoin(name, id);
@@ -177,7 +264,7 @@ public final class WaitingRouter {
waiting.remove(id);
player.sendMessage(Component.text(
"« " + w.serverName + " » is taking longer than expected to start. "
+ "You can keep waiting in the lobby or try again later.", NamedTextColor.YELLOW));
+ "You can try again from the lobby later.", NamedTextColor.YELLOW));
continue;
}
Boolean ready = readyCache.get(w.serverName);
@@ -196,6 +283,19 @@ public final class WaitingRouter {
if (backend.isEmpty()) {
continue; // ready but not yet registered → next tick
}
try {
if (!api.linkStatus(id)) {
waiting.remove(id);
player.sendMessage(Component.text(
"Your account is no longer linked. Reconnect to sign in again.",
NamedTextColor.RED));
continue;
}
} catch (LinkException ex) {
// Fail closed on an ambiguous identity. Keep the waiter so a later
// tick can retry the check without losing the requested target.
continue;
}
waiting.remove(id);
player.sendMessage(Component.text(
"« " + w.serverName + " » is ready — moving you in…", NamedTextColor.GREEN));
@@ -209,44 +309,55 @@ public final class WaitingRouter {
}
}
private void wakeAndWait(Player player, String serverName, boolean fromMenu) {
private void authorizeAndWait(Player player, String serverName, boolean fromMenu) {
UUID id = player.getUniqueId();
plugin.async(() -> {
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));
return; // policy gate refused → do not enqueue
case 429:
break; // a wake is already in flight → fall through to waiting
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.",
NamedTextColor.RED));
return;
if (!api.linkStatus(id)) {
player.sendMessage(Component.text(
"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.",
NamedTextColor.RED));
return;
}
player.sendMessage(Component.text(
"Starting « " + serverName + " » — you'll be moved in automatically.",
NamedTextColor.GRAY));
waiting.put(id, new Waiter(serverName, System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS, fromMenu));
wakeAndWaitLinked(player, serverName, fromMenu);
});
}
private void fireWake(UUID id, String serverName) {
plugin.async(() -> {
try {
api.wake(serverName, id);
} catch (LinkException e) {
if (e.statusCode() != 429 && e.statusCode() != 403) {
// Caller already ran the authoritative link-status check and is off the event
// thread. Keep the wake and queue mutation together so every entry has passed
// both the account gate and the server-side autostart policy.
private void wakeAndWaitLinked(Player player, String serverName, boolean fromMenu) {
UUID id = player.getUniqueId();
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));
return;
case 429:
break; // a wake is already in flight → join the existing wait
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.",
NamedTextColor.RED));
return;
}
});
}
player.sendMessage(Component.text(
"Starting « " + serverName + " » — you'll be moved in automatically.",
NamedTextColor.GRAY));
waiting.put(id, new Waiter(
serverName, System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS, fromMenu));
}
private void transfer(Player player, String serverName, RegisteredServer backend) {
@@ -259,8 +370,12 @@ public final class WaitingRouter {
});
}
private Optional<RegisteredServer> lobby() {
return lobbyServer == null ? Optional.empty() : proxy.getServer(lobbyServer);
private Optional<RegisteredServer> login() {
return proxy.getServer(loginServer);
}
private static boolean serverNamed(RegisteredServer server, String name) {
return server.getServerInfo().getName().equalsIgnoreCase(name);
}
private static Optional<String> virtualHost(Player player) {