Files
Felis/internal/api/handlers_files.go

301 lines
13 KiB
Go

package api
import (
"context"
"errors"
"net/http"
"regexp"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/maintenance"
"felis.lolicon.best/internal/naming"
)
// FileEditor is the server-file-editor surface the API depends on: list a
// directory, read a file, write a file, all inside one server's world volume. It
// is the repair lever for the case no other endpoint covers — a server that will
// not boot because one line of a config file is wrong.
//
// Like Restorer and Backuper it only describes the operation, never the mechanism:
// felis-api cannot touch a world in-process (the world PVC is ReadWriteOnce and
// owned by the operator's StatefulSet), so the production implementation hands off
// to a one-shot Job and reads the result back through pods/log — see
// internal/fileedit, which explains why that transport needs no RBAC felis-api
// does not already hold. Unlike Restorer and Backuper these calls are
// SYNCHRONOUS: the caller wants the listing or the bytes, so the handler blocks on
// the Job (seconds, dominated by Pod scheduling) rather than answering 202.
//
// It is an interface so the handlers are unit-tested against a fake; the
// production implementation is *fileedit.Editor. Using fileedit.Entry directly
// mirrors how ImageBuilder uses build.Request/build.Image rather than restating a
// parallel type on this side of the seam.
//
// It returns fileedit.ErrNotFound / ErrBadPath / ErrTooLarge / ErrConflict /
// ErrNoSpace, which writeFileEditError maps to 404 / 400 / 413 / 409 / 507.
//
// Read and Write both return the file's SHA-256 (hex). Write's expect is the hash
// a client read the file at; when set, a file that changed since is refused with
// ErrConflict instead of being overwritten.
type FileEditor interface {
List(ctx context.Context, server, path string) (entries []fileedit.Entry, truncated bool, err error)
Read(ctx context.Context, server, path string) (content []byte, sha256 string, err error)
Write(ctx context.Context, server, path string, content []byte, expect string) (sha256 string, err error)
}
// writeFileRequest is the PUT /servers/{name}/file body. Content is []byte, so
// encoding/json requires it to be base64 — which is what makes the write path
// binary-safe: a config file with CRLF line endings, a UTF-8 BOM, or a stray
// non-UTF-8 byte round-trips intact instead of being mangled by a string decode.
//
// It is a *[]byte, NOT a []byte, for the same reason permissionRequest.Value is a
// *bool: a plain slice makes "absent", "null", and "" indistinguishable, so a body
// of {} would decode to nil and TRUNCATE the target file to zero bytes while
// answering 200 — a client serialisation bug silently destroying the very config
// the caller opened this endpoint to repair. nil now means "the field was omitted"
// and is refused; an explicit "" is still a legitimate deliberate truncate.
//
// ExpectSHA256 is optional. The panel always sends the hash its read returned,
// so a save over a file someone else changed in the meantime answers 409
// file_changed; omitting it (a script, or "overwrite anyway") writes
// unconditionally.
type writeFileRequest struct {
Content *[]byte `json:"content"`
ExpectSHA256 string `json:"expect_sha256,omitempty"`
}
// handleListFiles serves GET /api/v1/servers/{name}/files?path=… — one directory's
// entries inside the server's world volume. An absent or empty path lists the
// world root.
//
// The path travels as a QUERY parameter, not a path segment, for the same reason
// handleRemoveImage takes ?ref=: a file path contains '/' and does not round-trip
// through a single {placeholder}. It is passed to the executor unmodified — this
// handler deliberately performs NO path validation, because the only containment
// that can be trusted is the one applied at the moment of opening the file, inside
// the Job, by os.Root (see fileedit.Execute). A pre-validating handler would
// invite exactly the false confidence that makes string-prefix containment fail.
func (a *API) handleListFiles(w http.ResponseWriter, r *http.Request) {
name, ok := a.authorizeFileOp(w, r)
if !ok {
return
}
path := r.URL.Query().Get("path")
entries, truncated, err := a.Files.List(r.Context(), name, path)
if err != nil {
writeFileEditError(w, r, err)
return
}
if entries == nil {
entries = []fileedit.Entry{} // an empty directory is [], never null
}
writeJSON(w, http.StatusOK, map[string]any{
"path": path, "entries": entries, "truncated": truncated,
})
}
// handleReadFile serves GET /api/v1/servers/{name}/file?path=… — one file's bytes,
// base64-encoded by encoding/json's []byte handling. Reading is capped at
// fileedit.MaxReadBytes inside the Job; an oversized file is 413, not a truncated
// read, because a config editor that silently returned half a file would let a
// subsequent save destroy the other half.
func (a *API) handleReadFile(w http.ResponseWriter, r *http.Request) {
name, ok := a.authorizeFileOp(w, r)
if !ok {
return
}
path := r.URL.Query().Get("path")
if path == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"the ?path= query parameter is required"))
return
}
content, sum, err := a.Files.Read(r.Context(), name, path)
if err != nil {
writeFileEditError(w, r, err)
return
}
if content == nil {
content = []byte{} // an empty file is "", never null
}
writeJSON(w, http.StatusOK, map[string]any{"path": path, "content": content, "sha256": sum})
}
// handleWriteFile serves PUT /api/v1/servers/{name}/file?path=… — replace a file's
// contents, creating the file if absent (but never its parent directories).
//
// The size ceiling is enforced here, before the executor renders a Job, so an
// oversized write is a clean 413 rather than an opaque rejection from the API
// server when the Job spec breaches etcd's object limit. A body so large it also
// breaches the shared 1 MiB envelope cap is refused earlier still, by decodeJSON,
// as a 400 — the ceilings are layered, and the specific one answers first for
// every plausible input.
//
// A write is audited; the two read operations are not, matching how the codebase
// audits state changes (backup.create, image.admit) and not reads.
func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) {
name, ok := a.authorizeFileOp(w, r)
if !ok {
return
}
path := r.URL.Query().Get("path")
if path == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"the ?path= query parameter is required"))
return
}
var body writeFileRequest
if err := decodeJSON(w, r, &body); err != nil {
writeError(w, r, err)
return
}
// decodeJSON enforces only DisallowUnknownFields, which rejects a MISSPELLED
// field but not an omitted one — so presence is checked here, exactly as the
// required ?path= is checked above and as docs/openapi.yaml already declares.
if body.Content == nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"the content field is required"))
return
}
if len(*body.Content) > fileedit.MaxWriteBytes {
writeError(w, r, newError(http.StatusRequestEntityTooLarge, "too_large",
"file content is %d bytes; the editor writes at most %d",
len(*body.Content), fileedit.MaxWriteBytes))
return
}
// The hash rides the Job's argv, so only its one legitimate shape is let
// through: 64 lowercase hex digits, exactly what a read returned.
if body.ExpectSHA256 != "" && !sha256Hex.MatchString(body.ExpectSHA256) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"expect_sha256 must be the 64-digit lowercase hex sha256 a read returned"))
return
}
// A write holds the world volume for its Job's lifetime (internal/maintenance);
// reads and listings do not, since a read-only mount cannot hurt a server
// starting beside it.
release, ok := a.acquireWorld(w, r, name, maintenance.KindFileWrite, "stop the server before editing its files")
if !ok {
return
}
defer release()
sum, err := a.Files.Write(r.Context(), name, path, *body.Content, body.ExpectSHA256)
if err != nil {
writeFileEditError(w, r, err)
return
}
a.audit(r, "file.write", name+":"+path)
writeJSON(w, http.StatusOK, map[string]any{"path": path, "status": "written", "sha256": sum})
}
var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`)
// authorizeFileOp is the shared front half of all three file handlers — the gate
// that decides whether this caller may touch this server's world at all. It
// mirrors the backup/restore gate step for step, because it is guarding the same
// resource under the same physical constraint:
//
// ① name validation — 400
// ② ServerByName — an unknown server is 404
// ③ owner-or-admin, else 403. An unowned (released) server fails for everyone
// but admin, which is the same "must re-claim first" rule restore enforces
// ④ stopped gate: the world PVC is RWO and held by a running server, so a file
// Job cannot mount it — refuse unless the server is fully stopped. Ready means
// it is up; any desiredState other than Stopped means it is up or coming up
// and still owns the volume. This yields a specific 409 instead of a Job that
// silently fails to mount
// ⑤ the FileEditor must be wired, else 503
//
// Single-sourcing it is what keeps the three faces from drifting: a read path that
// forgot the stopped gate would not merely fail, it would hang waiting for a Pod
// that can never be scheduled.
//
// It returns the validated server name and false if it has already written a
// response.
func (a *API) authorizeFileOp(w http.ResponseWriter, r *http.Request) (string, bool) {
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return "", false
}
p := principalFromContext(r.Context())
rec, err := a.Repo.ServerByName(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return "", false
}
if !a.isOwnerOrAdmin(p, rec) {
writeError(w, r, errForbidden)
return "", false
}
info, err := a.Cluster.GetServer(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return "", false
}
if info.Ready || info.DesiredState != string(v1alpha1.DesiredStopped) {
writeError(w, r, newError(http.StatusConflict, "not_stopped",
"stop the server before editing its files"))
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).
if a.Files == nil {
writeError(w, r, newError(http.StatusServiceUnavailable, "files_unavailable",
"the file editor is not configured"))
return "", false
}
return name, true
}
// writeFileEditError maps executor errors onto HTTP status codes. The three
// sentinels are caller-fault and get precise answers; a timeout is reported as 504
// so the caller knows to retry rather than believing the edit was rejected; and
// anything else collapses to a 500 by writeError, so no cluster detail leaks.
//
// ErrBadPath is 400 rather than 403 on purpose: a path that escapes the world root
// is a malformed request, not a permission the caller might be granted. Answering
// 403 would imply some caller somewhere may read /etc/passwd through this endpoint,
// and none may.
func writeFileEditError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, fileedit.ErrNotFound):
writeError(w, r, newError(http.StatusNotFound, "not_found", "%s", err.Error()))
case errors.Is(err, fileedit.ErrBadPath):
writeError(w, r, newError(http.StatusBadRequest, "bad_path", "%s", err.Error()))
case errors.Is(err, fileedit.ErrTooLarge):
writeError(w, r, newError(http.StatusRequestEntityTooLarge, "too_large", "%s", err.Error()))
case errors.Is(err, fileedit.ErrConflict):
writeError(w, r, newError(http.StatusConflict, "file_changed", "%s", err.Error()))
case errors.Is(err, fileedit.ErrNoSpace):
writeError(w, r, newError(http.StatusInsufficientStorage, "volume_full", "%s", err.Error()))
case errors.Is(err, context.DeadlineExceeded):
writeError(w, r, newError(http.StatusGatewayTimeout, "files_timeout",
"the file operation did not finish in time; retry shortly"))
default:
writeError(w, r, err)
}
}