549 lines
19 KiB
Go
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, ""
|
|
}
|