Unverified Commit fe4c92c1 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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.
parent 05cb8f63
Loading
Loading
Loading
Loading
+33 −0
Changes for cmd/felis/api.go: 33 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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) {

cmd/felis/files.go

0 → 100644
+84 −0
Changes for cmd/felis/files.go: 84 added lines, 0 removed lines.
Original line number Diff line number Diff line
package main

import (
	"encoding/base64"
	"flag"
	"fmt"
	"io"
	"os"

	"felis.lolicon.best/internal/fileedit"
)

// cmdFiles is the in-Pod entrypoint the file-editor Job runs. internal/fileedit
// renders a Pod whose command is `/usr/local/bin/felis files`. It performs ONE
// file operation against the mounted world volume, prints the result as a single
// marked JSON line on stdout, and exits — it is NOT a user-facing command and is
// never invoked by hand.
//
// Like cmdRestore it deliberately holds NO database credentials and never calls
// config.Load: felis-api made the authorization decision (the caller owns this
// server, and the server is stopped so the RWO world volume is free); this process
// is the unprivileged hands that touch bytes. Its entire input is the three flags
// below plus, for a write, one environment variable. Every isolation guarantee
// lives in the Pod spec (internal/fileedit/jobspec.go), and the path-containment
// guarantee lives in fileedit.Execute, which resolves the path through os.Root and
// therefore cannot be walked out of the world mount.
//
// Exit status carries a specific meaning that felis-api depends on: a CALLER-fault
// outcome — a path that escapes the root, a file that is missing or too large — is
// a SUCCESSFUL run that prints a Result carrying an error code, so the API can map
// it to a precise 4xx. A non-zero exit means the operation could not be attempted
// at all (the world mount is unreadable, the result unprintable), which the API
// reports as a 500.
func cmdFiles(args []string, stdout, stderr io.Writer) int {
	fs := flag.NewFlagSet("files", flag.ContinueOnError)
	fs.SetOutput(stderr)
	op := fs.String("op", "", "operation: list, read, or write")
	path := fs.String("path", "", "path to operate on, relative to the world root (empty = the root itself)")
	worldsRoot := fs.String("worlds-root", "/data", "mount path of the world PVC; every path resolves under it")
	if err := fs.Parse(args); err != nil {
		return 2
	}

	if *op == "" {
		fmt.Fprintln(stderr, "felis files: --op is required (list, read, or write)")
		return 2
	}

	// New content arrives base64-encoded in the environment rather than in argv:
	// a process's arguments are world-readable on the node (/proc/<pid>/cmdline),
	// whereas its environment is not, and a config file being written can carry
	// secrets — an RCON password in server.properties is the obvious case. The
	// encoding is what lets arbitrary bytes (CRLF endings, a BOM, a NUL) survive a
	// channel that must be a valid string.
	var content []byte
	if *op == fileedit.OpWrite {
		raw, ok := os.LookupEnv(fileedit.ContentEnv)
		if !ok {
			fmt.Fprintf(stderr, "felis files: a write needs %s in the environment\n", fileedit.ContentEnv)
			return 2
		}
		decoded, err := base64.StdEncoding.DecodeString(raw)
		if err != nil {
			fmt.Fprintf(stderr, "felis files: %s is not valid base64: %v\n", fileedit.ContentEnv, err)
			return 2
		}
		content = decoded
	}

	res, err := fileedit.Execute(*worldsRoot, *op, *path, content)
	if err != nil {
		// The operation could not be attempted — infrastructure, not caller fault.
		fmt.Fprintf(stderr, "felis files: %v\n", err)
		return 1
	}
	if err := fileedit.Print(stdout, res); err != nil {
		// The result exists but could not be delivered. Exiting non-zero is the only
		// honest signal left: felis-api would otherwise find no marked line and have
		// to guess why.
		fmt.Fprintf(stderr, "felis files: %v\n", err)
		return 1
	}
	return 0
}
+2 −0
Changes for cmd/felis/run.go: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ Commands:
  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)
@@ -45,6 +46,7 @@ var commands = map[string]func(args []string, stdout, stderr io.Writer) int{
	"reaper":           cmdReaper,
	"restore":          cmdRestore,
	"backup":           cmdBackup,
	"files":            cmdFiles,
	"manifests":        cmdManifests,
	"apply":            cmdApply,
	"setup":            cmdSetup,
+209 −0
Changes for docs/openapi.yaml: 209 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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:
+26 −0
Changes for internal/api/api.go: 26 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -63,6 +63,14 @@ type API struct {
	// authorization boundary is exercised before the backup-Job executor is wired.
	Backuper Backuper

	// Files is the server file editor (list / read / write a file in a stopped
	// server's world volume — the "one wrong line in server.properties" repair).
	// Like Restorer and Backuper it is optional: when nil the file routes report
	// 503, so the owner-or-admin and stopped gates are exercised before the
	// file-Job executor is wired. Unlike them its calls are synchronous, because
	// the caller wants the listing or the bytes back, not a 202.
	Files FileEditor

	// Submissions is the user-modpack approval lane (a user-directed extension over
	// the §16 build subsystem; see internal/submit). It is optional: when
	// nil the /me/submissions and /submissions routes report 503 rather than 404, so
@@ -401,6 +409,24 @@ func (a *API) externalAPIRoutes() []apiRoute {
		{Method: "GET", Pattern: "/api/v1/backups", h: a.handleListBackups},
		{Method: "POST", Pattern: "/api/v1/servers/{name}/restore-backup", h: a.handleRestoreBackup},
		{Method: "POST", Pattern: "/api/v1/servers/{name}/backup", h: a.handleBackupNow},
		// Server file editor: list / read / write a file in a STOPPED server's world
		// volume (handlers_files.go). App-tier, exactly like the backup pair above and
		// for the same reason — every route gates on owner-or-admin inside the handler,
		// so an owner repairs their own broken server without an admin's Zero-Trust
		// path. The path travels as ?path= rather than a segment because a file path
		// contains '/' (the same reason DELETE /images takes ?ref=). {name}/files is
		// the directory face; {name}/file is the single-file face.
		//
		// "Config editor" undersells the surface, so be precise about what app-tier
		// now reaches: the mount is the server's WHOLE working directory, not a
		// config subtree, so a write can place a loadable plugin jar (a deliberate
		// capability — see the op list in fileedit/exec.go) and a read can pull any
		// file in it. Exactly one path is denied, config/paper-global.yml, because it
		// holds the cluster-wide forwarding secret and is therefore the one thing in
		// the mount that is not the caller's own data (fileedit.secretConfigPath).
		{Method: "GET", Pattern: "/api/v1/servers/{name}/files", h: a.handleListFiles},
		{Method: "GET", Pattern: "/api/v1/servers/{name}/file", h: a.handleReadFile},
		{Method: "PUT", Pattern: "/api/v1/servers/{name}/file", h: a.handleWriteFile},
		// Account linking (spec §10), web side: /start reports link status (it is the
		// pointer handleClaim's 412 emits), /verify consumes the in-game code and binds
		// the account. App-tier, not admin — linking your own account is an ordinary
Loading