feat(files): add the server file editor

Give an owner a way to repair the one failure no other endpoint covers: a
server that will not boot because a single line of server.properties or a
plugin's YAML is wrong. Until now that needed a human with cluster access.

felis-api cannot touch a world in-process — the world PVC is ReadWriteOnce
and its lifecycle belongs to the operator's StatefulSet — so the work runs
as a one-shot Job, and the server must be stopped first because a running
one holds the volume. That is the same constraint that shapes restore and
backup, and the handlers enforce the stopped gate the same way.

What is different is that the caller wants the OUTPUT, not just the side
effect. The Job prints its result to stdout and felis-api reads it back
through the pods/log subresource, which needs no permission felis-api does
not already hold: jobs:create, pods:list, pods/log:get. No pods/exec, no
pods/portforward, not even pods:get. The price is latency — every operation
is a Pod schedule — which is why this is a repair tool and not a file
manager.

Containment is structural, not textual. Every filesystem access goes through
os.Root, the stdlib's escape-proof directory handle, which resolves each
component against the open root descriptor and refuses "..", absolute paths,
and symlinks leading outside. The string-prefix check used elsewhere is not
reused here: it validates a path as text and then opens it as a path, and a
world directory holds attacker-influenced content, so a symlink swapped in
between those two steps is a live threat rather than a theoretical one.
os.Root has no such window because the check and the open are one operation.

The Job's isolation is a strict subset of a restore Pod's: the weak
felis-restore SA with its token auto-mount disabled, exactly one volume (the
world PVC, mounted read-only for list and read so two of the three
operations cannot mutate anything), no Secret, no ConfigMap, no database
URL, non-root with an fsGroup matching the operator's so a written file is
readable by the server that later mounts it, and backoffLimit 0 so a failed
write is never silently retried as a second write.

Two limits on the surface are worth stating plainly, because the mount is
the server's whole working directory rather than a config subtree:

  * A write accepts arbitrary bytes at any path, so an owner can place a
    loadable plugin jar. This is deliberate — it is what a hosting panel's
    file manager does, scoped to a server the caller already owns and
    already drives through /command — but it is the one owner-tier route
    that lands executable code in a backend pod, since images are
    admin-only and modpack submissions need an admin verdict.
  * config/paper-global.yml is refused on read. felis-lobby's entrypoint
    writes FELIS_FORWARDING_SECRET into it on every boot, and that value is
    identical on every backend, so reading it from a server you own would
    hand you the handshake key for everyone else's. It is the only path in
    the mount that is not the caller's own data, and therefore the only
    denial. The comparison is on the cleaned path, or ./config/... would
    walk straight through it.

Writing that file is still allowed: it leaks nothing, and the entrypoint
rewrites it whole on every boot regardless.

The write body's content field is a *[]byte rather than a []byte for the
reason permissionRequest.Value is a *bool — a plain slice makes absent,
null, and empty indistinguishable, so a body of {} would decode to nil and
truncate the target to zero bytes while answering 200, destroying the very
config the caller opened the editor to repair.
This commit is contained in:
flyemoji committed 2026-07-20 14:33:29 +09:00
1 parent 05cb8f6320
commit fe4c92c1c5
14 files changed
+2994

No files matched your search

+209
View File
@@ -2771,6 +2771,215 @@ paths:
'503':
$ref: '#/components/responses/ServiceUnavailable'
# ------------------------------------------------- server file editor (app) ---
/api/v1/servers/{name}/files:
get:
tags: [files]
operationId: listServerFiles
summary: List a directory in a server's world volume (owner-or-admin; server must be stopped).
description: >-
Lists one directory inside the server's world volume — the repair lever for a
server that will not boot because a config file is wrong. The world PVC is RWO
and held by a running server, so the server must be fully stopped first (409
not_stopped otherwise). The listing runs as a one-shot Job whose output is read
back through pods/log, so the call is synchronous but takes seconds rather than
milliseconds. Paths are resolved inside the world root by os.Root, so "..", an
absolute path, and a symlink leaving the root are all refused with 400 bad_path.
Listings are capped; truncated reports that the cap was hit.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
in: query
required: false
description: Directory to list, relative to the world root. Empty lists the root itself.
schema: { type: string }
responses:
'200':
description: Directory listing.
content:
application/json:
schema:
type: object
required: [path, entries, truncated]
properties:
path: { type: string }
truncated: { type: boolean, description: The listing hit the entry cap and is incomplete. }
entries:
type: array
items:
type: object
required: [name, size, is_dir, mod_time]
properties:
name: { type: string }
size: { type: integer, format: int64 }
is_dir: { type: boolean }
mod_time: { type: string, format: date-time }
'400':
description: Invalid server name, or a path that escapes the world root.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server, or no such directory.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: Server is not stopped (its world PVC is still mounted).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
'504':
description: The file Job did not finish in time; retry.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/servers/{name}/file:
get:
tags: [files]
operationId: readServerFile
summary: Read a file from a server's world volume (owner-or-admin; server must be stopped).
description: >-
Returns one file's bytes, base64-encoded, from inside the server's world
volume. Same stopped-gate and os.Root containment as the directory listing.
Reads are capped at 1 MiB; a larger file is 413 rather than a truncated read,
because a config editor that silently returned half a file would let a
subsequent save destroy the other half.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
in: query
required: true
description: File to read, relative to the world root.
schema: { type: string }
responses:
'200':
description: File contents.
content:
application/json:
schema:
type: object
required: [path, content]
properties:
path: { type: string }
content: { type: string, format: byte, description: Base64-encoded file bytes. }
'400':
description: Missing path, invalid server name, or a path that escapes the world root.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server, or no such file.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: Server is not stopped (its world PVC is still mounted).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'413':
description: The file is larger than the editor reads.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
'504':
description: The file Job did not finish in time; retry.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
put:
tags: [files]
operationId: writeServerFile
summary: Write a file in a server's world volume (owner-or-admin; server must be stopped).
description: >-
Replaces a file's contents, creating the file if absent but never creating its
parent directories. Content is base64 so arbitrary bytes (CRLF endings, a BOM)
survive intact. Writes are capped at 256 KiB — the Job spec carries the content,
and etcd bounds the object — so a larger body is 413. Same stopped-gate and
os.Root containment as the read; a write through a symlink leaving the world
root is refused. Audited as file.write.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
in: query
required: true
description: File to write, relative to the world root.
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [content]
properties:
content: { type: string, format: byte, description: Base64-encoded file bytes. }
responses:
'200':
description: File written.
content:
application/json:
schema:
type: object
required: [path, status]
properties:
path: { type: string }
status: { type: string, const: written }
'400':
description: Missing path, malformed body, invalid server name, or a path that escapes the world root.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server, or the parent directory does not exist.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: Server is not stopped (its world PVC is still mounted).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'413':
description: The content is larger than the editor writes.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
'504':
description: The file Job did not finish in time; retry.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
# ------------------------------------------------------ users (admin tier) ----
/api/v1/users:
get: