Unverified Commit 688c86da authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

feat(proxy): enforce login-first routing

parent 16b7ad27
Loading
Loading
Loading
Loading
+4 −3
Changes for plugins/README.md: 4 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for plugins/velocity/build.gradle: 29 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityConfig.java: 36 added lines, 15 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java: 16 added lines, 17 removed lines.
Original line number Diff line number Diff line
@@ -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()) {
+7 −6
Changes for plugins/velocity/src/main/java/best/lolicon/felis/velocity/ServerRegistry.java: 7 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -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
Loading