546 lines
18 KiB
Go
546 lines
18 KiB
Go
package main
|
|
|
|
// backuppush.go — backup_push: send a backup this machine holds to a
|
|
// short-lived signed upload URL the host hands over.
|
|
//
|
|
// This is the way back for a backup that exists only here: a host being
|
|
// restored cannot reach this machine's disk, so it asks for the one backup
|
|
// file it needs to be put where it can fetch it. The file is ciphertext a
|
|
// host wrote (only a registered key, or a one-backup key resealed with the
|
|
// person's approval, opens it), so this is standing work under the backup
|
|
// folder the person configured here, like backup_fetch.
|
|
//
|
|
// What it refuses, each for the same reason as backup_fetch:
|
|
// - any file that is not named like a backup (<stamp>.tar.zst.age or
|
|
// <stamp>.key.age), not directly inside this pairing's backup folder or a
|
|
// folder the person shared with it (backuplist.go), reached through a link
|
|
// that leaves that folder, or itself a link;
|
|
// - a file whose sha256 is not the one the host named — the host must say
|
|
// which bytes it wants, and gets exactly those;
|
|
// - any URL that is not https or resolves to this machine or its network
|
|
// (validateBackupURL), checked again at the address actually dialled.
|
|
//
|
|
// HEADERS. The upload URL comes with the headers its signature covers
|
|
// (`headers`, as the platform's backup.put_url returns them). The platform
|
|
// signs exactly one: If-None-Match: *, so the storage refuses to write over an
|
|
// object that is there; without it the storage answers 403. That header, with
|
|
// that value, is the only one a host may hand over (the name in any case);
|
|
// every other name, and any other value, is refused at start, so the host
|
|
// cannot put anything else on this machine's request. No headers at all is
|
|
// refused too: the platform always hands the header over, so a start without
|
|
// it comes from a broken or tampered host.
|
|
//
|
|
// `start` answers at once: the file is hashed and checked in the background
|
|
// job, before anything is sent, because reading gigabytes does not fit in one
|
|
// dispatch. The host polls `status`, which answers only for jobs this pairing
|
|
// started. No reply or job error quotes the signed URL's query.
|
|
//
|
|
// A DEAD MOUNT. A job whose file stops answering fails at backupPushFileStall,
|
|
// but the read it was in cannot be stopped: it keeps a goroutine, an OS thread
|
|
// and the open file until the mount answers or goes away. Pushes and the
|
|
// background hashing keep their reads in one record (backupReads,
|
|
// backupstate.go): a start in a folder with a read of either that has not
|
|
// returned is refused with a sentence until it does, and the whole bastion
|
|
// keeps at most backupReadsMax reads of both.
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// backupPushReq is the host's ask, in the same shape as backup_fetch's: one
|
|
// JSON argument, {"op":"start",...} or {"op":"status","job_id":...}. `path`
|
|
// is relative to the backup folder.
|
|
type backupPushReq struct {
|
|
Op string `json:"op"` // start | status
|
|
JobID string `json:"job_id"`
|
|
Path string `json:"path"`
|
|
URL string `json:"url"`
|
|
SHA256 string `json:"sha256"`
|
|
// Headers the upload URL was signed with, sent on the PUT as given.
|
|
Headers map[string]string `json:"headers"`
|
|
}
|
|
|
|
// backupPushAllowedHeaders is every header a host may hand over with an
|
|
// upload URL, with the one value each may have: what the platform's
|
|
// backup.put_url signs into the URL. A change there is a change here.
|
|
var backupPushAllowedHeaders = map[string]string{"If-None-Match": "*"}
|
|
|
|
// backupPushHeaders checks the headers a host hands over with an upload URL
|
|
// against backupPushAllowedHeaders and returns them ready to send.
|
|
func backupPushHeaders(in map[string]string) (http.Header, error) {
|
|
if len(in) == 0 {
|
|
return nil, fmt.Errorf("no headers came with the upload address; the platform signs " +
|
|
"If-None-Match: * into every one and hands it over, so the host that asked is broken or not the platform's")
|
|
}
|
|
h := http.Header{}
|
|
for k, v := range in {
|
|
ck := http.CanonicalHeaderKey(k)
|
|
want, ok := backupPushAllowedHeaders[ck]
|
|
if !ok {
|
|
return nil, fmt.Errorf("header %q is not one an upload address is signed with (only If-None-Match: *)", clip(k, 64))
|
|
}
|
|
if v != want {
|
|
return nil, fmt.Errorf("header %s must be %q, not %q", ck, want, clip(v, 64))
|
|
}
|
|
h.Set(ck, v)
|
|
}
|
|
return h, nil
|
|
}
|
|
|
|
// clip is s cut to at most n bytes, for quoting what a host sent.
|
|
func clip(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "..."
|
|
}
|
|
|
|
type backupPushJob struct {
|
|
ID string `json:"id"`
|
|
Path string `json:"path"`
|
|
Bytes int64 `json:"bytes"`
|
|
SHA256 string `json:"sha256"`
|
|
State string `json:"state"` // pending | done | failed
|
|
Error string `json:"error,omitempty"`
|
|
Started string `json:"started_at"`
|
|
Finished string `json:"finished_at,omitempty"`
|
|
root string
|
|
done time.Time
|
|
headers http.Header // sent on the PUT (backupPushHeaders)
|
|
}
|
|
|
|
// A finished push is forgotten after this long; the host reads the outcome
|
|
// within a poll or two.
|
|
const backupPushKeep = 24 * time.Hour
|
|
|
|
var backupPushJobs = map[string]*backupPushJob{}
|
|
|
|
// backupPushFileStall bounds one operation on the file being pushed (a stat,
|
|
// the open, one read). A backup folder on a network mount that stopped
|
|
// answering can leave a read that never returns; past the bound the job fails
|
|
// with a sentence and its upload is stopped, instead of staying pending. It
|
|
// bounds a stall, not a big file: reads that keep returning never trip it, and
|
|
// neither does a slow upload (no file read is in flight while the network
|
|
// takes the bytes).
|
|
//
|
|
// backupPushRead is one read of the file being pushed; a test replaces it with
|
|
// one that never returns. Both are read under backupPushHookMu.
|
|
var (
|
|
backupPushHookMu sync.Mutex
|
|
backupPushFileStall = 2 * time.Minute
|
|
backupPushRead = func(f *os.File, p []byte) (int, error) { return f.Read(p) }
|
|
)
|
|
|
|
// ── one file operation per folder at a time ────────────────────────────────
|
|
//
|
|
// Every operation of a push on its file (a stat, the open, a read) holds its
|
|
// folder's push slot in backupReads (backupstate.go) while it runs, and counts
|
|
// toward backupReadsMax. Operations are short, so pushes from one folder, or
|
|
// from many, barely wait on each other. An operation that never returns keeps
|
|
// its slot until it does: a push behind it waits no longer than its bound and
|
|
// then fails with a sentence naming what it waited on, without an operation
|
|
// of its own, so a dead mount holds one stuck push read however often a push
|
|
// is started there. The slot is freed when the operation returns.
|
|
|
|
// pushStallBound is backupPushFileStall, read under backupPushHookMu.
|
|
func pushStallBound() time.Duration {
|
|
backupPushHookMu.Lock()
|
|
defer backupPushHookMu.Unlock()
|
|
return backupPushFileStall
|
|
}
|
|
|
|
// pushSlotWait is a push that waited past its bound for a slot to read its
|
|
// file; the error is the sentence the job fails with.
|
|
type pushSlotWait struct{ why string }
|
|
|
|
func (e *pushSlotWait) Error() string { return e.why }
|
|
|
|
// pushSlotTake takes dir's push slot for one operation on rel, a file in dir.
|
|
// It waits while dir's push slot is held, while dir's hashing read is stuck,
|
|
// or while backupReadsMax reads are in flight; for at most pushBound, then it
|
|
// returns a *pushSlotWait saying which; and not past ctx.
|
|
func pushSlotTake(ctx context.Context, dir, rel string, pushBound, hashBound time.Duration) error {
|
|
timer := time.NewTimer(pushBound)
|
|
defer timer.Stop()
|
|
late := false
|
|
for {
|
|
backupReadMu.Lock()
|
|
now := time.Now()
|
|
r := backupReads[dir]
|
|
_, stuck := folderStuckLocked(dir, now, pushBound, hashBound)
|
|
here := r != nil && !r.push.IsZero()
|
|
inFlight := readsInFlightLocked()
|
|
if !here && !stuck && inFlight < backupReadsMax {
|
|
if r == nil {
|
|
r = &folderReads{}
|
|
backupReads[dir] = r
|
|
}
|
|
r.push = now
|
|
backupReadMu.Unlock()
|
|
return nil
|
|
}
|
|
if late {
|
|
why, ok := folderStuckLocked(dir, now, pushBound, hashBound)
|
|
backupReadMu.Unlock()
|
|
if !ok && here {
|
|
why = "an earlier push"
|
|
}
|
|
msg := fmt.Sprintf("this push waited more than %s for a turn to read its file: ", pushBound)
|
|
if why != "" {
|
|
msg += folderRefusal(rel, why)
|
|
} else {
|
|
msg += capRefusal(inFlight)
|
|
}
|
|
return &pushSlotWait{msg}
|
|
}
|
|
freed := backupReadFreed
|
|
backupReadMu.Unlock()
|
|
select {
|
|
case <-freed:
|
|
case <-timer.C:
|
|
late = true
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
}
|
|
|
|
func folderRefusal(rel, what string) string {
|
|
return fmt.Sprintf("the folder of %s has not answered %s (a network mount that stopped?); "+
|
|
"nothing more is sent from that folder until it answers", rel, what)
|
|
}
|
|
|
|
func capRefusal(n int) string {
|
|
return fmt.Sprintf("%d reads of backup folders have not returned (network mounts that stopped?); "+
|
|
"no push starts on this machine until one does", n)
|
|
}
|
|
|
|
// pushSlotRefusal is why a push of rel, a file in dir, may not start now, or
|
|
// "" when it may: a read of dir (a push's or the hashing job's) has been in
|
|
// flight past its bound, or backupReadsMax reads have.
|
|
func pushSlotRefusal(dir, rel string, pushBound, hashBound time.Duration) string {
|
|
now := time.Now()
|
|
backupReadMu.Lock()
|
|
defer backupReadMu.Unlock()
|
|
if what, stuck := folderStuckLocked(dir, now, pushBound, hashBound); stuck {
|
|
return folderRefusal(rel, what)
|
|
}
|
|
if n := stuckReadsLocked(now, pushBound, hashBound); n >= backupReadsMax {
|
|
return capRefusal(n)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// fileWatch records since when an operation on the pushed file has been in
|
|
// flight (zero when none is). The wait for its folder's slot before it is not
|
|
// an operation on the file, and is bounded on its own (pushSlotTake).
|
|
type fileWatch struct {
|
|
mu sync.Mutex
|
|
since time.Time
|
|
ctx context.Context
|
|
job *backupPushJob
|
|
dir string
|
|
pushBound time.Duration
|
|
hashBound time.Duration
|
|
}
|
|
|
|
// do runs f holding the folder's slot. It returns an error, without running
|
|
// f, when the job ended while it waited for the slot, or when the wait
|
|
// outlasted its bound; then the job fails with the sentence of why.
|
|
func (w *fileWatch) do(f func()) error {
|
|
if err := pushSlotTake(w.ctx, w.dir, w.job.Path, w.pushBound, w.hashBound); err != nil {
|
|
var late *pushSlotWait
|
|
if errors.As(err, &late) {
|
|
finishPush(w.job, "failed", late.why)
|
|
}
|
|
return err
|
|
}
|
|
defer backupReadFree(w.dir, false)
|
|
w.mu.Lock()
|
|
w.since = time.Now()
|
|
w.mu.Unlock()
|
|
defer func() {
|
|
w.mu.Lock()
|
|
w.since = time.Time{}
|
|
w.mu.Unlock()
|
|
}()
|
|
f()
|
|
return nil
|
|
}
|
|
|
|
func (w *fileWatch) stuckFor(now time.Time) time.Duration {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
if w.since.IsZero() {
|
|
return 0
|
|
}
|
|
return now.Sub(w.since)
|
|
}
|
|
|
|
// watchedFile reads the pushed file under the watch.
|
|
type watchedFile struct {
|
|
f *os.File
|
|
w *fileWatch
|
|
read func(*os.File, []byte) (int, error)
|
|
}
|
|
|
|
func (r watchedFile) Read(p []byte) (n int, err error) {
|
|
if e := r.w.do(func() { n, err = r.read(r.f, p) }); e != nil {
|
|
return 0, e
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
func handleBackupPush(ws *websocket.Conn, root string, args []string) {
|
|
if len(args) < 1 {
|
|
backupFail(ws, "backup_push: need a JSON argument")
|
|
return
|
|
}
|
|
var req backupPushReq
|
|
if err := json.Unmarshal([]byte(args[0]), &req); err != nil {
|
|
backupFail(ws, "backup_push: argument is not JSON: "+err.Error())
|
|
return
|
|
}
|
|
switch req.Op {
|
|
case "status":
|
|
backupJobsMu.Lock()
|
|
j, ok := backupPushJobs[req.JobID]
|
|
var snap backupPushJob
|
|
if ok {
|
|
snap = *j
|
|
}
|
|
backupJobsMu.Unlock()
|
|
// Another pairing's job reads exactly as a job this bastion never had.
|
|
if !ok || snap.root != root {
|
|
backupOK(ws, map[string]string{"job_id": req.JobID, "state": "unknown"})
|
|
return
|
|
}
|
|
out := map[string]string{"job_id": snap.ID, "state": snap.State}
|
|
if snap.Error != "" {
|
|
out["error"] = snap.Error
|
|
}
|
|
backupOK(ws, out)
|
|
case "start", "":
|
|
backupPushStart(ws, root, req)
|
|
default:
|
|
backupFail(ws, "backup_push: unknown op "+req.Op)
|
|
}
|
|
}
|
|
|
|
func backupPushStart(ws *websocket.Conn, root string, req backupPushReq) {
|
|
abs, why := backupResolveFile(mgmtSelf(ws), root, req.Path)
|
|
if why != "" {
|
|
backupFail(ws, "backup_push: "+why)
|
|
return
|
|
}
|
|
if _, err := hex.DecodeString(req.SHA256); err != nil || len(req.SHA256) != 64 {
|
|
backupFail(ws, "backup_push: sha256 must be 64 hex characters")
|
|
return
|
|
}
|
|
if err := validateBackupURL(req.URL); err != nil {
|
|
backupFail(ws, "backup_push: "+err.Error())
|
|
return
|
|
}
|
|
headers, err := backupPushHeaders(req.Headers)
|
|
if err != nil {
|
|
backupFail(ws, "backup_push: "+err.Error())
|
|
return
|
|
}
|
|
if why := pushSlotRefusal(filepath.Dir(abs), req.Path, pushStallBound(), hashBound()); why != "" {
|
|
backupFail(ws, "backup_push: "+why)
|
|
return
|
|
}
|
|
now := time.Now()
|
|
job := &backupPushJob{ID: newBackupJobID("bp"), Path: req.Path, SHA256: strings.ToLower(req.SHA256),
|
|
State: "pending", Started: now.UTC().Format(time.RFC3339), root: root, headers: headers}
|
|
backupJobsMu.Lock()
|
|
for id, j := range backupPushJobs {
|
|
if !j.done.IsZero() && now.Sub(j.done) > backupPushKeep {
|
|
delete(backupPushJobs, id)
|
|
}
|
|
}
|
|
backupPushJobs[job.ID] = job
|
|
backupJobsMu.Unlock()
|
|
go runBackupPush(job, req.URL, abs)
|
|
backupOK(ws, map[string]string{"job_id": job.ID, "state": "pending"})
|
|
}
|
|
|
|
// finishPush records the outcome of a push job. The first outcome stands:
|
|
// a read that returns after the job was failed for not returning changes
|
|
// nothing.
|
|
func finishPush(job *backupPushJob, state, msg string) {
|
|
backupJobsMu.Lock()
|
|
defer backupJobsMu.Unlock()
|
|
if job.State != "pending" {
|
|
return
|
|
}
|
|
job.State, job.Error = state, redactURLs(msg)
|
|
job.done = time.Now()
|
|
job.Finished = job.done.UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
// runBackupPush runs the push and watches its file: when an operation on the
|
|
// file has not returned within backupPushFileStall, the job fails and the
|
|
// upload is stopped. A stuck operation cannot be stopped; it keeps its
|
|
// folder's slot until it returns by itself. A wait for the slot is not an
|
|
// operation on the file; it fails the job on its own bound (fileWatch.do).
|
|
func runBackupPush(job *backupPushJob, rawURL, abs string) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), backupFetchTimeout)
|
|
defer cancel()
|
|
backupPushHookMu.Lock()
|
|
bound, read := backupPushFileStall, backupPushRead
|
|
backupPushHookMu.Unlock()
|
|
w := &fileWatch{ctx: ctx, job: job, dir: filepath.Dir(abs), pushBound: bound, hashBound: hashBound()}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
pushFile(ctx, job, rawURL, abs, w, read)
|
|
}()
|
|
every := bound / 4
|
|
if every < 10*time.Millisecond {
|
|
every = 10 * time.Millisecond
|
|
}
|
|
tick := time.NewTicker(every)
|
|
defer tick.Stop()
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case now := <-tick.C:
|
|
if w.stuckFor(now) > bound {
|
|
finishPush(job, "failed", fmt.Sprintf("the backup folder stopped answering: an operation on the file "+
|
|
"did not return within %s (a network mount that stopped?); the upload was stopped", bound))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func pushFile(ctx context.Context, job *backupPushJob, rawURL, abs string, w *fileWatch,
|
|
read func(*os.File, []byte) (int, error)) {
|
|
finish := func(state, msg string) { finishPush(job, state, msg) }
|
|
var before os.FileInfo
|
|
var err error
|
|
if e := w.do(func() { before, err = os.Lstat(abs) }); e != nil {
|
|
finish("failed", e.Error())
|
|
return
|
|
}
|
|
if err != nil || !before.Mode().IsRegular() {
|
|
finish("failed", "no backup file at that path")
|
|
return
|
|
}
|
|
var f *os.File
|
|
if e := w.do(func() { f, err = os.Open(abs) }); e != nil {
|
|
finish("failed", e.Error())
|
|
return
|
|
}
|
|
if err != nil {
|
|
finish("failed", err.Error())
|
|
return
|
|
}
|
|
defer f.Close()
|
|
// What was opened is what was checked: a link put in its place between
|
|
// the check and the open is a different file, and is refused.
|
|
var fi os.FileInfo
|
|
if e := w.do(func() { fi, err = f.Stat() }); e != nil {
|
|
finish("failed", e.Error())
|
|
return
|
|
}
|
|
if err != nil || !os.SameFile(before, fi) {
|
|
finish("failed", "the file changed while it was being opened")
|
|
return
|
|
}
|
|
backupJobsMu.Lock()
|
|
job.Bytes = fi.Size()
|
|
backupJobsMu.Unlock()
|
|
file := watchedFile{f, w, read}
|
|
// The digest the host named, checked before anything is sent: from the
|
|
// cache when this exact file (size and modification time) was hashed
|
|
// already, else read once here.
|
|
sum := cachedHash(abs, fi)
|
|
if sum == "" {
|
|
h := sha256.New()
|
|
if _, err := io.Copy(h, file); err != nil {
|
|
finish("failed", err.Error())
|
|
return
|
|
}
|
|
sum = hex.EncodeToString(h.Sum(nil))
|
|
noteHash(abs, fi, sum)
|
|
if e := w.do(func() { _, err = f.Seek(0, io.SeekStart) }); e != nil {
|
|
err = e
|
|
}
|
|
if err != nil {
|
|
finish("failed", err.Error())
|
|
return
|
|
}
|
|
}
|
|
if sum != job.SHA256 {
|
|
finish("failed", "the file at that path is not the one named (sha256 differs)")
|
|
return
|
|
}
|
|
// The bytes sent are hashed again as they go. If the file changed after
|
|
// the check, the job says so rather than done.
|
|
h := sha256.New()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, rawURL, io.TeeReader(io.LimitReader(file, fi.Size()), h))
|
|
if err != nil {
|
|
finish("failed", err.Error())
|
|
return
|
|
}
|
|
for k, vs := range job.headers {
|
|
req.Header[k] = append([]string(nil), vs...)
|
|
}
|
|
req.ContentLength = fi.Size()
|
|
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 destination must be a public address", host)
|
|
}
|
|
return nil
|
|
}}
|
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
|
transport.DialContext = dialer.DialContext
|
|
transport.Proxy = nil
|
|
client := &http.Client{Transport: transport,
|
|
// A signed upload URL does not redirect; following one would send the
|
|
// backup somewhere the host did not name.
|
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
finish("failed", err.Error())
|
|
return
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode == http.StatusPreconditionFailed {
|
|
finish("failed", "the destination already holds an object there (HTTP 412); nothing was replaced")
|
|
return
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
finish("failed", fmt.Sprintf("the destination answered HTTP %d", resp.StatusCode))
|
|
return
|
|
}
|
|
if hex.EncodeToString(h.Sum(nil)) != job.SHA256 {
|
|
finish("failed", "the file changed while it was being sent; what arrived is not the backup named")
|
|
return
|
|
}
|
|
finish("done", "")
|
|
}
|