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:
14 files changed
+2994
No files matched your search
@@ -16,6 +16,7 @@ import (
|
||||
"felis.lolicon.best/internal/backupjob"
|
||||
"felis.lolicon.best/internal/build"
|
||||
"felis.lolicon.best/internal/config"
|
||||
"felis.lolicon.best/internal/fileedit"
|
||||
"felis.lolicon.best/internal/mail"
|
||||
"felis.lolicon.best/internal/panel"
|
||||
"felis.lolicon.best/internal/passkey"
|
||||
@@ -222,6 +223,24 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintln(stderr, "felis api: backup executor disabled (needs FELIS_IMAGE and FELIS_BACKUP_PVC) — backup endpoint returns 503")
|
||||
}
|
||||
|
||||
// Server file editor: a weak-SA Job mounts ONLY the target world PVC and runs
|
||||
// `felis files`, printing its result for felis-api to read back through
|
||||
// pods/log (see internal/fileedit). It needs FELIS_IMAGE but — unlike restore
|
||||
// and backup — no backup PVC, since it never touches the archive store, so it
|
||||
// is wired on the image alone; otherwise the editor is left nil and the file
|
||||
// endpoints honestly return 503. It takes the typed clientset rather than the
|
||||
// controller-runtime client because the log subresource lives only on the typed
|
||||
// CoreV1 client, and one client covers its Job create, Pod list, and log read.
|
||||
var files api.FileEditor
|
||||
if felisImage != "" {
|
||||
files = &fileedit.Editor{
|
||||
Runner: fileedit.NewK8sRunner(clientset),
|
||||
Config: fileEditConfig(cfg, felisImage),
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(stderr, "felis api: file editor disabled (needs FELIS_IMAGE) — file endpoints return 503")
|
||||
}
|
||||
|
||||
// One PGRepo instance backs both the handlers and the session verifier: the
|
||||
// SessionAuth that fronts the external face reads sessions/users/settings from
|
||||
// the same store the auth handlers write to, so a login and the next request
|
||||
@@ -240,6 +259,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
||||
Builder: builder,
|
||||
Restorer: restorer,
|
||||
Backuper: backuper,
|
||||
Files: files,
|
||||
Submissions: submissions,
|
||||
Mailer: mailer,
|
||||
// The external face is fronted by SessionAuth: it prefers a local session
|
||||
@@ -458,6 +478,19 @@ func backupConfig(cfg *config.Config, image, backupPVC string) backupjob.Config
|
||||
}
|
||||
}
|
||||
|
||||
// fileEditConfig builds the file editor's config from felis.toml plus the
|
||||
// deployment-supplied image. It is the shortest of the three: the editor mounts
|
||||
// only the world PVC, so it needs no archive coordinates at all, and everything
|
||||
// else — the weak SA, the "/data" world root that makes paths match what the
|
||||
// minecraft server itself sees, the runtime identity, and the size/time ceilings —
|
||||
// falls back to the fileedit package's hardened defaults.
|
||||
func fileEditConfig(cfg *config.Config, image string) fileedit.Config {
|
||||
return fileedit.Config{
|
||||
Namespace: cfg.K8s.Namespace,
|
||||
Image: image,
|
||||
}
|
||||
}
|
||||
|
||||
// reconcileBuilds polls unfinished builds on an interval and advances any whose
|
||||
// Job has reached a terminal phase. It exits when ctx is cancelled.
|
||||
func reconcileBuilds(ctx context.Context, b *build.Builder, stderr io.Writer) {
|
||||
|
||||
Reference in new issue
Block a user