# Login-limbo image (LOOHP/Limbo + felis-limbo) The always-on **login** auth gate. `felis setup` provisions it as a system service (`DesiredState=Running`, reaper-exempt) when `[velocity] login_image` is set in `felis.toml`. Every fresh connection lands here first; it is the only safe fallback (a stopped/starting backend routes here, never past authentication). > **Code-only.** This image is not built by the Go CI. It compiles the > `plugins/limbo` plugin (plus the shared `plugins/shared` link core it > srcDir-includes) and bundles it with a LOOHP/Limbo release. ## What the plugin does `felis-limbo` does two jobs — readiness and the in-game login flow. ### Readiness LOOHP/Limbo has no RCON, so Felis cannot use its usual RCON readiness probe (spec §5). A bare TCP check would report "ready" the instant the socket binds. The plugin instead serves an HTTP readiness endpoint that flips to `200` only after the first server tick — i.e. once the limbo has genuinely started. The `login` MinecraftServer sets `spec.startup.healthHTTPPort: 8080`, so the pod's HTTP readinessProbe (and, with RCON disabled, the operator's Ready gate) follows that true signal. - Endpoint: `GET /healthz` on `:8080` (override with `FELIS_HEALTH_PORT`). - `503 starting` before the first tick, `200 ok` after. - Fail-closed: if the endpoint cannot bind, readiness never turns green and the operator keeps the gate in `Starting` — it never advertises an unstarted gate. ### In-game login (spec §B3) A player reaching the limbo has been UUID-verified upstream (Velocity online-mode) but is not yet linked to a web account. On join, off the tick thread, the plugin: 1. checks the username-collision **blacklist** and disconnects a barred squatter UUID (the genuine Mojang player — different UUID — passes); 2. mints a one-time **Bind Code** for the verified UUID via the felis-api internal face; 3. opens a **book** with a clickable link to `console.` plus a chat line carrying the code, and tells the player to finish in their **system** browser — never the WeChat/QQ in-app browser, where passkey/WebAuthn does not work (the web entry additionally guards this; see `internal/panel`); 4. **polls** `link/status/{uuid}` until the player redeems the code on the web console, then **transfers** them to the lobby via a BungeeCord `Connect` plugin message on `bungeecord:main`; 5. **disconnects (fail-closed)** on blacklist, on a mint/transport failure, or when the login window elapses — "rather refuse than admit unauthenticated". Configuration (deployment inputs, never compiled in; env wins over a `felis-link.properties` template written in the plugin data dir on first run): | Env | Meaning | Default | | --- | ------- | ------- | | `FELIS_API_BASE_URL` | felis-api **internal** face base URL | *(required for login)* | | `FELIS_SERVICE_TOKEN` | internal service token (secret) | *(required for login)* | | `FELIS_ROOT_DOMAIN` | deployment zone, builds `https://console.` | *(required for login)* | | `FELIS_LOBBY_SERVER` | Velocity server name to transfer to | `lobby` | | `FELIS_LOGIN_TIMEOUT_SECONDS` | login window (clamped 30–3600) | `600` | | `FELIS_HEALTH_PORT` | readiness port | `8080` | | `FELIS_API_CONNECT_TIMEOUT_SECONDS` | felis-api connect timeout (1–120) | `10` | | `FELIS_API_REQUEST_TIMEOUT_SECONDS` | felis-api call timeout (1–120) | `10` | If the API config **or** the root domain is absent the login flow stays **OFF** and the plugin runs readiness-only (the same "load un-crippled" fail-safe the other Felis plugins use), so a bare image still boots — production must supply the config for the gate to authenticate. Transfer requires Velocity to accept the BungeeCord plugin-message channel (`bungee-plugin-message-channel` on the proxy). ## Build LOOHP/Limbo has no official image and no release zip. Its CI (`ci.loohpjames.com/job/Limbo`) publishes two **loose** artifacts per build — `target/Limbo-.jar` and `spawn.schem` — so the image is assembled from those two URLs (there is no bundled `server.properties`; Limbo writes a default on first run): ``` docker build -f deploy/limbo/Dockerfile \ --build-arg LIMBO_JAR_URL=https://ci.loohpjames.com/job/Limbo//artifact/target/Limbo-.jar \ --build-arg LIMBO_SCHEM_URL=https://ci.loohpjames.com/job/Limbo//artifact/spawn.schem \ --build-arg LIMBO_VERSION= \ -t felis-limbo:demo . ``` - `LIMBO_JAR_URL` (required) — the server jar; it is saved as `Limbo.jar`. - `LIMBO_SCHEM_URL` (optional) — the default spawn schematic, saved as `spawn.schem` and loaded as the spawn world. - `LIMBO_VERSION` — the Limbo **maven** API version the plugin compiles against (Gradle `-PlimboVersion`). This differs from the jar's CI build-qualified filename: e.g. the jar `Limbo-2026.0.2-ALPHA-26.2.jar` corresponds to maven version `2026.0.2-ALPHA` (the `-26.2` CI qualifier is not published to the maven repo). Publish it into the cluster's registry and point config at it. On the node itself (docker treats `127.0.0.1` as insecure by default): ``` docker tag felis-limbo:demo 127.0.0.1:5000/felis/limbo:demo docker push 127.0.0.1:5000/felis/limbo:demo # felis.toml → [velocity] login_image = "registry.felis.svc:5000/felis/limbo:demo" sudo felis setup ``` The registry keys a repository by the path after the host, so pushing through a `kubectl -n felis port-forward svc/registry 5000:5000` from another machine is equivalent. Hosting the image in the registry (rather than only importing it into containerd) is what lets kubelet re-pull it after an image GC. ## Ports (handled for you) The entrypoint (`deploy/limbo/entrypoint.sh`) pins Limbo's `server-port` to `FELIS_GAME_PORT` (default **25565**, the operator's `GamePort`) on every start — LOOHP/Limbo would otherwise default to `30000`, unreachable through the Velocity `NetworkPolicy` / Service / probe the operator drives off that one const. It is idempotent, so a persisted world volume keeps all its other `server.properties` settings. Do **not** override `FELIS_GAME_PORT` except in lockstep with the operator. It also pins `max-players=-1` (no cap, Limbo's own default): unbound players wait at the gate for up to ten minutes and a stopped server's players all fall back here at once, so a cap left on the volume would turn players away at the door. ## Configure (deployer's responsibility) One setting this image does **not** guess (it keeps the release's own default): - **Player forwarding** — align Limbo's forwarding with the off-cluster Velocity proxy so authenticated players hand off cleanly, and enable the BungeeCord plugin-message channel on the proxy so the login gate's `Connect` transfer to the lobby lands. The login flow's own inputs (`FELIS_API_BASE_URL`, `FELIS_ROOT_DOMAIN`, `FELIS_LOBBY_SERVER`, `FELIS_SERVICE_TOKEN`; see **[In-game login](#in-game-login-specb3)** above) are wired in for you — you do not set them by hand: - The three **non-secret** vars are baked into the `login` MinecraftServer's `spec.env` by `felis setup` (`cmd/felis` derives the internal API URL from the control namespace — the platform default `felis`; a renamed control namespace must be reflected by hand — and the root domain from `felis.toml`). - `FELIS_SERVICE_TOKEN` is a **secret**, so it is never written into the CRD. The login gate has its own internal-API token, `felis-limbo-token`, which may only mint link codes, poll link status and check the blacklist. The installer applies it into the minecraft namespace (and `felis setup` refreshes that replica from the control namespace), and the operator injects it into the `login` pod (only) as `FELIS_SERVICE_TOKEN` via a `secretKeyRef`, keyed off the reserved `login` name. `sudo felis rotate-token -yes limbo` replaces it and restarts the pod. Until the token is present the plugin fail-safes to readiness-only, so the gate is never broken — it simply does not authenticate yet. - **Service:** the login pod dials `FELIS_API_BASE_URL`, which resolves to the ClusterIP Service `felis-api-internal` (control namespace) that fronts the api pod's internal port 8081. That Service is deliberately separate from the external NodePort `felis-api` (443) so the no-Zero-Trust internal face is never published on a node's external IP. - **NetworkPolicy:** the minecraft namespace is egress-locked (`felis-server-egress`: DNS plus the public internet, every private range excluded), so the internal API is unreachable from a game server by default. `felis-login-to-internal-api` opens exactly the login pod → felis-api (8081) path, selecting on the reserved `login` name AND the setup-owned `felis.lolicon.best/system-role=login` label the operator copies onto the pod — the same pair that decides who receives `FELIS_SERVICE_TOKEN`, so a user server cannot match it by picking a name. The Velocity gate/lobby wiring is printed by `felis setup` and enforces the invariant: fresh connections hit `login` first, and only an authenticated release from that gate can enter the post-auth lobby or a remembered user backend.