复用现有 k3s 调度和 Job 生命周期,增加 worker 接入与批准、受保护节点身份、归档传输、持久迁移锁及活动 PVC 切换;同步管理员 API、CLI、面板和隔离规则。分布式模式默认关闭,保持单机兼容。 验证:Go 全量测试与 vet;面板 874 个测试、lint/build;Linux VM 安装器测试、清单服务端 dry-run、网络命名空间防火墙实测。A/B/C 三机 WireGuard、Velocity 和迁移验收仍待完成。
112 lines
4.1 KiB
Go
112 lines
4.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"felis.lolicon.best/internal/archivetransfer"
|
|
"felis.lolicon.best/internal/backup"
|
|
)
|
|
|
|
// cmdRestore is the in-Pod entrypoint the restore Job runs. internal/restore
|
|
// renders a Pod whose command is `/usr/local/bin/felis restore`. It extracts a
|
|
// world archive from the backup mount into the world mount and exits — it is NOT a
|
|
// user-facing command and is never invoked by hand.
|
|
//
|
|
// It deliberately holds NO database credentials and never calls config.Load:
|
|
// the felis-api made the authorization decision and looked up the archive ref;
|
|
// this process is the unprivileged hands that move bytes. Its entire input is
|
|
// the five flags below, mirroring the four-power isolation the rendered Job
|
|
// enforces (a restore Pod must not be able to reach the felis DB or the K8s
|
|
// API). All of those guarantees live in the Pod spec; this command only needs
|
|
// the archive store and the two mount roots.
|
|
func cmdRestore(args []string, stdout, stderr io.Writer) int {
|
|
fs := flag.NewFlagSet("restore", flag.ContinueOnError)
|
|
fs.SetOutput(stderr)
|
|
source := fs.String("source-url", "", "one-use archive download URL")
|
|
sum := fs.String("sha256", "", "required digest for remote archive")
|
|
limit := fs.Int64("max-bytes", archivetransfer.DefaultLimit, "maximum download size")
|
|
server := fs.String("server", "", "server name being restored (for logging)")
|
|
ref := fs.String("ref", "", "absolute path to the archive on the backup mount")
|
|
store := fs.String("archive-store", "tarLocal", "archive backend (only tarLocal is implemented)")
|
|
backupRoot := fs.String("backup-root", "/backups", "mount path of the backup PVC (archive refs must resolve under it)")
|
|
worldsRoot := fs.String("worlds-root", "/world", "mount path of the world PVC the archive extracts into")
|
|
if err := fs.Parse(args); err != nil {
|
|
return 2
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
if *source != "" {
|
|
dir, err := os.MkdirTemp("/tmp", "felis-restore-")
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err)
|
|
return 1
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
*backupRoot = dir
|
|
*ref = filepath.Join(dir, "world.tar.gz")
|
|
if err := archivetransfer.Fetch(ctx, *source, os.Getenv(archivetransfer.TokenEnv), *ref, *sum, *limit); err != nil {
|
|
fmt.Fprintln(stderr, err)
|
|
return 1
|
|
}
|
|
}
|
|
if *ref == "" {
|
|
fmt.Fprintln(stderr, "felis restore: --ref is required")
|
|
return 2
|
|
}
|
|
if *store != "tarLocal" {
|
|
fmt.Fprintf(stderr, "felis restore: archive store %q is not implemented in this build (only tarLocal)\n", *store)
|
|
return 1
|
|
}
|
|
// Defense in depth: the ref comes from felis-api (trusted), but this process
|
|
// is the one that opens it, so it confirms the ref stays within the backup
|
|
// mount. A ref outside it would mean reading an arbitrary host path, which a
|
|
// restore Pod must never do.
|
|
if !refWithinRoot(*ref, *backupRoot) {
|
|
fmt.Fprintf(stderr, "felis restore: ref %q is not under backup root %q\n", *ref, *backupRoot)
|
|
return 1
|
|
}
|
|
|
|
// The world PVC is mounted directly at worldsRoot, so the resolver returns it
|
|
// for any target; the archive ref is absolute and is opened directly. This is
|
|
// the same TarLocal the reaper uses to write archives, run in reverse.
|
|
archiver := &backup.TarLocal{
|
|
BackupRoot: *backupRoot,
|
|
Resolve: func(string) (string, error) {
|
|
return *worldsRoot, nil
|
|
},
|
|
}
|
|
|
|
if err := archiver.Restore(ctx, backup.ArchiveRef(*ref), *server); err != nil {
|
|
fmt.Fprintf(stderr, "felis restore: %v\n", err)
|
|
return 1
|
|
}
|
|
if *source != "" {
|
|
if err := backup.VerifyRestored(ctx, *ref, *worldsRoot); err != nil {
|
|
fmt.Fprintln(stderr, err)
|
|
return 1
|
|
}
|
|
}
|
|
fmt.Fprintf(stdout, "felis restore: server=%s restored from %s into %s\n", *server, *ref, *worldsRoot)
|
|
return 0
|
|
}
|
|
|
|
// refWithinRoot reports whether ref resolves to a path inside root. Both are
|
|
// cleaned first so "/backups/../etc/passwd" cannot slip through.
|
|
func refWithinRoot(ref, root string) bool {
|
|
cleanRoot := filepath.Clean(root)
|
|
cleanRef := filepath.Clean(ref)
|
|
if cleanRef == cleanRoot {
|
|
return true
|
|
}
|
|
return strings.HasPrefix(cleanRef, cleanRoot+string(filepath.Separator))
|
|
}
|