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") } }