From c7315e44b6ea4f0af737e4c3984934639981d9c7 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Thu, 2 Jul 2026 19:37:52 +0900 Subject: [PATCH] feat(deploy): login-limbo and lobby images with game-port pinning deploy/limbo assembles LOOHP/Limbo from its loose CI artifacts plus the felis-limbo plugin (and the shared link core), with an entrypoint that pins server-port to the operator's GamePort (25565) on every start. deploy/lobby carries the Paper + felis-paper hub image. .dockerignore re-includes plugins/limbo and plugins/shared so the plugin image build sees them. --- .dockerignore | 13 +++- deploy/limbo/Dockerfile | 86 ++++++++++++++++++++++ deploy/limbo/README.md | 142 +++++++++++++++++++++++++++++++++++++ deploy/limbo/entrypoint.sh | 31 ++++++++ deploy/lobby/Dockerfile | 47 ++++++++++++ deploy/lobby/README.md | 43 +++++++++++ 6 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 deploy/limbo/Dockerfile create mode 100644 deploy/limbo/README.md create mode 100644 deploy/limbo/entrypoint.sh create mode 100644 deploy/lobby/Dockerfile create mode 100644 deploy/lobby/README.md diff --git a/.dockerignore b/.dockerignore index 6256554..cc88bf5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,7 +7,18 @@ .git .claude **/node_modules -plugins +# plugins/ is code-only and excluded from the MAIN felis image — EXCEPT the two +# source trees the login-limbo image (deploy/limbo/Dockerfile) compiles: its own +# module (plugins/limbo) and the shared link core it srcDir-includes (plugins/shared). +# `plugins/*` excludes each sibling module individually (so plugins/ itself is still +# walked and the negations below can re-include), then the two build trees are pulled +# back in and their Gradle outputs re-excluded to keep the context lean. The main +# image copies none of plugins/, so carrying these two small source dirs is harmless. +plugins/* +!plugins/limbo +!plugins/shared +plugins/**/build +plugins/**/.gradle docs *.md Dockerfile diff --git a/deploy/limbo/Dockerfile b/deploy/limbo/Dockerfile new file mode 100644 index 0000000..6d8a721 --- /dev/null +++ b/deploy/limbo/Dockerfile @@ -0,0 +1,86 @@ +# Felis login-limbo image: LOOHP/Limbo + the felis-limbo readiness plugin. +# +# CODE-ONLY in this repo — it is not built by the Go CI. It packages the always-on +# "login" auth gate the setup provisioner points [velocity] login_image at. +# +# 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 (the server jar; Main-Class com.loohp.limbo.Limbo) +# - spawn.schem (the default spawn schematic) +# There is no bundled server.properties — Limbo writes a default on first run. +# So the runtime is assembled from those two URLs (not a zip) via --build-arg: +# +# 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 . +# +# Note LIMBO_VERSION (the maven API version the plugin compiles against, e.g. +# 2026.0.2-ALPHA) differs from the jar's CI build-qualified filename (e.g. +# Limbo-2026.0.2-ALPHA-26.2.jar): the CI qualifier is not published to the maven +# repo. Then import it the same way as the control-plane image (k3s ctr import) +# and set [velocity] login_image = "felis-limbo:demo" in felis.toml before setup. +# +# Two contracts the operator depends on: +# 1. The game server listens on 25565 (the CRD's GamePort). +# 2. The felis-limbo readiness endpoint listens on 8080 (StartupSpec.HealthHTTPPort, +# overridable via FELIS_HEALTH_PORT) and returns 200 only after the first tick. + +# ---- build the felis-limbo plugin jar ---- +# gradle:*-jdk21 — an official Gradle image on JDK 21. JDK 21 is required because +# current LOOHP/Limbo releases ship Java 21 API classes (class-file major 65); a +# JDK 17 fails to read them with "wrong version 65.0, should be 61.0". The image +# also provides the `gradle` binary (this tree vendors no Gradle wrapper). +# build.gradle still targets release 17 bytecode so the plugin loads on Java 17+. +FROM gradle:8.14-jdk21 AS plugin +WORKDIR /src +# Copy what the limbo module needs: its own tree plus the shared link core it +# srcDir-includes (../shared/src/main/java → /src/plugins/shared/src/main/java), so +# the account-link client + config loader compile straight into the jar. +COPY plugins/limbo/ ./plugins/limbo/ +COPY plugins/shared/ ./plugins/shared/ +ARG LIMBO_VERSION=+ +RUN cd plugins/limbo \ + && (test -x ./gradlew && ./gradlew --no-daemon -PlimboVersion="$LIMBO_VERSION" build \ + || gradle --no-daemon -PlimboVersion="$LIMBO_VERSION" build) \ + && cp build/libs/*.jar /felis-limbo.jar + +# ---- assemble the runtime ---- +# 21-jre: the Limbo jar is Java 21 bytecode (class-file major 65), so a Java 17 +# JRE cannot run it (UnsupportedClassVersionError). A 21 JRE also runs the +# plugin's release-17 bytecode fine. +FROM eclipse-temurin:21-jre +ARG LIMBO_JAR_URL +ARG LIMBO_SCHEM_URL +WORKDIR /limbo +# Pull the two loose LOOHP/Limbo CI artifacts: the server jar (required, saved as +# Limbo.jar) and the default spawn schematic (optional). Fail loudly if the jar +# URL was not supplied. +RUN set -eu; \ + if [ -z "${LIMBO_JAR_URL:-}" ]; then \ + echo "ERROR: --build-arg LIMBO_JAR_URL= is required" >&2; exit 1; \ + fi; \ + apt-get update && apt-get install -y --no-install-recommends curl ca-certificates; \ + curl -fSL "$LIMBO_JAR_URL" -o /limbo/Limbo.jar; \ + if [ -n "${LIMBO_SCHEM_URL:-}" ]; then \ + curl -fSL "$LIMBO_SCHEM_URL" -o /limbo/spawn.schem; \ + fi; \ + apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*; \ + mkdir -p /limbo/plugins +# Drop the login+readiness plugin in beside Limbo.jar. +COPY --from=plugin /felis-limbo.jar /limbo/plugins/felis-limbo.jar +# The entrypoint pins the game port to the operator's GamePort before launching Limbo. +COPY deploy/limbo/entrypoint.sh /usr/local/bin/felis-entrypoint.sh + +ENV FELIS_HEALTH_PORT=8080 +# FELIS_GAME_PORT is the port the entrypoint pins Limbo to; it MUST equal the operator's +# GamePort (internal/operator/builders.go). Default 25565 — override only in lockstep +# with the operator. +ENV FELIS_GAME_PORT=25565 +EXPOSE 25565 8080 +# felis-entrypoint.sh pins server-port then execs `java -jar Limbo.jar --nogui` (headless: +# the pod has no console). Limbo writes the rest of server.properties on first run and +# loads ./spawn.schem as the spawn world. Invoked via `sh` so no +x bit is needed from the +# (Windows) build host. +ENTRYPOINT ["/bin/sh", "/usr/local/bin/felis-entrypoint.sh"] diff --git a/deploy/limbo/README.md b/deploy/limbo/README.md new file mode 100644 index 0000000..497bfae --- /dev/null +++ b/deploy/limbo/README.md @@ -0,0 +1,142 @@ +# 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) | `300` | +| `FELIS_HEALTH_PORT` | readiness port | `8080` | + +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). + +Import into k3s and point config at it: + +``` +docker save felis-limbo:demo | sudo k3s ctr images import - +# felis.toml → [velocity] login_image = "felis-limbo:demo" +sudo felis setup +``` + +## 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. + +## 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. `felis + setup` replicates the `felis-service-token` Secret from the control namespace into + the minecraft namespace, and the operator injects it into the `login` pod (only) + via a `secretKeyRef`, keyed off the reserved `login` name. Until the token is + present the plugin fail-safes to readiness-only, so the gate is never broken — it + simply does not authenticate yet. +- **NetworkPolicy:** none is required today — neither the minecraft-namespace egress + nor the control-namespace ingress is policy-locked, so the login pod's call to the + API internal port is already reachable. If a future deployment adds a minecraft + egress lock or a control-namespace ingress fence, it must also open the + login-pod → felis-api internal-port (8081) path. + +The Velocity default-landing and waiting-park wiring is printed by `felis setup` +and enforces the invariant: fresh connections hit `login` first; nothing falls +back to the lobby. diff --git a/deploy/limbo/entrypoint.sh b/deploy/limbo/entrypoint.sh new file mode 100644 index 0000000..497d7df --- /dev/null +++ b/deploy/limbo/entrypoint.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Felis login-limbo entrypoint. +# +# Pin Limbo's game port to the pod-facing port the operator contract uses. LOOHP/Limbo +# defaults server-port to 30000, but the Felis operator drives everything — the Service +# Port/TargetPort, the TCP/HTTP readiness probe, the container port and the Velocity +# NetworkPolicy — off a single GamePort const (25565). A backend that bound 30000 would +# be unreachable through that fence. Limbo writes a full server.properties on first run +# and merges any partial we leave in place, so seeding/patching just server-port here is +# enough; the spawn schematic still loads from ./spawn.schem. +# +# Idempotent by design: it runs on every start and rewrites only the server-port line, +# so a persisted world volume that already carries a server.properties keeps all its +# other settings. +set -eu + +PORT="${FELIS_GAME_PORT:-25565}" +PROPS="server.properties" + +if [ -f "$PROPS" ]; then + if grep -q '^server-port=' "$PROPS"; then + sed -i "s/^server-port=.*/server-port=${PORT}/" "$PROPS" + else + printf 'server-port=%s\n' "$PORT" >> "$PROPS" + fi +else + printf 'server-port=%s\n' "$PORT" > "$PROPS" +fi + +echo "felis-limbo: pinned server-port=${PORT} (operator GamePort)" +exec java -jar Limbo.jar --nogui "$@" diff --git a/deploy/lobby/Dockerfile b/deploy/lobby/Dockerfile new file mode 100644 index 0000000..1aee3be --- /dev/null +++ b/deploy/lobby/Dockerfile @@ -0,0 +1,47 @@ +# Felis lobby image: Paper + the felis-paper /menu plugin. +# +# CODE-ONLY in this repo — not built by the Go CI. It packages the always-on +# "lobby" hub the setup provisioner points [velocity] lobby_image at. The lobby is +# the POST-auth /menu hub: it is reached only when the login gate transfers an +# authenticated player onward, and it must never be a fallback target. +# +# Build: +# docker build -f deploy/lobby/Dockerfile \ +# --build-arg PAPER_JAR_URL=https:///paper-1.21.x-.jar \ +# -t felis-lobby:demo . +# docker save felis-lobby:demo | sudo k3s ctr images import - +# # felis.toml → [velocity] lobby_image = "felis-lobby:demo" +# +# Contract: the game server listens on 25565 (the CRD GamePort). The lobby speaks +# only the felis:control plugin-message channel (spec §12) — it holds no felis-api +# token. + +# ---- build the felis-paper plugin jar (Paper API is Java 21) ---- +FROM eclipse-temurin:21-jdk AS plugin +WORKDIR /src +COPY plugins/paper/ ./plugins/paper/ +COPY plugins/shared/ ./plugins/shared/ +RUN cd plugins/paper \ + && (test -x ./gradlew && ./gradlew --no-daemon build \ + || gradle --no-daemon build) \ + && cp build/libs/*.jar /felis-paper.jar + +# ---- assemble the runtime ---- +FROM eclipse-temurin:21-jre +ARG PAPER_JAR_URL +WORKDIR /paper +RUN set -eu; \ + if [ -z "${PAPER_JAR_URL:-}" ]; then \ + echo "ERROR: --build-arg PAPER_JAR_URL= is required" >&2; exit 1; \ + fi; \ + apt-get update && apt-get install -y --no-install-recommends curl ca-certificates; \ + curl -fSL "$PAPER_JAR_URL" -o /paper/paper.jar; \ + apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*; \ + mkdir -p /paper/plugins; \ + echo "eula=true" > /paper/eula.txt +COPY --from=plugin /felis-paper.jar /paper/plugins/felis-paper.jar + +EXPOSE 25565 +# nogui headless; the first boot generates server.properties (align online-mode / +# forwarding with the Velocity proxy afterwards — see README). +ENTRYPOINT ["java", "-jar", "paper.jar", "--nogui"] diff --git a/deploy/lobby/README.md b/deploy/lobby/README.md new file mode 100644 index 0000000..6ef41fb --- /dev/null +++ b/deploy/lobby/README.md @@ -0,0 +1,43 @@ +# Lobby image (Paper + felis-paper) + +The always-on **lobby** hub. `felis setup` provisions it as a system service +(`DesiredState=Running`, reaper-exempt) when `[velocity] lobby_image` is set in +`felis.toml`. + +> **Code-only.** Not built by the Go CI. It compiles the `plugins/paper` +> `/menu` face and bundles it onto a Paper server. + +## Topology & the one invariant + +``` +connect → login (limbo auth gate) → lobby (/menu hub) → target backend +``` + +The lobby is reached **only** when the login gate transfers an authenticated +player onward. It is never a fallback or waiting-park target — routing a fresh +connection to the lobby would drop the player past authentication. Felis enforces +this at every layer: + +- the login system service has **no** fallback (refuse if down); +- the lobby and every user server fall back to **login**, never to the lobby; +- `buildSystemServer` refuses to construct any service whose fallback is `lobby`; +- `felis setup` prints the off-cluster Velocity wiring: default landing and + waiting-park both point at `login`. + +## Build + +``` +docker build -f deploy/lobby/Dockerfile \ + --build-arg PAPER_JAR_URL=https:///paper-1.21.x-.jar \ + -t felis-lobby:demo . +docker save felis-lobby:demo | sudo k3s ctr images import - +# felis.toml → [velocity] lobby_image = "felis-lobby:demo" +sudo felis setup +``` + +## Configure (deployer's responsibility) + +- Game port must be `25565` (the CRD `GamePort`). +- Align `online-mode` / player forwarding with the off-cluster Velocity proxy. +- The lobby speaks only the `felis:control` plugin-message channel; it holds no + felis-api token by design (spec §12).