fix(api,panel): refuse file operations on a server without a world volume (#58)
Live: the files page against a server whose world claim does not exist (never started, or reaped) created a Job whose Pod stayed Pending on FailedScheduling (persistentvolumeclaim not found) until the executor's 90s wait expired — a 90s spinner answered by a misleading 504 files_timeout, for a request that is knowably impossible. Backup and restore have refused this shape with 409 no_world_volume since the #42 round; the file routes now run the same gate before any Job is created, and the panel maps the code to a localized message (it previously fell back to the English server text).
This commit is contained in:
5 files changed
+58
No files matched your search
@@ -210,6 +210,19 @@ func (a *API) authorizeFileOp(w http.ResponseWriter, r *http.Request) (string, b
|
||||
return "", false
|
||||
}
|
||||
|
||||
// World-volume gate, matching the backup/restore faces: the Job mounts the
|
||||
// world PVC by claim name, so a server that has never started (or was already
|
||||
// reaped) has no claim to mount and its Pod sits Pending until the executor's
|
||||
// wait expires — a knowably impossible request answered by a 90s hang and a
|
||||
// misleading 504. Refuse up front with the same specific 409.
|
||||
if exists, err := a.Cluster.WorldVolumeExists(r.Context(), name); err != nil {
|
||||
writeError(w, r, err)
|
||||
return "", false
|
||||
} else if !exists {
|
||||
writeError(w, r, errNoWorldVolume())
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Files is optional: when unwired the endpoints report 503 rather than
|
||||
// panicking, so the authorization boundary above is exercised even before the
|
||||
// file-Job executor is wired (see FileEditor).
|
||||
|
||||
@@ -119,6 +119,47 @@ func TestFileEditorStoppedGate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileEditorWorldVolumeGate pins the second physical gate: a server with no
|
||||
// world PVC (never started, or already reaped) has no claim for the Job to mount,
|
||||
// so its Pod would sit Pending until the executor's wait timed out — a 90s hang
|
||||
// and a misleading 504 files_timeout for a request that is knowably impossible.
|
||||
// All three routes must refuse BEFORE creating a Job, with the same specific 409
|
||||
// the backup/restore faces use.
|
||||
func TestFileEditorWorldVolumeGate(t *testing.T) {
|
||||
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
|
||||
|
||||
routes := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{"list", "GET", "/api/v1/servers/survival/files?path=config", ""},
|
||||
{"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""},
|
||||
{"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`},
|
||||
}
|
||||
|
||||
for _, rt := range routes {
|
||||
t.Run(rt.name+" without a world volume -> 409 no_world_volume", func(t *testing.T) {
|
||||
api, _, cl, files := mkFiles()
|
||||
cl.noWorld["survival"] = true
|
||||
api.External = staticExternal{p: owner}
|
||||
|
||||
var hdr map[string]string
|
||||
if rt.body != "" {
|
||||
hdr = jsonHeader
|
||||
}
|
||||
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdr)
|
||||
if w.Code != http.StatusConflict || decodeErr(t, w) != "no_world_volume" {
|
||||
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if files.calls != 0 {
|
||||
t.Fatal("no claim to mount — the file Job must never be created")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileEditorAuthorization pins who may touch a world's files. It is the same
|
||||
// owner-or-admin rule the backup routes enforce, and it must hold on all three
|
||||
// routes — a read-only route leaking another owner's config (an RCON password
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"console_unavailable": "Can't reach the server console right now — try again shortly.",
|
||||
"no_backup": "There's no restorable backup for this server yet.",
|
||||
"not_stopped": "Stop the server completely before restoring — a restore overwrites the live world volume.",
|
||||
"no_world_volume": "This server has no world volume yet — start it once so it is created, then retry.",
|
||||
"restore_unavailable": "Restore isn't available right now — try again later.",
|
||||
"session_expired": "Your session expired — please sign in again.",
|
||||
"forbidden": "You are not allowed to do that.",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"console_unavailable": "暂时无法连接服务器控制台,请稍后重试。",
|
||||
"no_backup": "这台服务器暂时没有可回档的备份。",
|
||||
"not_stopped": "回档会覆盖世界的实时存储卷,请先把服务器完全停止再回档。",
|
||||
"no_world_volume": "这台服务器还没有世界卷——先启动一次让它创建,然后再试。",
|
||||
"restore_unavailable": "回档功能当前不可用,请稍后再试。",
|
||||
"session_expired": "会话已过期——请重新登录。",
|
||||
"forbidden": "你无权执行此操作。",
|
||||
|
||||
@@ -678,6 +678,8 @@ export function humanizeError(e: unknown): string {
|
||||
return t("no_backup");
|
||||
case "not_stopped":
|
||||
return t("not_stopped");
|
||||
case "no_world_volume":
|
||||
return t("no_world_volume");
|
||||
case "restore_unavailable":
|
||||
return t("restore_unavailable");
|
||||
default:
|
||||
|
||||
Reference in new issue
Block a user