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.
78 lines
3.0 KiB
Go
78 lines
3.0 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
)
|
|
|
|
const usage = `felis — Kubernetes-native Minecraft server orchestration
|
|
|
|
Usage:
|
|
felis <command> [flags]
|
|
|
|
Commands:
|
|
migrate up Apply embedded database migrations under an advisory lock
|
|
operator Run the MinecraftServer controller-manager
|
|
api Run the felis-api HTTP server
|
|
nano Run the Felis-nano hasJoined multiplexer (multi-Yggdrasil, no control plane)
|
|
reaper Run the world reaper / backup batch
|
|
restore Extract a world archive into a world volume (internal Job entrypoint)
|
|
backup Archive a world into the backup store and record it (internal Job entrypoint)
|
|
files List/read/write one file in a stopped server's world (internal Job entrypoint)
|
|
manifests Render the control-plane RBAC + NetworkPolicy install bundle as YAML
|
|
apply Create a MinecraftServer CRD (direct K8s write; use -f server.json)
|
|
setup Run host bootstrap + first-run setup console (TUI; requires root/sudo)
|
|
version Print the build stamp of this binary
|
|
update Report which platform components have updates available
|
|
breakGlass Open the local break-glass emergency console (TUI; requires root/sudo)
|
|
|
|
Run "felis <command> -h" for command-specific flags.
|
|
`
|
|
|
|
// commands is the dispatch table. It is a map rather than a switch so the router's
|
|
// contents are DATA a test can compare against the usage text above: `version`
|
|
// shipped once as an implemented-but-unreachable command (cmdVersion existed with
|
|
// nothing routing to it), and a switch offers no way to notice that. Adding an entry
|
|
// here without documenting it in usage — or vice versa — now fails a test instead of
|
|
// shipping.
|
|
//
|
|
// The help aliases are deliberately NOT entries: they print usage rather than run a
|
|
// subcommand, and listing them would make the table disagree with the command list.
|
|
var commands = map[string]func(args []string, stdout, stderr io.Writer) int{
|
|
"migrate": cmdMigrate,
|
|
"operator": cmdOperator,
|
|
"api": cmdAPI,
|
|
"nano": cmdNano,
|
|
"reaper": cmdReaper,
|
|
"restore": cmdRestore,
|
|
"backup": cmdBackup,
|
|
"files": cmdFiles,
|
|
"manifests": cmdManifests,
|
|
"apply": cmdApply,
|
|
"setup": cmdSetup,
|
|
"breakGlass": cmdBreakGlass,
|
|
"bootstrap-assets": cmdBootstrapAssets,
|
|
"version": cmdVersion,
|
|
"update": cmdUpdate,
|
|
}
|
|
|
|
// run dispatches a subcommand. It is separate from main so the router is
|
|
// testable without spawning a process.
|
|
func run(args []string, stdout, stderr io.Writer) int {
|
|
if len(args) == 0 {
|
|
fmt.Fprint(stderr, usage)
|
|
return 2
|
|
}
|
|
cmd, rest := args[0], args[1:]
|
|
switch cmd {
|
|
case "-h", "--help", "help":
|
|
fmt.Fprint(stdout, usage)
|
|
return 0
|
|
}
|
|
if fn, ok := commands[cmd]; ok {
|
|
return fn(rest, stdout, stderr)
|
|
}
|
|
fmt.Fprintf(stderr, "felis: unknown command %q\n\n%s", cmd, usage)
|
|
return 2
|
|
}
|