From 26ef538cccf2f5639d76954194aabec786dfbd9b Mon Sep 17 00:00:00 2001 From: bluebluesoda <300733005+bluebluesoda@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:49:14 +0000 Subject: [PATCH] feat(transfer): carry the account with the disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/internal/lx/backup.go | 57 ++++-- src/internal/mgr/mgr.go | 10 +- src/internal/mgr/transfer.go | 349 ++++++++++++++++++++++++-------- src/internal/mgr/transfersrv.go | 90 ++++---- src/main_transfer.go | 192 +++++++++++------- src/main_transfer_test.go | 242 ++++++++++++++++------ 6 files changed, 669 insertions(+), 271 deletions(-) diff --git a/src/internal/lx/backup.go b/src/internal/lx/backup.go index 42608f5..fd0c23a 100644 --- a/src/internal/lx/backup.go +++ b/src/internal/lx/backup.go @@ -3,6 +3,7 @@ package lx import ( "bytes" "context" + "crypto/rand" "encoding/json" "fmt" "io" @@ -93,8 +94,8 @@ func (c *Client) BackupExport(ctx context.Context, name string, opt BackupOption } // BackupImport creates the container from a backup tarball read from r. Incus -// takes the raw archive as the request body and the target name and pool from -// headers. +// takes the raw archive as the request body; the target name, pool, devices and +// any extra configuration come from headers. // // devices, when set, is merged into the archive's device map before the // instance is created. That override is not optional in practice: an archive @@ -105,9 +106,17 @@ func (c *Client) BackupExport(ctx context.Context, name string, opt BackupOption // device the archive has and this host does not want, which is why a transfer // expects both hosts to use the same IPv6 mode. // +// extraConfig is merged into the instance's own configuration at the same +// moment, and is where the fresh hardware addresses go. They have to be decided +// here rather than afterwards: Incus writes the DHCP reservation that hands a +// container its static IPv4 when the instance is created, keyed to the address +// it has then, and a later change to the address does not move the reservation +// with it — the container is then given a dynamic address and its port +// forwarding points at nothing. +// // Cancelling ctx aborts the upload, and the daemon discards the partial // instance rather than leaving one behind. -func (c *Client) BackupImport(ctx context.Context, name, pool string, devices map[string]Device, r io.Reader) error { +func (c *Client) BackupImport(ctx context.Context, name, pool string, devices map[string]Device, extraConfig map[string]string, r io.Reader) error { req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/1.0/instances", r) if err != nil { return err @@ -120,6 +129,9 @@ func (c *Client) BackupImport(ctx context.Context, name, pool string, devices ma if len(devices) > 0 { req.Header.Set("X-Incus-devices", deviceHeader(devices)) } + if len(extraConfig) > 0 { + req.Header.Set("X-Incus-config", configHeader(extraConfig)) + } resp, err := c.stream.Do(req) if err != nil { return err @@ -162,7 +174,7 @@ func (c *Client) ReplaceConfig(name string, config map[string]string, devices ma merged[k] = v } for k, v := range cur.Config { - if strings.HasPrefix(k, "volatile.") && !perInstanceVolatile(k) { + if strings.HasPrefix(k, "volatile.") { merged[k] = v } } @@ -179,16 +191,35 @@ func (c *Client) ReplaceConfig(name string, config map[string]string, devices ma return c.sendOp(http.MethodPut, "/1.0/instances/"+url.PathEscape(name), body, 2*time.Minute) } -// perInstanceVolatile reports whether a volatile.* key describes the instance -// an archive was taken from rather than the disk it holds: the hardware address -// its bridge leased it, and the name of the veth pair on the host it ran on. +// configHeader renders the X-Incus-config value: space-separated "key=value" +// entries, in a stable order so a failure is reproducible. +func configHeader(config map[string]string) string { + keys := make([]string, 0, len(config)) + for k := range config { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+"="+config[k]) + } + return strings.Join(parts, " ") +} + +// RandomMAC returns a fresh locally-administered unicast MAC address, in the +// form Incus stores in volatile..hwaddr. // -// Both belong to the machine the disk came from. Carried over, the new -// container claims an address that may already be in use here — importing the -// same archive twice fails outright with "MAC address ... already defined on -// another NIC" — so they are dropped and Incus issues fresh ones. -func perInstanceVolatile(key string) bool { - return strings.HasSuffix(key, ".hwaddr") || strings.HasSuffix(key, ".host_name") +// A re-homed container needs one of its own: the archive carries the address +// the container had on the host it came from, and importing the same archive +// twice into one host would otherwise fail with "MAC address ... already +// defined on another NIC". +func RandomMAC() (string, error) { + var b [6]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + b[0] = (b[0] | 0x02) & 0xfe // locally administered, unicast + return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", b[0], b[1], b[2], b[3], b[4], b[5]), nil } // DiskUsage returns the number of bytes the container's root volume currently diff --git a/src/internal/mgr/mgr.go b/src/internal/mgr/mgr.go index 8b1dacd..097a8cc 100644 --- a/src/internal/mgr/mgr.go +++ b/src/internal/mgr/mgr.go @@ -262,8 +262,12 @@ func (m *Manager) imageName() (string, error) { // rootPassScript sets the container root password via chpasswd. The password // is always generated from [a-zA-Z0-9] (pw.Generate) — no user-supplied value // ever reaches this string, so the single-quoted interpolation cannot break -// out of the shell command. +// out of the shell command. An empty password produces nothing, which leaves +// the stored one in place. func rootPassScript(pass string) string { + if pass == "" { + return "" + } return fmt.Sprintf("printf 'root:%s\\n' | chpasswd\n", pass) } @@ -283,6 +287,10 @@ func randomHostname() string { // the container a random hostname (never the username). A prebuilt managed // image (any `vpsmgr/*` alias, Debian or RHEL-family) only needs a light setup; // otherwise sshd is installed on the fly (Debian fallback only). +// +// An empty pass leaves the root password alone, which is what an imported +// container wants: its password is on the disk it brought with it and its owner +// already knows it. func (m *Manager) Provision(name, image, pass string) error { host := randomHostname() // hostSetup: apply the hostname live + persist it, and stop cloud-init diff --git a/src/internal/mgr/transfer.go b/src/internal/mgr/transfer.go index 3dee74d..f6ea931 100644 --- a/src/internal/mgr/transfer.go +++ b/src/internal/mgr/transfer.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "io" "strconv" @@ -13,27 +14,23 @@ import ( "vpsmgr/internal/cfg" "vpsmgr/internal/db" "vpsmgr/internal/lx" - "vpsmgr/internal/pw" ) // cross-machine transfer // // `vps transfer` moves one container's root disk between two vpsmgr hosts that // can reach each other over HTTPS but share nothing else. The source host -// exports the stopped container to a single backup tarball, serves it from a -// temporary listener, and the receiving host imports it over that listener. +// exports the stopped container to a backup tarball and serves it, together +// with a small JSON description of the account, from a temporary listener; the +// receiving host imports the disk over that listener and recreates the account. // -// Receiving is creating an account: the target name must be free, and the -// import builds it the way `vps add` would — same allocation code, same -// firewall rules, same panel row — then replaces the disk of the container it -// just created with the transferred one. Nothing that already exists on the -// receiving host is touched, so a failure anywhere is undone by deleting what -// was created, and the machine is exactly as it was. -// -// Domains, SSH keys, sticky notes and bandwidth history stay behind on the -// source: they are per-host configuration owned by that host's panel DB, and -// the two hosts deliberately keep separate sets (their admin keys are not the -// same). The account's quota does travel — the receive command carries it. +// The point is that the owner should notice nothing but their address: the +// quota, the SSH keys, the sticky notes and the init script all travel in that +// JSON, so none of it has to be retyped on a command line or remembered. What +// does NOT travel is everything that belongs to the source as a machine — +// domains, admin key grants, bandwidth history, snapshots (which stay on the +// host that took them) — and the receiving host's own IP, port block and IPv6 +// assignment, which stay randomly allocated as always. // TransferOptions carries the operator's choices for one transfer. type TransferOptions struct { @@ -46,19 +43,52 @@ type TransferOptions struct { Compression string // Actor is recorded in the audit log (the operator running the command). Actor string - // Account is how the receiving host sizes the account it creates. - Account AddOptions } -// TransferManifest describes the artefact an export produced, and the account -// settings the receiving host should recreate, so the printed receive command -// carries both. +// TransferMeta is the account's state as it travels with the archive: what the +// receiving host needs in order to put the account back the way it was, and +// how much room the disk it is about to receive needs. +type TransferMeta struct { + User string `json:"user"` + CPU int `json:"cpu"` + MemMB int `json:"mem_mb"` + DiskGB int `json:"disk_gb"` + BandwidthGB int `json:"bandwidth_gb"` + ExpiresAt string `json:"expires_at,omitempty"` + DiskUsed int64 `json:"disk_used"` + InitScript string `json:"init_script,omitempty"` + SSHKeys []MetaKey `json:"ssh_keys,omitempty"` + StickyNotes string `json:"sticky_notes,omitempty"` +} + +// MetaKey is one of the user's public keys, as the panel stores it. +type MetaKey struct { + Name string `json:"name"` + Key string `json:"key"` + Active bool `json:"active"` +} + +// Account is the meta as the flags `vps add` takes. The deadline is not part of +// it: it is an absolute date, applied on its own after the account exists. +func (t *TransferMeta) Account() AddOptions { + return AddOptions{ + CPU: t.CPU, + MemMB: t.MemMB, + DiskGB: t.DiskGB, + BandwidthGB: t.BandwidthGB, + } +} + +// TransferManifest describes the artefact an export produced, so the receiving +// side can verify both files it is served. type TransferManifest struct { - User string - Bytes int64 - SHA256 string - Elapsed time.Duration - Account AddOptions + User string + Bytes int64 + SHA256 string + Meta []byte + MetaSHA string + DiskUsed int64 + Elapsed time.Duration } // TransferResult describes a completed import, so the CLI can print the @@ -66,7 +96,6 @@ type TransferManifest struct { type TransferResult struct { User string PanelPass string - RootPass string IP string SSHPort int Ports string @@ -76,26 +105,31 @@ type TransferResult struct { // TransferEstimate returns the number of bytes the container's root disk // currently occupies, used to check the temp file will fit before a long export -// is started. It falls back to the user's disk quota when the daemon has no -// exact figure (a stopped container on some storage drivers reports none). +// is started, and to tell the far side how much room to find. func (m *Manager) TransferEstimate(name string) (int64, error) { u, err := m.db.GetUserByName(name) if err != nil { return 0, err } + return m.transferDiskUsed(u), nil +} + +// transferDiskUsed falls back to the user's disk quota when the daemon has no +// exact figure (a stopped container on some storage drivers reports none). +func (m *Manager) transferDiskUsed(u *db.User) int64 { if n, err := m.lx.DiskUsage(u.Name); err == nil && n > 0 { - return n, nil + return n } if all, err := m.lx.Metrics(); err == nil { if v, ok := all[u.Name]; ok && v.FilesystemSize > 0 && v.FilesystemAvail <= v.FilesystemSize { - return v.FilesystemSize - v.FilesystemAvail, nil + return v.FilesystemSize - v.FilesystemAvail } } - return int64(u.DiskGB) << 30, nil + return int64(u.DiskGB) << 30 } // TransferExport streams the user's stopped container to w and returns the -// manifest of what was written. +// manifest of what was written, including the metadata that travels with it. // // The container must already be stopped by the operator: exporting a running // container copies a live filesystem, and this command never changes a @@ -112,6 +146,18 @@ func (m *Manager) TransferExport(ctx context.Context, name string, w io.Writer, if err := m.requireStopped(u.Name); err != nil { return nil, err } + // An expired account is not migrated. Bringing one up on a new host would + // mean starting a container the panel has already locked and stopped, and + // an expiry that has already passed would be recreated as a live account. + // The operator decides: extend it here first, then move it. + if IsExpired(u.ExpiresAt, time.Now()) { + return nil, fmt.Errorf("%s expired on %s — extend it first (vps quota %s --days N), then move it", + u.Name, expiresDate(u.ExpiresAt), u.Name) + } + meta, err := m.buildTransferMeta(u) + if err != nil { + return nil, err + } started := time.Now() sum := sha256.New() @@ -124,45 +170,85 @@ func (m *Manager) TransferExport(ctx context.Context, name string, w io.Writer, if err != nil { return nil, err } + metaSHA := sha256.Sum256(meta) _ = m.db.AddAuditLog(opt.Actor, "transfer.export."+u.Name) return &TransferManifest{ - User: u.Name, - Bytes: n, - SHA256: hex.EncodeToString(sum.Sum(nil)), - Elapsed: time.Since(started), - Account: accountFor(u), + User: u.Name, + Bytes: n, + SHA256: hex.EncodeToString(sum.Sum(nil)), + Meta: meta, + MetaSHA: hex.EncodeToString(metaSHA[:]), + DiskUsed: m.transferDiskUsed(u), + Elapsed: time.Since(started), }, nil } -// accountFor is the account a transfer recreates on the far side: the quota the -// container has here, expressed as the flags `vps add` takes. -func accountFor(u *db.User) AddOptions { - opt := AddOptions{ +// buildTransferMeta collects everything about the account that lives in this +// host's panel database rather than inside the container. +func (m *Manager) buildTransferMeta(u *db.User) ([]byte, error) { + meta := TransferMeta{ + User: u.Name, CPU: u.CPU, MemMB: u.MemMB, DiskGB: u.DiskGB, BandwidthGB: u.BandwidthQuotaGB, + ExpiresAt: u.ExpiresAt, + DiskUsed: m.transferDiskUsed(u), + InitScript: u.InitScript, } - if u.ExpiresAt != "" { - if left := daysUntil(u.ExpiresAt); left > 0 { - opt.Days = left - } + keys, err := m.db.ListSSHKeys(u.ID) + if err != nil { + return nil, err } - return opt + for _, k := range keys { + meta.SSHKeys = append(meta.SSHKeys, MetaKey{Name: k.Name, Key: k.Key, Active: k.Active}) + } + notes, err := m.db.GetStickyNotes(u.ID) + if err != nil { + return nil, err + } + meta.StickyNotes = notes + return json.Marshal(meta) } -// daysUntil rounds an expiry deadline up to whole days, so a transfer never -// shortens the account it recreates. -func daysUntil(rfc3339 string) int { - t, err := time.Parse(time.RFC3339, rfc3339) +// expiresDate renders a deadline for a message, falling back to the raw value. +func expiresDate(rfc3339 string) string { + if t, err := time.Parse(time.RFC3339, rfc3339); err == nil { + return t.Format("2006-01-02 15:04 MST") + } + return rfc3339 +} + +// TransferSpaceCheck reports whether this host can take the disk described by +// meta, by the same rule that guards `vps add`: the pool must not already be +// 90% full. It is run before the archive is fetched, so a host that cannot take +// the container says so instead of spending an hour downloading it, and it uses +// the sending host's own figure for the disk rather than the archive's size, +// which says nothing about how much will be unpacked. +func (m *Manager) TransferSpaceCheck(meta []byte) error { + var t TransferMeta + if err := json.Unmarshal(meta, &t); err != nil { + return fmt.Errorf("unreadable transfer metadata: %w", err) + } + usage, err := m.PoolUsage() if err != nil { - return 0 + return err } - d := time.Until(t) - if d <= 0 { - return 0 + if usage >= 0.9 { + return fmt.Errorf("storage pool %s is %.0f%% used (>= 90%%), refusing to import %s", + m.cfg.Incus.Pool, usage*100, t.User) } - return int((d + 24*time.Hour - 1) / (24 * time.Hour)) + return nil +} + +// TransferMetaAccount parses the account settings out of the metadata, so the +// CLI can show the operator what it is about to create. +func TransferMetaAccount(meta []byte) (AddOptions, string, error) { + var t TransferMeta + if err := json.Unmarshal(meta, &t); err != nil { + return AddOptions{}, "", fmt.Errorf("unreadable transfer metadata: %w", err) + } + return t.Account(), t.User, nil } // TransferTarget checks the receiving name is usable, so the operator learns @@ -183,8 +269,8 @@ func (m *Manager) TransferTarget(name string) error { return nil } -// TransferImport creates the account from a backup tarball read from r and -// returns its connection details. +// TransferImport creates the account described by meta from a backup tarball +// read from r and returns its connection details. // // The account is created first, exactly as `vps add` would create it — so its // IP, port block, IPv6 assignment, firewall rules and panel row all come from @@ -192,16 +278,20 @@ func (m *Manager) TransferTarget(name string) error { // that produced is then replaced by the transferred one. Nothing that existed // before this call is modified, so any failure is undone by deleting what was // created. -func (m *Manager) TransferImport(ctx context.Context, name string, r io.Reader, opt TransferOptions) (*TransferResult, error) { +func (m *Manager) TransferImport(ctx context.Context, name string, r io.Reader, metaBytes []byte, opt TransferOptions) (*TransferResult, error) { // No opMu here: Add and Del take it themselves, and this runs the two of // them in sequence. if err := m.TransferTarget(name); err != nil { return nil, err } + var meta TransferMeta + if err := json.Unmarshal(metaBytes, &meta); err != nil { + return nil, fmt.Errorf("unreadable transfer metadata: %w", err) + } started := time.Now() counting := &countingReader{r: r} - created, err := m.Add(name, opt.Account) + created, err := m.Add(name, meta.Account()) if err != nil { return nil, err } @@ -217,6 +307,14 @@ func (m *Manager) TransferImport(ctx context.Context, name string, r io.Reader, return nil, err } + // The account's own data before anything expensive: it is a handful of DB + // writes, and doing it first means a problem here costs nothing. The keys + // are injected into the container further down, by the same call that keeps + // them in step on any other account. + if err := m.applyTransferMeta(u, &meta); err != nil { + return fail(err) + } + // The container Add just made has to go before the transfer's disk is // imported in its place. Deleting it is not merely tidiness: Incus serves a // container its static IPv4 from a DHCP reservation that the current lease @@ -235,7 +333,19 @@ func (m *Manager) TransferImport(ctx context.Context, name string, r io.Reader, // against this host's networks as it creates the instance, so this host's // own devices have to go in as part of the import rather than after it. spec, devices := m.instanceConfig(u) - if err := m.lx.BackupImport(ctx, u.Name, m.cfg.Incus.Pool, devices, counting); err != nil { + // ...along with fresh hardware addresses, for the same reason and one more: + // the archive also carries the source's, and importing the same archive + // twice into one host would otherwise collide ("MAC address ... already + // defined on another NIC"). They are set here because Incus writes the DHCP + // reservation that gives a container its static IPv4 when the instance is + // created, keyed to the address it has at that moment — decide it later and + // the reservation names the old one, so the container is handed a dynamic + // address while every rule and record points at the static one. + fresh, err := freshNICAddrs() + if err != nil { + return fail(err) + } + if err := m.lx.BackupImport(ctx, u.Name, m.cfg.Incus.Pool, devices, fresh, counting); err != nil { return fail(fmt.Errorf("import backup: %w", err)) } // ...and the configuration is then replaced outright, which also clears @@ -253,15 +363,19 @@ func (m *Manager) TransferImport(ctx context.Context, name string, r io.Reader, return fail(fmt.Errorf("wait for container: %w", err)) } - // The transferred disk still carries the source's root password, hostname, - // machine-id and SSH host keys. Rebuilding all of them is what makes the - // container this host's own rather than a copy of the other one: a shared - // machine-id makes dnsmasq drop DHCPv6 leases, and shared host keys would - // let two machines claim one identity to every client. - pass := pw.Generate(20) + // The transferred disk still carries the source's hostname, machine-id and + // SSH host keys. Rebuilding those is what makes the container this host's + // own rather than a copy of the other one: a shared machine-id makes + // dnsmasq drop DHCPv6 leases, and shared host keys would let two machines + // claim one identity to every client. + // + // The root password is deliberately NOT reset: it lives on the disk, the + // owner already knows it, and leaving it alone is the whole point of a + // migration that should feel like nothing happened. + // // image only picks the provisioning path; the rootfs came from another // vpsmgr host, so it is always one of the managed images. - if err := m.Provision(u.Name, m.cfg.Incus.Image, pass); err != nil { + if err := m.Provision(u.Name, m.cfg.Incus.Image, ""); err != nil { return fail(fmt.Errorf("provision container: %w", err)) } if err := m.regenerateMachineID(u.Name); err != nil { @@ -271,6 +385,7 @@ func (m *Manager) TransferImport(ctx context.Context, name string, r io.Reader, return fail(fmt.Errorf("regenerate ssh host keys: %w", err)) } // ...and the source's authorized_keys, or the source could still log in. + // The owner's own keys are written back by applyUserKeys below. m.clearAuthorizedKeys(u.Name) // The source's IPv6 stanza is still in the container's network config, and @@ -308,7 +423,6 @@ func (m *Manager) TransferImport(ctx context.Context, name string, r io.Reader, return &TransferResult{ User: u.Name, PanelPass: created.Password, - RootPass: pass, IP: u.IP, SSHPort: u.SSHPort, Ports: UserPorts(u.StartPort, cfg.PortsPerUser), @@ -317,6 +431,43 @@ func (m *Manager) TransferImport(ctx context.Context, name string, r io.Reader, }, nil } +// applyTransferMeta restores the account data that lives in the panel database: +// the owner's public keys, their notes, their init script and their deadline. +// It is done while the receiving container is still the empty one Add made, so +// a failure here is cheap — but it never runs the script: an init script +// belongs to a reinstall, and a migration must not re-run whatever it did the +// first time. +func (m *Manager) applyTransferMeta(u *db.User, meta *TransferMeta) error { + for _, k := range meta.SSHKeys { + name, key := k.Name, k.Key + if name == "" { + name = "imported" + } + if _, err := m.db.AddSSHKey(u.ID, name, key, k.Active); err != nil { + return fmt.Errorf("restore ssh key %q: %w", name, err) + } + } + if meta.StickyNotes != "" { + if err := m.db.SetStickyNotes(u.ID, meta.StickyNotes); err != nil { + return fmt.Errorf("restore sticky notes: %w", err) + } + } + if meta.InitScript != "" { + if err := m.db.UpdateInitScript(u.ID, meta.InitScript); err != nil { + return fmt.Errorf("restore init script: %w", err) + } + } + // The deadline is carried as the absolute date it was, so the account + // neither loses nor gains time by moving. An expired account never gets + // this far: the export refuses to start one. + if meta.ExpiresAt != "" { + if _, err := m.SetExpiry(u.Name, meta.ExpiresAt); err != nil { + return fmt.Errorf("restore the expiry: %w", err) + } + } + return nil +} + // stripForeignIPv6 removes the IPv6 configuration that the host this container // came from left in its networkd file. // @@ -369,26 +520,64 @@ mv "$CFG.new" "$CFG" return err } -// ensureStaticIPv4 checks that the container actually holds the IPv4 address its -// whole hosting setup is built around: the panel's records, the host's DNAT -// rules and the user's own notes all name it, so a container that ends up on -// another address is unreachable. Better to say so than to hand over a machine -// nobody can connect to. +// ensureStaticIPv4 waits for the container to take the IPv4 address its whole +// hosting setup is built around: the panel's records, the host's DNAT rules and +// the user's own notes all name it, so a container that ends up on another +// address is unreachable — better to say so than to hand over a machine nobody +// can connect to. +// +// The address is not configured at boot, it is leased: Incus hands a container +// its static IPv4 from a DHCP reservation keyed to its MAC, so it arrives a +// moment after the container is up (later than Ready means) and has to be +// waited for. If the server is still holding the lease of the container this +// one replaced, one restart is what makes it ask again. func (m *Manager) ensureStaticIPv4(u *db.User) error { if u.IP == "" { return nil } - out, err := m.lx.ExecSH(u.Name, "ip -4 -o addr show dev eth0 2>/dev/null | awk '{print $4}'") - if err != nil { - return err - } - for _, line := range strings.Split(out, "\n") { - if strings.HasPrefix(strings.TrimSpace(line), u.IP+"/") { - return nil + last := "" + for attempt := 0; attempt < 2; attempt++ { + deadline := time.Now().Add(40 * time.Second) + for { + out, err := m.lx.ExecSH(u.Name, "ip -4 -o addr show dev eth0 2>/dev/null | awk '{print $4}'") + if err != nil { + return err + } + last = strings.Join(strings.Fields(out), " ") + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), u.IP+"/") { + return nil + } + } + if time.Now().After(deadline) { + break + } + time.Sleep(3 * time.Second) + } + if attempt == 0 { + fmt.Printf(" · %s has not taken %s yet — restarting it once\n", u.Name, u.IP) + if err := m.lx.Restart(u.Name); err != nil { + return err + } } } return fmt.Errorf("the container came up on %s, not on its assigned %s — its port forwarding will not work until it takes that address", - strings.Join(strings.Fields(out), " "), u.IP) + last, u.IP) +} + +// freshNICAddrs returns a new hardware address for each NIC name a vpsmgr +// container can have. Setting one for a NIC the container turns out not to have +// is harmless: it is just an unused configuration key. +func freshNICAddrs() (map[string]string, error) { + out := map[string]string{} + for _, nic := range []string{"eth0", "eth1"} { + mac, err := lx.RandomMAC() + if err != nil { + return nil, err + } + out["volatile."+nic+".hwaddr"] = mac + } + return out, nil } // instanceConfig is the container specification this host gives the user: its diff --git a/src/internal/mgr/transfersrv.go b/src/internal/mgr/transfersrv.go index 3bd769e..03d15d1 100644 --- a/src/internal/mgr/transfersrv.go +++ b/src/internal/mgr/transfersrv.go @@ -52,12 +52,21 @@ type TransferStats struct { BytesPerSecond int64 } -// TransferServer serves a single exported tarball to whoever holds the token. +// TransferFiles are the two artefacts a send publishes: the container's disk, +// and the small JSON describing the account it belongs to. +type TransferFiles struct { + Archive string + ArchiveSHA string + Meta string + MetaSHA string +} + +// TransferServer serves one exported container to whoever holds the token. type TransferServer struct { - filePath string - token string - url string - finger string + files TransferFiles + token string + url string + finger string ln net.Listener srv *http.Server @@ -77,8 +86,11 @@ type TransferServer struct { // of its certificate. host is the address the peer should dial (the panel's // public address). // -// The caller owns filePath and is responsible for deleting it. -func NewTransferServer(filePath, host string, port int, sha string) (*TransferServer, error) { +// The two files are served from the one token, at /d/ (the disk) and /m/ (the +// account metadata, which the receiver fetches first so it can check it has +// room before pulling gigabytes). The caller owns both files and is responsible +// for deleting them. +func NewTransferServer(files TransferFiles, host string, port int) (*TransferServer, error) { if net.ParseIP(host) == nil { return nil, fmt.Errorf("cannot build a transfer address from %q", host) } @@ -103,19 +115,21 @@ func NewTransferServer(filePath, host string, port int, sha string) (*TransferSe sum := sha256.Sum256(der) finger := hex.EncodeToString(sum[:]) s := &TransferServer{ - filePath: filePath, - token: token, - // Both digests ride in the fragment: HTTP clients never send a - // fragment, so the listener is not told the checksum of what it is + files: files, + token: token, + // All three digests ride in the fragment: HTTP clients never send a + // fragment, so the listener is not told the checksums of what it is // serving, nor the fingerprint of its own certificate. - url: fmt.Sprintf("https://%s:%d/d/%s#sha256=%s&cert=%s", host, actual, token, sha, finger), + url: fmt.Sprintf("https://%s:%d/d/%s#sha256=%s&meta=%s&cert=%s", + host, actual, token, files.ArchiveSHA, files.MetaSHA, finger), finger: finger, ln: ln, conns: map[net.Conn]struct{}{}, lastProgress: time.Now(), } mux := http.NewServeMux() - mux.HandleFunc("/d/", s.handle) + mux.HandleFunc("/d/", s.handle(s.files.Archive, "/d/")) + mux.HandleFunc("/m/", s.handle(s.files.Meta, "/m/")) s.srv = &http.Server{ Handler: mux, ReadHeaderTimeout: 30 * time.Second, @@ -182,30 +196,34 @@ func (s *TransferServer) Close() error { return s.srv.Close() } -func (s *TransferServer) handle(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet && r.Method != http.MethodHead { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return +// handle serves one of the published files, only at the path its prefix names +// and only to a request carrying the token. +func (s *TransferServer) handle(path, prefix string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if subtle.ConstantTimeCompare([]byte(r.URL.Path), []byte(prefix+s.token)) != 1 { + http.NotFound(w, r) + return + } + f, err := os.Open(path) + if err != nil { + http.Error(w, "transfer file is gone", http.StatusInternalServerError) + return + } + defer f.Close() + st, err := f.Stat() + if err != nil { + http.Error(w, "transfer file is unreadable", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + // ServeContent handles HEAD, Range and If-Range from the file itself, + // which is what lets an interrupted download resume where it stopped. + http.ServeContent(w, r, "", st.ModTime(), &countingFile{f: f, s: s}) } - if subtle.ConstantTimeCompare([]byte(r.URL.Path), []byte("/d/"+s.token)) != 1 { - http.NotFound(w, r) - return - } - f, err := os.Open(s.filePath) - if err != nil { - http.Error(w, "transfer file is gone", http.StatusInternalServerError) - return - } - defer f.Close() - st, err := f.Stat() - if err != nil { - http.Error(w, "transfer file is unreadable", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/octet-stream") - // ServeContent handles HEAD, Range and If-Range from the file itself, which - // is what lets an interrupted download resume where it stopped. - http.ServeContent(w, r, "", st.ModTime(), &countingFile{f: f, s: s}) } func (s *TransferServer) connState(c net.Conn, st http.ConnState) { diff --git a/src/main_transfer.go b/src/main_transfer.go index bd62bc5..6429aa2 100644 --- a/src/main_transfer.go +++ b/src/main_transfer.go @@ -59,7 +59,7 @@ func cmdTransfer(args []string) error { var errTransferUsage = errors.New(`usage: vps transfer send [--optimized] [--compression none|gzip|zstd] [--idle 5m] [--port N] - vps transfer receive [--cpu N] [--mem N] [--disk N] [--bandwidth N] [--days N] + vps transfer receive The container must be stopped before a send, and is left stopped afterwards. Receiving creates the account, so must not exist on the receiving host @@ -207,20 +207,44 @@ func transferSend(args []string) error { } 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(path, host, port, manifest.SHA256) + 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' %s\n\n", srv.URL(), receiveFlags(manifest.Account)) + fmt.Printf(" vps transfer receive '%s' \n\n", srv.URL()) fmt.Printf("( 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 flags carry this account's quota; drop them to take the other host's defaults.\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) @@ -263,20 +287,6 @@ func interruptible() (context.Context, func()) { return ctx, cancel } -// receiveFlags renders the account settings as the flags the receive command -// takes, so the operator does not have to retype the quota from memory. -func receiveFlags(a mgr.AddOptions) string { - var b strings.Builder - fmt.Fprintf(&b, " --cpu %s --mem %d --disk %d", mgr.FormatCPU(a.CPU), a.MemMB, a.DiskGB) - if a.BandwidthGB > 0 { - fmt.Fprintf(&b, " --bandwidth %d", a.BandwidthGB) - } - if a.Days > 0 { - fmt.Fprintf(&b, " --days %d", a.Days) - } - return b.String() -} - // 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. @@ -299,55 +309,13 @@ func transferReceive(args []string) error { name := strings.ToLower(args[1]) fs := flag.NewFlagSet("transfer receive", flag.ContinueOnError) fs.SetOutput(io.Discard) - var cpuS, memS, diskS, bandwidthS string - var days int - fs.StringVar(&cpuS, "cpu", "", "") - fs.StringVar(&memS, "mem", "", "") - fs.StringVar(&diskS, "disk", "", "") - fs.StringVar(&bandwidthS, "bandwidth", "", "") - fs.IntVar(&days, "days", 0, "") if err := fs.Parse(args[2:]); err != nil { return err } if err := requireRoot(); err != nil { return err } - // The account is created the way `vps add` creates one. The sending host - // prints the flags that reproduce its own quota, so the default only shows - // up when the command is typed by hand. - account := mgr.AddOptions{CPU: 10, MemMB: 1024, DiskGB: 10} - provided := map[string]bool{} - fs.Visit(func(f *flag.Flag) { provided[f.Name] = true }) - if provided["cpu"] { - cpu, err := mgr.ParseCPU(cpuS) - if err != nil { - return err - } - account.CPU = cpu - } - if provided["mem"] { - mem, err := parseMemStrict(memS) - if err != nil { - return err - } - account.MemMB = mem - } - if provided["disk"] { - disk, err := parseDiskStrict(diskS) - if err != nil { - return err - } - account.DiskGB = disk - } - if provided["bandwidth"] { - bw, err := mgr.ParseBandwidthGB(bandwidthS) - if err != nil { - return err - } - account.BandwidthGB = bw - } - account.Days = days - target, wantSHA, pin, err := parseTransferURL(rawURL) + target, metaURL, wantSHA, wantMeta, pin, err := parseTransferURL(rawURL) if err != nil { return err } @@ -366,6 +334,27 @@ func transferReceive(args []string) error { 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 @@ -393,7 +382,7 @@ func transferReceive(args []string) error { return err } fmt.Printf("importing into %s (this creates the account)…\n", name) - res, err := m.TransferImport(ctx, name, archive, mgr.TransferOptions{Actor: transferActor(), Account: account}) + res, err := m.TransferImport(ctx, name, archive, meta, mgr.TransferOptions{Actor: transferActor()}) archive.Close() if err != nil { if ctx.Err() != nil { @@ -407,42 +396,91 @@ func transferReceive(args []string) error { 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: %s\n", res.RootPass) + 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("\nthis host's own settings apply now. The source's domains did not come along:\n") - fmt.Printf("add them here and point their DNS at this host before retiring the other one.\n") + 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. Both digests travel in the fragment, which HTTP clients never send -// to the server: the certificate pin and the archive checksum stay on the -// operator's command line, and the listener never learns them. -func parseTransferURL(raw string) (target, wantSHA, pin string, err error) { +// 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 + return "", "", "", "", "", err } if u.Scheme != "https" { - return "", "", "", fmt.Errorf("refusing %q: a transfer must be https", raw) + 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") + return "", "", "", "", "", errors.New("this is not a vps transfer URL") } frag, err := url.ParseQuery(u.Fragment) if err != nil { - return "", "", "", err + return "", "", "", "", "", err } - wantSHA, pin = frag.Get("sha256"), frag.Get("cert") - if !isHexDigest(wantSHA) || !isHexDigest(pin) { - return "", "", "", errors.New("the URL is missing its #sha256=…&cert=… part — copy the whole command from the sending host") + 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 = "", "" - return u.String(), wantSHA, pin, nil + // 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 diff --git a/src/main_transfer_test.go b/src/main_transfer_test.go index 336b266..a2d71ed 100644 --- a/src/main_transfer_test.go +++ b/src/main_transfer_test.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "os" "os/exec" @@ -15,45 +16,84 @@ import ( "vpsmgr/internal/mgr" ) -// startTransfer writes content to a file and serves it, returning the receive -// command and the pin/checksum it carries. The listener is closed on cleanup. -func startTransfer(t *testing.T, content []byte) (string, string, string) { +// transferFixture is a running listener and the digests its URL carries. +type transferFixture struct { + target string + metaURL string + archiveSHA string + metaSHA string + pin string +} + +// startTransfer writes an archive and the account metadata that travels with +// it, and serves both the way `vps transfer send` does. +func startTransfer(t *testing.T, content []byte) transferFixture { t.Helper() dir := t.TempDir() - file := filepath.Join(dir, "archive.tar.gz") - if err := os.WriteFile(file, content, 0o600); err != nil { + archive := filepath.Join(dir, "archive.tar.gz") + if err := os.WriteFile(archive, content, 0o600); err != nil { t.Fatal(err) } - sum := sha256.Sum256(content) - srv, err := mgr.NewTransferServer(file, "127.0.0.1", 0, hex.EncodeToString(sum[:])) + meta := []byte(`{"user":"alice","cpu":2,"mem_mb":1024,"disk_gb":8,"disk_used":123,` + + `"expires_at":"2030-01-02T15:04:05Z","ssh_keys":[{"name":"laptop","key":"ssh-ed25519 AAAA","active":true}]}`) + metaPath := filepath.Join(dir, "meta.json") + if err := os.WriteFile(metaPath, meta, 0o600); err != nil { + t.Fatal(err) + } + + archiveSum := sha256.Sum256(content) + metaSum := sha256.Sum256(meta) + srv, err := mgr.NewTransferServer(mgr.TransferFiles{ + Archive: archive, + ArchiveSHA: hex.EncodeToString(archiveSum[:]), + Meta: metaPath, + MetaSHA: hex.EncodeToString(metaSum[:]), + }, "127.0.0.1", 0) if err != nil { t.Fatalf("NewTransferServer: %v", err) } t.Cleanup(func() { srv.Close() }) go func() { _ = srv.Serve(time.Hour, nil) }() - target, wantSHA, pin, err := parseTransferURL(srv.URL()) + target, metaURL, wantSHA, wantMeta, pin, err := parseTransferURL(srv.URL()) if err != nil { t.Fatalf("parseTransferURL(%q): %v", srv.URL(), err) } - if sum := sha256.Sum256(content); wantSHA != hex.EncodeToString(sum[:]) { - t.Fatalf("URL checksum = %q, want the file's sha256", wantSHA) + if wantSHA != hex.EncodeToString(archiveSum[:]) { + t.Fatalf("URL archive checksum = %q, want the file's sha256", wantSHA) } - der := srv.Fingerprint() - if pin != der { - t.Fatalf("URL pin = %q, want the listener's fingerprint %q", pin, der) + if wantMeta != hex.EncodeToString(metaSum[:]) { + t.Fatalf("URL metadata checksum = %q, want the file's sha256", wantMeta) } - return target, wantSHA, pin + if !strings.Contains(metaURL, "/m/") { + t.Fatalf("metadata URL %q is not on the metadata path", metaURL) + } + if pin != srv.Fingerprint() { + t.Fatalf("URL pin = %q, want the listener's fingerprint %q", pin, srv.Fingerprint()) + } + return transferFixture{target: target, metaURL: metaURL, archiveSHA: wantSHA, metaSHA: wantMeta, pin: pin} } // TestTransferRoundTrip covers the whole receive path against a real listener: -// certificate pinning, the download, and the checksum. +// certificate pinning, the account metadata, the download and the checksum. func TestTransferRoundTrip(t *testing.T) { content := bytes.Repeat([]byte("vpsmgr transfer payload "), 64*1024) - target, wantSHA, pin := startTransfer(t, content) + f := startTransfer(t, content) + + meta, err := fetchMeta(t.Context(), f.metaURL, f.pin, f.metaSHA) + if err != nil { + t.Fatalf("fetchMeta: %v", err) + } + var parsed mgr.TransferMeta + if err := json.Unmarshal(meta, &parsed); err != nil { + t.Fatalf("metadata is not readable: %v", err) + } + if parsed.User != "alice" || parsed.DiskGB != 8 || len(parsed.SSHKeys) != 1 { + t.Fatalf("metadata did not survive the trip: %+v", parsed) + } dest := filepath.Join(t.TempDir(), "got.part") - if err := fetchArchive(t.Context(), target, dest, pin, wantSHA); err != nil { + if err := fetchArchive(t.Context(), f.target, dest, f.pin, f.archiveSHA); err != nil { t.Fatalf("fetchArchive: %v", err) } got, err := os.ReadFile(dest) @@ -70,13 +110,13 @@ func TestTransferRoundTrip(t *testing.T) { // the checksum must still cover the whole file. func TestTransferResume(t *testing.T) { content := bytes.Repeat([]byte("resumed payload "), 64*1024) - target, wantSHA, pin := startTransfer(t, content) + f := startTransfer(t, content) dest := filepath.Join(t.TempDir(), "got.part") if err := os.WriteFile(dest, content[:len(content)/3], 0o600); err != nil { t.Fatal(err) } - if err := fetchArchive(t.Context(), target, dest, pin, wantSHA); err != nil { + if err := fetchArchive(t.Context(), f.target, dest, f.pin, f.archiveSHA); err != nil { t.Fatalf("fetchArchive: %v", err) } got, err := os.ReadFile(dest) @@ -89,45 +129,74 @@ func TestTransferResume(t *testing.T) { } // TestTransferRejectsWrongPin: a peer that does not present the pinned -// certificate must be refused before the transfer starts. +// certificate must be refused before anything is trusted, on both files. func TestTransferRejectsWrongPin(t *testing.T) { - content := []byte("payload") - target, wantSHA, _ := startTransfer(t, content) - + f := startTransfer(t, []byte("payload")) other := sha256.Sum256([]byte("some other certificate")) - err := fetchArchive(t.Context(), target, filepath.Join(t.TempDir(), "got.part"), - hex.EncodeToString(other[:]), wantSHA) - if err == nil { - t.Fatal("a mismatch on the certificate pin was accepted") - } - if !strings.Contains(err.Error(), "pin") && !strings.Contains(err.Error(), "certificate") { - t.Fatalf("error does not mention the certificate: %v", err) + pin := hex.EncodeToString(other[:]) + + for _, tc := range []struct { + name string + call func() error + }{ + {"metadata", func() error { + _, err := fetchMeta(t.Context(), f.metaURL, pin, f.metaSHA) + return err + }}, + {"archive", func() error { + return fetchArchive(t.Context(), f.target, filepath.Join(t.TempDir(), "got.part"), pin, f.archiveSHA) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + err := tc.call() + if err == nil { + t.Fatal("a mismatch on the certificate pin was accepted") + } + if !strings.Contains(err.Error(), "pin") && !strings.Contains(err.Error(), "certificate") { + t.Fatalf("error does not mention the certificate: %v", err) + } + }) } } -// TestTransferRejectsWrongChecksum: a complete download that does not hash to -// the expected digest must not be handed to the importer. +// TestTransferRejectsWrongChecksum: neither file may be handed on when it does +// not hash to the digest that came with the command. The metadata matters as +// much as the disk: it carries the account's quota and deadline. func TestTransferRejectsWrongChecksum(t *testing.T) { - content := []byte("payload") - target, _, pin := startTransfer(t, content) - + f := startTransfer(t, []byte("payload")) wrong := sha256.Sum256([]byte("something else")) - err := fetchArchive(t.Context(), target, filepath.Join(t.TempDir(), "got.part"), - pin, hex.EncodeToString(wrong[:])) - if err == nil { - t.Fatal("a checksum mismatch was accepted") - } - if !strings.Contains(err.Error(), "checksum") { - t.Fatalf("error does not mention the checksum: %v", err) + digest := hex.EncodeToString(wrong[:]) + + for _, tc := range []struct { + name string + call func() error + }{ + {"metadata", func() error { + _, err := fetchMeta(t.Context(), f.metaURL, f.pin, digest) + return err + }}, + {"archive", func() error { + return fetchArchive(t.Context(), f.target, filepath.Join(t.TempDir(), "got.part"), f.pin, digest) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + err := tc.call() + if err == nil { + t.Fatal("a checksum mismatch was accepted") + } + if !strings.Contains(err.Error(), "did not survive") { + t.Fatalf("unexpected error: %v", err) + } + }) } } // TestTransferUnknownToken: only the one path the sender printed is served. func TestTransferUnknownToken(t *testing.T) { - target, wantSHA, pin := startTransfer(t, []byte("payload")) + f := startTransfer(t, []byte("payload")) - u := target[:strings.Index(target, "/d/")] + "/d/00000000-0000-4000-8000-000000000000" - err := fetchArchive(t.Context(), u, filepath.Join(t.TempDir(), "got.part"), pin, wantSHA) + u := f.target[:strings.Index(f.target, "/d/")] + "/d/00000000-0000-4000-8000-000000000000" + err := fetchArchive(t.Context(), u, filepath.Join(t.TempDir(), "got.part"), f.pin, f.archiveSHA) if err == nil { t.Fatal("a request for an unknown token was served") } @@ -136,26 +205,28 @@ func TestTransferUnknownToken(t *testing.T) { } } -// TestParseTransferURL: the digests must travel in the URL, and plain http is -// never acceptable. +// TestParseTransferURL: all three digests must travel in the URL, and plain +// http is never acceptable. func TestParseTransferURL(t *testing.T) { sum := sha256.Sum256(nil) - digest := hex.EncodeToString(sum[:]) + d := hex.EncodeToString(sum[:]) + frag := "#sha256=" + d + "&meta=" + d + "&cert=" + d for _, tc := range []struct { name string url string ok bool }{ - {"complete", "https://1.2.3.4:8443/d/tok#sha256=" + digest + "&cert=" + digest, true}, - {"plain http", "http://1.2.3.4:8443/d/tok#sha256=" + digest + "&cert=" + digest, false}, + {"complete", "https://1.2.3.4:8443/d/tok" + frag, true}, + {"plain http", "http://1.2.3.4:8443/d/tok" + frag, false}, {"no fragment", "https://1.2.3.4:8443/d/tok", false}, - {"no pin", "https://1.2.3.4:8443/d/tok#sha256=" + digest, false}, - {"not a digest", "https://1.2.3.4:8443/d/tok#sha256=zz&cert=" + digest, false}, - {"wrong path", "https://1.2.3.4:8443/other/tok#sha256=" + digest + "&cert=" + digest, false}, + {"no pin", "https://1.2.3.4:8443/d/tok#sha256=" + d + "&meta=" + d, false}, + {"no metadata digest", "https://1.2.3.4:8443/d/tok#sha256=" + d + "&cert=" + d, false}, + {"not a digest", "https://1.2.3.4:8443/d/tok#sha256=zz&meta=" + d + "&cert=" + d, false}, + {"wrong path", "https://1.2.3.4:8443/other/tok" + frag, false}, } { t.Run(tc.name, func(t *testing.T) { - _, _, _, err := parseTransferURL(tc.url) + _, _, _, _, _, err := parseTransferURL(tc.url) if tc.ok && err != nil { t.Fatalf("rejected a valid URL: %v", err) } @@ -166,17 +237,60 @@ func TestParseTransferURL(t *testing.T) { } } +// TestTransferMetaRoundTrip: what the sending host puts into the metadata is +// what the receiving host reads back out of it, including the deadline — an +// account must not gain or lose time by moving. +func TestTransferMetaRoundTrip(t *testing.T) { + meta := mgr.TransferMeta{ + User: "alice", + CPU: 20, + MemMB: 1024, + DiskGB: 8, + BandwidthGB: 100, + ExpiresAt: "2030-01-02T15:04:05Z", + DiskUsed: 400 << 20, + InitScript: "apt-get update", + SSHKeys: []mgr.MetaKey{{Name: "laptop", Key: "ssh-ed25519 AAAA", Active: true}}, + StickyNotes: "envelope", + } + raw, err := json.Marshal(meta) + if err != nil { + t.Fatal(err) + } + acct, user, err := mgr.TransferMetaAccount(raw) + if err != nil { + t.Fatalf("TransferMetaAccount: %v", err) + } + if user != "alice" || acct.CPU != 20 || acct.MemMB != 1024 || acct.DiskGB != 8 || acct.BandwidthGB != 100 { + t.Fatalf("account did not survive the round trip: %+v (%s)", acct, user) + } + + var back mgr.TransferMeta + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatal(err) + } + if back.ExpiresAt != meta.ExpiresAt || back.InitScript != meta.InitScript || + back.StickyNotes != meta.StickyNotes || len(back.SSHKeys) != 1 { + t.Fatalf("the account's own data did not survive the round trip: %+v", back) + } +} + // TestTransferServerIdleExit: with nobody connected the listener gives up on // its own and releases the port, which is what stops a forgotten `send` from // leaving an archive on the network indefinitely. func TestTransferServerIdleExit(t *testing.T) { dir := t.TempDir() - file := filepath.Join(dir, "archive.tar") - if err := os.WriteFile(file, []byte("payload"), 0o600); err != nil { + archive := filepath.Join(dir, "archive.tar") + if err := os.WriteFile(archive, []byte("payload"), 0o600); err != nil { t.Fatal(err) } - sum := sha256.Sum256([]byte("payload")) - srv, err := mgr.NewTransferServer(file, "127.0.0.1", 0, hex.EncodeToString(sum[:])) + meta := filepath.Join(dir, "meta.json") + if err := os.WriteFile(meta, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + srv, err := mgr.NewTransferServer(mgr.TransferFiles{ + Archive: archive, ArchiveSHA: "00", Meta: meta, MetaSHA: "00", + }, "127.0.0.1", 0) if err != nil { t.Fatal(err) } @@ -206,9 +320,9 @@ func TestSpaceGuardStopsRunawayExport(t *testing.T) { } } -// TestSweepTransferArchives: an archive left by a process that was killed -// outright must be picked up by the next run, while a live transfer's archive -// and unrelated files are left alone. +// TestSweepTransferArchives: a file left by a process that was killed outright +// must be picked up by the next run, while a live transfer's files and +// unrelated ones are left alone. func TestSweepTransferArchives(t *testing.T) { dir := t.TempDir() @@ -229,13 +343,13 @@ func TestSweepTransferArchives(t *testing.T) { sweepTransferArchives(dir) if _, err := os.Stat(stale); !os.IsNotExist(err) { - t.Error("an archive left by a killed transfer was not swept") + t.Error("a file left by a killed transfer was not swept") } if _, err := os.Stat(live); err != nil { - t.Error("a running transfer's archive was swept") + t.Error("a running transfer's file was swept") } if _, err := os.Stat(other); err != nil { - t.Error("a file that is not a transfer archive was swept") + t.Error("a file that is not a transfer file was swept") } }