feat(files): 文件管理可新建、建目录、删除、重命名和上传,写入内容拆成多个环境变量不再超内核单变量上限
This commit is contained in:
37 files changed
+5972
-416
No files matched your search
+68
-20
@@ -1,11 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/fileedit"
|
||||
)
|
||||
@@ -20,32 +24,42 @@ import (
|
||||
// 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 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.
|
||||
// below plus, for a write, the content variables and, for an upload, one token.
|
||||
// Every isolation guarantee lives in the Pod spec (internal/fileedit/jobspec.go),
|
||||
// and the path-containment guarantee lives in fileedit.Execute, which resolves
|
||||
// every 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.
|
||||
// at all (the world mount is unreadable, an upload's bytes could not be fetched
|
||||
// intact, 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")
|
||||
op := fs.String("op", "", "operation: list, read, write, mkdir, delete, rename or upload")
|
||||
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")
|
||||
expect := fs.String("expect-sha256", "", "write only: refuse unless the file's current SHA-256 (hex) is this")
|
||||
createOnly := fs.Bool("create-only", false, "write only: refuse a path that already exists")
|
||||
to := fs.String("to", "", "rename only: the destination path")
|
||||
sourceURL := fs.String("source-url", "", "upload only: felis-api URL to fetch the bytes from")
|
||||
size := fs.Int64("size", -1, "upload only: the byte count the fetched file must have")
|
||||
sum := fs.String("sha256", "", "upload only: the SHA-256 (hex) the fetched file must have")
|
||||
overwrite := fs.Bool("overwrite", false, "upload only: replace a file already at the path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
|
||||
if *op == "" {
|
||||
fmt.Fprintln(stderr, "felis files: --op is required (list, read, or write)")
|
||||
fmt.Fprintln(stderr, "felis files: --op is required")
|
||||
return 2
|
||||
}
|
||||
req := fileedit.Request{
|
||||
Op: *op, Path: *path, To: *to, Expect: *expect, CreateOnly: *createOnly, Overwrite: *overwrite,
|
||||
}
|
||||
|
||||
// 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),
|
||||
@@ -53,22 +67,29 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
|
||||
// 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)
|
||||
switch *op {
|
||||
case fileedit.OpWrite:
|
||||
content, err := fileedit.ContentFromEnv(os.LookupEnv)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis files: %s is not valid base64: %v\n", fileedit.ContentEnv, err)
|
||||
fmt.Fprintf(stderr, "felis files: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
content = decoded
|
||||
req.Content = content
|
||||
case fileedit.OpUpload:
|
||||
token := os.Getenv(fileedit.UploadTokenEnv)
|
||||
if *sourceURL == "" || token == "" {
|
||||
fmt.Fprintf(stderr, "felis files: an upload needs --source-url and %s\n", fileedit.UploadTokenEnv)
|
||||
return 2
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
req.Upload = &fileedit.Upload{
|
||||
Size: *size, SHA256: *sum,
|
||||
Open: func() (io.ReadCloser, error) { return fetchUpload(ctx, *sourceURL, token) },
|
||||
}
|
||||
}
|
||||
|
||||
res, err := fileedit.Execute(*worldsRoot, *op, *path, content, *expect)
|
||||
res, err := fileedit.Execute(*worldsRoot, req)
|
||||
if err != nil {
|
||||
// The operation could not be attempted — infrastructure, not caller fault.
|
||||
fmt.Fprintf(stderr, "felis files: %v\n", err)
|
||||
@@ -83,3 +104,30 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// fetchUpload opens the staged upload on felis-api's internal face. There is no
|
||||
// retry: the token opens the upload once (fileedit.Stage), so a second attempt
|
||||
// could only be refused, and felis-api answers the failed Job with a 500 the
|
||||
// caller can retry whole. Redirects are refused because the request carries the
|
||||
// token and the internal face never redirects; the header timeout catches a
|
||||
// wedged endpoint, and the Job's activeDeadlineSeconds bounds the body.
|
||||
func fetchUpload(ctx context.Context, url, token string) (io.ReadCloser, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{ResponseHeaderTimeout: 30 * time.Second},
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("GET returned %s", resp.Status)
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
Reference in new issue
Block a user