Files
privatae-bastion/client-go/backupfetch.go
T
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

549 lines
19 KiB
Go

package main
// backup_fetch — the bastion PULLS a backup artifact onto the user's own machine.
//
// WHY PULL AND NOT PUSH, measured rather than assumed. The obvious route was
// ctx_put: it already writes a blob under a sandboxed root and it is already
// granted. It cannot carry a backup. The whole management job is ONE WebSocket
// frame (`C` + JSON of {op,args}, the host's mgmt worker)
// and the payload rides inside args[1] as base64, so nothing streams and the
// bytes are buffered whole on both sides. Measured (ctxcap_measure_test.go):
// the frame crosses the host's 16 MiB WebSocket frame ceiling — its live
// limit, since nothing overrides the server default — at just under 12 MiB of
// plaintext. A 13 GiB export (a real entity's tree) is 1,110 sequential
// dispatches, each one job per
// worker connection, against a 30 s host-side dispatch timeout. Chunking that
// would be a file-transfer protocol written on top of a command channel.
//
// So the host sends a short-lived signed URL and a digest, and this machine
// fetches it over its OWN internet connection, streaming to disk with constant
// memory. The artifact is already encrypted, to keys the platform does not hold,
// before it leaves the host, so the URL is not a
// secret worth much and the bytes are not readable in flight by anyone.
//
// ASYNC, because the host cannot wait. The host's management dispatch has a
// 30 s timeout; a multi-gigabyte download does not finish inside that. `start`
// validates, spawns, and returns a job id immediately; `status` reports
// progress. Each call is its own short dispatch. (The 25 s mgmtCmdTimeout in
// mgmt.go does NOT apply here — it wraps only the p8 shell exec path.)
//
// WHAT THIS REFUSES, and why each one is here rather than assumed safe:
// * a non-HTTPS URL — the digest would still catch tampering, but there is no
// reason to fetch a backup in the clear;
// * a URL that resolves to this machine or to a private/link-local address —
// the host chooses this URL, and this process sits INSIDE the user's
// network. Without the check, "fetch my backup" is a request the host can
// point at the user's router. The egress lane can already reach the LAN,
// but it is separately granted and separately audited; this op has no
// legitimate need, so it does not get the reach.
// * a body larger than the declared max — a hostile or buggy host must not be
// able to fill the user's disk;
// * a digest or length that does not match what arrived — verified BEFORE the
// file is put in place, so a truncated download never appears as a backup.
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"github.com/gorilla/websocket"
)
const (
// A backup fetch is long by nature; this bounds a stall, not a big file.
backupFetchTimeout = 6 * time.Hour
// Nothing to do with the artifact size — this is the read buffer.
backupFetchChunk = 1 << 20
// Refuse anything the caller has not sized. Also the ceiling when the
// declared size is absurd: 512 GiB is far past any entity and still leaves
// the check meaningful.
backupFetchMaxBytes = 512 << 30
)
type backupJob struct {
ID string `json:"id"`
RPath string `json:"rpath"`
Want int64 `json:"want_bytes"`
SHA256 string `json:"sha256"`
Got int64 `json:"got_bytes"`
State string `json:"state"` // running | done | failed
Error string `json:"error,omitempty"`
Started string `json:"started_at"`
Finished string `json:"finished_at,omitempty"`
Path string `json:"path,omitempty"`
// Pruned is how many older backup files the arrival of this one retired
// from its folder (backupretention.go).
Pruned int `json:"pruned,omitempty"`
// root is the pairing's backup folder: a status call from another
// pairing reads this job as unknown, and a backup file that lands at its
// top is recorded as an arrival there.
root string
arrival bool
}
var (
backupJobsMu sync.Mutex
backupJobs = map[string]*backupJob{}
)
// backupRootPath is backupRoot without creating it: the folder this pairing's
// backups WOULD go to, for comparing pairings without touching the disk.
func (c *Config) backupRootPath() string {
if c.BackupRoot != "" {
return c.BackupRoot
}
d, err := os.UserConfigDir()
if err != nil {
return ""
}
return filepath.Join(d, "privatae-bastion", "backups", c.HostName)
}
// backupRoot returns the directory this bastion writes fetched backups into.
//
// Defaults to backups/<host_name> under the bastion's config dir, mirroring
// contextRoot() exactly — per host, because one daemon serves many and their
// artifacts must not mix. A user who wants them on a NAS sets `backup_root` in
// this pairing's config to the mount point; it is deliberately a local choice,
// because a destination the HOST could pick is not a sandbox.
//
// "" on any failure to resolve or create it, and the mgmt lane then refuses the
// op the same way it refuses ctx_* with no context store.
func (c *Config) backupRoot() string {
root := c.backupRootPath()
if root == "" {
return ""
}
if err := os.MkdirAll(root, 0o700); err != nil {
return ""
}
return root
}
type backupFetchReq struct {
Op string `json:"op"` // start | status
JobID string `json:"job_id"` // status
URL string `json:"url"` // start
RPath string `json:"rpath"` // start — relative, sandboxed under backupRoot
SHA256 string `json:"sha256"` // start — of the artifact as served
Size int64 `json:"size"` // start — expected bytes
MaxBytes int64 `json:"max_bytes"`
}
// handleBackupFetch serves both ops on the single `backup_fetch` job name, so
// the host-side allowlist grants one op rather than two.
func handleBackupFetch(ws *websocket.Conn, root string, args []string) {
if len(args) < 1 {
backupFail(ws, "backup_fetch: need a JSON argument")
return
}
var req backupFetchReq
if err := json.Unmarshal([]byte(args[0]), &req); err != nil {
backupFail(ws, "backup_fetch: argument is not JSON: "+err.Error())
return
}
switch req.Op {
case "status":
backupStatus(ws, root, req.JobID)
case "start", "":
backupStart(ws, root, req)
default:
backupFail(ws, "backup_fetch: unknown op "+req.Op)
}
}
// backupFail refuses with msg, with any signed URL's query taken out: a URL
// the host sent can end up quoted in an error, and its signature does not
// belong in a reply or a log.
func backupFail(ws *websocket.Conn, msg string) {
sendMgmt(ws, 'R', []byte(redactURLs(msg)+"\n"))
sendMgmtExit(ws, 1)
}
func backupOK(ws *websocket.Conn, v any) {
b, _ := json.Marshal(v)
sendMgmt(ws, 'O', b)
sendMgmtExit(ws, 0)
}
func backupStatus(ws *websocket.Conn, root, id string) {
backupJobsMu.Lock()
j, ok := backupJobs[id]
var snap backupJob
if ok {
snap = *j
}
backupJobsMu.Unlock()
// Another pairing's job reads exactly as a job this bastion never had.
if !ok || snap.root != root {
// A restart loses in-flight jobs. Say "unknown", not "failed": the host
// re-starts the fetch, which is idempotent on (rpath, sha256).
backupOK(ws, map[string]string{"state": "unknown", "id": id})
return
}
backupOK(ws, snap)
}
func backupStart(ws *websocket.Conn, root string, req backupFetchReq) {
abs, arrival, why := backupFetchDest(root, req.RPath)
if why != "" {
backupFail(ws, "backup_fetch: "+why)
return
}
if len(req.SHA256) != 64 {
backupFail(ws, "backup_fetch: sha256 must be 64 hex characters")
return
}
if _, err := hex.DecodeString(req.SHA256); err != nil {
backupFail(ws, "backup_fetch: sha256 is not hex")
return
}
// IDEMPOTENT, AND CHECKED BEFORE THE URL. If the artifact is already here
// and already correct, say done without fetching it again — a retry after a
// bastion restart, or a host that lost our reply, must not re-download
// gigabytes.
//
// The order matters and was wrong first time round (caught by
// TestBackupFetchIsIdempotent): validating the URL first meant a retry whose
// signed URL had since EXPIRED — or that arrived while DNS was down —
// re-failed even though the correct bytes were already sitting on disk. The
// local check is free and needs no network; do it first.
//
// The digest comes from what this bastion already knows about the file
// (the record of what it fetched, the hash cache) and is read from disk
// only for a file small enough to hash well inside one dispatch; a large
// file with no known digest is fetched again.
if sum, n, err := knownOrQuickHash(root, abs); err == nil && sum == strings.ToLower(req.SHA256) &&
(req.Size == 0 || n == req.Size) {
backupOK(ws, backupJob{ID: "already-present", RPath: req.RPath, State: "done",
Got: n, Want: n, SHA256: sum, Path: abs,
Finished: time.Now().UTC().Format(time.RFC3339)})
return
}
if err := validateBackupURL(req.URL); err != nil {
backupFail(ws, "backup_fetch: "+err.Error())
return
}
max := req.MaxBytes
if max <= 0 {
max = req.Size
}
if max <= 0 || max > backupFetchMaxBytes {
backupFail(ws, fmt.Sprintf(
"backup_fetch: size/max_bytes must be between 1 and %d", int64(backupFetchMaxBytes)))
return
}
id := newBackupJobID("bf")
job := &backupJob{ID: id, RPath: req.RPath, Want: req.Size, SHA256: strings.ToLower(req.SHA256),
State: "running", Started: time.Now().UTC().Format(time.RFC3339), root: root, arrival: arrival}
backupJobsMu.Lock()
backupJobs[id] = job
backupJobsMu.Unlock()
go runBackupFetch(job, req.URL, abs, max)
backupOK(ws, map[string]string{"id": id, "state": "running"})
}
func runBackupFetch(job *backupJob, rawURL, abs string, max int64) {
finish := func(state, errMsg, path string) {
backupJobsMu.Lock()
job.State, job.Error, job.Path = state, redactURLs(errMsg), path
job.Finished = time.Now().UTC().Format(time.RFC3339)
backupJobsMu.Unlock()
}
ctx, cancel := context.WithTimeout(context.Background(), backupFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
finish("failed", err.Error(), "")
return
}
// Re-check every redirect hop: a signed URL that 302s to a private address
// would otherwise walk straight past the check done at start().
// THE ADDRESS DIALLED IS THE ADDRESS CHECKED. validateBackupURL resolves
// the name for the check and the transport would resolve it again for the
// dial — a rebinding name answers public to the first and private to the
// second. The dialer's Control hook sees the address actually connected
// to and refuses one that is not public (the same fix egress got).
dialer := &net.Dialer{Timeout: 30 * time.Second, Control: func(network, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
if ip := net.ParseIP(host); ip == nil || !backupDialAllowed(ip) {
return fmt.Errorf("refused: %s is on this machine or this network — a backup source must be a public address", host)
}
return nil
}}
// The default transport, cloned, with only the dial replaced — its TLS
// settings (and whatever the tests put in them) stay as they were.
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = dialer.DialContext
// NO PROXY. Through an environment proxy the hook would vet the proxy's
// address and the proxy would resolve the target itself, outside it; the
// backup source is fetched directly or not at all.
transport.Proxy = nil
client := &http.Client{
Transport: transport,
CheckRedirect: func(r *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
return validateBackupURL(r.URL.String())
},
}
resp, err := client.Do(req)
if err != nil {
finish("failed", err.Error(), "")
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// The status code is the diagnosis the user needs: 403 on a signed URL
// means it expired, which is a different problem from a 404.
finish("failed", fmt.Sprintf("source returned HTTP %d", resp.StatusCode), "")
return
}
if err := os.MkdirAll(filepath.Dir(abs), 0o700); err != nil {
finish("failed", err.Error(), "")
return
}
tmp, err := os.CreateTemp(filepath.Dir(abs), ".backup-*.part")
if err != nil {
finish("failed", err.Error(), "")
return
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // no-op once renamed
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
finish("failed", err.Error(), "")
return
}
h := sha256.New()
buf := make([]byte, backupFetchChunk)
var total int64
for {
n, rerr := resp.Body.Read(buf)
if n > 0 {
total += int64(n)
if total > max {
_ = tmp.Close()
finish("failed", fmt.Sprintf(
"source is larger than the declared %d bytes — stopped", max), "")
return
}
if _, werr := tmp.Write(buf[:n]); werr != nil {
_ = tmp.Close()
finish("failed", werr.Error(), "")
return
}
h.Write(buf[:n])
backupJobsMu.Lock()
job.Got = total
backupJobsMu.Unlock()
}
if rerr == io.EOF {
break
}
if rerr != nil {
_ = tmp.Close()
finish("failed", rerr.Error(), "")
return
}
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
finish("failed", err.Error(), "")
return
}
if err := tmp.Close(); err != nil {
finish("failed", err.Error(), "")
return
}
// VERIFY BEFORE PUBLISHING. A truncated or substituted download must never
// end up sitting at the destination path looking like a backup.
got := hex.EncodeToString(h.Sum(nil))
if got != job.SHA256 {
finish("failed", fmt.Sprintf("sha256 mismatch: got %s, expected %s", got, job.SHA256), "")
return
}
if job.Want > 0 && total != job.Want {
finish("failed", fmt.Sprintf("size mismatch: got %d bytes, expected %d", total, job.Want), "")
return
}
if err := os.Rename(tmpName, abs); err != nil {
finish("failed", err.Error(), "")
return
}
// Record the arrival, then thin the folder now that a new backup is
// safely in it (backupretention.go). A failure of either is not a failed
// fetch: the backup arrived.
if job.arrival {
if err := recordArrival(job.root, filepath.Base(abs), got, time.Now()); err == nil {
if removed, err := pruneBackupRoot(job.root, filepath.Base(abs)); err == nil && len(removed) > 0 {
backupJobsMu.Lock()
job.Pruned = len(removed)
backupJobsMu.Unlock()
}
}
}
finish("done", "", abs)
}
// validateBackupURL is the SSRF wall. The host picks this URL and this process
// runs inside the user's network, so "https and not pointed at your own LAN" is
// the minimum. Every resolved address must be public — a name that resolves to
// one public and one private address is refused, because which one gets dialled
// is not ours to predict.
func validateBackupURL(raw string) error {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return fmt.Errorf("url is not parseable: %w", err)
}
if u.Scheme != "https" {
return fmt.Errorf("url must be https (got %q)", u.Scheme)
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("url has no host")
}
ips, err := backupLookupIP(host)
if err != nil {
return fmt.Errorf("cannot resolve %s: %w", host, err)
}
if len(ips) == 0 {
return fmt.Errorf("%s resolves to nothing", host)
}
for _, ip := range ips {
if !backupAddrAllowed(ip) {
return fmt.Errorf(
"%s resolves to %s, which is on this machine or this network — "+
"a backup source must be a public address", host, ip)
}
}
return nil
}
// backupLookupIP is net.LookupIP; a test answers for a name without DNS.
var backupLookupIP = net.LookupIP
// backupDialAllowed is the dial-time wall (backupAddrAllowed). A package var
// only so the fetch tests, which serve the artifact from a loopback test
// server, can let loopback through at the dial while keeping the wall on
// everything else in the same binary.
var backupDialAllowed = backupAddrAllowed
// backupAddrAllowed is the wall both backup lanes apply, to every address a
// name resolves to and again to the address dialled: a public address that is
// not this machine. A public address can still be this machine (one on its
// NIC, or a range a `local` route delivers here; ownaddr.go), and a service on
// 0.0.0.0 answers there. When the machine cannot tell, it refuses.
func backupAddrAllowed(ip net.IP) bool {
if !isPublicIP(ip) {
return false
}
own, err := ownDestination([]net.IP{ip})
return err == nil && !own
}
// isPublicIP classifies the address alone; whether it is this machine is
// backupAddrAllowed's question. It is the coder lane's classifier
// (coderInternalIP, coder_egress.go), so the backup lanes refuse what a coder
// run's proxy refuses: loopback, private, link-local, multicast, unspecified,
// CGNAT, ULA, 0.0.0.0/8, 198.18.0.0/15 and 240.0.0.0/4, site-local fec0::/10,
// IPv4-compatible ::/96 and IPv4-translated ::ffff:0:0/96, the 64:ff9b::/32
// NAT64 space beyond the well-known prefix, and a NAT64 64:ff9b::/96, 6to4
// 2002::/16 or IPv4-mapped address whose IPv4 address is any of those.
func isPublicIP(ip net.IP) bool {
return !coderInternalIP(ip)
}
func hashFile(path string) (string, int64, error) {
f, err := os.Open(path)
if err != nil {
return "", 0, err
}
defer f.Close()
h := sha256.New()
n, err := io.Copy(h, f)
if err != nil {
return "", 0, err
}
return hex.EncodeToString(h.Sum(nil)), n, nil
}
// quickHashMax is the largest file start hashes inside a dispatch: about a
// second or two of reading on ordinary disks, well inside the host's 30 s.
const quickHashMax = 512 << 20
// knownOrQuickHash is the digest of the file at abs from what this bastion
// already knows (the hash cache, the arrivals record), or read from disk when
// it is small; a large file with no known digest is an error.
func knownOrQuickHash(root, abs string) (string, int64, error) {
fi, err := os.Lstat(abs)
if err != nil {
return "", 0, err
}
if !fi.Mode().IsRegular() {
return "", 0, fmt.Errorf("not a file")
}
if sum := cachedHash(abs, fi); sum != "" {
return sum, fi.Size(), nil
}
if filepath.Dir(abs) == filepath.Clean(root) {
backupStateMu.Lock()
a, err := arrivalsLoadLocked(root)
backupStateMu.Unlock()
if err == nil {
if rec, ok := a.Files[filepath.Base(abs)]; ok && stillTheArrival(fi, rec) {
return rec.SHA256, fi.Size(), nil
}
}
}
if fi.Size() > quickHashMax {
return "", 0, fmt.Errorf("digest not known")
}
sum, n, err := hashFile(abs)
if err == nil {
noteHash(abs, fi, sum)
}
return sum, n, err
}
// backupFetchDest is where a fetch of rpath lands. A backup file
// (<stamp>.tar.zst.age, <stamp>.key.age) lands at the top of this pairing's
// own backup folder, whatever folder the host named: that is the one place
// thinning looks (backupretention.go) and the one place backup_list reads,
// so the host cannot scatter backups where they are never thinned. Anything
// else lands where the host named, inside the folder (cleanRel).
func backupFetchDest(root, rpath string) (string, bool, string) {
abs, ok := cleanRel(root, rpath)
if !ok {
return "", false, "path rejected"
}
if base := filepath.Base(abs); backupFileRe.MatchString(base) {
return filepath.Join(root, base), true, ""
}
return abs, false, ""
}