feat(proxy): enforce login-first routing
This commit is contained in:
6 files changed
+272
-113
No files matched your search
@@ -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
|
||||
|
||||
+36
-15
@@ -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;
|
||||
}
|
||||
|
||||
+16
-17
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user