From 10bd2ddad0a3ba6a7465fdc5b459f79fdfcd2551 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sun, 4 Oct 2026 12:38:39 +0800 Subject: [PATCH] feat: customize login and lobby spaces from the panel --- deploy/bootstrap.sh | 24 +-- deploy/limbo/README.md | 17 ++ deploy/lobby/README.md | 31 ++- deploy/lobby/entrypoint.sh | 21 +- docs/openapi.yaml | 24 ++- game_entrypoint_test.go | 8 +- internal/api/api.go | 3 +- internal/api/exports.go | 5 +- internal/api/handlers_access.go | 6 +- internal/api/handlers_backups.go | 8 +- internal/api/handlers_console.go | 6 +- internal/api/handlers_fileops.go | 5 +- internal/api/handlers_logstream.go | 8 +- internal/api/handlers_user.go | 19 +- internal/api/jobstatus.go | 6 +- internal/api/systemservers.go | 28 +++ internal/api/systemservers_test.go | 123 ++++++++++++ internal/fileedit/exec.go | 11 +- internal/fileedit/exec_test.go | 20 ++ internal/fileedit/guard.go | 4 +- internal/naming/naming.go | 7 +- internal/operator/builders.go | 15 +- internal/operator/builders_internal_test.go | 19 +- panel/dev/mockApi.ts | 13 +- panel/e2e/a11y.spec.ts | 1 + panel/e2e/mobile.spec.ts | 11 ++ panel/e2e/smoke.spec.ts | 21 +- panel/src/App.tsx | 4 + panel/src/components/EditServerDialog.tsx | 13 +- panel/src/i18n/index.ts | 4 + panel/src/i18n/resources/en-US/lobby.json | 85 ++++++++ .../src/i18n/resources/en-US/navigation.json | 3 +- panel/src/i18n/resources/en-US/servers.json | 6 +- panel/src/i18n/resources/zh-CN/lobby.json | 85 ++++++++ .../src/i18n/resources/zh-CN/navigation.json | 3 +- panel/src/i18n/resources/zh-CN/servers.json | 6 +- panel/src/lib/experience.ts | 95 +++++++++ panel/src/lib/nav.ts | 2 + panel/src/lib/openapi.gen.ts | 11 +- panel/src/pages/ServerConsole.tsx | 1 + panel/src/pages/ServerFiles.tsx | 5 +- panel/src/pages/admin/LobbyPage.test.tsx | 83 ++++++++ panel/src/pages/admin/LobbyPage.tsx | 182 ++++++++++++++++++ panel/src/pages/servers/ServersPage.tsx | 4 +- .../lolicon/felis/limbo/FelisLimboPlugin.java | 15 +- .../best/lolicon/felis/limbo/LoginBook.java | 15 +- .../lolicon/felis/limbo/LoginFlowTest.java | 20 +- plugins/paper/build.gradle | 4 +- .../lolicon/felis/paper/FelisPaperPlugin.java | 16 +- .../best/lolicon/felis/paper/LobbyGuard.java | 89 ++++++--- .../best/lolicon/felis/paper/LobbyMenu.java | 11 +- plugins/paper/src/main/resources/config.yml | 5 +- .../lolicon/felis/paper/LobbyGuardTest.java | 30 +++ .../lolicon/felis/link/ExperienceConfig.java | 43 +++++ 54 files changed, 1146 insertions(+), 158 deletions(-) create mode 100644 internal/api/systemservers.go create mode 100644 internal/api/systemservers_test.go create mode 100644 panel/src/i18n/resources/en-US/lobby.json create mode 100644 panel/src/i18n/resources/zh-CN/lobby.json create mode 100644 panel/src/lib/experience.ts create mode 100644 panel/src/pages/admin/LobbyPage.test.tsx create mode 100644 panel/src/pages/admin/LobbyPage.tsx create mode 100644 plugins/shared/src/main/java/best/lolicon/felis/link/ExperienceConfig.java diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index e76b87b..b11efa8 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -3659,16 +3659,12 @@ build_velocity_plugin() { # signed with a matching HMAC. Only protocol 47 was measured; the rest of Via's 1.7-1.12 range # is its own documented support. # -# Pinned by hash and not by "latest" on purpose. These three jars sit in front of every packet -# on the proxy, and they are the exact bytes FL-007 measured — a moving tag would quietly make -# this an unmeasured configuration. Bumping a version means bumping its checksum here. -# -# The three versions are a set, not three independent pins. ViaRewind is the component that -# carries 1.8/1.7 support, and 4.1.2 against ViaVersion/ViaBackwards 5.11.0 fails to load -# Protocol1_9To1_8 — the single protocol every 1.8 client needs — with "Invalid version: 1" -# at proxy startup. 4.1.3 is the release that adds 5.11.0 compatibility; a two-arm run of the -# same proxy image logs that error three times on 4.1.2 and not at all on 4.1.3. Read the -# ViaRewind release notes before moving ViaVersion or ViaBackwards. +# Pin the three jars as one compatible set. ViaVersion/ViaBackwards 5.12.0 add +# the 26.3 protocol used by game-stack.lock; ViaRewind 4.2.0 explicitly supports +# that pair. The earlier FL-007 join measured 1.8 against Paper 1.21.11, not every +# client on 26.3. Release notes: +# https://github.com/ViaVersion/ViaBackwards/releases/tag/5.12.0 +# https://github.com/ViaVersion/ViaRewind/releases/tag/4.2.0 install_via_plugins() { prepare_velocity_layout local name version want target url tmp have @@ -3694,12 +3690,12 @@ install_via_plugins() { || die "${name} ${version} checksum mismatch: got ${have}, expected ${want}" atomic_install_file "$tmp" "$target" 0644 root root done <<'EOF' -ViaVersion 5.11.0 18d19e90fc9467d68128c076630ae8700449c901402a3ef421837ce006bc8cae -ViaBackwards 5.11.0 b21983d561e3f92df257683f0133ab6c68ec68175e8acfd82c6231723bf83587 -ViaRewind 4.1.3 2d5970d22b4711c9ab2800932326c7b08acdace25ed7c6bbb8f6ea81054962b4 +ViaVersion 5.12.0 72c40a6a702d67f226fc9a0d8ad82aba1483fdabe2e6159bcdddb2dc070750b0 +ViaBackwards 5.12.0 194e9250224632274d7b3c17e411e031a9223c1863c6f5138d53c721f07ab78d +ViaRewind 4.2.0 d6634ba57bb82d5161c68dfb393571cdf40511a0beb1b04b8c7ed794a3532c6a EOF pin_via_block_connections - ok "Via staged; clients from 1.8 up can join under modern forwarding" + ok "Via staged with 26.3 support; verify client versions against your chosen backend images" } # pin_via_block_connections turns ViaVersion's serverside block-connection tracking off. diff --git a/deploy/limbo/README.md b/deploy/limbo/README.md index 39a38f5..e27305c 100644 --- a/deploy/limbo/README.md +++ b/deploy/limbo/README.md @@ -164,3 +164,20 @@ set them by hand: 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. + +## Customize in the panel + +Administrators open **Login & lobby**, select **Login space**, and stop it before +editing. The form configures the login book title/author/heading/link text/help, +automatic book opening and the login timeout (30–3600 seconds). These settings +persist in `/data/felis-experience.json`; an explicit +`FELIS_LOGIN_TIMEOUT_SECONDS` environment variable takes precedence. The generated +code, generated login URL and chat guidance are preserved. The authentication +and transfer destination are not player-facing customization fields. + +Use the linked file manager to upload a replacement `/data/spawn.schem`, edit +Limbo's `server.properties` or add Limbo-compatible plugins, then start the space. +Paper world ZIPs and Paper plugins do not work in Limbo. The page also exposes +logs, backups/restore and image/resource settings. New joins are unavailable +while this front door is stopped; a custom image must retain the login plugin +and support the proxy's forwarding protocol. diff --git a/deploy/lobby/README.md b/deploy/lobby/README.md index 7d16741..368eada 100644 --- a/deploy/lobby/README.md +++ b/deploy/lobby/README.md @@ -28,7 +28,7 @@ this at every layer: ``` docker build -f deploy/lobby/Dockerfile \ - --build-arg PAPER_JAR_URL=https:///paper-1.21.x-.jar \ + --build-arg PAPER_JAR_URL=https:///paper-26.3-.jar \ --build-arg PAPER_JAR_SHA256= \ -t felis-lobby:demo . # Publish into the cluster's registry (on the node; docker treats 127.0.0.1 as @@ -40,6 +40,30 @@ docker push 127.0.0.1:5000/felis/lobby:demo sudo felis setup ``` +## Customize in the panel + +Administrators open **Login & lobby** (`/admin/lobby`). Stop the selected space +before reading or saving its settings, then start it to apply them. The lobby +form configures welcome text, menu titles, join behavior, game mode, building +protection, damage/hunger/void handling, difficulty, time/weather and world rules. +Settings live in `/data/felis-experience.json`, independently of the image, and +retain unknown keys when saved. Existing installations without this file use the +same protected-lobby defaults as before. + +The page also exposes the existing file manager (including upload and ZIP +extraction), console, backups/restore, builder permissions and image/resource +settings. To replace a map: back up and stop the lobby, upload a world ZIP, +extract it at the volume root, verify the world directory directly contains +`level.dat`, and set `level-name` in `server.properties`. Use `setworldspawn x y z` +in the running lobby console to set its spawn. Plugin JARs go in `plugins/` and +must match Paper's version; the bundled Felis and LuckPerms JARs are refreshed +from the image at boot. A custom image must retain the menu/control plugin. + +The operator reuses its `init-forwarding` YAML merge for the lobby, preserving +custom Paper globals while refreshing mandatory authentication settings. The +image only rewrites that file for standalone runs without a managed forwarding +initContainer. RCON secrets and the proxy forwarding secret remain managed. + ## Configure (deployer's responsibility) - Game port must be `25565` (the CRD `GamePort`). @@ -47,7 +71,7 @@ sudo felis setup - The lobby speaks only the `felis:control` plugin-message channel; it holds no felis-api token by design (spec §12). -## What the lobby allows +## Default lobby behavior felis-paper's `LobbyGuard` keeps the lobby a hub that nobody can hurt, get hurt in, or leave a mark on: @@ -65,6 +89,7 @@ or leave a mark on: LuckPerms (`lp user permission set felis.lobby.build true` on the lobby console) or op them. -The entrypoint pins `max-players=200` on every boot, over Paper's default of 20: every +The entrypoint seeds `max-players=200` when absent, over Paper's default of 20; +subsequent file-editor changes survive restarts: every authenticated player passes through here, and a stopped server's players arrive all at once. diff --git a/deploy/lobby/entrypoint.sh b/deploy/lobby/entrypoint.sh index 295adf3..ca6eb71 100644 --- a/deploy/lobby/entrypoint.sh +++ b/deploy/lobby/entrypoint.sh @@ -76,7 +76,10 @@ set_prop online-mode false # what one node serves at once, and a flood beyond it is refused at the door instead # of running the 1Gi lobby out of memory. What the world itself allows (no damage, no # building, the /menu hint) is felis-paper's LobbyGuard. -set_prop max-players 200 +# Seed capacity once; administrators can tune it in the panel file editor. +if ! grep -q '^max-players=' "$PROPS"; then + set_prop max-players 200 +fi # RCON is the control plane's write channel (spec §8 写=RCON): the operator probes it # for readiness and the player tally, and felis-api runs console/permission commands over @@ -105,21 +108,21 @@ else echo " injects it from the -rcon Secret when spec.rcon.enabled is true." >&2 fi -# Rewritten whole, not merged. Paper loads this file and fills every key it does -# not find with the default, then writes the full tree back — so a proxies-only file is a -# complete, stable input, and the lobby's other globals are simply always the defaults. -# That is true of a system server Felis owns end to end; if admins are ever allowed to tune -# the lobby's globals, this has to become a real YAML merge (yq) instead. +# The operator's existing init-forwarding step merges the proxy keys on every +# start, preserving other Paper globals. Standalone runs retain the mandatory +# rewrite because no initContainer has verified their forwarding settings. mkdir -p config -cat > config/paper-global.yml <&2; exit 1; } +else + cat > config/paper-global.yml <- True for a platform-provisioned system service (the login gate, the - lobby). Their reserved names are rejected by every per-server route, - so the cockpit renders them read-only instead of offering actions - that would 400. + lobby). Staff can manage them through the existing server routes; + players see them read-only. Creating, claiming and deleting these + reserved names remain prohibited. RetireState: type: object @@ -2437,6 +2443,8 @@ paths: properties: name: { type: string } desiredState: { type: string, const: Stopped } + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -2465,6 +2473,8 @@ paths: properties: name: { type: string } claimed: { type: boolean, const: true } + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -3182,6 +3192,8 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/ServerInfo' } + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -4488,7 +4500,7 @@ paths: recorded when it was written as it streams; a mismatch cuts the download off short of its end. On the way out config/paper-global.yml (the cluster's forwarding secret) is left out and server.properties has - its rcon.password redacted, so the download carries no Content-Length. + its rcon.password and forwarding-secrets redacted, so the download carries no Content-Length. A user gets 404 for a backup outside their scope, as their list never shows it. One export per user at a time, 2 across the install, 6 per user per hour. @@ -4850,7 +4862,7 @@ paths: type: string pattern: '^[0-9a-f]{64}$' description: >- - SHA-256 of the file as stored (before the rcon.password redaction in + SHA-256 of the file as stored (before secret redaction in server.properties). Send it back as expect_sha256 on the next write. content_sha256: type: string @@ -5204,7 +5216,7 @@ paths: without one, with symbolic links, devices and sockets left out. config/paper-global.yml, the cluster's forwarding secret, is refused as a file and left out of a folder, and server.properties goes out with - its rcon.password redacted; both are matched by the file itself, so a + its rcon.password and forwarding-secrets redacted; both are matched by the file itself, so a link to either under another name is guarded too. The server cannot start until the download has ended. Two file downloads per user at a time, 4 across the install, 30 per user per hour, counted apart from diff --git a/game_entrypoint_test.go b/game_entrypoint_test.go index 1937da4..d1e5cea 100644 --- a/game_entrypoint_test.go +++ b/game_entrypoint_test.go @@ -9,7 +9,7 @@ import ( ) // The lobby and login gate take their player cap from server.properties, which the -// entrypoint rewrites on every boot over whatever the volume already holds. These run +// login gate rewrites on every boot; the lobby seeds it only when absent. These run // the shipped entrypoints the way a pod does (image and volume paths pointed into temp // dirs, java replaced by a stub that exits) and read the file the server would start on. @@ -91,11 +91,11 @@ func assertProp(t *testing.T, props, key, want string) { var lobbyImage = []string{"paper.jar", "plugins/felis-paper.jar", "plugins/LuckPerms.jar"} -func TestLobbyEntrypointLiftsThePlayerCap(t *testing.T) { - t.Run("over the cap Paper wrote on an earlier boot", func(t *testing.T) { +func TestLobbyEntrypointSeedsAndPreservesThePlayerCap(t *testing.T) { + t.Run("preserves an administrator capacity", func(t *testing.T) { props := runEntrypoint(t, "deploy/lobby/entrypoint.sh", `RUNTIME_DIR="/paper"`, lobbyImage, "#Minecraft server properties\nmax-players=20\nmotd=Kept as it was\n") - assertProp(t, props, "max-players", "200") + assertProp(t, props, "max-players", "20") assertProp(t, props, "motd", "Kept as it was") }) t.Run("on a first boot", func(t *testing.T) { diff --git a/internal/api/api.go b/internal/api/api.go index ef92dbf..b2e6455 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -26,6 +26,7 @@ import ( "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/fileedit" + "felis.lolicon.best/internal/naming" ) // API holds the dependencies shared by every handler. @@ -1197,7 +1198,7 @@ func (l *streamLimiter) release(key string) { // reconcile is idempotent and the §18 reaper / §9.3 quota bound steady-state // load; the cap exists to refuse an obvious flood, not to hold a hard ceiling. func (a *API) withinRunningCap(ctx context.Context, info *ServerInfo) (bool, error) { - if a.MaxRunningServers <= 0 { + if a.MaxRunningServers <= 0 || naming.IsSystemServer(info.Name) { return true, nil } if info.DesiredState == string(v1alpha1.DesiredRunning) { diff --git a/internal/api/exports.go b/internal/api/exports.go index 93ee2ec..845f6c4 100644 --- a/internal/api/exports.go +++ b/internal/api/exports.go @@ -21,7 +21,6 @@ import ( "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/fileedit" "felis.lolicon.best/internal/maintenance" - "felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/worldexport" ) @@ -605,11 +604,11 @@ func errExportUnavailable() error { func (a *API) exportGate(w http.ResponseWriter, r *http.Request) (string, *ServerRecord, bool) { p := principalFromContext(r.Context()) name := r.PathValue("name") - if err := naming.ValidateServerName(name); err != nil { + if err := validateManagedServerName(r, name); err != nil { writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err)) return "", nil, false } - rec, err := a.Repo.ServerByName(r.Context(), name) + rec, err := a.managedServerRecord(r.Context(), name) if err != nil { a.writeLookupError(w, r, err) return "", nil, false diff --git a/internal/api/handlers_access.go b/internal/api/handlers_access.go index 89ac356..97ba860 100644 --- a/internal/api/handlers_access.go +++ b/internal/api/handlers_access.go @@ -7,8 +7,6 @@ import ( "regexp" "strconv" "strings" - - "felis.lolicon.best/internal/naming" ) // Access / permissions domain (spec §7). These endpoints let an owner manage @@ -101,12 +99,12 @@ func (a *API) issueLuckPermsCommand(w http.ResponseWriter, r *http.Request, name // the path, not the body) is validated here. func (a *API) issueAccessCommand(w http.ResponseWriter, r *http.Request, name, command string) (string, bool) { p := principalFromContext(r.Context()) - if err := naming.ValidateServerName(name); err != nil { + if err := validateManagedServerName(r, name); err != nil { writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err)) return "", false } - rec, err := a.Repo.ServerByName(r.Context(), name) + rec, err := a.managedServerRecord(r.Context(), name) if err != nil { a.writeLookupError(w, r, err) return "", false diff --git a/internal/api/handlers_backups.go b/internal/api/handlers_backups.go index 30995d5..b419848 100644 --- a/internal/api/handlers_backups.go +++ b/internal/api/handlers_backups.go @@ -183,7 +183,7 @@ func restoreMayRead(jobs []AsyncJob, id string) bool { func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) name := r.PathValue("name") - if err := naming.ValidateServerName(name); err != nil { + if err := validateManagedServerName(r, name); err != nil { writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err)) return } @@ -191,7 +191,7 @@ func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { // Ownership: owner or admin, mirroring handleStop. An unknown server is 404; an // unowned (released) server fails the owner check for everyone but admin, which // is exactly the "must re-claim first" rule. - rec, err := a.Repo.ServerByName(r.Context(), name) + rec, err := a.managedServerRecord(r.Context(), name) if err != nil { a.writeLookupError(w, r, err) return @@ -367,12 +367,12 @@ func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { func (a *API) handleBackupNow(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) name := r.PathValue("name") - if err := naming.ValidateServerName(name); err != nil { + if err := validateManagedServerName(r, name); err != nil { writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err)) return } - rec, err := a.Repo.ServerByName(r.Context(), name) + rec, err := a.managedServerRecord(r.Context(), name) if err != nil { a.writeLookupError(w, r, err) return diff --git a/internal/api/handlers_console.go b/internal/api/handlers_console.go index a22cdfc..db835b2 100644 --- a/internal/api/handlers_console.go +++ b/internal/api/handlers_console.go @@ -3,8 +3,6 @@ package api import ( "errors" "net/http" - - "felis.lolicon.best/internal/naming" ) // maxConsoleCommandLen caps the command body well under RCON's single-packet @@ -39,7 +37,7 @@ type commandRequest struct { func (a *API) handleCommand(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) name := r.PathValue("name") - if err := naming.ValidateServerName(name); err != nil { + if err := validateManagedServerName(r, name); err != nil { writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err)) return } @@ -58,7 +56,7 @@ func (a *API) handleCommand(w http.ResponseWriter, r *http.Request) { } // Ownership: owner or admin, mirroring handleStop. An unknown server is 404. - rec, err := a.Repo.ServerByName(r.Context(), name) + rec, err := a.managedServerRecord(r.Context(), name) if err != nil { a.writeLookupError(w, r, err) return diff --git a/internal/api/handlers_fileops.go b/internal/api/handlers_fileops.go index 7ccced5..a4a5e63 100644 --- a/internal/api/handlers_fileops.go +++ b/internal/api/handlers_fileops.go @@ -11,7 +11,6 @@ import ( "felis.lolicon.best/internal/fileedit" "felis.lolicon.best/internal/maintenance" - "felis.lolicon.best/internal/naming" ) // A file too big for the one-request upload (handleUploadFile) arrives as an @@ -430,11 +429,11 @@ func (a *API) requireFileStage(w http.ResponseWriter, r *http.Request) (string, // staff. It returns the server name. func (a *API) authorizeServerFiles(w http.ResponseWriter, r *http.Request) (string, bool) { name := r.PathValue("name") - if err := naming.ValidateServerName(name); err != nil { + if err := validateManagedServerName(r, name); err != nil { writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err)) return "", false } - rec, err := a.Repo.ServerByName(r.Context(), name) + rec, err := a.managedServerRecord(r.Context(), name) if err != nil { a.writeLookupError(w, r, err) return "", false diff --git a/internal/api/handlers_logstream.go b/internal/api/handlers_logstream.go index 68fd260..a00f192 100644 --- a/internal/api/handlers_logstream.go +++ b/internal/api/handlers_logstream.go @@ -4,8 +4,6 @@ import ( "context" "errors" "net/http" - - "felis.lolicon.best/internal/naming" ) // handleServerConsole streams the caller's server console as Server-Sent Events @@ -39,13 +37,13 @@ import ( func (a *API) handleServerConsole(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) name := r.PathValue("name") - if err := naming.ValidateServerName(name); err != nil { + if err := validateManagedServerName(r, name); err != nil { writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err)) return } // Ownership: owner or admin, mirroring handleCommand. An unknown server is 404. - rec, err := a.Repo.ServerByName(r.Context(), name) + rec, err := a.managedServerRecord(r.Context(), name) if err != nil { a.writeLookupError(w, r, err) return @@ -105,7 +103,7 @@ func (a *API) handleServerConsole(w http.ResponseWriter, r *http.Request) { a.audit(r, "console.attach", name) relayLogStream(w, r, src, a.streamRecheck(r, func(ctx context.Context, p *Principal) error { - rec, err := a.Repo.ServerByName(ctx, name) + rec, err := a.managedServerRecord(ctx, name) switch { case errors.Is(err, ErrNotFound): return errForbidden // the server is gone, and the grant with it diff --git a/internal/api/handlers_user.go b/internal/api/handlers_user.go index e6169b1..889415b 100644 --- a/internal/api/handlers_user.go +++ b/internal/api/handlers_user.go @@ -20,7 +20,7 @@ import ( func (a *API) handleWake(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) name := r.PathValue("name") - if err := naming.ValidateServerName(name); err != nil { + if err := validateManagedServerName(r, name); err != nil { writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err)) return } @@ -97,12 +97,12 @@ func (a *API) handleWake(w http.ResponseWriter, r *http.Request) { func (a *API) handleStop(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) name := r.PathValue("name") - if err := naming.ValidateServerName(name); err != nil { + if err := validateManagedServerName(r, name); err != nil { writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err)) return } - rec, err := a.Repo.ServerByName(r.Context(), name) + rec, err := a.managedServerRecord(r.Context(), name) if err != nil { a.writeLookupError(w, r, err) return @@ -217,7 +217,7 @@ func (a *API) claimResources(ctx context.Context, name string) (ResourceSpec, er // handleStatus returns the CRD status view (spec §7 GET /servers/{name}/status). func (a *API) handleStatus(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") - if err := naming.ValidateServerName(name); err != nil { + if err := validateManagedServerName(r, name); err != nil { writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err)) return } @@ -899,7 +899,7 @@ const ( // the adminOnly wrapper in routing — every caller here is already an admin. func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") - if err := naming.ValidateServerName(name); err != nil { + if err := validateManagedServerName(r, name); err != nil { writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err)) return } @@ -910,6 +910,13 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) { return } + if naming.IsSystemServer(name) && + ((body.AutostartPolicy != nil && *body.AutostartPolicy != string(v1alpha1.AutostartPublic)) || + (body.IdleStopSeconds != nil && *body.IdleStopSeconds != 0)) { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", "system services must remain public and exempt from idle stop")) + return + } + // An empty patch is a client mistake, not a no-op success. if body.DisplayName == nil && body.AutostartPolicy == nil && body.Image == nil && body.Memory == nil && body.Resources == nil && body.Storage == nil && body.IdleStopSeconds == nil { @@ -1073,7 +1080,7 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) { // fits (ResizeServer). Only growth is held to the caps: a change that grows // neither CPU nor memory cannot push the owner past one, and it is how an admin // brings a server back under a cap lowered below what the owner already uses. - if resUpdated { + if resUpdated && !naming.IsSystemServer(name) { newCPU := quantityToMilli(newResources.Limits[corev1.ResourceCPU]) newMemMB := quantityToMB(newResources.Limits[corev1.ResourceMemory]) diff --git a/internal/api/jobstatus.go b/internal/api/jobstatus.go index e1bafaf..cf2cfa4 100644 --- a/internal/api/jobstatus.go +++ b/internal/api/jobstatus.go @@ -4,8 +4,6 @@ import ( "context" "net/http" "time" - - "felis.lolicon.best/internal/naming" ) // AsyncJob is the observable outcome of one asynchronous world operation. The API @@ -45,11 +43,11 @@ type JobStatusReader interface { func (a *API) handleServerJobs(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) name := r.PathValue("name") - if err := naming.ValidateServerName(name); err != nil { + if err := validateManagedServerName(r, name); err != nil { writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err)) return } - rec, err := a.Repo.ServerByName(r.Context(), name) + rec, err := a.managedServerRecord(r.Context(), name) if err != nil { a.writeLookupError(w, r, err) return diff --git a/internal/api/systemservers.go b/internal/api/systemservers.go new file mode 100644 index 0000000..de70297 --- /dev/null +++ b/internal/api/systemservers.go @@ -0,0 +1,28 @@ +package api + +import ( + "context" + "net/http" + + "felis.lolicon.best/internal/naming" +) + +// System services have no player owner or business-layer row. Staff manage their +// existing cluster objects; creation and claiming keep the reserved-name gate. +func validateManagedServerName(r *http.Request, name string) error { + if p := principalFromContext(r.Context()); naming.IsSystemServer(name) && p != nil && p.IsAdmin() { + return naming.ValidateSystemServerName(name) + } + return naming.ValidateServerName(name) +} + +func (a *API) managedServerRecord(ctx context.Context, name string) (*ServerRecord, error) { + if !naming.IsSystemServer(name) { + return a.Repo.ServerByName(ctx, name) + } + info, err := a.Cluster.GetServer(ctx, name) + if err != nil { + return nil, err + } + return &ServerRecord{Name: name, Subdomain: info.Subdomain}, nil +} diff --git a/internal/api/systemservers_test.go b/internal/api/systemservers_test.go new file mode 100644 index 0000000..fd0a160 --- /dev/null +++ b/internal/api/systemservers_test.go @@ -0,0 +1,123 @@ +package api + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +func TestSystemServerManagement(t *testing.T) { + for _, name := range []string{"login", "lobby"} { + for _, principal := range []*Principal{ + {UserID: "staff", Role: "admin", ViaAdminAccess: true}, + {UserID: "staff", Role: "admin"}, + {UserID: "player", Role: "user"}, + } { + t.Run(fmt.Sprintf("%s/%s/operator=%t", name, principal.Role, principal.ViaAdminAccess), func(t *testing.T) { + api, repo, cl, files := mkFiles(t) + api.External = staticExternal{p: principal} + cl.byName[name] = &ServerInfo{Name: name, Subdomain: name, Phase: "Stopped", DesiredState: "Stopped", ReaperExempt: true} + // These services are cluster-owned; there deliberately is no database row. + for _, route := range []struct { + method, suffix, body string + success int + }{ + {"GET", "/status", "", 200}, + {"GET", "/files", "", 200}, + {"GET", "/file?path=felis-experience.json", "", 200}, + {"PUT", "/file?path=felis-experience.json", `{"content":"aGk=","content_sha256":"` + hiSum + `"}`, 200}, + {"PATCH", "", `{"displayName":"Custom Space"}`, 200}, + {"POST", "/stop", "", 202}, + } { + before := files.calls + result := do(api.ExternalHandler(), route.method, "/api/v1/servers/"+name+route.suffix, route.body, jsonHeader) + if principal.IsAdmin() { + if result.Code != route.success { + t.Fatalf("%s %s: %d %s", route.method, route.suffix, result.Code, result.Body.String()) + } + } else { + if result.Code < 400 || files.calls != before { + t.Fatalf("player management admitted: %d %s", result.Code, result.Body.String()) + } + } + } + result := do(api.ExternalHandler(), "POST", "/api/v1/servers/"+name+"/claim", "", nil) + if result.Code != http.StatusBadRequest { + t.Fatalf("system service claim: %d", result.Code) + } + if _, exists := repo.byName[name]; exists { + t.Fatal("management created a claimable business row") + } + }) + } + } +} + +func TestSystemServerResourcesAndInvariants(t *testing.T) { + api, repo, cl, _ := newPatchAPI() + cl.byName["lobby"] = &ServerInfo{Name: "lobby", ReaperExempt: true, Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("1Gi")}, + Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("1Gi")}, + }} + result := do(api.ExternalHandler(), "PATCH", "/api/v1/servers/lobby", `{"memory":"2Gi"}`, jsonHeader) + if result.Code != http.StatusOK || cl.patched["lobby"].JavaMemory == nil { + t.Fatalf("resource patch: %d %s", result.Code, result.Body.String()) + } + if _, exists := repo.byName["lobby"]; exists { + t.Fatal("system resources added an ownership row") + } + for _, body := range []string{`{"autostartPolicy":"ownerOnly"}`, `{"idleStopSeconds":600}`} { + result := do(api.ExternalHandler(), "PATCH", "/api/v1/servers/lobby", body, jsonHeader) + if result.Code != http.StatusBadRequest { + t.Fatalf("system invariant changed: %d %s", result.Code, result.Body.String()) + } + } + delete(cl.byName, "lobby") + result = do(api.ExternalHandler(), "POST", "/api/v1/servers/lobby/stop", "", nil) + if result.Code != http.StatusNotFound { + t.Fatalf("missing cluster service: %d %s", result.Code, result.Body.String()) + } +} + +func TestSystemLobbyBuilderAccess(t *testing.T) { + api, repo, cl, console := mkAccess(t) + api.External = staticExternal{p: &Principal{UserID: "staff", Role: "admin", ViaAdminAccess: true}} + cl.byName["lobby"] = &ServerInfo{Name: "lobby", Phase: "Running", Ready: true} + result := do(api.ExternalHandler(), "POST", "/api/v1/servers/lobby/access/permission", + `{"action":"set","player":"Steve","node":"felis.lobby.build","value":true}`, jsonHeader) + if result.Code != http.StatusOK || console.gotCommand != "lp user Steve permission set felis.lobby.build true" { + t.Fatalf("builder grant: %d %s; command %q", result.Code, result.Body.String(), console.gotCommand) + } + if _, exists := repo.byName["lobby"]; exists { + t.Fatal("builder access created an ownership row") + } +} + +func TestSystemConsoleStreamRechecksWithoutOwnershipRow(t *testing.T) { + shrinkStreamTimers(t, 10*time.Millisecond, 80*time.Millisecond) + a, _, cl, _ := mkAccess(t) + a.External = staticExternal{p: &Principal{UserID: "staff", Role: "admin", ViaAdminAccess: true}} + cl.byName["lobby"] = &ServerInfo{Name: "lobby", Phase: "Running", Ready: true} + a.Logs = &fakeLogStreamer{srcFromCtx: func(ctx context.Context) io.ReadCloser { + return &ctxBlockingReadCloser{ctx: ctx, first: []byte("boot\n"), firstRead: make(chan struct{}), closed: make(chan struct{})} + }} + begun := time.Now() + done := make(chan *httptest.ResponseRecorder, 1) + go func() { done <- do(a.ExternalHandler(), "GET", "/api/v1/servers/lobby/console", "", nil) }() + select { + case result := <-done: + if result.Code != http.StatusOK || strings.Contains(result.Body.String(), "event: revoked") || time.Since(begun) < 80*time.Millisecond { + t.Fatalf("system logs ended before their lifetime: %d %s", result.Code, result.Body.String()) + } + case <-time.After(2 * time.Second): + t.Fatal("system log stream outlived its lifetime") + } +} diff --git a/internal/fileedit/exec.go b/internal/fileedit/exec.go index c288782..d3fcdd2 100644 --- a/internal/fileedit/exec.go +++ b/internal/fileedit/exec.go @@ -456,7 +456,7 @@ const ( redactedValue = "" ) -// redactSecretProps blanks the RCON password when server.properties is read. +// redactSecretProps hides RCON and Limbo forwarding secrets when server.properties is read. // // Unlike secretConfigPath this is a value redaction rather than a whole-file // denial, because the file is not platform material that merely happens to sit in @@ -468,6 +468,9 @@ const ( // regardless of blast radius, and because the console already gives an owner every // capability the password would. // +// Limbo stores its cluster forwarding key in this file too; that value is +// withheld by the same redaction and refreshed by the Limbo entrypoint. +// // The write path is left alone on purpose, mirroring the reasoning at // secretConfigPath: felis-lobby's entrypoint rewrites all three rcon keys from the // injected Secret on every boot, so saving the placeholder back cannot lock the @@ -481,8 +484,10 @@ func redactSecretProps(name string, content []byte) []byte { for i, line := range lines { // TrimSpace before matching: a properties key may be indented, and the // trailing \r of a CRLF file would otherwise ride along into the value. - if bytes.HasPrefix(bytes.TrimSpace(line), []byte(rconPasswordKey+"=")) { - lines[i] = []byte(rconPasswordKey + "=" + redactedValue) + for _, key := range []string{rconPasswordKey, "forwarding-secrets"} { + if bytes.HasPrefix(bytes.TrimSpace(line), []byte(key+"=")) { + lines[i] = []byte(key + "=" + redactedValue) + } } } return bytes.Join(lines, []byte("\n")) diff --git a/internal/fileedit/exec_test.go b/internal/fileedit/exec_test.go index 7242492..153a8d4 100644 --- a/internal/fileedit/exec_test.go +++ b/internal/fileedit/exec_test.go @@ -678,3 +678,23 @@ func assertNoTemporaries(t *testing.T, dir string) { } } } + +func TestReadRedactsLimboForwardingSecret(t *testing.T) { + root := t.TempDir() + props := "spawn-x=8\nforwarding-secrets=shared-key\nvelocity-modern=true\n" + if err := os.WriteFile(filepath.Join(root, "server.properties"), []byte(props), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Link(filepath.Join(root, "server.properties"), filepath.Join(root, "copy.properties")); err != nil { + t.Fatal(err) + } + for _, path := range []string{"server.properties", "copy.properties"} { + res, err := run(root, OpRead, path, nil, "") + if err != nil || res.Code != "" { + t.Fatalf("read: %+v %v", res, err) + } + if strings.Contains(string(res.Content), "shared-key") || !strings.Contains(string(res.Content), "spawn-x=8") { + t.Fatalf("unsafe redaction: %s", res.Content) + } + } +} diff --git a/internal/fileedit/guard.go b/internal/fileedit/guard.go index 38aff8f..757d632 100644 --- a/internal/fileedit/guard.go +++ b/internal/fileedit/guard.go @@ -9,7 +9,7 @@ import ( // What leaves a world mount — a read, a download, a world export, a backup // export — passes the same two guards: the forwarding-secret file // (secretConfigPath) is withheld, and server.properties has its RCON password -// redacted (propsPath). +// redacted (propsPath), along with Limbo's forwarding-secrets. // // On a live mount both are matched by the file itself (os.SameFile), not by the // name it was reached under. A plugin runs arbitrary code as the game uid and can @@ -51,7 +51,7 @@ func ArchiveRule(name string) (withhold, redact bool) { return name == secretConfigPath, name == propsPath } -// RedactProps replaces the RCON password in server.properties content with +// RedactProps replaces RCON and Limbo forwarding secrets in server.properties with // redactedValue (see redactSecretProps for why a placeholder and not a blank). func RedactProps(content []byte) []byte { return redactSecretProps(propsPath, content) diff --git a/internal/naming/naming.go b/internal/naming/naming.go index 1ab3ffd..164d323 100644 --- a/internal/naming/naming.go +++ b/internal/naming/naming.go @@ -48,11 +48,8 @@ const ( SystemLobbyServer = "lobby" ) -// IsSystemServer reports whether name is one of the platform-provisioned system -// services above. They carry reserved names on purpose, and the API's per-server -// routes reject those names outright (ValidateServerName) — so a caller that only -// DISPLAYS fleet rows uses this to mark them as not user-manageable instead of -// offering actions (claim/wake/stop/console) that would answer 400. +// IsSystemServer identifies platform services whose reserved names may be managed +// by staff, but never created or claimed through player-facing routes. func IsSystemServer(name string) bool { return name == SystemLoginServer || name == SystemLobbyServer } diff --git a/internal/operator/builders.go b/internal/operator/builders.go index 7f61b02..5a4286e 100644 --- a/internal/operator/builders.go +++ b/internal/operator/builders.go @@ -269,15 +269,18 @@ func buildStatefulSet(server *v1alpha1.MinecraftServer, replicas int32, felisIma // Every server first hands its world volume to the game uid (prepareDataInitContainer), // since the pod runs as that uid and a world an older root-run release wrote would // otherwise be read-only to it. An arbitrary user Paper image then gets the forwarding - // config written for it (it does not consume FELIS_FORWARDING_SECRET itself); system - // servers (login/lobby) are Felis-built and handle forwarding in their own - // entrypoints. Last, every server waits for its egress fence (egressGateInitContainer). + // config written for it (it does not consume FELIS_FORWARDING_SECRET itself). + // The lobby uses the same merge so custom settings survive; the login Limbo + // handles its own properties format. Every server then waits for its egress fence. // Without a felis image name there is nothing to run any step with. var initContainers []corev1.Container if felisImage != "" { initContainers = append(initContainers, prepareDataInitContainer(felisImage)) - if server.Labels[v1alpha1.LabelSystemRole] == "" { + if server.Labels[v1alpha1.LabelSystemRole] != naming.SystemLoginServer { initContainers = append(initContainers, forwardingInitContainer(felisImage)) + if server.Labels[v1alpha1.LabelSystemRole] == naming.SystemLobbyServer { + container.Env = append(container.Env, corev1.EnvVar{Name: "FELIS_MANAGED_FORWARDING", Value: "true"}) + } } gate := egressGateInitContainer(felisImage) if server.Spec.NodeName != "" || (len(gateProbe) > 0 && gateProbe[0] != "") { @@ -447,8 +450,8 @@ func forwardingSecretEnvVar() corev1.EnvVar { // at all: no capability, a read-only root filesystem, and the files it writes are // owned by the very uid that rewrites them on boot. // -// Only user servers get it: the Felis-built system images (login limbo, lobby) already -// consume the secret in their own entrypoints, and the login limbo is not Paper at all. +// User Paper servers and the lobby share this merge. The login Limbo handles +// its own properties format in its entrypoint. func forwardingInitContainer(felisImage string) corev1.Container { return corev1.Container{ Name: "init-forwarding", diff --git a/internal/operator/builders_internal_test.go b/internal/operator/builders_internal_test.go index 322c8f4..19da2cb 100644 --- a/internal/operator/builders_internal_test.go +++ b/internal/operator/builders_internal_test.go @@ -89,7 +89,7 @@ func TestReadinessProbeHTTPCustomPath(t *testing.T) { // A user server (no system-role label) gets the forwarding-config initContainer after // prepare-data, running the felis image and mounting the world volume. A system -// server gets no forwarding step, and a build with no felis image name gets no step. +// login gate handles its own properties; a build with no felis image name gets no step. func TestBuildStatefulSetForwardingInitContainer(t *testing.T) { user := &v1alpha1.MinecraftServer{} user.Spec.Storage.Size = "1Gi" @@ -143,14 +143,23 @@ func TestBuildStatefulSetForwardingInitContainer(t *testing.T) { t.Error("no felis image must yield no initContainer") } - // System server handles forwarding in its own entrypoint, but its world still - // needs handing to the game uid and its image waits for the fence all the same. + // The lobby shares the forwarding merge, preserving its custom Paper globals. sys := &v1alpha1.MinecraftServer{} sys.Spec.Storage.Size = "1Gi" sys.Labels = map[string]string{v1alpha1.LabelSystemRole: "lobby"} sysSts, _ := buildStatefulSet(sys, 1, "felis:demo") - if got := sysSts.Spec.Template.Spec.InitContainers; len(got) != 2 || got[0].Name != "prepare-data" || got[1].Name != "egress-gate" { - t.Errorf("system server must get [prepare-data egress-gate], got %+v", got) + if got := sysSts.Spec.Template.Spec.InitContainers; len(got) != 3 || got[0].Name != "prepare-data" || got[1].Name != "init-forwarding" || got[2].Name != "egress-gate" { + t.Errorf("lobby must get [prepare-data init-forwarding egress-gate], got %+v", got) + } + + managed := false + for _, env := range sysSts.Spec.Template.Spec.Containers[0].Env { + if env.Name == "FELIS_MANAGED_FORWARDING" && env.Value == "true" { + managed = true + } + } + if !managed { + t.Fatal("lobby entrypoint would overwrite the merged forwarding config") } } diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index 6fd61f6..c6d62fb 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -286,6 +286,10 @@ function initialState(): MockState { server("claim-me", "Claimable Node", "Stopped", null, { playersMax: 10, }), + server("login", "Login space", "Stopped", null, { + autostartPolicy: "public", idleStopSeconds: 0, reaperExempt: true, + image: "registry.felis.svc:5000/felis/limbo:demo", memory: "512Mi", storageSize: "1Gi", + }), ...generatedServers(), ], access: { @@ -764,7 +768,8 @@ function fleetView(state: MockState, accountInfo: MockAccount): FleetServer[] { playersOnline: s.ready ? s.playersOnline : 0, owner: owner ? state.accounts[owner].email : "", owned: owner === accountInfo.id, - claimable: owner === null && !s.retiring, + claimable: owner === null && !s.retiring && !s.reaperExempt, + system: s.reaperExempt, }; }); } @@ -2241,6 +2246,10 @@ async function handleServerRoute(ctx: SessionContext): Promise { sendError(ctx.res, 404, "not_found", "server not found"); return true; } + if (serverInfo.reaperExempt && !isAdmin(ctx.account.role)) { + sendError(ctx.res, 400, "bad_name", "system service is reserved for staff"); + return true; + } if (!canSee(ctx.account, serverInfo)) { sendError(ctx.res, 403, "forbidden", "server is not visible to this account"); return true; @@ -2474,7 +2483,7 @@ async function handleFilesMock(ctx: SessionContext, serverInfo: MockServer): Pro if (!node) return fail(404, "not_found", `${p} does not exist`); if (node.is_dir) return fail(400, "bad_path", `${p} is a directory, not a file`); if (node.data.length > MOCK_MAX_READ) return fail(413, "too_large", `${p} is larger than the editor reads`); - sendJSON(ctx.res, 200, { path: p, content: node.data.toString("base64"), sha256: mockSha(node.data) }); + sendJSON(ctx.res, 200, { path: p, content: node.data.toString("base64"), sha256: mockSha(node.data), content_sha256: mockSha(node.data) }); return true; } case "PUT file": { diff --git a/panel/e2e/a11y.spec.ts b/panel/e2e/a11y.spec.ts index 2b748e3..e8c104a 100644 --- a/panel/e2e/a11y.spec.ts +++ b/panel/e2e/a11y.spec.ts @@ -30,6 +30,7 @@ for (const [account, path] of [ ["linked", "/account"], ["owner", "/"], ["owner", "/servers"], + ["owner", "/admin/lobby?space=login"], ["owner", "/admin/images"], ["owner", "/admin/builds"], ["owner", "/admin/submissions"], diff --git a/panel/e2e/mobile.spec.ts b/panel/e2e/mobile.spec.ts index 275a6e9..5024fec 100644 --- a/panel/e2e/mobile.spec.ts +++ b/panel/e2e/mobile.spec.ts @@ -50,3 +50,14 @@ test("the LuckPerms player list leaves the rest of the page on the first screen" await page.getByRole("button", { name: "Herobrine", exact: true }).click(); await expect(page.getByRole("textbox", { name: t("servers:luckperms_player_lookup") })).toHaveValue("Herobrine"); }); + +test("login customization fits a phone and retains a saved title", async ({ page, signIn }) => { + await signIn("owner"); + await page.goto("/admin/lobby?space=login"); + await page.getByLabel(t("lobby:bookTitle")).fill("Our Network"); + await expectFitsScreen(page); + await page.getByRole("button", { name: t("lobby:save"), exact: true }).click(); + await expect(page.getByText(t("lobby:saved"), { exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByLabel(t("lobby:bookTitle"))).toHaveValue("Our Network"); +}); diff --git a/panel/e2e/smoke.spec.ts b/panel/e2e/smoke.spec.ts index 138a019..a23dba8 100644 --- a/panel/e2e/smoke.spec.ts +++ b/panel/e2e/smoke.spec.ts @@ -98,7 +98,7 @@ test("an unlinked Owner can manage the panel, then preview and confirm a game ro // Admin pages are chunks of their own, so a player never downloads them: every // page module is fetched by its name (/src/pages/admin/UsersPage.tsx under the // dev server, /assets/UsersPage-.js in a build). -const ADMIN_PAGE = /\/(UsersPage|UserDetailPage|ImageAdmin|ImageBuildPage|SubmissionsPage|UpdatesPage)[.-]/; +const ADMIN_PAGE = /\/(UsersPage|UserDetailPage|LobbyPage|ImageAdmin|ImageBuildPage|SubmissionsPage|UpdatesPage)[.-]/; const ACCOUNT_PAGE = /\/Account[.-]/; test("a player's pages load on demand and never pull in the admin pages", async ({ page, signIn }) => { @@ -130,3 +130,22 @@ test("on a wide screen the LuckPerms player list runs down its column", async ({ await page.setViewportSize({ width: 1280, height: 1000 }); await expect.poll(async () => (await list.locator("..").boundingBox())!.height).toBe(700); }); + +test("staff customize the login space through the persistent file editor", async ({ page, signIn }) => { + await signIn("owner"); + await page.goto("/admin/lobby?space=login"); + await expect(page.getByRole("heading", { name: t("lobby:title"), exact: true })).toBeVisible(); + const heading = page.getByLabel(t("lobby:bookHeading")); + await expect(heading).toBeVisible(); + await heading.fill("Welcome to our network"); + await page.getByRole("button", { name: t("lobby:save"), exact: true }).click(); + await expect(page.getByText(t("lobby:saved"), { exact: true })).toBeVisible(); + await page.reload(); + await expect(heading).toHaveValue("Welcome to our network"); +}); + +test("players cannot open the lobby administration page", async ({ page, signIn }) => { + await signIn("linked"); + await page.goto("/admin/lobby"); + await expect(page.getByRole("heading", { name: t("lobby:title"), exact: true })).toHaveCount(0); +}); diff --git a/panel/src/App.tsx b/panel/src/App.tsx index 19d1f6e..4460e2b 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -59,6 +59,9 @@ const UsersPage = lazyWithReload(() => const UserDetailPage = lazyWithReload(() => import("@/pages/admin/UserDetailPage").then((m) => ({ default: m.UserDetailPage })), ); +const LobbyPage = lazyWithReload(() => + import("@/pages/admin/LobbyPage").then((m) => ({ default: m.LobbyPage })), +); const UpdatesPage = lazyWithReload(() => import("@/pages/admin/UpdatesPage").then((m) => ({ default: m.UpdatesPage })), ); @@ -121,6 +124,7 @@ export default function App() { so the section root and any stale bookmarks land somewhere useful. */} }> } /> + } /> } /> } /> } /> diff --git a/panel/src/components/EditServerDialog.tsx b/panel/src/components/EditServerDialog.tsx index 9c79a7a..40839fb 100644 --- a/panel/src/components/EditServerDialog.tsx +++ b/panel/src/components/EditServerDialog.tsx @@ -83,6 +83,7 @@ interface EditServerForm { interface Props { serverName: string; + systemService?: boolean; currentDisplayName?: string; currentPolicy?: AutostartPolicy; currentImage?: string; @@ -97,6 +98,7 @@ interface Props { export function EditServerDialog({ serverName, + systemService = false, currentDisplayName = "", currentPolicy = "ownerOnly", currentImage = "", @@ -176,7 +178,7 @@ export function EditServerDialog({ if (displayName !== currentDisplayName) { payload.displayName = displayName; } - if (form.autostartPolicy !== currentPolicy) { + if (!systemService && form.autostartPolicy !== currentPolicy) { payload.autostartPolicy = form.autostartPolicy; } if (imageChanged) { @@ -190,7 +192,7 @@ export function EditServerDialog({ if (cpu !== currentCpu) { payload.resources = { cpu }; } - if (form.idleStop !== currentIdleStop) { + if (!systemService && form.idleStop !== currentIdleStop) { payload.idleStopSeconds = Number(form.idleStop); } @@ -218,7 +220,7 @@ export function EditServerDialog({

{t("edit_server_title")}

- {t("edit_server_desc")} + {t(systemService ? "edit_system_desc" : "edit_server_desc")}

@@ -228,7 +230,7 @@ export function EditServerDialog({ {t("edit_server_title")} - {t("edit_server_desc_long")} + {t(systemService ? "edit_system_desc" : "edit_server_desc_long")} @@ -350,6 +352,7 @@ export function EditServerDialog({ set("idleStop", v)}> + set(e.target.checked)} disabled={!stopped || saving} className="h-4 w-4 accent-primary" /> + {t(field.key)} + + ); + return ( +
+ + {field.choices ? ( + + ) : field.multiline ? ( +