From 2f90851c03aac60cea2aa6ec1dfde21ad8f93947 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Wed, 23 Sep 2026 06:51:45 +0800 Subject: [PATCH] fix(panel): mark platform system services read-only in the fleet table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit login/lobby carry reserved names, so every per-server route rejects them — yet the cockpit offered claim/stop/wake and a console link on their rows, each answering 400 bad_name. The fleet view now marks them (system:true, shared naming.IsSystemServer) and the panel renders a plain label instead of dead actions. --- docs/openapi.yaml | 7 ++++ internal/api/api_test.go | 38 +++++++++++++++++++++ internal/api/handlers_user.go | 10 ++++-- internal/naming/naming.go | 9 +++++ internal/naming/naming_test.go | 15 ++++++++ panel/src/i18n/resources/en-US/servers.json | 2 ++ panel/src/i18n/resources/zh-CN/servers.json | 2 ++ panel/src/lib/types.ts | 4 +++ panel/src/pages/servers/ServersPage.tsx | 16 +++++++-- 9 files changed, 99 insertions(+), 4 deletions(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 4943b64..f9b5ead 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2731,6 +2731,13 @@ paths: The owner's display identity (email, or username when the address is absent). Absent for an unclaimed server or when the best-effort owner lookup failed. + system: + type: boolean + description: >- + 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. '401': $ref: '#/components/responses/Unauthorized' '403': diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 4af2ece..cf5fa99 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1809,6 +1809,44 @@ func TestFleetAdminRead(t *testing.T) { } }) + t.Run("system services are marked read-only", func(t *testing.T) { + // The login gate and the lobby carry reserved names, so every per-server + // route rejects them; the fleet row must say "system" so the cockpit + // renders them without actions that would 400. + sysCl := newFakeCluster() + sysCl.list = []ServerInfo{ + {Name: "login", Phase: "Running", Ready: true}, + {Name: "lobby", Phase: "Running", Ready: true}, + {Name: "survival", Phase: "Stopped"}, + } + api := newTestAPI(newFakeRepo(), sysCl) + api.External = staticExternal{p: &Principal{UserID: "a1", Email: "a1@example.net", + Role: "admin", ViaAdminAccess: true}} + w := do(api.ExternalHandler(), "GET", "/api/v1/fleet", "", nil) + if w.Code != http.StatusOK { + t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + var got struct { + Servers []struct { + Name string `json:"name"` + System bool `json:"system"` + } `json:"servers"` + } + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("body not JSON: %v", err) + } + byName := map[string]bool{} + for _, r := range got.Servers { + byName[r.Name] = r.System + } + if !byName["login"] || !byName["lobby"] { + t.Errorf("system flags = %+v, want login+lobby marked", byName) + } + if byName["survival"] { + t.Errorf("survival marked system; only platform services are") + } + }) + t.Run("owner merges for claimed, absent for unclaimed", func(t *testing.T) { repo := newFakeRepo() // Only "survival" is claimed; "creative"/"skyblock" stay unowned. diff --git a/internal/api/handlers_user.go b/internal/api/handlers_user.go index b2cd25d..dbfdd92 100644 --- a/internal/api/handlers_user.go +++ b/internal/api/handlers_user.go @@ -259,7 +259,8 @@ func (a *API) handleFleet(w http.ResponseWriter, r *http.Request) { owners, _ := a.Repo.ServerOwners(r.Context()) views := make([]fleetServerView, len(servers)) for i, s := range servers { - views[i] = fleetServerView{ServerInfo: s, Owner: owners[s.Name]} + views[i] = fleetServerView{ServerInfo: s, Owner: owners[s.Name], + System: naming.IsSystemServer(s.Name)} } writeJSON(w, http.StatusOK, map[string]any{"servers": views}) } @@ -267,13 +268,18 @@ func (a *API) handleFleet(w http.ResponseWriter, r *http.Request) { // fleetServerView is one row of the SysAdmin cockpit's fleet read: the CRD // lifecycle view (ServerInfo, §1 authority) with the owner's display identity // joined alongside. The embed keeps every lifecycle field flat in the JSON so the -// shape is a strict superset of ServerInfo; Owner is the only addition. +// shape is a strict superset of ServerInfo. type fleetServerView struct { ServerInfo // Owner is the claiming user's display identity (email, or username when the // address is absent), or "" when the server is unclaimed or the best-effort // owner lookup failed — the cockpit renders "" as "unclaimed". Owner string `json:"owner,omitempty"` + // System marks a platform-provisioned system service (the login gate and the + // lobby, naming.IsSystemServer). Their names are reserved, so every per-server + // API route rejects them — the cockpit must render them read-only rather than + // offer claim/wake/stop/console actions that would answer 400. + System bool `json:"system,omitempty"` } // createServerRequest is the structured §15 create-server form. This is the diff --git a/internal/naming/naming.go b/internal/naming/naming.go index 7cb6835..86f14f1 100644 --- a/internal/naming/naming.go +++ b/internal/naming/naming.go @@ -46,6 +46,15 @@ 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. +func IsSystemServer(name string) bool { + return name == SystemLoginServer || name == SystemLobbyServer +} + // ServiceTokenSecretName / ServiceTokenSecretKey name the internal-API bearer // credential Secret (spec §7). They are one source of truth shared across // subsystems: the platform renderer wires this Secret into the felis-api diff --git a/internal/naming/naming_test.go b/internal/naming/naming_test.go index cdcb7c2..8950d0a 100644 --- a/internal/naming/naming_test.go +++ b/internal/naming/naming_test.go @@ -42,6 +42,21 @@ func TestValidateServerName(t *testing.T) { // ValidateSystemServerName keeps the format rule but drops the reservation // check, so the platform can provision the reserved system names (login, lobby) // that ValidateServerName correctly refuses to hand to users. +func TestIsSystemServer(t *testing.T) { + // Exactly the platform's two system services answer true; a user server that + // merely sounds systemic does not. + for _, name := range []string{"login", "lobby"} { + if !naming.IsSystemServer(name) { + t.Errorf("IsSystemServer(%q) = false, want true", name) + } + } + for _, name := range []string{"survival", "admin", "login2", "", "lobby-"} { + if naming.IsSystemServer(name) { + t.Errorf("IsSystemServer(%q) = true, want false", name) + } + } +} + func TestValidateSystemServerName(t *testing.T) { cases := []struct { name string diff --git a/panel/src/i18n/resources/en-US/servers.json b/panel/src/i18n/resources/en-US/servers.json index 35a9a21..c66aeaa 100644 --- a/panel/src/i18n/resources/en-US/servers.json +++ b/panel/src/i18n/resources/en-US/servers.json @@ -51,6 +51,8 @@ "backups_link_desc": "View and restore world backups for this server.", "files_link_title": "Server files", "files_link_desc": "Browse and repair files in the world volume.", + "system_service": "System service", + "system_service_hint": "Provisioned and managed by the platform (felis systemservers); it accepts no user operations.", "players_back_to_console": "Back to console", "players_not_yours_title": "Not your server", "players_not_yours_body": "Only the owner or an admin can manage this server's players.", diff --git a/panel/src/i18n/resources/zh-CN/servers.json b/panel/src/i18n/resources/zh-CN/servers.json index b615d74..b64f837 100644 --- a/panel/src/i18n/resources/zh-CN/servers.json +++ b/panel/src/i18n/resources/zh-CN/servers.json @@ -51,6 +51,8 @@ "backups_link_desc": "查看并恢复该服务器的世界备份。", "files_link_title": "服务器文件", "files_link_desc": "浏览并修复世界卷中的文件。", + "system_service": "系统服务", + "system_service_hint": "由平台预置并管理(felis systemservers),不接受用户操作。", "players_back_to_console": "返回控制台", "players_not_yours_title": "这不是你的服务器", "players_not_yours_body": "只有所有者或管理员才能管理此服务器的玩家。", diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index ecc4373..3213179 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -119,6 +119,10 @@ export interface FleetServer { * Empty/absent for an unclaimed server or when the best-effort owner lookup * failed — the cockpit renders that as "unclaimed". */ owner?: string; + /** 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. */ + system?: boolean; } /** BackupView is one row of GET /api/v1/backups (spec §7 backups). A backup is diff --git a/panel/src/pages/servers/ServersPage.tsx b/panel/src/pages/servers/ServersPage.tsx index aa7d8ba..8b3b59f 100644 --- a/panel/src/pages/servers/ServersPage.tsx +++ b/panel/src/pages/servers/ServersPage.tsx @@ -82,6 +82,7 @@ interface UnifiedServer { endpointAddress?: string | null; claimable?: boolean; owned?: boolean; + system?: boolean; } export function ServersPage() { @@ -126,6 +127,7 @@ export function ServersPage() { endpointAddress: s.endpointAddress, claimable: !s.owner, owned: s.owner === identity?.email, + system: s.system, })); } else { return (data as ServerInfo[]).map((s) => ({ @@ -443,7 +445,11 @@ function ServerRow({ {isAdmin && ( - {server.owner ? ( + {server.system ? ( + + {ts("system_service")} + + ) : server.owner ? ( @@ -486,7 +492,13 @@ function ServerRow({
- {server.claimable && !server.owned ? ( + {server.system ? ( + // A system service carries a reserved name that every per-server route + // rejects, so offer no actions — just the honest label. + + {ts("system_service")} + + ) : server.claimable && !server.owned ? ( <>