921 lines
39 KiB
Go
921 lines
39 KiB
Go
package fileedit
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"math"
|
|
"os"
|
|
"path"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"felis.lolicon.best/internal/naming"
|
|
)
|
|
|
|
// The operations the editor supports: list a directory, read a file, write a
|
|
// file, make a directory, delete, rename, upload, and extract a .zip. The set is
|
|
// closed; there is no chmod, chown, link or copy. Every op resolves every path
|
|
// through os.Root (see Execute), and each mutating op carries its own
|
|
// containment note below.
|
|
//
|
|
// A write, upload or unzip DOES land arbitrary bytes at any path inside the mount, and
|
|
// that is a real capability rather than an oversight: the root is the server's
|
|
// whole working directory (see Config.WorldsRoot), so an owner can upload
|
|
// plugins/<x>.jar and Paper will load it on the next boot. It is the same power a
|
|
// hosting panel's file manager gives, scoped to a server the caller already owns
|
|
// and already controls through /command. Note what it is NOT scoped by: admin
|
|
// image curation. Images are admin-only (POST /images, POST /images/build) and
|
|
// modpack submissions need an admin verdict, so this is the one owner-tier route
|
|
// that lands executable code in a backend pod. That trade was made deliberately;
|
|
// if it is ever revisited, the guard belongs in land() below, the choke point
|
|
// write and upload route through, and in unzip's extractOne (unzip.go).
|
|
const (
|
|
OpList = "list"
|
|
OpRead = "read"
|
|
OpWrite = "write"
|
|
OpMkdir = "mkdir"
|
|
OpDelete = "delete"
|
|
OpRename = "rename"
|
|
OpUpload = "upload"
|
|
OpUnzip = "unzip"
|
|
)
|
|
|
|
// mutates reports whether op changes the world, and so whether its Job gets the
|
|
// world mount read-write. Only list and read leave the world alone; anything else
|
|
// counts as a change. maintenance.JobKind draws the same line for the world-volume
|
|
// lock.
|
|
func mutates(op string) bool { return op != OpList && op != OpRead }
|
|
|
|
// validOp reports whether op is one the Job knows.
|
|
func validOp(op string) bool {
|
|
switch op {
|
|
case OpList, OpRead, OpWrite, OpMkdir, OpDelete, OpRename, OpUpload, OpUnzip:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Result codes. A failure that is the CALLER's fault travels back as a Result
|
|
// with a Code rather than as a non-zero exit, so felis-api can map it onto a
|
|
// precise 4xx (handlers_files.go) instead of collapsing every failure into "the
|
|
// Job died" 500. Only an infrastructure failure — the world mount unreadable, the
|
|
// result unprintable — exits non-zero.
|
|
const (
|
|
CodeBadPath = "bad_path" // escapes the world root, is absolute, or is otherwise unopenable
|
|
CodeNotFound = "not_found" // resolves inside the root but nothing is there
|
|
CodeTooLarge = "too_large" // the file exceeds MaxReadBytes
|
|
// CodeConflict is a write whose expected hash no longer matches the file: it
|
|
// changed (or vanished) after the caller read it, so saving would silently
|
|
// discard someone else's edit.
|
|
CodeConflict = "conflict"
|
|
// CodeNoSpace is a write the volume had no room for. The file is unchanged
|
|
// (the write is atomic), so this is the caller's volume being full rather
|
|
// than a bad request.
|
|
CodeNoSpace = "no_space"
|
|
// CodeExists is a create, mkdir, rename or upload whose target is already
|
|
// there. None of them replaces anything unless told to (an upload's
|
|
// Overwrite), so a name collision is reported rather than resolved.
|
|
CodeExists = "exists"
|
|
)
|
|
|
|
// ResultPrefix marks the single stdout line carrying the JSON Result. The Job's
|
|
// output is read back through the pods/log subresource, which returns the
|
|
// container's stdout and stderr MERGED — so a Go runtime warning, a libc message,
|
|
// or anything else the container writes to stderr lands in the same stream. The
|
|
// marker is what makes the payload findable in that mixed stream: felis-api scans
|
|
// for the last line carrying this prefix rather than assuming the log is pure
|
|
// JSON. Without it any stray stderr byte would corrupt every response.
|
|
const ResultPrefix = "FELIS-FILES-RESULT: "
|
|
|
|
// ContentEnv names the environment variables the write path carries new file
|
|
// content in (base64). It travels on the Job spec felis-api creates, because
|
|
// felis-api holds `jobs: create` but NOT `secrets: create` in the minecraft
|
|
// namespace (internal/platform.APIMinecraftRole) — a Secret is not available to
|
|
// it, so the Job spec is the only channel into the Pod. The consequence is that
|
|
// written content is readable by anyone holding jobs:get in the minecraft
|
|
// namespace, which is a cluster-admin-level power; it is NOT readable by
|
|
// felis-operator, felis-reaper, or any weak Job SA, none of which hold that verb.
|
|
//
|
|
// The base64 is split across ContentEnv_0 … ContentEnv_<n-1>, with n in
|
|
// ContentPartsEnv. One variable cannot carry it: execve refuses any single
|
|
// environment string longer than MAX_ARG_STRLEN (32 pages, 128 KiB with 4 KiB
|
|
// pages), so a container whose one variable held the base64 of a 100 KiB file
|
|
// never started — the Pod failed with exit 255 before felis ran, and the save
|
|
// came back as an opaque 500. contentChunk keeps every part well under that.
|
|
const (
|
|
ContentEnv = "FELIS_FILE_CONTENT"
|
|
ContentPartsEnv = ContentEnv + "_PARTS"
|
|
contentChunk = 64 << 10
|
|
)
|
|
|
|
// UploadTokenEnv carries the one-time token an upload Job presents to felis-api
|
|
// to fetch the bytes it lands (see Stage). Like the content it rides the Job
|
|
// spec, and it opens exactly one thing — the one upload that Job was created
|
|
// for, once.
|
|
const UploadTokenEnv = "FELIS_UPLOAD_TOKEN"
|
|
|
|
// Size and count ceilings. Every one of them exists because the result travels
|
|
// through a Kubernetes object or a pod log, neither of which is an unbounded pipe:
|
|
//
|
|
// - MaxWriteBytes bounds the env var on the Job spec. etcd refuses an object
|
|
// over ~1.5MiB, and the base64 of the content is ~4/3 of it, so 256KiB leaves
|
|
// an order of magnitude of headroom for the rest of the spec. Any real
|
|
// server.properties / ops.json / bukkit.yml is a few KiB.
|
|
// - MaxReadBytes bounds what a read pulls back through the pod log INTO
|
|
// felis-api's memory. Without it a caller could name a 500MiB region file and
|
|
// make the API buffer it — a trivial memory DoS from an ordinary owner-tier
|
|
// request. 1MiB comfortably covers every config file and refuses world data.
|
|
// - MaxEntries bounds a listing. A world's region/ directory legitimately holds
|
|
// thousands of .mca files, so this truncates rather than errors (Truncated
|
|
// says so), keeping the log line bounded while still being useful.
|
|
// - MaxUploadBytes bounds an upload sent as ONE request body: the Cloudflare
|
|
// edge refuses bodies over 100 MB on the Free and Pro plans, and 64 MiB
|
|
// covers the largest plugin jars (a Geyser build is about 20 MiB) with room
|
|
// to spare. It is felis-api's bound on that route only. A bigger file arrives
|
|
// in parts and is bounded by nothing but the room on the server's volume,
|
|
// which the Job checks before it fetches a byte (upload).
|
|
const (
|
|
MaxWriteBytes = 256 << 10 // 256 KiB
|
|
MaxReadBytes = 1 << 20 // 1 MiB
|
|
MaxEntries = 2000
|
|
MaxUploadBytes = 64 << 20 // 64 MiB
|
|
)
|
|
|
|
// Entry is one directory entry in a listing. It carries only what a file browser
|
|
// needs to render a row and decide whether the entry is descendable; mode bits,
|
|
// ownership, and inode data are deliberately absent — they are not actionable
|
|
// through this editor (there is no chmod/chown op) and would only widen what a
|
|
// listing discloses about the node.
|
|
type Entry struct {
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
IsDir bool `json:"is_dir"`
|
|
ModTime time.Time `json:"mod_time"`
|
|
}
|
|
|
|
// Result is the single JSON object the Job prints and felis-api parses back. One
|
|
// shape covers every op so the transport has exactly one thing to find and
|
|
// unmarshal; the op decides which fields are populated.
|
|
//
|
|
// Content is []byte, so encoding/json base64-encodes it on the way out and
|
|
// decodes it on the way back with no hand-rolled codec. That is what makes the
|
|
// read path binary-safe: a config file with a stray non-UTF-8 byte round-trips
|
|
// intact instead of being mangled into U+FFFD by a string round-trip.
|
|
type Result struct {
|
|
// Code and Error are set together on a caller-fault failure; both empty means
|
|
// the op succeeded.
|
|
Code string `json:"code,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
|
|
Entries []Entry `json:"entries,omitempty"`
|
|
Content []byte `json:"content,omitempty"`
|
|
// Truncated reports that the listing hit MaxEntries and is incomplete, so a
|
|
// client renders "showing first N" rather than silently implying the directory
|
|
// is smaller than it is.
|
|
Truncated bool `json:"truncated,omitempty"`
|
|
// SHA256 is the hex digest of the file's on-disk bytes: after a read, the file
|
|
// as read (before any redaction); after a write, the bytes written; on a
|
|
// conflict, the file as it is now. A client hands it back as the expected
|
|
// hash of its next write (see write).
|
|
SHA256 string `json:"sha256,omitempty"`
|
|
|
|
// Conflicts lists, relative to the root and sorted, the existing files an
|
|
// unzip would replace: the first MaxConflicts of them. ConflictCount is how
|
|
// many there are in all.
|
|
Conflicts []string `json:"conflicts,omitempty"`
|
|
ConflictCount int `json:"conflict_count,omitempty"`
|
|
// Entry names what an unzip refused: the archive entry, or the path on the
|
|
// server it collides with.
|
|
Entry string `json:"entry,omitempty"`
|
|
// Need and Avail are, on a no_space an upload or unzip saw coming, the bytes
|
|
// it needs and the bytes the volume has free. A listing sets Avail too.
|
|
Need int64 `json:"need,omitempty"`
|
|
Avail int64 `json:"avail,omitempty"`
|
|
// Files and Bytes are what a successful unzip extracted.
|
|
Files int `json:"files,omitempty"`
|
|
Bytes int64 `json:"bytes,omitempty"`
|
|
}
|
|
|
|
// Request is one file operation. Op decides which of the other fields it reads.
|
|
type Request struct {
|
|
Op string
|
|
Path string
|
|
// To is a rename's destination.
|
|
To string
|
|
// Content and Expect are a write's bytes and precondition: when Expect is
|
|
// non-empty, the write lands only if the file's current SHA-256 (hex) equals
|
|
// it.
|
|
Content []byte
|
|
Expect string
|
|
// CreateOnly makes a write refuse a path that already exists. It is the
|
|
// panel's "new file", which must never truncate a file it did not know was
|
|
// there.
|
|
CreateOnly bool
|
|
// Upload is where an upload's bytes come from. Overwrite lets an upload
|
|
// replace a file already at the path, and an unzip replace the files it
|
|
// collides with.
|
|
Upload *Upload
|
|
Overwrite bool
|
|
// Progress, when set, hears how far an upload or unzip has got: bytes landed
|
|
// so far out of the total. It is called from the copy loop, often; the caller
|
|
// throttles.
|
|
Progress func(done, total int64)
|
|
}
|
|
|
|
// Upload describes the bytes an upload lands. Size and SHA256 are what felis-api
|
|
// received from the caller; the fetched bytes must match both before they replace
|
|
// anything.
|
|
type Upload struct {
|
|
Size int64
|
|
SHA256 string
|
|
// Open starts the transfer. It runs only once the target has passed every
|
|
// check, so a refused upload never pulls the bytes.
|
|
Open func() (io.ReadCloser, error)
|
|
}
|
|
|
|
// Execute performs one operation inside root and returns the Result to print.
|
|
// root is the in-Pod mount path of the server's world PVC; every path in req is
|
|
// relative to it.
|
|
//
|
|
// CONTAINMENT INVARIANT: every filesystem access goes through *os.Root, never
|
|
// through a path string this function assembled. os.Root is the stdlib's
|
|
// escape-proof directory handle — it resolves each component against the open root
|
|
// descriptor and refuses any traversal that would leave it, whether by "..", by an
|
|
// absolute path, or by a SYMLINK pointing outside. That last case is why the
|
|
// string-prefix check in internal/backup/tarlocal.go is not reused here: a prefix
|
|
// test validates the path as text, then opens it as a path, and between those two
|
|
// steps a symlink can be swapped in (TOCTOU). A world directory holds
|
|
// attacker-influenced content — players create files through ordinary gameplay,
|
|
// and plugins create more — so a symlink escaping to /etc or to another server's
|
|
// mount is a live threat, not a theoretical one. os.Root closes it structurally:
|
|
// there is no window between the check and the open because they are the same
|
|
// operation.
|
|
//
|
|
// The path is passed to os.Root verbatim apart from mapping "" to ".". In
|
|
// particular an ABSOLUTE path is NOT rewritten into a relative one — it is handed
|
|
// to os.Root as-is and refused. Silently reinterpreting "/etc/passwd" as
|
|
// "<root>/etc/passwd" would turn an unambiguous escape attempt into a successful
|
|
// read of a file the caller did not name, which is exactly the confusion this
|
|
// editor must not have. mkdir, delete and rename do path.Clean the path first (so
|
|
// a trailing slash cannot make them act on a link's target), and Clean keeps both
|
|
// a leading "/" and a leading "..", so an escape stays an escape.
|
|
//
|
|
// The error is an infrastructure failure: the world mount unopenable, an unknown
|
|
// op, or an upload whose transfer broke. A caller's mistake is a Result with a
|
|
// Code.
|
|
func Execute(root string, req Request) (Result, error) {
|
|
r, err := os.OpenRoot(root)
|
|
if err != nil {
|
|
// The world mount itself is unopenable: infrastructure, not caller fault.
|
|
return Result{}, fmt.Errorf("open world root %q: %w", root, err)
|
|
}
|
|
defer r.Close()
|
|
|
|
path := req.Path
|
|
if path == "" {
|
|
path = "."
|
|
}
|
|
|
|
switch req.Op {
|
|
case OpList:
|
|
return list(r, root, path), nil
|
|
case OpRead:
|
|
return read(r, path), nil
|
|
case OpWrite:
|
|
return write(r, path, req.Content, req.Expect, req.CreateOnly), nil
|
|
case OpMkdir:
|
|
return mkdir(r, path), nil
|
|
case OpDelete:
|
|
return remove(r, path), nil
|
|
case OpRename:
|
|
return rename(r, path, req.To), nil
|
|
case OpUpload:
|
|
if req.Upload == nil {
|
|
return Result{}, errors.New("an upload needs a source")
|
|
}
|
|
return upload(r, root, path, *req.Upload, req.Overwrite, req.Progress)
|
|
case OpUnzip:
|
|
return unzip(r, root, path, req.Overwrite, req.Progress), nil
|
|
default:
|
|
return Result{}, fmt.Errorf("unknown op %q", req.Op)
|
|
}
|
|
}
|
|
|
|
// list reads one directory. It does not recurse: a browser asks for one level at
|
|
// a time, and recursion would make both the result size and the traversal cost
|
|
// unbounded in a world directory. It also reports the room left on the volume
|
|
// (Avail), so the panel can refuse an upload the volume cannot take before
|
|
// sending a byte of it.
|
|
func list(r *os.Root, rootPath, path string) Result {
|
|
f, err := r.Open(path)
|
|
if err != nil {
|
|
return failure(err, path)
|
|
}
|
|
defer f.Close()
|
|
|
|
// ReadDir(MaxEntries+1) reads one MORE than the ceiling so the overflow is
|
|
// detectable without walking the whole directory: if the extra entry came back,
|
|
// the listing is truncated. io.EOF means the directory ended within the limit.
|
|
dirents, err := f.ReadDir(MaxEntries + 1)
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
|
return failure(err, path)
|
|
}
|
|
|
|
truncated := len(dirents) > MaxEntries
|
|
if truncated {
|
|
dirents = dirents[:MaxEntries]
|
|
}
|
|
|
|
entries := make([]Entry, 0, len(dirents))
|
|
for _, de := range dirents {
|
|
e := Entry{Name: de.Name(), IsDir: de.IsDir()}
|
|
// Info() can fail on an entry deleted between the ReadDir and the stat (a
|
|
// running plugin rotating a log, say). That is not a reason to fail the whole
|
|
// listing, so the entry is reported with a zero size/mtime rather than dropped
|
|
// — a name that exists is still useful to the caller.
|
|
if info, err := de.Info(); err == nil {
|
|
e.Size, e.ModTime = info.Size(), info.ModTime()
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
res := Result{Entries: entries, Truncated: truncated}
|
|
if avail, _, err := statfs(rootPath); err == nil {
|
|
res.Avail = int64(min(avail, math.MaxInt64))
|
|
}
|
|
return res
|
|
}
|
|
|
|
// secretConfigPath is the one file in a world mount holding PLATFORM secret
|
|
// material rather than the owner's own configuration. felis-lobby's entrypoint
|
|
// writes FELIS_FORWARDING_SECRET into it on every boot, and that value is
|
|
// identical on every backend in the cluster — operator.buildEnv injects one Secret
|
|
// everywhere. Reading it out of a server you own would therefore hand you the
|
|
// Velocity modern-forwarding handshake key for EVERYONE's servers: cross-tenant
|
|
// material that merely happens to sit in your volume. Every other path here is the
|
|
// caller's own data, which is why this is the only denial.
|
|
//
|
|
// Refusing it costs no legitimate repair. The entrypoint rewrites the file whole
|
|
// on every boot and its own header says "Do not hand-edit", so an edit made
|
|
// through this editor could never survive a restart anyway. Only the READ is
|
|
// denied; a write is left alone because writing the file leaks nothing and is
|
|
// equally futile.
|
|
//
|
|
// An exact match on one cleaned path, not a pattern. This is the whole
|
|
// known exposure — grep FELIS_FORWARDING_SECRET across deploy/ — and if another
|
|
// image ever persists a platform secret into the mount, add its path here rather
|
|
// than inventing a matcher.
|
|
const secretConfigPath = "config/paper-global.yml"
|
|
|
|
// read returns a file's bytes. It stats first so an oversized file is refused
|
|
// BEFORE any of it is buffered — checking after the read would mean the memory
|
|
// blow-up this ceiling exists to prevent has already happened.
|
|
func read(r *os.Root, name string) Result {
|
|
// path.Clean, not a raw compare: "./config/paper-global.yml",
|
|
// "config//paper-global.yml" and "config/../config/paper-global.yml" all name
|
|
// the same file, and a string equality test would wave every one of them
|
|
// through. Cleaning collapses them to the single canonical form this matches.
|
|
// Slash-based path (not filepath) is correct because the Job container is always
|
|
// Linux, whatever the developer machine rendering the spec runs.
|
|
if path.Clean(name) == secretConfigPath {
|
|
return Result{Code: CodeBadPath, Error: fmt.Sprintf(
|
|
"%s holds the proxy forwarding secret, which is shared cluster-wide, and is not readable through the editor", name)}
|
|
}
|
|
|
|
f, err := r.Open(name)
|
|
if err != nil {
|
|
return failure(err, name)
|
|
}
|
|
defer f.Close()
|
|
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return failure(err, name)
|
|
}
|
|
if info.IsDir() {
|
|
return Result{Code: CodeBadPath, Error: fmt.Sprintf("%s is a directory, not a file", name)}
|
|
}
|
|
// The name check above answers the plain path with a clear reason; this one
|
|
// catches the same file reached through a link (see Guard).
|
|
withhold, redact := NewGuard(r).Rule(info)
|
|
if withhold {
|
|
return Result{Code: CodeBadPath, Error: fmt.Sprintf(
|
|
"%s is the file holding the proxy forwarding secret, which is shared cluster-wide, and is not readable through the editor", name)}
|
|
}
|
|
if info.Size() > MaxReadBytes {
|
|
return Result{Code: CodeTooLarge, Error: fmt.Sprintf(
|
|
"%s is %d bytes; the editor reads at most %d", name, info.Size(), MaxReadBytes)}
|
|
}
|
|
|
|
// LimitReader is belt-and-braces against the file growing between the Stat and
|
|
// the read: the ceiling then holds on the bytes actually buffered, not merely on
|
|
// the size observed a moment earlier.
|
|
b, err := io.ReadAll(io.LimitReader(f, MaxReadBytes))
|
|
if err != nil {
|
|
return failure(err, name)
|
|
}
|
|
content := b
|
|
if redact {
|
|
content = RedactProps(b)
|
|
}
|
|
return Result{Content: content, SHA256: digest(b)}
|
|
}
|
|
|
|
// propsPath is the server's main config file, and rconPasswordKey the one line in
|
|
// it the editor must not hand back (spec §286: RCON 密码绝不下发前端).
|
|
const (
|
|
propsPath = "server.properties"
|
|
rconPasswordKey = "rcon.password"
|
|
// redactedValue is deliberately not empty: a blank value would read as "RCON has
|
|
// no password", which is a very different and much more alarming claim than "you
|
|
// are not being shown it".
|
|
redactedValue = "<redacted by felis>"
|
|
)
|
|
|
|
// redactSecretProps blanks the RCON password when server.properties is read.
|
|
//
|
|
// Unlike secretConfigPath this is a value redaction rather than a whole-file
|
|
// denial, because the file is not platform material that merely happens to sit in
|
|
// the volume — it is the single most-edited config a server owner has (MOTD,
|
|
// view-distance, difficulty, gamemode), and refusing it outright would cost real
|
|
// repair to hide one line. The password is also per-server and garbage-collected
|
|
// with it, so unlike the cluster-wide forwarding secret it leaks nothing about
|
|
// anyone else's server; it is withheld because §286 draws the line at the frontend
|
|
// regardless of blast radius, and because the console already gives an owner every
|
|
// capability the password would.
|
|
//
|
|
// The write path is left alone on purpose, mirroring the reasoning at
|
|
// secretConfigPath: felis-lobby's entrypoint rewrites all three rcon keys from the
|
|
// injected Secret on every boot, so saving the placeholder back cannot lock the
|
|
// control plane out — the next restart restores the real value. That is what makes
|
|
// redaction safe here; without the boot-time rewrite this would be a footgun.
|
|
func redactSecretProps(name string, content []byte) []byte {
|
|
if path.Clean(name) != propsPath {
|
|
return content
|
|
}
|
|
lines := bytes.Split(content, []byte("\n"))
|
|
for i, line := range lines {
|
|
// TrimSpace before matching: a properties key may be indented, and the
|
|
// trailing \r of a CRLF file would otherwise ride along into the value.
|
|
if bytes.HasPrefix(bytes.TrimSpace(line), []byte(rconPasswordKey+"=")) {
|
|
lines[i] = []byte(rconPasswordKey + "=" + redactedValue)
|
|
}
|
|
}
|
|
return bytes.Join(lines, []byte("\n"))
|
|
}
|
|
|
|
// write replaces a file's contents. It does NOT create parent directories: every
|
|
// path this editor writes is an existing config file being corrected, so an
|
|
// unexpected mkdir would more likely be a typo materialising a stray directory in
|
|
// the world mount than an intent. A missing file is still created, so a config
|
|
// the server has not yet generated can be authored; createOnly refuses a path that
|
|
// already exists, which is how the panel's "new file" avoids truncating a file it
|
|
// did not know was there.
|
|
//
|
|
// expect, when set, is the SHA-256 the caller read the file at (Result.SHA256 of
|
|
// its read). A file that has changed since — another manager saved it, or the
|
|
// server rewrote it on its last run — is refused with CodeConflict instead of
|
|
// being overwritten, which is how two people editing the same file find out.
|
|
// The world lock (internal/maintenance) already serialises writes, so the check
|
|
// and the rename cannot interleave with another write.
|
|
func write(r *os.Root, name string, content []byte, expect string, createOnly bool) Result {
|
|
if len(content) > MaxWriteBytes {
|
|
// Defence in depth: felis-api already refuses an oversized write with a 413
|
|
// before rendering the Job. Re-checking here keeps the ceiling true even if
|
|
// this entrypoint is ever driven directly.
|
|
return Result{Code: CodeTooLarge, Error: fmt.Sprintf(
|
|
"content is %d bytes; the editor writes at most %d", len(content), MaxWriteBytes)}
|
|
}
|
|
target, mode, res := landingTarget(r, name, !createOnly)
|
|
if res.Code != "" {
|
|
return res
|
|
}
|
|
if expect != "" {
|
|
if res := checkUnchanged(r, name, target, expect); res.Code != "" {
|
|
return res
|
|
}
|
|
}
|
|
// A write's fill never returns a transfer error, so land's error is always nil.
|
|
res, _ = land(r, name, target, mode, func(w io.Writer) error {
|
|
_, err := w.Write(content)
|
|
return err
|
|
})
|
|
if res.Code == "" {
|
|
res.SHA256 = digest(content)
|
|
}
|
|
return res
|
|
}
|
|
|
|
// landingTarget decides where a write or upload lands and with what mode. A
|
|
// symlink at name is followed only while it stays inside the root (resolveLink), so
|
|
// a planted link to a file outside is refused and the rename in land replaces the
|
|
// file the link names, never the link itself. The target keeps its mode; a new file
|
|
// gets 0644.
|
|
//
|
|
// mayExist false refuses a name that is already there in any form — file,
|
|
// directory or link, dangling or not — before any link is followed.
|
|
func landingTarget(r *os.Root, name string, mayExist bool) (string, fs.FileMode, Result) {
|
|
if !mayExist {
|
|
if _, err := r.Lstat(name); err == nil {
|
|
return "", 0, Result{Code: CodeExists, Error: fmt.Sprintf("%s already exists", name)}
|
|
} else if !errors.Is(err, fs.ErrNotExist) {
|
|
return "", 0, failure(err, name)
|
|
}
|
|
}
|
|
target, res := resolveLink(r, name)
|
|
if res.Code != "" {
|
|
return "", 0, res
|
|
}
|
|
info, err := r.Lstat(target)
|
|
switch {
|
|
case err == nil && info.IsDir():
|
|
return "", 0, Result{Code: CodeBadPath, Error: fmt.Sprintf("%s is a directory, not a file", name)}
|
|
case err == nil && !info.Mode().IsRegular():
|
|
return "", 0, Result{Code: CodeBadPath, Error: fmt.Sprintf("%s is not a regular file", name)}
|
|
case err == nil:
|
|
return target, info.Mode().Perm(), Result{}
|
|
case errors.Is(err, fs.ErrNotExist):
|
|
return target, 0o644, Result{}
|
|
default:
|
|
return "", 0, failure(err, name)
|
|
}
|
|
}
|
|
|
|
// transferError marks an upload whose bytes could not be fetched intact. It is
|
|
// infrastructure — felis-api staged the bytes and serves them to this Job — so it
|
|
// leaves Execute as an error (a non-zero exit, a 500) rather than a caller-facing
|
|
// code.
|
|
type transferError struct{ err error }
|
|
|
|
func (e *transferError) Error() string { return "fetch upload: " + e.err.Error() }
|
|
func (e *transferError) Unwrap() error { return e.err }
|
|
|
|
// land atomically puts the bytes fill writes at target, the path landingTarget
|
|
// returned for name. Write and upload both land through it; unzip lands a whole
|
|
// tree at once and has its own path (unzip.go).
|
|
//
|
|
// The bytes go to a temporary sibling that is synced and then renamed over the
|
|
// target, so a full disk, a Job killed at its deadline or a crashed node leaves
|
|
// either the old file or the new one — never the zero-length or half-written
|
|
// server.properties an in-place truncate would, which is a server that no longer
|
|
// boots. The sibling gets mode and is handed to the game uid before the rename, so
|
|
// the file the server finds is never root's. On failure it is removed; only a kill
|
|
// between create and rename leaves one behind, named ".<file>.felis-edit-<hex>" so
|
|
// no loader mistakes it for a plugin jar or a config.
|
|
//
|
|
// A *transferError from fill comes back as the error; every other failure is a
|
|
// Result.
|
|
func land(r *os.Root, name, target string, mode fs.FileMode, fill func(io.Writer) error) (Result, error) {
|
|
var suffix [6]byte
|
|
if _, err := rand.Read(suffix[:]); err != nil {
|
|
return Result{Code: CodeBadPath, Error: fmt.Sprintf("generate a temporary name: %v", err)}, nil
|
|
}
|
|
tmp := path.Join(path.Dir(target), "."+path.Base(target)+".felis-edit-"+hex.EncodeToString(suffix[:]))
|
|
f, err := r.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
|
if err != nil {
|
|
return writeFailure(err, name), nil
|
|
}
|
|
renamed := false
|
|
defer func() {
|
|
if !renamed {
|
|
_ = r.Remove(tmp)
|
|
}
|
|
}()
|
|
// Chmod explicitly: the create mode passed through the umask, and a file the
|
|
// owner had at 0664 or 0600 should come back the same.
|
|
if err := f.Chmod(mode); err != nil {
|
|
f.Close()
|
|
return writeFailure(err, name), nil
|
|
}
|
|
if err := fill(f); err != nil {
|
|
f.Close()
|
|
var te *transferError
|
|
if errors.As(err, &te) {
|
|
return Result{}, err
|
|
}
|
|
return writeFailure(err, name), nil
|
|
}
|
|
// Sync before the rename, or a crash could leave the new name pointing at
|
|
// blocks that never reached the disk. Close is where a buffered-write error
|
|
// surfaces, so its error is honoured rather than deferred-and-dropped.
|
|
if err := syncWritten(f); err != nil {
|
|
f.Close()
|
|
return writeFailure(err, name), nil
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
return writeFailure(err, name), nil
|
|
}
|
|
// The Job runs as root, so the file it just created is root's. The server runs
|
|
// as the game uid and could read it but never rewrite it — a config the panel
|
|
// authored that Paper then fails to save. Best effort: the server's
|
|
// prepare-data initContainer re-owns anything left behind on its next start.
|
|
_ = ownWritten(r, tmp)
|
|
if err := r.Rename(tmp, target); err != nil {
|
|
return writeFailure(err, name), nil
|
|
}
|
|
renamed = true
|
|
syncDir(r, path.Dir(target))
|
|
return Result{}, nil
|
|
}
|
|
|
|
// syncDir syncs a directory so an entry just added, renamed or removed survives a
|
|
// crash too. Best effort: the change has happened and reporting failure would lie.
|
|
func syncDir(r *os.Root, dir string) {
|
|
if d, err := r.Open(dir); err == nil {
|
|
_ = d.Sync()
|
|
d.Close()
|
|
}
|
|
}
|
|
|
|
// upload lands a file fetched from felis-api (see Stage). Everything that can
|
|
// refuse it is checked before u.Open, so a refused upload never pulls its bytes.
|
|
// The fetched bytes must match both the size and the SHA-256 felis-api received;
|
|
// either mismatch is a broken transfer, and the target is left as it was. A
|
|
// success has landed exactly what felis-api staged, whose digest it already holds.
|
|
//
|
|
// There is no size ceiling here. The volume is the bound, and it is checked up
|
|
// front: the new bytes land beside the file they replace until the rename, so
|
|
// they need their whole size free whatever is already at the path.
|
|
func upload(r *os.Root, rootPath, name string, u Upload, overwrite bool, progress func(done, total int64)) (Result, error) {
|
|
target, mode, res := landingTarget(r, name, overwrite)
|
|
if res.Code != "" {
|
|
return res, nil
|
|
}
|
|
if avail, _, err := statfs(rootPath); err == nil && uint64(u.Size) > avail {
|
|
return Result{Code: CodeNoSpace, Need: u.Size, Avail: int64(min(avail, math.MaxInt64)), Error: fmt.Sprintf(
|
|
"%s is %d bytes and the server's volume has %d free; nothing was changed", name, u.Size, avail)}, nil
|
|
}
|
|
return land(r, name, target, mode, func(w io.Writer) error {
|
|
body, err := u.Open()
|
|
if err != nil {
|
|
return &transferError{err}
|
|
}
|
|
defer body.Close()
|
|
h := sha256.New()
|
|
var done int64
|
|
out := io.MultiWriter(w, h)
|
|
if progress != nil {
|
|
out = countingWriter{out, func(n int) { done += int64(n); progress(done, u.Size) }}
|
|
}
|
|
// One byte past Size so a source that sends more than it promised is seen.
|
|
n, err := io.Copy(out, sourceReader{io.LimitReader(body, u.Size+1)})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n != u.Size {
|
|
return &transferError{fmt.Errorf("got %d bytes, expected %d", n, u.Size)}
|
|
}
|
|
if sum := hex.EncodeToString(h.Sum(nil)); sum != u.SHA256 {
|
|
return &transferError{fmt.Errorf("got sha256 %s, expected %s", sum, u.SHA256)}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// sourceReader tags the source's read errors as transfer errors, so land can tell
|
|
// a broken fetch from the volume filling up underneath the copy.
|
|
type sourceReader struct{ r io.Reader }
|
|
|
|
func (s sourceReader) Read(p []byte) (int, error) {
|
|
n, err := s.r.Read(p)
|
|
if err != nil && err != io.EOF {
|
|
err = &transferError{err}
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
// mkdir makes one directory, handed to the game uid. It does not make parents:
|
|
// the panel creates a folder inside the one it is showing, so a missing parent is
|
|
// a stale view, reported as such.
|
|
//
|
|
// The path is cleaned first so a trailing slash cannot slip past the "." check.
|
|
// Clean keeps a leading "/" or "..", so os.Root still sees — and refuses — an
|
|
// escape.
|
|
func mkdir(r *os.Root, name string) Result {
|
|
name = path.Clean(name)
|
|
if name == "." {
|
|
return Result{Code: CodeBadPath, Error: "a folder needs a name"}
|
|
}
|
|
if err := r.Mkdir(name, 0o755); err != nil {
|
|
switch {
|
|
case errors.Is(err, fs.ErrExist):
|
|
return Result{Code: CodeExists, Error: fmt.Sprintf("%s already exists", name)}
|
|
case errors.Is(err, fs.ErrNotExist):
|
|
return Result{Code: CodeNotFound, Error: fmt.Sprintf("folder %s does not exist", path.Dir(name))}
|
|
}
|
|
return writeFailure(err, name)
|
|
}
|
|
_ = ownWritten(r, name)
|
|
syncDir(r, path.Dir(name))
|
|
return Result{}
|
|
}
|
|
|
|
// remove deletes a file, a link or a whole directory. RemoveAll removes a symlink
|
|
// itself, never what it points at, and os.Root keeps it inside the mount; the Lstat
|
|
// is there because RemoveAll reports nothing for a path that is not there. The
|
|
// root itself is refused — emptying a server's whole volume is a reset, which has
|
|
// its own path.
|
|
func remove(r *os.Root, name string) Result {
|
|
name = path.Clean(name)
|
|
if name == "." {
|
|
return Result{Code: CodeBadPath, Error: "the server's root folder cannot be deleted"}
|
|
}
|
|
if _, err := r.Lstat(name); err != nil {
|
|
return failure(err, name)
|
|
}
|
|
if err := r.RemoveAll(name); err != nil {
|
|
return failure(err, name)
|
|
}
|
|
syncDir(r, path.Dir(name))
|
|
return Result{}
|
|
}
|
|
|
|
// rename moves from to to, both inside the root. It never replaces: a destination
|
|
// that exists is CodeExists, so a mistyped name cannot silently destroy another
|
|
// file. A missing destination folder is not made.
|
|
func rename(r *os.Root, from, to string) Result {
|
|
from, to = path.Clean(from), path.Clean(to)
|
|
if from == "." || to == "." {
|
|
return Result{Code: CodeBadPath, Error: "the server's root folder cannot be moved"}
|
|
}
|
|
info, err := r.Lstat(from)
|
|
if err != nil {
|
|
return failure(err, from)
|
|
}
|
|
if res := guardMove(r, from, info); res.Code != "" {
|
|
return res
|
|
}
|
|
if _, err := r.Lstat(to); err == nil {
|
|
return Result{Code: CodeExists, Error: fmt.Sprintf("%s already exists", to)}
|
|
} else if !errors.Is(err, fs.ErrNotExist) {
|
|
return failure(err, to)
|
|
}
|
|
if err := r.Rename(from, to); err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return Result{Code: CodeNotFound, Error: fmt.Sprintf("folder %s does not exist", path.Dir(to))}
|
|
}
|
|
return failure(err, to)
|
|
}
|
|
syncDir(r, path.Dir(from))
|
|
syncDir(r, path.Dir(to))
|
|
return Result{}
|
|
}
|
|
|
|
// guardedPaths are the paths read guards by name: secretConfigPath is refused and
|
|
// propsPath has its RCON password redacted. Moving either — or the config folder
|
|
// holding the first — to another name would make the next read hand back what the
|
|
// guard withholds, so rename refuses them.
|
|
var guardedPaths = []string{secretConfigPath, path.Dir(secretConfigPath), propsPath}
|
|
|
|
// guardMove refuses a rename whose source is a guarded path under any name: the
|
|
// comparison is by file identity, so "./config", a link's target or a folder
|
|
// reached through a link are all caught.
|
|
func guardMove(r *os.Root, from string, info fs.FileInfo) Result {
|
|
for _, g := range guardedPaths {
|
|
for _, stat := range []func(string) (fs.FileInfo, error){r.Lstat, r.Stat} {
|
|
if gi, err := stat(g); err == nil && os.SameFile(info, gi) {
|
|
return Result{Code: CodeBadPath, Error: fmt.Sprintf(
|
|
"%s is managed by felis and cannot be moved or renamed", from)}
|
|
}
|
|
}
|
|
}
|
|
return Result{}
|
|
}
|
|
|
|
// writeFailure is failure for the steps that move bytes, where a full volume is
|
|
// the likely cause and deserves its own answer: the file is still whole, and the
|
|
// fix is to free space, not to change the path.
|
|
func writeFailure(err error, name string) Result {
|
|
if errors.Is(err, syscall.ENOSPC) || errors.Is(err, syscall.EDQUOT) {
|
|
return Result{Code: CodeNoSpace, Error: fmt.Sprintf(
|
|
"the server's volume is full; %s was left unchanged", name)}
|
|
}
|
|
return failure(err, name)
|
|
}
|
|
|
|
// maxLinkHops bounds resolveLink, matching the kernel's own loop limit in spirit:
|
|
// a link cycle is a bad path, not a hang.
|
|
const maxLinkHops = 8
|
|
|
|
// resolveLink follows symlinks at the final component of name and returns the
|
|
// path of the file they lead to, relative to the root. An absolute link target is
|
|
// refused (os.Root would refuse it anyway); a relative one is resolved against
|
|
// the link's directory and must stay inside the root, which the next Lstat
|
|
// through os.Root enforces.
|
|
func resolveLink(r *os.Root, name string) (string, Result) {
|
|
cur := name
|
|
for range maxLinkHops {
|
|
info, err := r.Lstat(cur)
|
|
if err != nil || info.Mode()&fs.ModeSymlink == 0 {
|
|
// Missing (a new file) or not a link: the path names itself. Any other
|
|
// Lstat error surfaces from the caller's own Lstat of the same path.
|
|
return cur, Result{}
|
|
}
|
|
dest, err := r.Readlink(cur)
|
|
if err != nil {
|
|
return "", failure(err, name)
|
|
}
|
|
if path.IsAbs(dest) {
|
|
return "", Result{Code: CodeBadPath, Error: fmt.Sprintf(
|
|
"%s is a symbolic link to %s, outside the server's files", name, dest)}
|
|
}
|
|
cur = path.Join(path.Dir(cur), dest)
|
|
if cur == ".." || strings.HasPrefix(cur, "../") {
|
|
return "", Result{Code: CodeBadPath, Error: fmt.Sprintf(
|
|
"%s is a symbolic link leading outside the server's files", name)}
|
|
}
|
|
}
|
|
return "", Result{Code: CodeBadPath, Error: fmt.Sprintf("%s: too many levels of symbolic links", name)}
|
|
}
|
|
|
|
// checkUnchanged compares the file's current digest with expect. A file larger
|
|
// than MaxReadBytes cannot be the one the caller read, so it is a conflict without
|
|
// hashing it.
|
|
func checkUnchanged(r *os.Root, name, target, expect string) Result {
|
|
conflict := func(now, why string) Result {
|
|
return Result{Code: CodeConflict, SHA256: now, Error: fmt.Sprintf(
|
|
"%s %s since it was opened; reload it, or save again to overwrite", name, why)}
|
|
}
|
|
f, err := r.Open(target)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return conflict("", "was deleted")
|
|
}
|
|
if err != nil {
|
|
return failure(err, name)
|
|
}
|
|
defer f.Close()
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return failure(err, name)
|
|
}
|
|
if info.Size() > MaxReadBytes {
|
|
return conflict("", "has changed")
|
|
}
|
|
h := sha256.New()
|
|
if _, err := io.Copy(h, io.LimitReader(f, MaxReadBytes+1)); err != nil {
|
|
return failure(err, name)
|
|
}
|
|
if now := hex.EncodeToString(h.Sum(nil)); now != expect {
|
|
return conflict(now, "has changed")
|
|
}
|
|
return Result{}
|
|
}
|
|
|
|
// digest is the hex SHA-256 carried in Result.SHA256.
|
|
func digest(b []byte) string {
|
|
sum := sha256.Sum256(b)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// syncWritten flushes the temporary sibling. A var so a test can fail it the way
|
|
// a full disk would.
|
|
var syncWritten = func(f *os.File) error { return f.Sync() }
|
|
|
|
// ownWritten hands a written file to the game uid. os.Root.Chown follows a symlink
|
|
// only within the root, so this can never re-own a file outside the mount. A var so
|
|
// tests, which cannot chown, can observe the call.
|
|
var ownWritten = func(r *os.Root, name string) error {
|
|
return r.Chown(name, int(naming.GameUID), int(naming.GameGID))
|
|
}
|
|
|
|
// failure maps a filesystem error onto a caller-facing Result code. Anything that
|
|
// is genuinely "nothing is there" becomes not_found; EVERYTHING else — including
|
|
// every os.Root containment refusal — becomes bad_path.
|
|
//
|
|
// That default is deliberate. os.Root reports an escape as a *fs.PathError with no
|
|
// exported sentinel to match on, so the mapping cannot test for "escaped"
|
|
// positively; it tests for the one benign case it can name and refuses the rest.
|
|
// Failing closed this way means a future os.Root error kind is reported as a bad
|
|
// path rather than leaking through as a success.
|
|
//
|
|
// The error text is included because it is generated by the stdlib from the
|
|
// caller's OWN path inside their OWN world mount, so it discloses nothing they
|
|
// could not learn by listing — and it is the difference between a usable "no such
|
|
// file" and an opaque 400.
|
|
func failure(err error, path string) Result {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return Result{Code: CodeNotFound, Error: fmt.Sprintf("%s does not exist", path)}
|
|
}
|
|
return Result{Code: CodeBadPath, Error: err.Error()}
|
|
}
|
|
|
|
// Print writes r as the single marked stdout line the Job's reader looks for. The
|
|
// JSON is written with no indentation on purpose: the payload must occupy exactly
|
|
// ONE log line, because the reader identifies it by a line prefix. An indented
|
|
// encoding would split it across lines and make it unfindable.
|
|
func Print(w io.Writer, res Result) error {
|
|
b, err := json.Marshal(res)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal result: %w", err)
|
|
}
|
|
_, err = fmt.Fprintf(w, "%s%s\n", ResultPrefix, b)
|
|
return err
|
|
}
|