fix(panel): mark platform system services read-only in the fleet table

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.
This commit is contained in:
Lemon-miaow committed 2026-09-23 06:51:45 +08:00
1 parent 0a36b3fda9
commit 2f90851c03
9 files changed
+99 -4

No files matched your search

+7
View File
@@ -2731,6 +2731,13 @@ paths:
The owner's display identity (email, or username when The owner's display identity (email, or username when
the address is absent). Absent for an unclaimed server the address is absent). Absent for an unclaimed server
or when the best-effort owner lookup failed. 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': '401':
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
'403': '403':
+38
View File
@@ -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: "[email protected]",
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) { t.Run("owner merges for claimed, absent for unclaimed", func(t *testing.T) {
repo := newFakeRepo() repo := newFakeRepo()
// Only "survival" is claimed; "creative"/"skyblock" stay unowned. // Only "survival" is claimed; "creative"/"skyblock" stay unowned.
+8 -2
View File
@@ -259,7 +259,8 @@ func (a *API) handleFleet(w http.ResponseWriter, r *http.Request) {
owners, _ := a.Repo.ServerOwners(r.Context()) owners, _ := a.Repo.ServerOwners(r.Context())
views := make([]fleetServerView, len(servers)) views := make([]fleetServerView, len(servers))
for i, s := range 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}) 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 // fleetServerView is one row of the SysAdmin cockpit's fleet read: the CRD
// lifecycle view (ServerInfo, §1 authority) with the owner's display identity // 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 // 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 { type fleetServerView struct {
ServerInfo ServerInfo
// Owner is the claiming user's display identity (email, or username when the // 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 // address is absent), or "" when the server is unclaimed or the best-effort
// owner lookup failed — the cockpit renders "" as "unclaimed". // owner lookup failed — the cockpit renders "" as "unclaimed".
Owner string `json:"owner,omitempty"` 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 // createServerRequest is the structured §15 create-server form. This is the
+9
View File
@@ -46,6 +46,15 @@ const (
SystemLobbyServer = "lobby" 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 // ServiceTokenSecretName / ServiceTokenSecretKey name the internal-API bearer
// credential Secret (spec §7). They are one source of truth shared across // credential Secret (spec §7). They are one source of truth shared across
// subsystems: the platform renderer wires this Secret into the felis-api // subsystems: the platform renderer wires this Secret into the felis-api
+15
View File
@@ -42,6 +42,21 @@ func TestValidateServerName(t *testing.T) {
// ValidateSystemServerName keeps the format rule but drops the reservation // ValidateSystemServerName keeps the format rule but drops the reservation
// check, so the platform can provision the reserved system names (login, lobby) // check, so the platform can provision the reserved system names (login, lobby)
// that ValidateServerName correctly refuses to hand to users. // 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) { func TestValidateSystemServerName(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
@@ -51,6 +51,8 @@
"backups_link_desc": "View and restore world backups for this server.", "backups_link_desc": "View and restore world backups for this server.",
"files_link_title": "Server files", "files_link_title": "Server files",
"files_link_desc": "Browse and repair files in the world volume.", "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_back_to_console": "Back to console",
"players_not_yours_title": "Not your server", "players_not_yours_title": "Not your server",
"players_not_yours_body": "Only the owner or an admin can manage this server's players.", "players_not_yours_body": "Only the owner or an admin can manage this server's players.",
@@ -51,6 +51,8 @@
"backups_link_desc": "查看并恢复该服务器的世界备份。", "backups_link_desc": "查看并恢复该服务器的世界备份。",
"files_link_title": "服务器文件", "files_link_title": "服务器文件",
"files_link_desc": "浏览并修复世界卷中的文件。", "files_link_desc": "浏览并修复世界卷中的文件。",
"system_service": "系统服务",
"system_service_hint": "由平台预置并管理(felis systemservers),不接受用户操作。",
"players_back_to_console": "返回控制台", "players_back_to_console": "返回控制台",
"players_not_yours_title": "这不是你的服务器", "players_not_yours_title": "这不是你的服务器",
"players_not_yours_body": "只有所有者或管理员才能管理此服务器的玩家。", "players_not_yours_body": "只有所有者或管理员才能管理此服务器的玩家。",
+4
View File
@@ -119,6 +119,10 @@ export interface FleetServer {
* Empty/absent for an unclaimed server or when the best-effort owner lookup * Empty/absent for an unclaimed server or when the best-effort owner lookup
* failed — the cockpit renders that as "unclaimed". */ * failed — the cockpit renders that as "unclaimed". */
owner?: string; 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 /** BackupView is one row of GET /api/v1/backups (spec §7 backups). A backup is
+14 -2
View File
@@ -82,6 +82,7 @@ interface UnifiedServer {
endpointAddress?: string | null; endpointAddress?: string | null;
claimable?: boolean; claimable?: boolean;
owned?: boolean; owned?: boolean;
system?: boolean;
} }
export function ServersPage() { export function ServersPage() {
@@ -126,6 +127,7 @@ export function ServersPage() {
endpointAddress: s.endpointAddress, endpointAddress: s.endpointAddress,
claimable: !s.owner, claimable: !s.owner,
owned: s.owner === identity?.email, owned: s.owner === identity?.email,
system: s.system,
})); }));
} else { } else {
return (data as ServerInfo[]).map((s) => ({ return (data as ServerInfo[]).map((s) => ({
@@ -443,7 +445,11 @@ function ServerRow({
</td> </td>
{isAdmin && ( {isAdmin && (
<td className="px-4 py-3 align-middle text-left"> <td className="px-4 py-3 align-middle text-left">
{server.owner ? ( {server.system ? (
<span className="text-xs text-muted-foreground/70" title={ts("system_service_hint")}>
{ts("system_service")}
</span>
) : server.owner ? (
<span className="inline-flex max-w-[16rem] items-center gap-1.5 truncate"> <span className="inline-flex max-w-[16rem] items-center gap-1.5 truncate">
<UserRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <UserRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate" title={server.owner}> <span className="truncate" title={server.owner}>
@@ -486,7 +492,13 @@ function ServerRow({
</td> </td>
<td className="px-4 py-3 align-middle"> <td className="px-4 py-3 align-middle">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
{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.
<span className="text-xs text-muted-foreground/70" title={ts("system_service_hint")}>
{ts("system_service")}
</span>
) : server.claimable && !server.owned ? (
<> <>
<Button <Button
size="sm" size="sm"