The owner should notice nothing but their address, and that is now mostly true: the quota, the deadline, their SSH keys, their sticky notes and their init script travel with the archive in a small JSON file served next to it and checked against its own digest, so nothing has to be retyped on a command line or remembered from the old machine. The init script is written to the database and deliberately not run: it belongs to a reinstall, and a migration must not re-run whatever it did the first time. The root password is no longer reset either. It lives on the disk, its owner already knows it, and leaving it alone is the point of a migration that should feel like nothing happened — verified by logging into the moved container with the password set on the sending host before it moved. An account whose deadline has passed is refused outright, with the command to extend it: starting a container the panel has already locked and stopped is not something a transfer should do, and an expiry in the past would otherwise be recreated as a live account. The deadline travels as the absolute date it was, so moving neither gains nor loses time. The receiving host checks it has room before fetching anything: the metadata is small and comes first, and the disk figure in it is the sending host's own, the same number the export sized its temp file from. The account's settings are no longer passed as flags. --cpu/--mem/--disk/ --bandwidth/--days on `receive` are gone, along with the code that could have been used to shrink a disk on the way — quotas may exceed what the target has room for (the pool is over-committed by design, as it is for `vps add`), but a migration never reduces one. Two more things the real daemon taught us, both about the same DHCP handshake: - A container is handed its static IPv4 through a reservation keyed to its hardware address, written when the instance is created. Changing that address afterwards — which is what dropping the source's MAC did — leaves the reservation naming the old one, and the container then comes up on a dynamic address while every rule and record points at the static one. The fresh address is therefore set as part of the import, via X-Incus-config, where Incus will write the reservation around it. Confirmed on the wire: the configured, configured-in-the-guest and reserved addresses now agree, and the lease follows. - That address arrives over DHCP a moment after the container is up, later than Readiness means, so it is waited for rather than assumed, with one restart if the server is still holding the address of the container that was replaced. Verified on two hosts (Ubuntu 24.04 and 26.04, Incus 7.0.1, zfs): an account with a key, notes, an init script and a deadline moved with all four intact, the init script not run, the old root password still working, the transferred quota in place, its assigned static IPv4 taken and reachable over its published SSH port; an expired account refused on the sending side with nothing created; and a failed import rolled back to no account, no container and no files. Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
807 lines
25 KiB
Go
807 lines
25 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/hex"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
"vpsmgr/internal/cfg"
|
|
"vpsmgr/internal/db"
|
|
"vpsmgr/internal/mgr"
|
|
)
|
|
|
|
// cmdTransfer moves one container's root disk from this host to another vpsmgr
|
|
// host (or receives one). It is a root-only command with no panel entry: it
|
|
// publishes a container's disk on the network, which is not something a panel
|
|
// request should ever be able to trigger.
|
|
//
|
|
// send <user> export the stopped container, serve it, and print the
|
|
// command the other host runs
|
|
// receive <url> <user>
|
|
// fetch the archive, verify it, and replace that user's
|
|
// container with it
|
|
//
|
|
// Only the disk travels. Domains, SSH keys, quotas, expiry and history stay on
|
|
// the source host; the receiving host re-homes the container onto its own
|
|
// network and quota.
|
|
func cmdTransfer(args []string) error {
|
|
if len(args) < 1 {
|
|
return errTransferUsage
|
|
}
|
|
switch args[0] {
|
|
case "send":
|
|
return transferSend(args[1:])
|
|
case "receive":
|
|
return transferReceive(args[1:])
|
|
default:
|
|
return errTransferUsage
|
|
}
|
|
}
|
|
|
|
var errTransferUsage = errors.New(`usage:
|
|
vps transfer send <user> [--optimized] [--compression none|gzip|zstd] [--idle 5m] [--port N]
|
|
vps transfer receive <url> <user>
|
|
|
|
The container must be stopped before a send, and is left stopped afterwards.
|
|
Receiving creates the account, so <user> must not exist on the receiving host
|
|
yet. Both halves must run as root, and the archive is written to (and read
|
|
from) the directory the command is started in.`)
|
|
|
|
// transferActor is what the audit log records as the operator.
|
|
func transferActor() string {
|
|
if u := os.Getenv("SUDO_USER"); u != "" {
|
|
return u
|
|
}
|
|
return "root"
|
|
}
|
|
|
|
// requireRoot guards the transfer commands. Nothing else in this CLI checks:
|
|
// those commands fail naturally when they cannot read /etc/vpsmgr. A transfer
|
|
// must not, because its job is to publish a container's disk on the network and
|
|
// to write the archive next to the operator's shell.
|
|
func requireRoot() error {
|
|
if os.Geteuid() != 0 {
|
|
return errors.New("must run as root")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func transferManager() (*cfg.Config, *mgr.Manager, func(), error) {
|
|
c, err := cfg.Load()
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
d, err := db.Open(c.Panel.DB)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
return c, mgr.New(c, d), func() { d.Close() }, nil
|
|
}
|
|
|
|
func transferSend(args []string) error {
|
|
if len(args) < 1 {
|
|
return errTransferUsage
|
|
}
|
|
name := strings.ToLower(args[0])
|
|
fs := flag.NewFlagSet("transfer send", flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard)
|
|
var optimized bool
|
|
var compression string
|
|
var idle time.Duration
|
|
var port int
|
|
fs.BoolVar(&optimized, "optimized", false, "")
|
|
fs.StringVar(&compression, "compression", "gzip", "")
|
|
fs.DurationVar(&idle, "idle", 5*time.Minute, "")
|
|
fs.IntVar(&port, "port", 0, "")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return err
|
|
}
|
|
if err := requireRoot(); err != nil {
|
|
return err
|
|
}
|
|
if err := mgr.ValidateExistingName(name); err != nil {
|
|
return err
|
|
}
|
|
ext, ok := map[string]string{"none": "tar", "gzip": "tar.gz", "zstd": "tar.zst"}[compression]
|
|
if !ok {
|
|
return fmt.Errorf("unknown compression %q (none, gzip or zstd)", compression)
|
|
}
|
|
if optimized {
|
|
fmt.Println("note: --optimized stores a storage-driver native stream; the other host can")
|
|
fmt.Println(" only restore it into a pool using the same driver (zfs with zfs).")
|
|
}
|
|
|
|
c, m, closeDB, err := transferManager()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer closeDB()
|
|
|
|
// Handle the signals from the very start, not just while the listener is
|
|
// up: the export is the longest part of a send, and an operator who
|
|
// interrupts it must still get their working directory back clean. The
|
|
// context is what unwinds the in-flight Incus request.
|
|
ctx, cancel := interruptible()
|
|
defer cancel()
|
|
|
|
// The archive lands in the directory the operator started the command in,
|
|
// so it is written where they can see and account for it — never /tmp,
|
|
// which is tmpfs on most hosts and would spend RAM on it.
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sweepTransferArchives(dir)
|
|
estimate, err := m.TransferEstimate(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
free, err := freeBytes(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if estimate > free {
|
|
return fmt.Errorf("%s has %s free but the container holds about %s — free some space, or run the command from a larger filesystem",
|
|
dir, mgr.HumanBytes(free), mgr.HumanBytes(estimate))
|
|
}
|
|
|
|
path, err := transferArchivePath(dir, ext)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// The archive is a copy of a user's whole disk: root-only while it exists,
|
|
// and removed on every exit path.
|
|
defer func() {
|
|
f.Close()
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
fmt.Printf(" ! warn: could not remove %s: %v\n", path, err)
|
|
}
|
|
}()
|
|
|
|
fmt.Printf("exporting %s (up to %s)…\n", path, mgr.HumanBytes(estimate))
|
|
counter := &countingWriter{w: f}
|
|
stop := printTicker(time.Second, func() {
|
|
fmt.Printf("\r\033[K exporting… %s", mgr.HumanBytes(counter.n.Load()))
|
|
})
|
|
// Refuse to write past the space that was free when the check ran, so a
|
|
// mis-estimate cannot fill the filesystem.
|
|
guard := &spaceGuard{limit: free - free/20}
|
|
manifest, err := m.TransferExport(ctx, name, io.MultiWriter(counter, guard),
|
|
mgr.TransferOptions{Optimized: optimized, Compression: compression, Actor: transferActor()})
|
|
stop()
|
|
fmt.Print("\r\033[K")
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return errors.New("interrupted — no archive was left behind")
|
|
}
|
|
return err
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
return err
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("exported %s in %s\n", mgr.HumanBytes(manifest.Bytes), manifest.Elapsed.Round(time.Second))
|
|
|
|
// The account metadata goes next to the archive and is served from the same
|
|
// listener, so the receiving host can read it — and check it has room —
|
|
// before pulling the disk down.
|
|
metaPath, err := transferArchivePath(dir, "json")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if err := os.Remove(metaPath); err != nil && !os.IsNotExist(err) {
|
|
fmt.Printf(" ! warn: could not remove %s: %v\n", metaPath, err)
|
|
}
|
|
}()
|
|
if err := os.WriteFile(metaPath, manifest.Meta, 0o600); err != nil {
|
|
return err
|
|
}
|
|
|
|
host := c.DisplayIP()
|
|
srv, err := mgr.NewTransferServer(mgr.TransferFiles{
|
|
Archive: path,
|
|
ArchiveSHA: manifest.SHA256,
|
|
Meta: metaPath,
|
|
MetaSHA: manifest.MetaSHA,
|
|
}, host, port)
|
|
if err != nil {
|
|
return fmt.Errorf("%w (check panel.public_ip — it is the address the other host will dial)", err)
|
|
}
|
|
defer srv.Close()
|
|
|
|
acct, _, _ := mgr.TransferMetaAccount(manifest.Meta)
|
|
fmt.Printf("sha256 %s\n", manifest.SHA256)
|
|
fmt.Printf("the container stays stopped here; start it again whenever you like\n\n")
|
|
fmt.Printf("on the other machine, run:\n\n")
|
|
fmt.Printf(" vps transfer receive '%s' <user>\n\n", srv.URL())
|
|
fmt.Printf("(<user> is the name to create there; this host's %q can only be reused once its own\n", name)
|
|
fmt.Printf(" account is gone from that machine.)\n")
|
|
fmt.Printf("the account travels with it — %s, its ssh keys, notes and init script — so nothing\n",
|
|
mgr.MachineSpecs(acct.CPU, acct.MemMB, acct.DiskGB))
|
|
fmt.Printf("has to be retyped there. The disk needs about %s of room.\n", mgr.HumanBytes(manifest.DiskUsed))
|
|
fmt.Printf("listening on port %d — the archive is deleted when this command exits.\n", srv.Port())
|
|
fmt.Printf("it stops on its own after %s with nobody connected.\n", idle)
|
|
|
|
served := make(chan error, 1)
|
|
go func() { served <- srv.Serve(idle, transferTick(idle)) }()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
fmt.Print("\r\033[K")
|
|
fmt.Println("interrupted — closing the listener and removing the archive")
|
|
srv.Close()
|
|
return nil
|
|
case err := <-served:
|
|
fmt.Print("\r\033[K")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("nobody connected for %s — closing the listener and removing the archive\n", idle)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// interruptible returns a context that is cancelled by SIGINT, SIGTERM or
|
|
// SIGHUP (the last one is what an SSH session that goes away sends). Both
|
|
// halves of a transfer run under it so that interrupting the command unwinds
|
|
// the request in flight and lets the deferred cleanup run — leaving no archive,
|
|
// no half-written download and no stray listener behind.
|
|
func interruptible() (context.Context, func()) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
sigs := make(chan os.Signal, 1)
|
|
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
|
|
go func() {
|
|
select {
|
|
case <-sigs:
|
|
cancel()
|
|
case <-ctx.Done():
|
|
}
|
|
signal.Stop(sigs)
|
|
}()
|
|
return ctx, cancel
|
|
}
|
|
|
|
// transferTick prints one line a second while the listener waits. The output is
|
|
// not just cosmetic: it is what keeps an idle SSH session (or a NAT mapping)
|
|
// from being dropped during a long transfer.
|
|
func transferTick(idle time.Duration) func(mgr.TransferStats) {
|
|
return func(st mgr.TransferStats) {
|
|
if st.Active == 0 {
|
|
fmt.Printf("\r\033[K waiting for the other host… stops in %s", st.IdleLeft.Round(time.Second))
|
|
return
|
|
}
|
|
fmt.Printf("\r\033[K connected %s — %s sent, %s/s",
|
|
st.Connected.Round(time.Second), mgr.HumanBytes(st.Served), mgr.HumanBytes(st.BytesPerSecond))
|
|
}
|
|
}
|
|
|
|
func transferReceive(args []string) error {
|
|
if len(args) < 2 {
|
|
return errTransferUsage
|
|
}
|
|
rawURL := args[0]
|
|
name := strings.ToLower(args[1])
|
|
fs := flag.NewFlagSet("transfer receive", flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard)
|
|
if err := fs.Parse(args[2:]); err != nil {
|
|
return err
|
|
}
|
|
if err := requireRoot(); err != nil {
|
|
return err
|
|
}
|
|
target, metaURL, wantSHA, wantMeta, pin, err := parseTransferURL(rawURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, m, closeDB, err := transferManager()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer closeDB()
|
|
|
|
ctx, cancel := interruptible()
|
|
defer cancel()
|
|
|
|
// Fail on a bad target now rather than after a long download.
|
|
if err := m.TransferTarget(name); err != nil {
|
|
return err
|
|
}
|
|
|
|
// The account metadata is tiny and comes first: it says what will be
|
|
// created and how much room the disk needs, so a host that cannot take the
|
|
// container can say so before pulling gigabytes through the wire.
|
|
fmt.Printf("fetching from %s\n", target)
|
|
meta, err := fetchMeta(ctx, metaURL, pin, wantMeta)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := m.TransferSpaceCheck(meta); err != nil {
|
|
return err
|
|
}
|
|
acct, from, err := mgr.TransferMetaAccount(meta)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("this will create %s as %s, replacing its IP, ports and IPv6 with this host's own\n",
|
|
name, mgr.MachineSpecs(acct.CPU, acct.MemMB, acct.DiskGB))
|
|
if from != "" && from != name {
|
|
fmt.Printf("(it was %q on the sending host)\n", from)
|
|
}
|
|
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sweepTransferArchives(dir)
|
|
path, err := transferArchivePath(dir, "part")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_ = os.Remove(path)
|
|
defer func() {
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
fmt.Printf(" ! warn: could not remove %s: %v\n", path, err)
|
|
}
|
|
}()
|
|
|
|
fmt.Printf("fetching from %s\n", target)
|
|
if err := fetchArchive(ctx, target, path, pin, wantSHA); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("checksum verified")
|
|
|
|
archive, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("importing into %s (this creates the account)…\n", name)
|
|
res, err := m.TransferImport(ctx, name, archive, meta, mgr.TransferOptions{Actor: transferActor()})
|
|
archive.Close()
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return fmt.Errorf("interrupted — %s was removed again; nothing was left behind", name)
|
|
}
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("\n%s is up after %s, with %s of data from the other host\n",
|
|
res.User, res.Elapsed.Round(time.Second), mgr.HumanBytes(res.BytesRead))
|
|
fmt.Printf(" ip: %s\n", res.IP)
|
|
fmt.Printf(" ssh: ssh -p %d root@%s\n", res.SSHPort, res.IP)
|
|
fmt.Printf(" ports: %s\n", res.Ports)
|
|
fmt.Printf(" root pw: unchanged (it came with the disk)\n")
|
|
if res.PanelPass != "" {
|
|
fmt.Printf(" panel pw: %s (shown once)\n", res.PanelPass)
|
|
}
|
|
fmt.Printf("\nthe account came across with its quota, keys, notes and init script; what did not\n")
|
|
fmt.Printf("are the source host's domains and snapshots. Point the DNS here and add the domains\n")
|
|
fmt.Printf("before retiring the other machine.\n")
|
|
return nil
|
|
}
|
|
|
|
// parseTransferURL splits the receive URL into what to fetch and what to verify
|
|
// against. The digests travel in the fragment, which HTTP clients never send to
|
|
// the server: the certificate pin and both checksums stay on the operator's
|
|
// command line, and the listener never learns them.
|
|
func parseTransferURL(raw string) (target, metaURL, wantSHA, wantMeta, pin string, err error) {
|
|
u, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return "", "", "", "", "", err
|
|
}
|
|
if u.Scheme != "https" {
|
|
return "", "", "", "", "", fmt.Errorf("refusing %q: a transfer must be https", raw)
|
|
}
|
|
if u.Host == "" || !strings.HasPrefix(u.Path, "/d/") {
|
|
return "", "", "", "", "", errors.New("this is not a vps transfer URL")
|
|
}
|
|
frag, err := url.ParseQuery(u.Fragment)
|
|
if err != nil {
|
|
return "", "", "", "", "", err
|
|
}
|
|
wantSHA, wantMeta, pin = frag.Get("sha256"), frag.Get("meta"), frag.Get("cert")
|
|
if !isHexDigest(wantSHA) || !isHexDigest(wantMeta) || !isHexDigest(pin) {
|
|
return "", "", "", "", "", errors.New("the URL is missing its #sha256=…&meta=…&cert=… part — copy the whole command from the sending host")
|
|
}
|
|
u.Fragment, u.RawFragment = "", ""
|
|
// The account metadata is served from the same token on its own path.
|
|
m := *u
|
|
m.Path = strings.Replace(u.Path, "/d/", "/m/", 1)
|
|
return u.String(), m.String(), wantSHA, wantMeta, pin, nil
|
|
}
|
|
|
|
// fetchMeta downloads the small account metadata into memory and checks it
|
|
// against the digest that came with the command.
|
|
func fetchMeta(ctx context.Context, url, pin, wantSHA string) ([]byte, error) {
|
|
client := &http.Client{Transport: &http.Transport{
|
|
DialContext: (&net.Dialer{Timeout: 30 * time.Second}).DialContext,
|
|
TLSHandshakeTimeout: 30 * time.Second,
|
|
ResponseHeaderTimeout: 60 * time.Second,
|
|
TLSClientConfig: &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
VerifyPeerCertificate: pinVerifier(pin),
|
|
},
|
|
}}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("the sending host answered %s for the account details", resp.Status)
|
|
}
|
|
// The metadata is small by construction (a quota, some keys, notes); a cap
|
|
// keeps a wrong URL from buffering something enormous.
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxTransferMetaBytes+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(body) > maxTransferMetaBytes {
|
|
return nil, errors.New("the account details are implausibly large")
|
|
}
|
|
sum := sha256.Sum256(body)
|
|
if got := hex.EncodeToString(sum[:]); got != wantSHA {
|
|
return nil, fmt.Errorf("the account details did not survive the trip (got %s, expected %s)", got, wantSHA)
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// maxTransferMetaBytes bounds the account metadata. The items it carries are
|
|
// each capped by the panel (init script, sticky notes), so this is generous
|
|
// headroom rather than a limit anyone should meet.
|
|
const maxTransferMetaBytes = 1 << 20
|
|
|
|
func isHexDigest(s string) bool {
|
|
if len(s) != sha256.Size*2 {
|
|
return false
|
|
}
|
|
_, err := hex.DecodeString(s)
|
|
return err == nil
|
|
}
|
|
|
|
// fetchArchive downloads target into path, resuming from a partial file left by
|
|
// an earlier attempt, and finally checks the whole file against wantSHA.
|
|
//
|
|
// The peer's certificate is checked against pin. InsecureSkipVerify only turns
|
|
// off the chain check — a self-signed peer cannot satisfy one — and
|
|
// VerifyPeerCertificate puts a stronger check in its place: an exact match on
|
|
// the certificate whose fingerprint came with the command.
|
|
func fetchArchive(ctx context.Context, target, path, pin, wantSHA string) error {
|
|
// Only the connection setup is bounded: the body of a multi-gigabyte
|
|
// transfer legitimately takes as long as it takes, and a stalled one is
|
|
// caught by the retry loop instead of a total deadline.
|
|
dialer := &net.Dialer{Timeout: 30 * time.Second}
|
|
client := &http.Client{Transport: &http.Transport{
|
|
DialContext: dialer.DialContext,
|
|
TLSHandshakeTimeout: 30 * time.Second,
|
|
ResponseHeaderTimeout: 60 * time.Second,
|
|
TLSClientConfig: &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
VerifyPeerCertificate: pinVerifier(pin),
|
|
},
|
|
}}
|
|
// Read-write, not write-only: the finished file is read back to checksum it.
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
var written, total atomic.Int64
|
|
if st, err := f.Stat(); err == nil {
|
|
written.Store(st.Size())
|
|
}
|
|
rate := &rateMeter{}
|
|
stop := printTicker(time.Second, func() {
|
|
progress := ""
|
|
if t := total.Load(); t > 0 {
|
|
progress = fmt.Sprintf(" (%d%%)", written.Load()*100/t)
|
|
}
|
|
fmt.Printf("\r\033[K downloading%s — %s at %s/s",
|
|
progress, mgr.HumanBytes(written.Load()), mgr.HumanBytes(rate.rate(written.Load())))
|
|
})
|
|
|
|
var have int64
|
|
if st, err := f.Stat(); err == nil {
|
|
have = st.Size()
|
|
}
|
|
var lastErr error
|
|
for attempt := 1; attempt <= transferMaxAttempts; attempt++ {
|
|
done, err := fetchOnce(ctx, client, target, f, have, &written, &total)
|
|
if err == nil {
|
|
lastErr = nil
|
|
break
|
|
}
|
|
lastErr = err
|
|
if done || ctx.Err() != nil {
|
|
break
|
|
}
|
|
delay := transferRetryDelay(attempt)
|
|
fmt.Printf("\r\033[K interrupted (%v) — retrying in %s\n", err, delay)
|
|
select {
|
|
case <-time.After(delay):
|
|
case <-ctx.Done():
|
|
}
|
|
if st, statErr := f.Stat(); statErr == nil {
|
|
have = st.Size()
|
|
}
|
|
}
|
|
stop()
|
|
fmt.Print("\r\033[K")
|
|
if lastErr != nil {
|
|
return lastErr
|
|
}
|
|
|
|
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
|
return err
|
|
}
|
|
sum := sha256.New()
|
|
if _, err := io.Copy(sum, f); err != nil {
|
|
return err
|
|
}
|
|
if got := hex.EncodeToString(sum.Sum(nil)); got != wantSHA {
|
|
return fmt.Errorf("checksum mismatch: the archive did not survive the trip (got %s, expected %s)", got, wantSHA)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// transferMaxAttempts bounds the download retries. A transfer can be resumed
|
|
// where it stopped, so a flaky link only costs the bytes in flight.
|
|
const transferMaxAttempts = 10
|
|
|
|
func transferRetryDelay(attempt int) time.Duration {
|
|
d := time.Duration(attempt) * 2 * time.Second
|
|
if d > 30*time.Second {
|
|
d = 30 * time.Second
|
|
}
|
|
return d
|
|
}
|
|
|
|
// fetchOnce performs one download attempt, appending to f from offset have. It
|
|
// reports done=true when the failure is not worth retrying (a rejected pin, a
|
|
// 4xx, a range the server will not satisfy).
|
|
func fetchOnce(ctx context.Context, client *http.Client, target string, f *os.File, have int64, written, total *atomic.Int64) (bool, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
|
if err != nil {
|
|
return true, err
|
|
}
|
|
if have > 0 {
|
|
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", have))
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
// A rejected certificate will be rejected every time: retrying only
|
|
// delays telling the operator they are talking to the wrong host.
|
|
if errors.Is(err, errPinMismatch) || ctx.Err() != nil {
|
|
return true, err
|
|
}
|
|
return false, err
|
|
}
|
|
defer resp.Body.Close()
|
|
switch resp.StatusCode {
|
|
case http.StatusOK:
|
|
// The server sent the whole file: whatever we had is void.
|
|
if have > 0 {
|
|
if err := f.Truncate(0); err != nil {
|
|
return true, err
|
|
}
|
|
written.Store(0)
|
|
have = 0
|
|
}
|
|
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
|
return true, err
|
|
}
|
|
case http.StatusPartialContent:
|
|
if _, err := f.Seek(have, io.SeekStart); err != nil {
|
|
return true, err
|
|
}
|
|
case http.StatusRequestedRangeNotSatisfiable:
|
|
// The file is already complete.
|
|
return true, nil
|
|
default:
|
|
return true, fmt.Errorf("the sending host answered %s", resp.Status)
|
|
}
|
|
if resp.ContentLength > 0 {
|
|
total.Store(have + resp.ContentLength)
|
|
}
|
|
if _, err := io.Copy(io.MultiWriter(f, &counterSink{n: written}), resp.Body); err != nil {
|
|
return ctx.Err() != nil, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// errPinMismatch marks a certificate that is not the one the receive command
|
|
// pinned. It is a sentinel because the failure is permanent: the download loop
|
|
// must report it instead of retrying it.
|
|
var errPinMismatch = errors.New("certificate pin mismatch")
|
|
|
|
// pinVerifier checks the peer's leaf certificate against the expected SHA-256.
|
|
// A self-signed peer cannot be validated by a chain, so the pin is the whole
|
|
// trust decision — and a mismatch has to abort before any bytes are read.
|
|
func pinVerifier(pin string) func([][]byte, [][]*x509.Certificate) error {
|
|
return func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
|
if len(rawCerts) == 0 {
|
|
return fmt.Errorf("%w: the peer presented no certificate", errPinMismatch)
|
|
}
|
|
sum := sha256.Sum256(rawCerts[0])
|
|
if hex.EncodeToString(sum[:]) != pin {
|
|
return fmt.Errorf("%w: the host at the other end is not the one that printed this command", errPinMismatch)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// transferArchivePath names the archive after the owning process and a random
|
|
// UUID. The PID is what lets a later run recognise an archive whose owner was
|
|
// killed outright — SIGKILL, or the machine going down — because that is the
|
|
// one exit no cleanup code can run on. The UUID keeps two transfers started in
|
|
// one directory apart.
|
|
func transferArchivePath(dir, ext string) (string, error) {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return "", err
|
|
}
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
|
return fmt.Sprintf("%s/vps-transfer-%d-%x-%x-%x-%x-%x.%s",
|
|
dir, os.Getpid(), b[0:4], b[4:6], b[6:8], b[8:10], b[10:16], ext), nil
|
|
}
|
|
|
|
// sweepTransferArchives removes transfer files that a previous run left in dir
|
|
// after being killed without a chance to clean up. The archive is a copy of a
|
|
// user's whole disk, so leaving one lying around is not acceptable.
|
|
//
|
|
// A file whose PID still belongs to a running process is somebody's live
|
|
// transfer and is left alone. If a PID has been reused by an unrelated process
|
|
// the file is kept too — the sweep errs towards keeping, never towards deleting
|
|
// something in use.
|
|
func sweepTransferArchives(dir string) {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, e := range entries {
|
|
rest, ok := strings.CutPrefix(e.Name(), "vps-transfer-")
|
|
if !ok {
|
|
continue
|
|
}
|
|
dash := strings.IndexByte(rest, '-')
|
|
if dash <= 0 {
|
|
continue
|
|
}
|
|
pid, err := strconv.Atoi(rest[:dash])
|
|
if err != nil || processAlive(pid) {
|
|
continue
|
|
}
|
|
path := filepath.Join(dir, e.Name())
|
|
if err := os.Remove(path); err == nil {
|
|
fmt.Printf("removed %s — left behind by a transfer that was killed\n", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// processAlive reports whether pid names a process this host can signal. EPERM
|
|
// means it exists but belongs to someone else, which still counts as alive.
|
|
func processAlive(pid int) bool {
|
|
if pid <= 0 {
|
|
return false
|
|
}
|
|
err := syscall.Kill(pid, 0)
|
|
return err == nil || errors.Is(err, syscall.EPERM)
|
|
}
|
|
|
|
// freeBytes reports the space available in dir.
|
|
func freeBytes(dir string) (int64, error) {
|
|
var st syscall.Statfs_t
|
|
if err := syscall.Statfs(dir, &st); err != nil {
|
|
return 0, err
|
|
}
|
|
return int64(st.Bavail) * int64(st.Bsize), nil
|
|
}
|
|
|
|
// countingWriter counts the bytes written through it.
|
|
type countingWriter struct {
|
|
w io.Writer
|
|
n atomic.Int64
|
|
}
|
|
|
|
func (c *countingWriter) Write(p []byte) (int, error) {
|
|
n, err := c.w.Write(p)
|
|
c.n.Add(int64(n))
|
|
return n, err
|
|
}
|
|
|
|
// counterSink adds the bytes copied through it to a shared counter.
|
|
type counterSink struct{ n *atomic.Int64 }
|
|
|
|
func (c *counterSink) Write(p []byte) (int, error) {
|
|
c.n.Add(int64(len(p)))
|
|
return len(p), nil
|
|
}
|
|
|
|
// spaceGuard fails the export if it would write past the space that was free
|
|
// when the check ran, so a mis-estimate can never fill the filesystem.
|
|
type spaceGuard struct {
|
|
limit int64
|
|
seen int64
|
|
}
|
|
|
|
func (g *spaceGuard) Write(p []byte) (int, error) {
|
|
g.seen += int64(len(p))
|
|
if g.seen > g.limit {
|
|
return 0, errors.New("the export ran past the free space that was available when it started")
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
// rateMeter turns a growing byte counter into a per-second rate. Only the
|
|
// ticker goroutine calls rate, so it needs no locking.
|
|
type rateMeter struct {
|
|
at time.Time
|
|
val int64
|
|
}
|
|
|
|
func (m *rateMeter) rate(cur int64) int64 {
|
|
now := time.Now()
|
|
var perSecond int64
|
|
if !m.at.IsZero() {
|
|
if secs := now.Sub(m.at).Seconds(); secs > 0 {
|
|
perSecond = int64(float64(cur-m.val) / secs)
|
|
}
|
|
}
|
|
m.at, m.val = now, cur
|
|
if perSecond < 0 {
|
|
return 0
|
|
}
|
|
return perSecond
|
|
}
|
|
|
|
// printTicker runs fn every d until the returned stop function is called.
|
|
func printTicker(d time.Duration, fn func()) func() {
|
|
done := make(chan struct{})
|
|
var once sync.Once
|
|
go func() {
|
|
t := time.NewTicker(d)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-t.C:
|
|
fn()
|
|
}
|
|
}
|
|
}()
|
|
return func() { once.Do(func() { close(done) }) }
|
|
}
|