295 lines
13 KiB
Go
295 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
|
|
}
|
|
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
|
|
}
|
|
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)
|
|
}
|
|
}
|