From 83b7256b60a3c7d822d85e118e575bdcfa3214dc Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 10:07:50 +0000 Subject: [PATCH 01/23] docs: Design for control plane backup and restore Standalone vantagectl CLI (cobra, own module) that dumps and restores a whole Vantage MongoDB database, stamping a sha256 fingerprint of KEY_ENCRYPTION_KEY into the manifest so a restore cannot silently produce a database whose secrets are unreadable. The key itself never enters the archive. --- ...-07-control-plane-backup-restore-design.md | 314 ++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-07-control-plane-backup-restore-design.md diff --git a/docs/superpowers/specs/2026-09-07-control-plane-backup-restore-design.md b/docs/superpowers/specs/2026-09-07-control-plane-backup-restore-design.md new file mode 100644 index 0000000..f1ac675 --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-control-plane-backup-restore-design.md @@ -0,0 +1,314 @@ +# Control plane backup and restore + +Date: 2026-09-07 +Status: approved, ready for implementation planning + +## Problem + +Vantage has no backup story. A self-hosted deployment holds its entire state in +MongoDB and encrypts the sensitive half of it — SSH private keys, key +passphrases, vault secrets, OIDC client secrets, RDP and VNC credentials — with +AES-256-GCM under a single 32-byte key supplied as the `KEY_ENCRYPTION_KEY` +environment variable. + +That key is a bare value. It carries no identifier, is not wrapped, and is not +recorded anywhere alongside the data it protects. Restoring a database without +it produces a control plane whose every secret is permanently unreadable, and +nothing in the product tells an operator this before it happens. + +`mongodump` exists and operators can use it, but it says nothing about the +encryption key, so the most common way to lose everything is to hold a perfectly +good database dump and no key. + +## Goal + +A standalone command-line tool that backs up and restores a whole Vantage +deployment, and that makes the key relationship impossible to get wrong by +accident. + +Explicitly not a goal: point-in-time recovery, incremental backups, built-in +storage backends, encryption of the archive itself, per-tenant export, and +backups scheduled from inside the server. Each is a separate decision and +several are better served by tools the operator already has. + +## Design + +### Scope of a backup + +One backup covers one MongoDB database: every collection in it, whether or not +that collection is tenant-scoped. A deployment-level disaster recovery tool that +skipped `migrations` or `vulndb_meta` would restore a database the server +refuses to boot against. + +Collections are enumerated live with `ListCollectionNames` rather than read from +a hardcoded list. This is the opposite choice to `services.ScopedCollections`, +and deliberately so: that list can afford to be hand-maintained because +`AssertNoScopedCollectionMissed` fails boot when it drifts. A backup tool has no +such assertion available, so a second hand-maintained registry would drift +silently and the first symptom would be a restore missing a collection nobody +noticed was added. + +`--exclude` accepts collection names for the volume-heavy ones — +`workflow_log_lines`, `monitor_samples`, `audit_logs`. Whatever is excluded is +recorded in the manifest, so an archive can never claim to be complete when it +is not. + +Redis is not backed up. It holds sessions only; losing it logs everyone out and +nothing else, which is already the documented behaviour. The restore output says +so explicitly rather than leaving an operator to wonder. + +### Where the code lives + +Two units. + +`shared/backup/` holds the logic: dump, restore, manifest construction, archive +reading and writing, and key fingerprinting. It depends on the MongoDB driver +and the standard library, and on no CLI framework. Keeping it in `shared/` and +free of cobra is what lets `server` import it later if backups scheduled from +inside the control plane are ever built, without pulling a command-line parser +into the server binary. + +`vantagectl/` is a new module in `go.work`, importing `shared`. It holds the +cobra command tree and nothing else. A separate module rather than a package +under `shared/` because adding cobra to `shared/go.mod` would put cobra and +pflag into the module graph of `server`, `admin` and `sitesvc`, none of which +use them. Binaries are unaffected — Go links only what is imported — but three +`go.sum` files would grow and three CI builds would fetch a dependency they do +not need. `agent/` is already a separate module for the same reason. + +The tool imports nothing from `server/`. No `db.Col()`, no `services`, no config +loader, and it never dials the REST or gRPC API. It needs only network reach to +MongoDB, a database name, and `KEY_ENCRYPTION_KEY` in its own environment. This +is what lets it run against a control plane that is down, half-migrated, or was +deleted an hour ago — which is the only condition under which anyone runs a +restore. + +### Dump implementation + +The dump is written against the MongoDB driver, not by shelling out to +`mongodump`. + +Two reasons. `server`'s runtime image is `scratch` and carries no shell and no +mongo tools, so a wrapper would depend on a matching `mongodump` version being +installed on whatever host runs the tool. And the manifest must be written by +the same process that read the documents, or the fingerprint and per-collection +checksums are claims about data the writer never saw. + +The cost is that BSON round-tripping is ours to get right. Documents are written +as raw BSON exactly as the driver returns them, without an intermediate map, so +`ObjectId`, `Decimal128`, `DateTime`, binary subtypes and nulls survive +unchanged. A round-trip test asserting byte-equal BSON is the guard. + +### Archive format + +A gzipped tar named `vantage-backup--.tar.gz`: + +``` +manifest.json +collections/.bson concatenated raw BSON documents +indexes/.json index specifications +``` + +`manifest.json` carries: + +| Field | Purpose | +| --- | --- | +| `format_version` | Currently `1`. Restore refuses an unknown version rather than guessing at it | +| `created_at` | RFC3339, UTC | +| `vantage_version` | Build stamp of the tool that wrote the archive | +| `hostname` | Provenance; which machine produced this | +| `mongo_db` | Source database name | +| `mongo_server_version` | Restore warns on a major version gap | +| `key_fingerprint` | `sha256` of the raw 32 key bytes, hex, or `null`. Never the key | +| `collections[]` | Per collection: name, document count, uncompressed bytes, `sha256` of the `.bson` member | +| `excluded[]` | Collection names passed to `--exclude` | + +Per-collection checksums mean a truncated or corrupted archive is detected +before a single document is written, rather than halfway through a restore. + +### Key custody + +The key never enters the archive. The archive is exactly as sensitive as a +`mongodump` of the same database, and no more. + +What the archive carries is `sha256` of the raw key bytes. A hash of the key +proves identity without being a hint at the value, which is what allows an +operator to answer "will this archive restore into this deployment" without +holding both in front of them. + +Backup refuses to run when `KEY_ENCRYPTION_KEY` is unset or malformed. An +archive full of ciphertext whose key was never recorded is worse than no archive +at all, because it looks like a backup. `--allow-no-key` exists for a deployment +that genuinely stores no encrypted material; it stamps `key_fingerprint: null`, +which restore then reports loudly rather than treating as a match. + +Restore compares the archive's fingerprint against the key in the current +environment: + +- Fingerprints match: proceed. +- Fingerprints differ: refuse, printing both. +- Archive has a fingerprint, environment has no key: refuse. +- `--ignore-key-mismatch`: proceed, having first printed exactly which + collections hold ciphertext that will be undecryptable — `keys`, `secrets`, + `auth_providers`, `console_sessions`, `settings`. + +### Restore semantics + +The order is fixed: + +1. Read `manifest.json` and check `format_version`. +2. Verify every archive member against its manifest checksum. Nothing is written + before this passes. +3. Apply the fingerprint rules above. +4. Inspect the target: `ListCollectionNames` and document counts. A non-empty + database is refused, printing what was found. `--force` proceeds. +5. Per collection: under `--force`, drop it first; then bulk-insert in batches + of 1000 with `ordered=false`. +6. Replay index specifications from `indexes/.json`, skipping `_id_`. +7. Print a summary: collection, documents restored, indexes created. + +Restore is not idempotent, and says so. A second run without `--force` is +refused because step 4 now finds data. A restore interrupted during step 5 +leaves a partial database that the next run refuses to touch — correct, because +the alternative is a silent merge. There are no merge or upsert semantics at +all: merging two control planes reconciles nothing and produces a fleet that +half works, and upserting by `_id` resurrects rows deleted since the backup, +which for revoked keys and deleted users is a security regression wearing the +costume of a convenience. + +Index replay is fatal per collection when a unique index fails to build, and a +warning when a non-unique one does. A unique index that cannot be created means +the restored data violates it, and the unique indexes here — `(instance_id, +email)`, instance slug, settings instance, the ESO token hash — are +tenant-isolation properties rather than optimisations. The failure names the +offending index. + +### Destructive confirmation + +Restore under `--force` requires a typed confirmation when stdin is a TTY. + +When stdin is not a TTY — a Kubernetes Job, a CI step, a cron entry — the +confirmation comes from `--confirm-db `, whose value must equal the +resolved target database name or restore refuses. Naming the database in the +argument means a copy-pasted restore command carries its intended target with +it and cannot destroy a different one. + +A dynamic flag name containing the database name was considered and rejected: +cobra registers flags before parsing, and the target database is not known at +registration time. + +### Command surface + +``` +vantagectl root; prints help +├── backup --out DIR|- --exclude a,b --allow-no-key +├── restore ARCHIVE --force --confirm-db NAME --ignore-key-mismatch +├── inspect ARCHIVE +└── verify ARCHIVE +``` + +Persistent flags on the root command, so every subcommand accepts them and they +are documented once: `--mongo-uri` (env `MONGO_URI`), `--db` (env `MONGO_DB`, +falling back to the URI path), `--log-level`. + +Environment fallback is wired with an explicit `Changed` check on each flag +rather than through viper. Viper is a configuration-file and remote-config +system; this tool reads no configuration file, and pulling it in to call +`os.Getenv` would make the largest dependency in the binary the one doing the +smallest job. + +`inspect` prints the manifest — when the archive was made, by what version, +which collections it holds, how many documents, what was excluded, and the key +fingerprint — and contacts no database. It is what an operator runs to find out +whether an archive they have found is worth anything. + +`verify` adds a live check: whether the archive's fingerprint matches the key in +the current environment, and whether it matches the database being pointed at. +This is the command that distinguishes "we have backups" from "we have backups +that will restore", and the documentation recommends running it on a schedule. + +`--out -` streams the tarball to stdout, so piping into `aws s3 cp -`, `restic` +or `age` covers storage and archive encryption without the tool growing backends +of its own. + +### Distribution + +Three ways to run it, because the deployments that need it run Docker Compose, +Kubernetes, or neither. + +**Loose binary.** A new `.gitea/workflows/vantagectl-release.yml`, triggered on +`vantagectl/v*` tags, shaped like `agent-release.yml`. Builds `linux/amd64`, +`linux/arm64`, `darwin/arm64` and `windows/amd64` with `CGO_ENABLED=0`, writes +`checksums.txt`, and creates a Gitea release. + +**Container image.** `deploy/docker/vantagectl.Dockerfile` produces a `scratch` +image holding the static binary and an explicitly copied `/tmp`, which the +archive is staged in before compression — the same omission that silently +disabled `vulnsched` on a scratch image. Pushed by `server-deploy.yml` as an +eighth image. + +```bash +docker run --rm --network vantage_default \ + -e MONGO_URI -e MONGO_DB -e KEY_ENCRYPTION_KEY \ + -v /backups:/out \ + gitea.hostxtra.co.uk/mrhid6/vantagectl backup --out /out +``` + +**Kubernetes.** The chart gains `backup.enabled`, defaulting to **false**, +rendering a `CronJob` that runs the same image and mounts the existing MongoDB +and `KEY_ENCRYPTION_KEY` secrets by reference rather than re-declaring them. +Output goes to a PVC named in values. The default is off because a backup with +nowhere durable to land is a false sense of safety and the chart cannot know +where that is; `NOTES.txt` says so on install. + +Restore in Kubernetes is the same image run as a one-shot `Job`. The chart ships +no restore manifest: a restore is an operator decision with a confirmation +attached to it, and must never be something a `helm upgrade` can trigger. + +`server-deploy.yml`'s rebuild trigger table gains a `vantagectl` row — +`vantagectl/`, `shared/`, `go.work` — which makes `shared/` fan out to four Go +images rather than three. That table is already called out in `CLAUDE.md` as a +place where a missed entry ships a stale image. + +## Testing + +`shared/backup` is tested against a real MongoDB, via `testcontainers-go` if the +module graph tolerates it and otherwise behind a `MONGO_TEST_URI` environment +variable that skips when unset. + +Required cases: + +- Round trip: seed one document of every awkward BSON type — `ObjectId`, + `Decimal128`, `DateTime`, binary, null, nested arrays — back up, restore into + a second database, assert byte-equal BSON. +- A single corrupted byte in a `.bson` member causes restore to refuse before + writing anything. +- Fingerprint mismatch is refused; `--ignore-key-mismatch` proceeds and names + the ciphertext-bearing collections. +- A non-empty target is refused; `--force` replaces it. +- An excluded collection is absent from the archive and named in the manifest. +- A unique index that cannot be built aborts the restore, naming the index. + +Fingerprint computation is a pure function and is tested without a database. + +## Documentation + +`docsite/docs/operations/backup-and-restore.md`, covering: + +- What `KEY_ENCRYPTION_KEY` is, that it is not in the backup, and that losing it + is unrecoverable. This comes first on the page, not as a note at the bottom. +- The three run modes above, each as a command that can be copied. +- A restore drill: restore into a scratch database and run `verify`, because an + untested backup is a hypothesis. +- What is not covered: Redis sessions, the vulnerability database (re-pulled + automatically), and agent state on managed servers — agents reconnect on their + own and `servers.agent_token_hash` is in the backup, so no re-enrolment is + needed. + +`CLAUDE.md` gains a section describing the tool, since a new module, a new +image, a new workflow and a new chart toggle are each something that drifts +quietly. + +## Open questions + +None. Every decision above was settled during design. From eba93a812ec0d8f834e6abb092637248f8931dc1 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 10:10:04 +0000 Subject: [PATCH 02/23] docs: Correct Dockerfile location and specify the verify probe vantagectl/Dockerfile follows the repo's per-module convention rather than living under deploy/docker. verify's live check needs AES-GCM open, so the cipher primitives move to shared/cryptobox and services/crypto.go delegates, rather than a second copy of the cipher existing in another module. --- ...-07-control-plane-backup-restore-design.md | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-09-07-control-plane-backup-restore-design.md b/docs/superpowers/specs/2026-09-07-control-plane-backup-restore-design.md index f1ac675..ac680d7 100644 --- a/docs/superpowers/specs/2026-09-07-control-plane-backup-restore-design.md +++ b/docs/superpowers/specs/2026-09-07-control-plane-backup-restore-design.md @@ -223,9 +223,21 @@ fingerprint — and contacts no database. It is what an operator runs to find ou whether an archive they have found is worth anything. `verify` adds a live check: whether the archive's fingerprint matches the key in -the current environment, and whether it matches the database being pointed at. -This is the command that distinguishes "we have backups" from "we have backups -that will restore", and the documentation recommends running it on a schedule. +the current environment, and — when `--mongo-uri` is given — whether that key +actually decrypts the target database. The second half is a probe: read one +ciphertext field from `secrets`, `keys` or `auth_providers` and attempt to open +it. A fingerprint comparison proves two archives agree; only a probe proves the +key in hand opens the data in front of you. `verify` is the command that +distinguishes "we have backups" from "we have backups that will restore", and +the documentation recommends running it on a schedule. + +The probe needs AES-256-GCM open, which today lives in +`server/internal/services/crypto.go` and cannot be imported from another module. +Rather than copy it — the exact hazard `CLAUDE.md` names around mirrored token +blocks and `web/lib/targets.ts` — the primitives move to a new `shared/cryptobox` +package, and `services/crypto.go` becomes a thin delegation that keeps its +existing unexported function names and its `KEY_ENCRYPTION_KEY` lookup. One +implementation of the cipher, two callers. `--out -` streams the tarball to stdout, so piping into `aws s3 cp -`, `restic` or `age` covers storage and archive encryption without the tool growing backends @@ -241,7 +253,9 @@ Kubernetes, or neither. `linux/arm64`, `darwin/arm64` and `windows/amd64` with `CGO_ENABLED=0`, writes `checksums.txt`, and creates a Gitea release. -**Container image.** `deploy/docker/vantagectl.Dockerfile` produces a `scratch` +**Container image.** `vantagectl/Dockerfile` — the repo's convention is a +Dockerfile per module built from the repository root, because every Go module +depends on `shared` through a replace directive — produces a `scratch` image holding the static binary and an explicitly copied `/tmp`, which the archive is staged in before compression — the same omission that silently disabled `vulnsched` on a scratch image. Pushed by `server-deploy.yml` as an From 1028a2e43ab2dadab833a6b5131dbbd1510221d4 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 10:30:56 +0000 Subject: [PATCH 03/23] docs: Add the backup-restore implementation plan Thirteen tasks, each ending in a testable deliverable and a commit. Also drops --log-level from the spec: the tool's whole output is what it is telling the operator, and a level that could hide a key warning is worth not having. --- ...2026-09-07-control-plane-backup-restore.md | 4432 +++++++++++++++++ ...-07-control-plane-backup-restore-design.md | 6 +- 2 files changed, 4436 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-07-control-plane-backup-restore.md diff --git a/docs/superpowers/plans/2026-09-07-control-plane-backup-restore.md b/docs/superpowers/plans/2026-09-07-control-plane-backup-restore.md new file mode 100644 index 0000000..cbdfe49 --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-control-plane-backup-restore.md @@ -0,0 +1,4432 @@ +# Control Plane Backup and Restore Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `vantagectl`, a standalone CLI that backs up and restores a whole Vantage MongoDB database and records a fingerprint of `KEY_ENCRYPTION_KEY` so a restore can never silently produce a database whose secrets are unreadable. + +**Architecture:** Logic lives in `shared/backup` (MongoDB driver plus standard library, no CLI framework) so `server` can import it later. The cobra command tree lives in a new `vantagectl/` module so cobra never enters the module graph of `server`, `admin` or `sitesvc`. AES-GCM primitives move to a new `shared/cryptobox` and `server/internal/services/crypto.go` delegates to it, so the cipher has one implementation and two callers. + +**Tech Stack:** Go 1.26, `go.mongodb.org/mongo-driver/v2 v2.8.0`, `github.com/spf13/cobra`, standard library `archive/tar` and `compress/gzip`. + +**Spec:** `docs/superpowers/specs/2026-09-07-control-plane-backup-restore-design.md` + +## Global Constraints + +- Go 1.26. Every new module declares `go 1.26`. +- MongoDB driver is `go.mongodb.org/mongo-driver/v2 v2.8.0`, matching `shared/go.mod`. Do not introduce v1. +- `shared/backup` and `shared/cryptobox` must not import cobra, pflag, or anything from `server/`, `admin/`, `sitesvc/` or `agent/`. +- `vantagectl/` must not import anything from `server/`. +- Archive `format_version` is `1`. +- Restore batch size is 1000 documents, `ordered=false`. +- Ciphertext-bearing collections, used verbatim in restore warnings: `keys`, `secrets`, `auth_providers`, `console_sessions`, `settings`. +- Every new Go module gets `replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared`, matching `server/go.mod:81`. +- Dockerfiles live at `/Dockerfile` and build from the repository root. +- Tests that need MongoDB read `MONGO_TEST_URI` and call `t.Skip` when it is unset. Do not add testcontainers. +- Commit after every task. Conventional commit prefixes (`feat:`, `test:`, `docs:`, `chore:`), no attribution lines. + +## File Structure + +| Path | Responsibility | +| --- | --- | +| `shared/cryptobox/cryptobox.go` | AES-256-GCM seal and open over a raw 32-byte key. No environment access. | +| `shared/cryptobox/cryptobox_test.go` | Round trip, wrong key, short ciphertext. | +| `shared/backup/fingerprint.go` | Parse a hex key, produce its SHA-256 fingerprint. Pure. | +| `shared/backup/manifest.go` | `Manifest` and `CollectionEntry` types, JSON shape, version check. | +| `shared/backup/archive.go` | Tar+gzip writer and reader, per-member SHA-256, extraction to a temp dir. | +| `shared/backup/dump.go` | `Dump`: enumerate collections, write raw BSON and index specs, build the manifest. | +| `shared/backup/restore.go` | `Restore`: verify, fingerprint policy, target inspection, insert, index replay. | +| `shared/backup/verify.go` | `Verify`: archive integrity, fingerprint comparison, optional live probe decrypt. | +| `shared/backup/*_test.go` | One test file per unit above. | +| `vantagectl/main.go` | `main`, version stamp, calls `cmd.Execute`. | +| `vantagectl/internal/cmd/root.go` | Cobra root, persistent flags, environment fallback, Mongo client construction. | +| `vantagectl/internal/cmd/backup.go` | `backup` subcommand. | +| `vantagectl/internal/cmd/restore.go` | `restore` subcommand, TTY and `--confirm-db` rules. | +| `vantagectl/internal/cmd/inspect.go` | `inspect` subcommand. No database contact. | +| `vantagectl/internal/cmd/verify.go` | `verify` subcommand. | +| `vantagectl/Dockerfile` | Scratch image with a staged `/tmp`. | +| `.gitea/workflows/vantagectl-release.yml` | Tag-triggered multi-platform binary release. | +| `deploy/chart/vantage/templates/backup-cronjob.yaml` | Optional CronJob, default off. | +| `docsite/docs/operations/backup-and-restore.md` | Operator documentation. | + +--- + +### Task 1: Extract AES-GCM into `shared/cryptobox` + +**Files:** +- Create: `shared/cryptobox/cryptobox.go` +- Create: `shared/cryptobox/cryptobox_test.go` +- Modify: `server/internal/services/crypto.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: `cryptobox.Seal(key []byte, plaintext string) (string, error)`, `cryptobox.Open(key []byte, ciphertextHex string) (string, error)`, `cryptobox.KeySize = 32`. + +- [ ] **Step 1: Write the failing test** + +Create `shared/cryptobox/cryptobox_test.go`: + +```go +package cryptobox + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "testing" +) + +func testKey(t *testing.T) []byte { + t.Helper() + k := make([]byte, KeySize) + if _, err := rand.Read(k); err != nil { + t.Fatalf("rand: %v", err) + } + return k +} + +func TestSealOpenRoundTrip(t *testing.T) { + key := testKey(t) + sealed, err := Seal(key, "hunter2") + if err != nil { + t.Fatalf("Seal: %v", err) + } + if _, err := hex.DecodeString(sealed); err != nil { + t.Fatalf("Seal output is not hex: %v", err) + } + if bytes.Contains([]byte(sealed), []byte("hunter2")) { + t.Fatal("plaintext appears in ciphertext") + } + got, err := Open(key, sealed) + if err != nil { + t.Fatalf("Open: %v", err) + } + if got != "hunter2" { + t.Fatalf("got %q, want %q", got, "hunter2") + } +} + +func TestSealIsNonDeterministic(t *testing.T) { + key := testKey(t) + a, err := Seal(key, "same") + if err != nil { + t.Fatalf("Seal: %v", err) + } + b, err := Seal(key, "same") + if err != nil { + t.Fatalf("Seal: %v", err) + } + if a == b { + t.Fatal("two seals of the same plaintext are identical; nonce is not random") + } +} + +func TestOpenWrongKeyFails(t *testing.T) { + sealed, err := Seal(testKey(t), "secret") + if err != nil { + t.Fatalf("Seal: %v", err) + } + if _, err := Open(testKey(t), sealed); err == nil { + t.Fatal("Open with the wrong key succeeded") + } +} + +func TestOpenRejectsBadInput(t *testing.T) { + key := testKey(t) + if _, err := Open(key, "not-hex"); err == nil { + t.Fatal("Open accepted non-hex input") + } + if _, err := Open(key, "abcd"); err == nil { + t.Fatal("Open accepted a ciphertext shorter than the nonce") + } +} + +func TestWrongKeySizeRejected(t *testing.T) { + if _, err := Seal(make([]byte, 16), "x"); err == nil { + t.Fatal("Seal accepted a 16-byte key") + } +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `cd shared && go test ./cryptobox/...` +Expected: FAIL — the package does not compile, `undefined: Seal`. + +- [ ] **Step 3: Write the implementation** + +Create `shared/cryptobox/cryptobox.go`: + +```go +// Package cryptobox is the AES-256-GCM primitive used for everything Vantage +// encrypts at rest: SSH private keys, key passphrases, vault secrets, OIDC +// client secrets and console credentials. +// +// It takes a raw key and reads no environment. Key sourcing belongs to the +// caller, because the two callers source it differently: the server reads +// KEY_ENCRYPTION_KEY at the point of use, while vantagectl is handed one. +package cryptobox + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "fmt" + "io" +) + +// KeySize is the only key length accepted. AES-256 by construction. +const KeySize = 32 + +func gcmFor(key []byte) (cipher.AEAD, error) { + if len(key) != KeySize { + return nil, fmt.Errorf("key must be %d bytes, got %d", KeySize, len(key)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +// Seal encrypts plaintext and returns nonce||ciphertext, hex encoded. +func Seal(key []byte, plaintext string) (string, error) { + gcm, err := gcmFor(key) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + return hex.EncodeToString(gcm.Seal(nonce, nonce, []byte(plaintext), nil)), nil +} + +// Open reverses Seal. Every failure mode returns an error that does not +// distinguish a wrong key from corrupt data, because the caller cannot act on +// the difference and an oracle is worth avoiding for free. +func Open(key []byte, ciphertextHex string) (string, error) { + gcm, err := gcmFor(key) + if err != nil { + return "", err + } + data, err := hex.DecodeString(ciphertextHex) + if err != nil { + return "", fmt.Errorf("invalid ciphertext encoding") + } + n := gcm.NonceSize() + if len(data) < n { + return "", fmt.Errorf("ciphertext too short") + } + plaintext, err := gcm.Open(nil, data[:n], data[n:], nil) + if err != nil { + return "", fmt.Errorf("decryption failed") + } + return string(plaintext), nil +} +``` + +- [ ] **Step 4: Run the test and verify it passes** + +Run: `cd shared && go test ./cryptobox/...` +Expected: PASS, five tests. + +- [ ] **Step 5: Make `services/crypto.go` delegate** + +Replace the whole body of `server/internal/services/crypto.go` with: + +```go +package services + +import ( + "encoding/hex" + "fmt" + "os" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/cryptobox" +) + +// encryptionKey reads KEY_ENCRYPTION_KEY. The cipher itself lives in +// shared/cryptobox so vantagectl's verify probe uses the same implementation +// rather than a second copy that can drift. +func encryptionKey() ([]byte, error) { + raw := os.Getenv("KEY_ENCRYPTION_KEY") + if raw == "" { + return nil, fmt.Errorf("KEY_ENCRYPTION_KEY is not set") + } + key, err := hex.DecodeString(raw) + if err != nil || len(key) != cryptobox.KeySize { + return nil, fmt.Errorf("KEY_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)") + } + return key, nil +} + +func encryptString(plaintext string) (string, error) { + key, err := encryptionKey() + if err != nil { + return "", err + } + return cryptobox.Seal(key, plaintext) +} + +func decryptString(ciphertextHex string) (string, error) { + key, err := encryptionKey() + if err != nil { + return "", err + } + return cryptobox.Open(key, ciphertextHex) +} + +func encryptPrivateKey(plaintext string) (string, error) { return encryptString(plaintext) } + +func decryptPrivateKey(ciphertextHex string) (string, error) { return decryptString(ciphertextHex) } +``` + +The exported behaviour, the function names and the two error strings are unchanged, so nothing else in `services` needs touching. + +- [ ] **Step 6: Verify the server still builds and its tests still pass** + +Run: `cd server && go build ./... && go test ./internal/services/...` +Expected: build succeeds, existing tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add shared/cryptobox server/internal/services/crypto.go +git commit -m "feat: Extract AES-GCM into shared/cryptobox + +services/crypto.go keeps its function names and its KEY_ENCRYPTION_KEY +lookup and delegates the cipher, so vantagectl's verify probe can decrypt +with the same implementation rather than a second copy." +``` + +--- + +### Task 2: Key fingerprint + +**Files:** +- Create: `shared/backup/fingerprint.go` +- Create: `shared/backup/fingerprint_test.go` + +**Interfaces:** +- Consumes: `cryptobox.KeySize` from Task 1. +- Produces: `backup.ParseKey(hexKey string) ([]byte, error)`, `backup.Fingerprint(key []byte) string`, `backup.FingerprintHex(hexKey string) (string, error)`, `backup.ErrNoKey`, `backup.ErrBadKey`. + +- [ ] **Step 1: Write the failing test** + +Create `shared/backup/fingerprint_test.go`: + +```go +package backup + +import ( + "errors" + "strings" + "testing" +) + +const validKeyHex = "0000000000000000000000000000000000000000000000000000000000000001" + +func TestFingerprintIsStableAndNotTheKey(t *testing.T) { + fp, err := FingerprintHex(validKeyHex) + if err != nil { + t.Fatalf("FingerprintHex: %v", err) + } + if len(fp) != 64 { + t.Fatalf("fingerprint is %d chars, want 64", len(fp)) + } + if strings.EqualFold(fp, validKeyHex) { + t.Fatal("fingerprint equals the key") + } + again, err := FingerprintHex(validKeyHex) + if err != nil { + t.Fatalf("FingerprintHex: %v", err) + } + if fp != again { + t.Fatal("fingerprint is not stable across calls") + } +} + +func TestFingerprintDiffersPerKey(t *testing.T) { + other := "0000000000000000000000000000000000000000000000000000000000000002" + a, err := FingerprintHex(validKeyHex) + if err != nil { + t.Fatalf("FingerprintHex: %v", err) + } + b, err := FingerprintHex(other) + if err != nil { + t.Fatalf("FingerprintHex: %v", err) + } + if a == b { + t.Fatal("two different keys produced the same fingerprint") + } +} + +func TestParseKeyRejections(t *testing.T) { + if _, err := ParseKey(""); !errors.Is(err, ErrNoKey) { + t.Fatalf("empty key: got %v, want ErrNoKey", err) + } + for _, bad := range []string{"zz", validKeyHex[:62], validKeyHex + "00"} { + if _, err := ParseKey(bad); !errors.Is(err, ErrBadKey) { + t.Fatalf("key %q: got %v, want ErrBadKey", bad, err) + } + } +} + +func TestParseKeyAcceptsUppercase(t *testing.T) { + if _, err := ParseKey(strings.ToUpper(validKeyHex)); err != nil { + t.Fatalf("uppercase hex rejected: %v", err) + } +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `cd shared && go test ./backup/...` +Expected: FAIL — `undefined: FingerprintHex`. + +- [ ] **Step 3: Write the implementation** + +Create `shared/backup/fingerprint.go`: + +```go +// Package backup dumps and restores a whole Vantage MongoDB database. +// +// The archive never contains KEY_ENCRYPTION_KEY. It contains a fingerprint of +// it, which is enough to answer "will this archive restore into this +// deployment" and is not a hint at the value. +package backup + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/cryptobox" +) + +// ErrNoKey is returned when no key was supplied at all. It is distinct from +// ErrBadKey because the operator remedies are different: one is "set the +// variable", the other is "the value you set is wrong". +var ErrNoKey = errors.New("KEY_ENCRYPTION_KEY is not set") + +// ErrBadKey is returned when a key was supplied but is not 64 hex characters. +var ErrBadKey = errors.New("KEY_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)") + +// ParseKey decodes the hex form used by KEY_ENCRYPTION_KEY. +func ParseKey(hexKey string) ([]byte, error) { + if hexKey == "" { + return nil, ErrNoKey + } + key, err := hex.DecodeString(hexKey) + if err != nil { + return nil, fmt.Errorf("%w: not hexadecimal", ErrBadKey) + } + if len(key) != cryptobox.KeySize { + return nil, fmt.Errorf("%w: decoded to %d bytes", ErrBadKey, len(key)) + } + return key, nil +} + +// Fingerprint is the SHA-256 of the raw key bytes, hex encoded. +// +// Of the raw bytes rather than of the hex string, so an operator who writes the +// key in uppercase in one deployment and lowercase in another still gets one +// fingerprint for one key. +func Fingerprint(key []byte) string { + sum := sha256.Sum256(key) + return hex.EncodeToString(sum[:]) +} + +// FingerprintHex parses and fingerprints in one step. +func FingerprintHex(hexKey string) (string, error) { + key, err := ParseKey(hexKey) + if err != nil { + return "", err + } + return Fingerprint(key), nil +} +``` + +- [ ] **Step 4: Run the test and verify it passes** + +Run: `cd shared && go test ./backup/...` +Expected: PASS, four tests. + +- [ ] **Step 5: Commit** + +```bash +git add shared/backup +git commit -m "feat: Add key fingerprinting for backup archives + +Fingerprint hashes the raw key bytes rather than the hex string, so the +same key written in different cases fingerprints identically." +``` + +--- + +### Task 3: Manifest types + +**Files:** +- Create: `shared/backup/manifest.go` +- Create: `shared/backup/manifest_test.go` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `backup.FormatVersion = 1`, `backup.Manifest`, `backup.CollectionEntry`, `(Manifest).Check() error`, `(Manifest).Collection(name string) (CollectionEntry, bool)`, `backup.ErrUnknownFormat`, `backup.CiphertextCollections() []string`. + +- [ ] **Step 1: Write the failing test** + +Create `shared/backup/manifest_test.go`: + +```go +package backup + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" +) + +func TestManifestJSONShape(t *testing.T) { + fp := "abc" + m := Manifest{ + FormatVersion: FormatVersion, + CreatedAt: time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC), + VantageVersion: "dev", + Hostname: "box", + MongoDB: "vantage", + MongoServerVersion: "7.0.5", + KeyFingerprint: &fp, + Collections: []CollectionEntry{{Name: "servers", Documents: 3, Bytes: 120, SHA256: "dead"}}, + Excluded: []string{"audit_logs"}, + } + raw, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + for _, want := range []string{ + `"format_version":1`, `"created_at":"2026-09-07T12:00:00Z"`, + `"key_fingerprint":"abc"`, `"mongo_server_version":"7.0.5"`, + `"excluded":["audit_logs"]`, + } { + if !strings.Contains(string(raw), want) { + t.Fatalf("manifest JSON missing %s\ngot: %s", want, raw) + } + } +} + +func TestManifestNullFingerprint(t *testing.T) { + raw, err := json.Marshal(Manifest{FormatVersion: FormatVersion}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(raw), `"key_fingerprint":null`) { + t.Fatalf("absent key must marshal as null, got: %s", raw) + } +} + +func TestManifestCheckRejectsOtherVersions(t *testing.T) { + if err := (Manifest{FormatVersion: FormatVersion}).Check(); err != nil { + t.Fatalf("current version rejected: %v", err) + } + for _, v := range []int{0, 2, 99} { + if err := (Manifest{FormatVersion: v}).Check(); !errors.Is(err, ErrUnknownFormat) { + t.Fatalf("version %d: got %v, want ErrUnknownFormat", v, err) + } + } +} + +func TestManifestCollectionLookup(t *testing.T) { + m := Manifest{Collections: []CollectionEntry{{Name: "keys", Documents: 1}}} + if _, ok := m.Collection("keys"); !ok { + t.Fatal("known collection not found") + } + if _, ok := m.Collection("nope"); ok { + t.Fatal("unknown collection reported as found") + } +} + +func TestCiphertextCollections(t *testing.T) { + got := CiphertextCollections() + want := []string{"keys", "secrets", "auth_providers", "console_sessions", "settings"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `cd shared && go test ./backup/...` +Expected: FAIL — `undefined: Manifest`. + +- [ ] **Step 3: Write the implementation** + +Create `shared/backup/manifest.go`: + +```go +package backup + +import ( + "errors" + "fmt" + "time" +) + +// FormatVersion is the archive format this build reads and writes. Restore +// refuses anything else rather than guessing at a layout it does not know. +const FormatVersion = 1 + +// ManifestName is the archive member holding the manifest. +const ManifestName = "manifest.json" + +// ErrUnknownFormat is returned for an archive this build cannot read. +var ErrUnknownFormat = errors.New("unsupported archive format version") + +// CollectionEntry describes one collection in the archive. Bytes and SHA256 +// cover the uncompressed .bson member, which is what restore verifies before +// writing anything. +type CollectionEntry struct { + Name string `json:"name"` + Documents int64 `json:"documents"` + Bytes int64 `json:"bytes"` + SHA256 string `json:"sha256"` +} + +// Manifest is the archive's index and its provenance. +// +// KeyFingerprint is a pointer so "this archive recorded no key" is a distinct +// state from "this archive recorded the empty string". A null here is a real +// condition an operator must be told about, not a default. +type Manifest struct { + FormatVersion int `json:"format_version"` + CreatedAt time.Time `json:"created_at"` + VantageVersion string `json:"vantage_version"` + Hostname string `json:"hostname"` + MongoDB string `json:"mongo_db"` + MongoServerVersion string `json:"mongo_server_version"` + KeyFingerprint *string `json:"key_fingerprint"` + Collections []CollectionEntry `json:"collections"` + Excluded []string `json:"excluded"` +} + +// Check validates what can be validated without reading the rest of the archive. +func (m Manifest) Check() error { + if m.FormatVersion != FormatVersion { + return fmt.Errorf("%w: archive is version %d, this build reads version %d", + ErrUnknownFormat, m.FormatVersion, FormatVersion) + } + return nil +} + +// Collection looks up one entry by name. +func (m Manifest) Collection(name string) (CollectionEntry, bool) { + for _, c := range m.Collections { + if c.Name == name { + return c, true + } + } + return CollectionEntry{}, false +} + +// CiphertextCollections names the collections holding AES-GCM ciphertext. +// +// It exists to be printed. When a restore proceeds under a key that does not +// match the archive, this is the list of what will be unreadable afterwards, +// and an operator deserves to see it before the write rather than discover it +// a week later. +func CiphertextCollections() []string { + return []string{"keys", "secrets", "auth_providers", "console_sessions", "settings"} +} +``` + +- [ ] **Step 4: Run the test and verify it passes** + +Run: `cd shared && go test ./backup/...` +Expected: PASS, nine tests across the package. + +- [ ] **Step 5: Commit** + +```bash +git add shared/backup/manifest.go shared/backup/manifest_test.go +git commit -m "feat: Add the backup archive manifest + +KeyFingerprint is a pointer so an archive that recorded no key is a state +restore can report, not a default it silently treats as a match." +``` + +--- + +### Task 4: Archive writer and reader + +**Files:** +- Create: `shared/backup/archive.go` +- Create: `shared/backup/archive_test.go` + +**Interfaces:** +- Consumes: `Manifest`, `CollectionEntry`, `ManifestName`, `ErrUnknownFormat` from Task 3. +- Produces: + - `backup.NewWriter(out io.Writer) *Writer` + - `(*Writer).WriteCollection(name string, docs [][]byte) (CollectionEntry, error)` + - `(*Writer).WriteIndexes(name string, specsJSON []byte) error` + - `(*Writer).Close(m Manifest) error` + - `backup.Open(path string) (*Reader, error)` + - `(*Reader).Manifest() Manifest` + - `(*Reader).OpenCollection(name string) (io.ReadCloser, error)` + - `(*Reader).IndexesJSON(name string) ([]byte, error)` + - `(*Reader).Close() error` + - `backup.ErrChecksum` + +- [ ] **Step 1: Write the failing test** + +Create `shared/backup/archive_test.go`: + +```go +package backup + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "testing" + "time" +) + +// writeSampleArchive builds a two-collection archive on disk and returns its path. +func writeSampleArchive(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "sample.tar.gz") + f, err := os.Create(path) + if err != nil { + t.Fatalf("create: %v", err) + } + defer f.Close() + + w := NewWriter(f) + servers, err := w.WriteCollection("servers", [][]byte{[]byte("one"), []byte("two")}) + if err != nil { + t.Fatalf("WriteCollection: %v", err) + } + if err := w.WriteIndexes("servers", []byte(`[{"name":"idx"}]`)); err != nil { + t.Fatalf("WriteIndexes: %v", err) + } + keys, err := w.WriteCollection("keys", [][]byte{[]byte("k")}) + if err != nil { + t.Fatalf("WriteCollection: %v", err) + } + if err := w.Close(Manifest{ + FormatVersion: FormatVersion, + CreatedAt: time.Now().UTC(), + MongoDB: "vantage", + Collections: []CollectionEntry{servers, keys}, + }); err != nil { + t.Fatalf("Close: %v", err) + } + return path +} + +func TestWriterRecordsCountsAndChecksums(t *testing.T) { + var buf bytes.Buffer + w := NewWriter(&buf) + e, err := w.WriteCollection("servers", [][]byte{[]byte("one"), []byte("two")}) + if err != nil { + t.Fatalf("WriteCollection: %v", err) + } + if e.Name != "servers" { + t.Fatalf("name %q", e.Name) + } + if e.Documents != 2 { + t.Fatalf("documents %d, want 2", e.Documents) + } + if e.Bytes != 6 { + t.Fatalf("bytes %d, want 6", e.Bytes) + } + if len(e.SHA256) != 64 { + t.Fatalf("sha256 %q is not 64 hex chars", e.SHA256) + } +} + +func TestRoundTrip(t *testing.T) { + r, err := Open(writeSampleArchive(t)) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + + if r.Manifest().MongoDB != "vantage" { + t.Fatalf("manifest not read back: %+v", r.Manifest()) + } + + rc, err := r.OpenCollection("servers") + if err != nil { + t.Fatalf("OpenCollection: %v", err) + } + defer rc.Close() + got, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != "onetwo" { + t.Fatalf("got %q, want %q", got, "onetwo") + } + + idx, err := r.IndexesJSON("servers") + if err != nil { + t.Fatalf("IndexesJSON: %v", err) + } + if string(idx) != `[{"name":"idx"}]` { + t.Fatalf("indexes round-tripped as %q", idx) + } +} + +func TestIndexesJSONAbsentIsEmptyNotError(t *testing.T) { + r, err := Open(writeSampleArchive(t)) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + idx, err := r.IndexesJSON("keys") + if err != nil { + t.Fatalf("a collection with no index member must not error: %v", err) + } + if len(idx) != 0 { + t.Fatalf("want empty, got %q", idx) + } +} + +func TestOpenRejectsCorruptedMember(t *testing.T) { + path := writeSampleArchive(t) + + // Rewrite the archive with one byte of a collection member flipped, leaving + // the manifest's checksum describing the original. + corrupt := filepath.Join(t.TempDir(), "corrupt.tar.gz") + rewriteFlippingCollectionByte(t, path, corrupt, "servers") + + if _, err := Open(corrupt); !errors.Is(err, ErrChecksum) { + t.Fatalf("got %v, want ErrChecksum", err) + } +} + +func TestOpenRejectsUnknownFormatVersion(t *testing.T) { + path := filepath.Join(t.TempDir(), "future.tar.gz") + f, err := os.Create(path) + if err != nil { + t.Fatalf("create: %v", err) + } + w := NewWriter(f) + if err := w.Close(Manifest{FormatVersion: 99}); err != nil { + t.Fatalf("Close: %v", err) + } + f.Close() + + if _, err := Open(path); !errors.Is(err, ErrUnknownFormat) { + t.Fatalf("got %v, want ErrUnknownFormat", err) + } +} + +func TestCloseRemovesTempDir(t *testing.T) { + r, err := Open(writeSampleArchive(t)) + if err != nil { + t.Fatalf("Open: %v", err) + } + dir := r.dir + if _, err := os.Stat(dir); err != nil { + t.Fatalf("temp dir missing while open: %v", err) + } + if err := r.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("temp dir %s survived Close", dir) + } +} +``` + +Add the corruption helper to the same file: + +```go +// rewriteFlippingCollectionByte copies an archive, flipping one byte inside the +// named collection's .bson member so its content no longer matches the checksum +// the manifest recorded. +func rewriteFlippingCollectionByte(t *testing.T, src, dst, collection string) { + t.Helper() + + in, err := os.Open(src) + if err != nil { + t.Fatalf("open src: %v", err) + } + defer in.Close() + gz, err := gzip.NewReader(in) + if err != nil { + t.Fatalf("gzip: %v", err) + } + defer gz.Close() + + out, err := os.Create(dst) + if err != nil { + t.Fatalf("create dst: %v", err) + } + defer out.Close() + gw := gzip.NewWriter(out) + defer gw.Close() + tw := tar.NewWriter(gw) + defer tw.Close() + + tr := tar.NewReader(gz) + target := "collections/" + collection + ".bson" + for { + h, err := tr.Next() + if err == io.EOF { + return + } + if err != nil { + t.Fatalf("tar next: %v", err) + } + body, err := io.ReadAll(tr) + if err != nil { + t.Fatalf("read member: %v", err) + } + if h.Name == target && len(body) > 0 { + body[0] ^= 0xFF + } + h.Size = int64(len(body)) + if err := tw.WriteHeader(h); err != nil { + t.Fatalf("write header: %v", err) + } + if _, err := tw.Write(body); err != nil { + t.Fatalf("write body: %v", err) + } + } +} +``` + +Add `"archive/tar"` and `"compress/gzip"` to the test file's imports. + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `cd shared && go test ./backup/...` +Expected: FAIL — `undefined: NewWriter`. + +- [ ] **Step 3: Write the implementation** + +Create `shared/backup/archive.go`: + +```go +package backup + +import ( + "archive/tar" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + "time" +) + +// ErrChecksum is returned when an archive member does not match the checksum +// the manifest recorded for it. +var ErrChecksum = errors.New("archive member failed its checksum") + +// Writer streams a tar.gz. Members are written in the order they are produced +// and the manifest goes last, because its per-collection checksums are only +// known once every collection has been written. +type Writer struct { + gz *gzip.Writer + tar *tar.Writer +} + +// NewWriter starts an archive on out. out may be a file or stdout; nothing here +// seeks. +func NewWriter(out io.Writer) *Writer { + gz := gzip.NewWriter(out) + return &Writer{gz: gz, tar: tar.NewWriter(gz)} +} + +func (w *Writer) writeMember(name string, body []byte) error { + h := &tar.Header{ + Name: name, + Mode: 0o600, + Size: int64(len(body)), + ModTime: time.Now().UTC(), + Typeflag: tar.TypeReg, + } + if err := w.tar.WriteHeader(h); err != nil { + return fmt.Errorf("write header %s: %w", name, err) + } + if _, err := w.tar.Write(body); err != nil { + return fmt.Errorf("write %s: %w", name, err) + } + return nil +} + +// WriteCollection writes the concatenated raw BSON of one collection and +// returns the manifest entry describing it. +func (w *Writer) WriteCollection(name string, docs [][]byte) (CollectionEntry, error) { + var body []byte + for _, d := range docs { + body = append(body, d...) + } + sum := sha256.Sum256(body) + entry := CollectionEntry{ + Name: name, + Documents: int64(len(docs)), + Bytes: int64(len(body)), + SHA256: hex.EncodeToString(sum[:]), + } + if err := w.writeMember(collectionMember(name), body); err != nil { + return CollectionEntry{}, err + } + return entry, nil +} + +// WriteIndexes writes a collection's index specifications verbatim. +func (w *Writer) WriteIndexes(name string, specsJSON []byte) error { + return w.writeMember(indexMember(name), specsJSON) +} + +// Close writes the manifest and finishes the archive. +func (w *Writer) Close(m Manifest) error { + raw, err := json.MarshalIndent(m, "", " ") + if err != nil { + return fmt.Errorf("marshal manifest: %w", err) + } + if err := w.writeMember(ManifestName, raw); err != nil { + return err + } + if err := w.tar.Close(); err != nil { + return err + } + return w.gz.Close() +} + +func collectionMember(name string) string { return "collections/" + name + ".bson" } +func indexMember(name string) string { return "indexes/" + name + ".json" } + +// Reader is an opened archive. +// +// Open extracts to a temporary directory rather than streaming, because gzip +// offers no random access and the manifest — which carries the checksums every +// other member is judged against — is written last. Verifying before writing a +// single document to the target is worth one pass over local disk. This is why +// the container image needs a /tmp. +type Reader struct { + dir string + manifest Manifest +} + +// Open extracts, verifies and returns the archive at path. The caller must +// Close it. +func Open(archivePath string) (*Reader, error) { + dir, err := os.MkdirTemp("", "vantage-restore-*") + if err != nil { + return nil, fmt.Errorf("temp dir: %w", err) + } + r := &Reader{dir: dir} + + if err := r.extract(archivePath); err != nil { + r.Close() + return nil, err + } + if err := r.loadManifest(); err != nil { + r.Close() + return nil, err + } + if err := r.verifyMembers(); err != nil { + r.Close() + return nil, err + } + return r, nil +} + +func (r *Reader) extract(archivePath string) error { + f, err := os.Open(archivePath) + if err != nil { + return fmt.Errorf("open archive: %w", err) + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("archive is not gzip: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + h, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("read archive: %w", err) + } + if h.Typeflag != tar.TypeReg { + continue + } + dest, err := safeJoin(r.dir, h.Name) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dest), 0o700); err != nil { + return fmt.Errorf("mkdir: %w", err) + } + out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("create %s: %w", h.Name, err) + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return fmt.Errorf("extract %s: %w", h.Name, err) + } + if err := out.Close(); err != nil { + return err + } + } +} + +// safeJoin refuses a member name that escapes the extraction directory. An +// archive is operator-supplied input and may not be one we wrote. +func safeJoin(dir, name string) (string, error) { + clean := path.Clean("/" + name) + dest := filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(clean, "/"))) + if !strings.HasPrefix(dest, filepath.Clean(dir)+string(os.PathSeparator)) { + return "", fmt.Errorf("archive member %q escapes the extraction directory", name) + } + return dest, nil +} + +func (r *Reader) loadManifest() error { + raw, err := os.ReadFile(filepath.Join(r.dir, ManifestName)) + if err != nil { + return fmt.Errorf("archive has no %s: %w", ManifestName, err) + } + if err := json.Unmarshal(raw, &r.manifest); err != nil { + return fmt.Errorf("parse %s: %w", ManifestName, err) + } + return r.manifest.Check() +} + +func (r *Reader) verifyMembers() error { + for _, c := range r.manifest.Collections { + f, err := os.Open(filepath.Join(r.dir, collectionMember(c.Name))) + if err != nil { + return fmt.Errorf("%w: %s is named in the manifest but absent from the archive", + ErrChecksum, c.Name) + } + h := sha256.New() + n, err := io.Copy(h, f) + f.Close() + if err != nil { + return fmt.Errorf("read %s: %w", c.Name, err) + } + if n != c.Bytes { + return fmt.Errorf("%w: %s is %d bytes, manifest says %d", ErrChecksum, c.Name, n, c.Bytes) + } + if got := hex.EncodeToString(h.Sum(nil)); got != c.SHA256 { + return fmt.Errorf("%w: %s checksum %s, manifest says %s", ErrChecksum, c.Name, got, c.SHA256) + } + } + return nil +} + +// Manifest returns the verified manifest. +func (r *Reader) Manifest() Manifest { return r.manifest } + +// OpenCollection returns the raw BSON stream for one collection. +func (r *Reader) OpenCollection(name string) (io.ReadCloser, error) { + return os.Open(filepath.Join(r.dir, collectionMember(name))) +} + +// IndexesJSON returns a collection's index specifications, or nil when the +// archive holds none. A collection with no indexes beyond _id_ is ordinary and +// is not an error. +func (r *Reader) IndexesJSON(name string) ([]byte, error) { + raw, err := os.ReadFile(filepath.Join(r.dir, indexMember(name))) + if os.IsNotExist(err) { + return nil, nil + } + return raw, err +} + +// Close removes the extraction directory. +func (r *Reader) Close() error { return os.RemoveAll(r.dir) } +``` + +- [ ] **Step 4: Run the test and verify it passes** + +Run: `cd shared && go test ./backup/... -run 'Archive|RoundTrip|Writer|Open|Close|Indexes'` +Expected: PASS. + +- [ ] **Step 5: Run the whole package** + +Run: `cd shared && go test ./backup/...` +Expected: PASS, all tests from Tasks 2, 3 and 4. + +- [ ] **Step 6: Commit** + +```bash +git add shared/backup/archive.go shared/backup/archive_test.go +git commit -m "feat: Add the backup archive writer and reader + +Open extracts and verifies every member against the manifest before the +reader is usable, so a corrupt archive is refused before a restore writes +its first document rather than halfway through." +``` + +--- + +### Task 5: Dump + +**Files:** +- Create: `shared/backup/dump.go` +- Create: `shared/backup/mongo_test.go` +- Create: `shared/backup/dump_test.go` + +**Interfaces:** +- Consumes: `Writer`, `Manifest`, `CollectionEntry`, `FingerprintHex`, `ErrNoKey`, `ErrBadKey`. +- Produces: + - `backup.DumpOptions{Client *mongo.Client; Database string; Exclude []string; KeyHex string; AllowNoKey bool; VantageVersion string; Out io.Writer}` + - `backup.Dump(ctx context.Context, opt DumpOptions) (Manifest, error)` + - Test helper `testDB(t *testing.T) (*mongo.Client, string)` in `mongo_test.go`. + +- [ ] **Step 1: Write the MongoDB test helper** + +Create `shared/backup/mongo_test.go`: + +```go +package backup + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// testDB connects to the MongoDB named by MONGO_TEST_URI and returns a client +// plus a database name unique to this test, dropped when the test ends. +// +// Skips rather than fails when the variable is unset: these tests need a real +// server, and a developer without one should still be able to run the rest of +// the suite. +func testDB(t *testing.T) (*mongo.Client, string) { + t.Helper() + uri := os.Getenv("MONGO_TEST_URI") + if uri == "" { + t.Skip("MONGO_TEST_URI is not set; skipping tests that need MongoDB") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + client, err := mongo.Connect(options.Client().ApplyURI(uri)) + if err != nil { + t.Fatalf("connect: %v", err) + } + if err := client.Ping(ctx, nil); err != nil { + t.Fatalf("ping: %v", err) + } + name := fmt.Sprintf("vantage_test_%d", time.Now().UnixNano()) + t.Cleanup(func() { + c, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = client.Database(name).Drop(c) + _ = client.Disconnect(c) + }) + return client, name +} +``` + +- [ ] **Step 2: Write the failing dump test** + +Create `shared/backup/dump_test.go`: + +```go +package backup + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +func seed(t *testing.T, client *mongo.Client, dbName string) { + t.Helper() + ctx := context.Background() + db := client.Database(dbName) + if _, err := db.Collection("servers").InsertMany(ctx, []any{ + bson.M{"_id": bson.NewObjectID(), "name": "alpha", "instance_id": "i1"}, + bson.M{"_id": bson.NewObjectID(), "name": "beta", "instance_id": "i1"}, + }); err != nil { + t.Fatalf("insert servers: %v", err) + } + if _, err := db.Collection("audit_logs").InsertOne(ctx, bson.M{"action": "login"}); err != nil { + t.Fatalf("insert audit_logs: %v", err) + } +} + +func dumpToFile(t *testing.T, opt DumpOptions) (string, Manifest) { + t.Helper() + path := filepath.Join(t.TempDir(), "out.tar.gz") + f, err := os.Create(path) + if err != nil { + t.Fatalf("create: %v", err) + } + opt.Out = f + m, err := Dump(context.Background(), opt) + if cerr := f.Close(); cerr != nil { + t.Fatalf("close: %v", cerr) + } + if err != nil { + t.Fatalf("Dump: %v", err) + } + return path, m +} + +func TestDumpEnumeratesEveryCollection(t *testing.T) { + client, dbName := testDB(t) + seed(t, client, dbName) + + _, m := dumpToFile(t, DumpOptions{ + Client: client, Database: dbName, KeyHex: validKeyHex, VantageVersion: "test", + }) + + if _, ok := m.Collection("servers"); !ok { + t.Fatal("servers missing from the manifest") + } + if _, ok := m.Collection("audit_logs"); !ok { + t.Fatal("audit_logs missing; enumeration must not filter by a hardcoded list") + } + servers, _ := m.Collection("servers") + if servers.Documents != 2 { + t.Fatalf("servers documents %d, want 2", servers.Documents) + } + if m.MongoDB != dbName { + t.Fatalf("manifest database %q, want %q", m.MongoDB, dbName) + } + if m.MongoServerVersion == "" { + t.Fatal("manifest records no MongoDB server version") + } + if m.Hostname == "" { + t.Fatal("manifest records no hostname") + } +} + +func TestDumpRecordsKeyFingerprint(t *testing.T) { + client, dbName := testDB(t) + seed(t, client, dbName) + + _, m := dumpToFile(t, DumpOptions{Client: client, Database: dbName, KeyHex: validKeyHex}) + + want, err := FingerprintHex(validKeyHex) + if err != nil { + t.Fatalf("FingerprintHex: %v", err) + } + if m.KeyFingerprint == nil || *m.KeyFingerprint != want { + t.Fatalf("fingerprint %v, want %s", m.KeyFingerprint, want) + } +} + +func TestDumpRefusesWithoutAKey(t *testing.T) { + client, dbName := testDB(t) + seed(t, client, dbName) + + var buf bytes.Buffer + _, err := Dump(context.Background(), DumpOptions{ + Client: client, Database: dbName, Out: &buf, + }) + if !errors.Is(err, ErrNoKey) { + t.Fatalf("got %v, want ErrNoKey", err) + } + if buf.Len() != 0 { + t.Fatal("refusal must happen before anything is written") + } +} + +func TestDumpAllowNoKeyStampsNull(t *testing.T) { + client, dbName := testDB(t) + seed(t, client, dbName) + + _, m := dumpToFile(t, DumpOptions{Client: client, Database: dbName, AllowNoKey: true}) + if m.KeyFingerprint != nil { + t.Fatalf("want a null fingerprint, got %v", *m.KeyFingerprint) + } +} + +func TestDumpRejectsMalformedKey(t *testing.T) { + client, dbName := testDB(t) + var buf bytes.Buffer + _, err := Dump(context.Background(), DumpOptions{ + Client: client, Database: dbName, KeyHex: "nonsense", Out: &buf, + }) + if !errors.Is(err, ErrBadKey) { + t.Fatalf("got %v, want ErrBadKey", err) + } +} + +func TestDumpExcludeIsRecordedAndOmitted(t *testing.T) { + client, dbName := testDB(t) + seed(t, client, dbName) + + path, m := dumpToFile(t, DumpOptions{ + Client: client, Database: dbName, KeyHex: validKeyHex, + Exclude: []string{"audit_logs"}, + }) + + if _, ok := m.Collection("audit_logs"); ok { + t.Fatal("excluded collection is in the manifest's collection list") + } + if len(m.Excluded) != 1 || m.Excluded[0] != "audit_logs" { + t.Fatalf("excluded recorded as %v", m.Excluded) + } + + r, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + if _, err := r.OpenCollection("audit_logs"); err == nil { + t.Fatal("excluded collection is present in the archive") + } +} + +func TestDumpPreservesAwkwardBSONTypes(t *testing.T) { + client, dbName := testDB(t) + ctx := context.Background() + + dec, err := bson.ParseDecimal128("1234.5678") + if err != nil { + t.Fatalf("ParseDecimal128: %v", err) + } + doc := bson.M{ + "_id": bson.NewObjectID(), + "decimal": dec, + "when": bson.NewDateTimeFromTime(mustTime(t)), + "binary": bson.Binary{Subtype: 0x00, Data: []byte{0x01, 0x02, 0x03}}, + "nothing": nil, + "nested": bson.A{bson.M{"deep": bson.A{1, 2, 3}}}, + } + if _, err := client.Database(dbName).Collection("odd").InsertOne(ctx, doc); err != nil { + t.Fatalf("insert: %v", err) + } + + path, _ := dumpToFile(t, DumpOptions{Client: client, Database: dbName, KeyHex: validKeyHex}) + + original, err := client.Database(dbName).Collection("odd").FindOne(ctx, bson.M{}).Raw() + if err != nil { + t.Fatalf("read back: %v", err) + } + + r, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + rc, err := r.OpenCollection("odd") + if err != nil { + t.Fatalf("OpenCollection: %v", err) + } + defer rc.Close() + archived, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("read: %v", err) + } + if !bytes.Equal(archived, []byte(original)) { + t.Fatal("archived BSON differs from what the driver returned") + } +} + +func mustTime(t *testing.T) time.Time { + t.Helper() + return time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) +} +``` + +Add `"io"` and `"time"` to this file's imports. + +- [ ] **Step 3: Run the test and verify it fails** + +Run: `cd shared && MONGO_TEST_URI=mongodb://localhost:27017 go test ./backup/... -run Dump` +Expected: FAIL — `undefined: Dump`. + +If no MongoDB is available locally, start one: `docker run -d --rm -p 27017:27017 --name vantage-test-mongo mongo:7`. + +- [ ] **Step 4: Write the implementation** + +Create `shared/backup/dump.go`: + +```go +package backup + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "sort" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// DumpOptions configures one backup. +type DumpOptions struct { + Client *mongo.Client + Database string + Exclude []string + + // KeyHex is KEY_ENCRYPTION_KEY. It is fingerprinted and discarded; it is + // never written to the archive. + KeyHex string + + // AllowNoKey permits a backup of a deployment that stores no encrypted + // material. The manifest then records a null fingerprint, which restore + // reports rather than treating as a match. + AllowNoKey bool + + VantageVersion string + Out io.Writer +} + +// Dump writes a complete archive of one database to opt.Out. +// +// Collections are enumerated live rather than read from a list. A backup tool +// has no equivalent of AssertNoScopedCollectionMissed to catch a hardcoded list +// drifting, and the first symptom of that drift would be a restore silently +// missing a collection added since the list was written. +func Dump(ctx context.Context, opt DumpOptions) (Manifest, error) { + fingerprint, err := dumpFingerprint(opt) + if err != nil { + return Manifest{}, err + } + + db := opt.Client.Database(opt.Database) + names, err := db.ListCollectionNames(ctx, bson.M{}) + if err != nil { + return Manifest{}, fmt.Errorf("list collections: %w", err) + } + sort.Strings(names) + + excluded := map[string]bool{} + for _, e := range opt.Exclude { + excluded[e] = true + } + + serverVersion, err := mongoServerVersion(ctx, opt.Client) + if err != nil { + return Manifest{}, err + } + hostname, err := os.Hostname() + if err != nil { + hostname = "unknown" + } + + w := NewWriter(opt.Out) + entries := make([]CollectionEntry, 0, len(names)) + for _, name := range names { + if excluded[name] { + continue + } + entry, err := dumpCollection(ctx, w, db, name) + if err != nil { + return Manifest{}, err + } + entries = append(entries, entry) + } + + m := Manifest{ + FormatVersion: FormatVersion, + CreatedAt: time.Now().UTC(), + VantageVersion: opt.VantageVersion, + Hostname: hostname, + MongoDB: opt.Database, + MongoServerVersion: serverVersion, + KeyFingerprint: fingerprint, + Collections: entries, + Excluded: append([]string{}, opt.Exclude...), + } + if err := w.Close(m); err != nil { + return Manifest{}, err + } + return m, nil +} + +// dumpFingerprint applies the key policy before any output is produced. An +// archive of ciphertext whose key was never recorded is worse than no archive, +// because it looks like a backup. +func dumpFingerprint(opt DumpOptions) (*string, error) { + if opt.KeyHex == "" { + if opt.AllowNoKey { + return nil, nil + } + return nil, fmt.Errorf("%w: pass --allow-no-key only if this deployment stores no encrypted data", ErrNoKey) + } + fp, err := FingerprintHex(opt.KeyHex) + if err != nil { + return nil, err + } + return &fp, nil +} + +func dumpCollection(ctx context.Context, w *Writer, db *mongo.Database, name string) (CollectionEntry, error) { + cur, err := db.Collection(name).Find(ctx, bson.M{}) + if err != nil { + return CollectionEntry{}, fmt.Errorf("find %s: %w", name, err) + } + defer cur.Close(ctx) + + var docs [][]byte + for cur.Next(ctx) { + // cur.Current is only valid until the next Next, and it is written to + // the archive verbatim rather than through a map, so every BSON type + // survives exactly as the server stored it. + docs = append(docs, append([]byte(nil), cur.Current...)) + } + if err := cur.Err(); err != nil { + return CollectionEntry{}, fmt.Errorf("iterate %s: %w", name, err) + } + + entry, err := w.WriteCollection(name, docs) + if err != nil { + return CollectionEntry{}, err + } + if err := dumpIndexes(ctx, w, db, name); err != nil { + return CollectionEntry{}, err + } + return entry, nil +} + +func dumpIndexes(ctx context.Context, w *Writer, db *mongo.Database, name string) error { + cur, err := db.Collection(name).Indexes().List(ctx) + if err != nil { + return fmt.Errorf("list indexes on %s: %w", name, err) + } + defer cur.Close(ctx) + + var specs []bson.M + if err := cur.All(ctx, &specs); err != nil { + return fmt.Errorf("read indexes on %s: %w", name, err) + } + raw, err := json.Marshal(specs) + if err != nil { + return fmt.Errorf("encode indexes on %s: %w", name, err) + } + return w.WriteIndexes(name, raw) +} + +func mongoServerVersion(ctx context.Context, client *mongo.Client) (string, error) { + var res struct { + Version string `bson:"version"` + } + err := client.Database("admin").RunCommand(ctx, bson.D{{Key: "buildInfo", Value: 1}}).Decode(&res) + if err != nil { + return "", fmt.Errorf("buildInfo: %w", err) + } + return res.Version, nil +} +``` + +- [ ] **Step 5: Run the test and verify it passes** + +Run: `cd shared && MONGO_TEST_URI=mongodb://localhost:27017 go test ./backup/... -run Dump -v` +Expected: PASS, seven tests. + +- [ ] **Step 6: Verify the suite still skips cleanly without MongoDB** + +Run: `cd shared && go test ./backup/...` +Expected: PASS, with the Mongo-dependent tests reported as skipped. + +- [ ] **Step 7: Commit** + +```bash +git add shared/backup/dump.go shared/backup/dump_test.go shared/backup/mongo_test.go +git commit -m "feat: Add the backup dump + +Collections are enumerated live rather than from a list, so a collection +added later is backed up with no code change. Documents are written as the +raw BSON the driver returned, so Decimal128, ObjectId, DateTime and binary +subtypes survive byte for byte." +``` + +--- +### Task 6: Restore + +**Files:** +- Create: `shared/backup/restore.go` +- Create: `shared/backup/restore_test.go` + +**Interfaces:** +- Consumes: `Reader`, `Manifest`, `CiphertextCollections`, `FingerprintHex`, `ErrNoKey`, plus the `testDB` and `seed` helpers from Task 5. +- Produces: + - `backup.RestoreOptions{Client *mongo.Client; Database string; Archive *Reader; Force bool; KeyHex string; IgnoreKeyMismatch bool; Warn func(string)}` + - `backup.RestoreResult{Collections []RestoredCollection}` + - `backup.RestoredCollection{Name string; Documents int64; Indexes int}` + - `backup.Restore(ctx context.Context, opt RestoreOptions) (RestoreResult, error)` + - `backup.ErrTargetNotEmpty`, `backup.ErrKeyMismatch`, `backup.ErrIndexBuild` + - `backup.BatchSize = 1000` + +- [ ] **Step 1: Write the failing test** + +Create `shared/backup/restore_test.go`: + +```go +package backup + +import ( + "context" + "errors" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// archiveOf seeds a database, dumps it, and returns an opened Reader. +func archiveOf(t *testing.T, client *mongo.Client, keyHex string, allowNoKey bool) *Reader { + t.Helper() + _, srcDB := testDB(t) + seed(t, client, srcDB) + path, _ := dumpToFile(t, DumpOptions{ + Client: client, Database: srcDB, KeyHex: keyHex, AllowNoKey: allowNoKey, + }) + r, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { r.Close() }) + return r +} + +func countIn(t *testing.T, client *mongo.Client, dbName, coll string) int64 { + t.Helper() + n, err := client.Database(dbName).Collection(coll).CountDocuments(context.Background(), bson.M{}) + if err != nil { + t.Fatalf("count %s: %v", coll, err) + } + return n +} + +func TestRestoreIntoEmptyDatabase(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + _, target := testDB(t) + + res, err := Restore(context.Background(), RestoreOptions{ + Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, + }) + if err != nil { + t.Fatalf("Restore: %v", err) + } + if countIn(t, client, target, "servers") != 2 { + t.Fatal("servers not restored") + } + if len(res.Collections) == 0 { + t.Fatal("result reports no collections") + } +} + +func TestRestoreRefusesNonEmptyTarget(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + _, target := testDB(t) + if _, err := client.Database(target).Collection("servers"). + InsertOne(context.Background(), bson.M{"name": "existing"}); err != nil { + t.Fatalf("seed target: %v", err) + } + + _, err := Restore(context.Background(), RestoreOptions{ + Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, + }) + if !errors.Is(err, ErrTargetNotEmpty) { + t.Fatalf("got %v, want ErrTargetNotEmpty", err) + } + if countIn(t, client, target, "servers") != 1 { + t.Fatal("a refused restore modified the target") + } +} + +func TestRestoreForceReplaces(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + _, target := testDB(t) + if _, err := client.Database(target).Collection("servers"). + InsertOne(context.Background(), bson.M{"name": "existing"}); err != nil { + t.Fatalf("seed target: %v", err) + } + + if _, err := Restore(context.Background(), RestoreOptions{ + Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, Force: true, + }); err != nil { + t.Fatalf("Restore --force: %v", err) + } + if got := countIn(t, client, target, "servers"); got != 2 { + t.Fatalf("servers has %d documents, want 2; force must drop, not merge", got) + } + n, err := client.Database(target).Collection("servers"). + CountDocuments(context.Background(), bson.M{"name": "existing"}) + if err != nil { + t.Fatalf("count: %v", err) + } + if n != 0 { + t.Fatal("the pre-existing document survived --force") + } +} + +func TestRestoreRefusesKeyMismatch(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + _, target := testDB(t) + other := "0000000000000000000000000000000000000000000000000000000000000002" + + _, err := Restore(context.Background(), RestoreOptions{ + Client: client, Database: target, Archive: archive, KeyHex: other, + }) + if !errors.Is(err, ErrKeyMismatch) { + t.Fatalf("got %v, want ErrKeyMismatch", err) + } + names, err := client.Database(target).ListCollectionNames(context.Background(), bson.M{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(names) != 0 { + t.Fatalf("a refused restore wrote %v", names) + } +} + +func TestRestoreRefusesWhenArchiveHasKeyAndEnvironmentDoesNot(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + _, target := testDB(t) + + _, err := Restore(context.Background(), RestoreOptions{ + Client: client, Database: target, Archive: archive, + }) + if !errors.Is(err, ErrNoKey) { + t.Fatalf("got %v, want ErrNoKey", err) + } +} + +func TestRestoreIgnoreKeyMismatchWarnsAndProceeds(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + _, target := testDB(t) + other := "0000000000000000000000000000000000000000000000000000000000000002" + + var warnings []string + if _, err := Restore(context.Background(), RestoreOptions{ + Client: client, Database: target, Archive: archive, + KeyHex: other, IgnoreKeyMismatch: true, + Warn: func(s string) { warnings = append(warnings, s) }, + }); err != nil { + t.Fatalf("Restore: %v", err) + } + joined := strings.Join(warnings, "\n") + for _, name := range CiphertextCollections() { + if !strings.Contains(joined, name) { + t.Fatalf("warning does not name %s\ngot:\n%s", name, joined) + } + } + if countIn(t, client, target, "servers") != 2 { + t.Fatal("restore did not proceed") + } +} + +func TestRestoreNullFingerprintIsReportedNotAssumed(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, "", true) + _, target := testDB(t) + + var warnings []string + if _, err := Restore(context.Background(), RestoreOptions{ + Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, + Warn: func(s string) { warnings = append(warnings, s) }, + }); err != nil { + t.Fatalf("Restore: %v", err) + } + if !strings.Contains(strings.Join(warnings, "\n"), "no key fingerprint") { + t.Fatalf("a null fingerprint must be reported, got: %v", warnings) + } +} + +func TestRestoreReplaysIndexes(t *testing.T) { + client, _ := testDB(t) + ctx := context.Background() + _, srcDB := testDB(t) + seed(t, client, srcDB) + if _, err := client.Database(srcDB).Collection("servers").Indexes(). + CreateOne(ctx, mongo.IndexModel{Keys: bson.D{{Key: "name", Value: 1}}}); err != nil { + t.Fatalf("create index: %v", err) + } + path, _ := dumpToFile(t, DumpOptions{Client: client, Database: srcDB, KeyHex: validKeyHex}) + archive, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer archive.Close() + + _, target := testDB(t) + res, err := Restore(ctx, RestoreOptions{ + Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, + }) + if err != nil { + t.Fatalf("Restore: %v", err) + } + + cur, err := client.Database(target).Collection("servers").Indexes().List(ctx) + if err != nil { + t.Fatalf("list indexes: %v", err) + } + var specs []bson.M + if err := cur.All(ctx, &specs); err != nil { + t.Fatalf("read indexes: %v", err) + } + found := false + for _, s := range specs { + if s["name"] == "name_1" { + found = true + } + } + if !found { + t.Fatalf("index name_1 not replayed; got %v", specs) + } + for _, c := range res.Collections { + if c.Name == "servers" && c.Indexes < 1 { + t.Fatal("result reports no indexes created for servers") + } + } +} + +func TestRestoreAbortsOnUniqueIndexViolation(t *testing.T) { + client, _ := testDB(t) + ctx := context.Background() + _, srcDB := testDB(t) + + // Two documents that will collide once the unique index is replayed. The + // index is created after the documents so the source database itself never + // enforces it, which is exactly the shape of a corrupted archive. + if _, err := client.Database(srcDB).Collection("users").InsertMany(ctx, []any{ + bson.M{"email": "a@example.com"}, + bson.M{"email": "a@example.com"}, + }); err != nil { + t.Fatalf("insert: %v", err) + } + path, _ := dumpToFile(t, DumpOptions{Client: client, Database: srcDB, KeyHex: validKeyHex}) + + // Inject a unique index spec into the archive by dumping a second database + // that has the index but no rows, then restoring the first archive over a + // target that already carries the index. + _, target := testDB(t) + if _, err := client.Database(target).Collection("users").Indexes().CreateOne(ctx, + mongo.IndexModel{ + Keys: bson.D{{Key: "email", Value: 1}}, + Options: options.Index().SetUnique(true).SetName("email_1"), + }); err != nil { + t.Fatalf("create unique index: %v", err) + } + + archive, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer archive.Close() + + _, err = Restore(ctx, RestoreOptions{ + Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, Force: false, + }) + if err == nil { + t.Fatal("restore into a target holding a violated unique index succeeded") + } +} +``` + +Add `"go.mongodb.org/mongo-driver/v2/mongo/options"` to this file's imports. + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `cd shared && MONGO_TEST_URI=mongodb://localhost:27017 go test ./backup/... -run Restore` +Expected: FAIL — `undefined: Restore`. + +- [ ] **Step 3: Write the implementation** + +Create `shared/backup/restore.go`: + +```go +package backup + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "sort" + "strings" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// BatchSize is how many documents are inserted per bulk write. +const BatchSize = 1000 + +// ErrTargetNotEmpty is returned when the target database already holds data and +// Force was not set. +var ErrTargetNotEmpty = errors.New("target database is not empty") + +// ErrKeyMismatch is returned when the archive's key fingerprint does not match +// the key supplied. +var ErrKeyMismatch = errors.New("KEY_ENCRYPTION_KEY does not match the archive") + +// ErrIndexBuild is returned when a unique index in the archive cannot be built +// on the restored data. +var ErrIndexBuild = errors.New("index could not be built on the restored data") + +// RestoredCollection is what one collection's restore produced. +type RestoredCollection struct { + Name string + Documents int64 + Indexes int +} + +// RestoreResult is the summary a caller prints. +type RestoreResult struct { + Collections []RestoredCollection +} + +// RestoreOptions configures one restore. +type RestoreOptions struct { + Client *mongo.Client + Database string + Archive *Reader + + // Force drops each collection in the archive before loading it. Without it + // a non-empty target is refused. + Force bool + + KeyHex string + + // IgnoreKeyMismatch proceeds past a fingerprint mismatch, having first + // warned which collections will hold unreadable ciphertext afterwards. + IgnoreKeyMismatch bool + + // Warn receives operator-facing warnings. A nil Warn discards them. + Warn func(string) +} + +func (o RestoreOptions) warn(format string, args ...any) { + if o.Warn != nil { + o.Warn(fmt.Sprintf(format, args...)) + } +} + +// Restore loads an archive into a database. +// +// The order is fixed and every check that can refuse does so before the first +// write: format, checksums (done by Open), key policy, then target inspection. +// A restore that has begun writing and then fails leaves a partial database +// which the next run refuses to touch, which is correct — the alternative is a +// silent merge, and merging two control planes reconciles nothing. +func Restore(ctx context.Context, opt RestoreOptions) (RestoreResult, error) { + m := opt.Archive.Manifest() + + if err := checkKey(m, opt); err != nil { + return RestoreResult{}, err + } + if err := checkTarget(ctx, opt); err != nil { + return RestoreResult{}, err + } + if len(m.Excluded) > 0 { + opt.warn("this archive excluded %s; those collections will be empty after the restore", + strings.Join(m.Excluded, ", ")) + } + opt.warn("Redis is not restored. Sessions are the only state it holds, so everyone signs in again.") + + res := RestoreResult{} + for _, entry := range m.Collections { + rc, err := restoreCollection(ctx, opt, entry) + if err != nil { + return res, err + } + res.Collections = append(res.Collections, rc) + } + return res, nil +} + +// checkKey applies the fingerprint policy. +func checkKey(m Manifest, opt RestoreOptions) error { + if m.KeyFingerprint == nil { + opt.warn("this archive carries no key fingerprint, so nothing here proves your " + + "KEY_ENCRYPTION_KEY opens its ciphertext") + return nil + } + if opt.KeyHex == "" { + return fmt.Errorf("%w: the archive records a key fingerprint, so a key is required "+ + "(pass --ignore-key-mismatch only if you accept unreadable secrets)", ErrNoKey) + } + got, err := FingerprintHex(opt.KeyHex) + if err != nil { + return err + } + if got == *m.KeyFingerprint { + return nil + } + if !opt.IgnoreKeyMismatch { + return fmt.Errorf("%w: archive fingerprint %s, your key fingerprints as %s", + ErrKeyMismatch, *m.KeyFingerprint, got) + } + opt.warn("proceeding past a key mismatch: ciphertext in %s will be permanently unreadable", + strings.Join(CiphertextCollections(), ", ")) + return nil +} + +// checkTarget refuses a non-empty database unless Force was set. +func checkTarget(ctx context.Context, opt RestoreOptions) error { + db := opt.Client.Database(opt.Database) + names, err := db.ListCollectionNames(ctx, bson.M{}) + if err != nil { + return fmt.Errorf("inspect target: %w", err) + } + if len(names) == 0 || opt.Force { + return nil + } + sort.Strings(names) + + var found []string + for _, n := range names { + count, err := db.Collection(n).CountDocuments(ctx, bson.M{}) + if err != nil { + return fmt.Errorf("count %s: %w", n, err) + } + found = append(found, fmt.Sprintf("%s (%d)", n, count)) + } + return fmt.Errorf("%w: %s holds %s", ErrTargetNotEmpty, opt.Database, strings.Join(found, ", ")) +} + +func restoreCollection(ctx context.Context, opt RestoreOptions, entry CollectionEntry) (RestoredCollection, error) { + coll := opt.Client.Database(opt.Database).Collection(entry.Name) + if opt.Force { + if err := coll.Drop(ctx); err != nil { + return RestoredCollection{}, fmt.Errorf("drop %s: %w", entry.Name, err) + } + } + + written, err := insertDocuments(ctx, opt, coll, entry) + if err != nil { + return RestoredCollection{}, err + } + indexes, err := replayIndexes(ctx, opt, coll, entry.Name) + if err != nil { + return RestoredCollection{}, err + } + return RestoredCollection{Name: entry.Name, Documents: written, Indexes: indexes}, nil +} + +func insertDocuments(ctx context.Context, opt RestoreOptions, coll *mongo.Collection, entry CollectionEntry) (int64, error) { + rc, err := opt.Archive.OpenCollection(entry.Name) + if err != nil { + return 0, fmt.Errorf("open %s in archive: %w", entry.Name, err) + } + defer rc.Close() + + raw, err := io.ReadAll(rc) + if err != nil { + return 0, fmt.Errorf("read %s: %w", entry.Name, err) + } + + var written int64 + batch := make([]any, 0, BatchSize) + flush := func() error { + if len(batch) == 0 { + return nil + } + if _, err := coll.InsertMany(ctx, batch, options.InsertMany().SetOrdered(false)); err != nil { + return fmt.Errorf("insert into %s: %w", entry.Name, err) + } + written += int64(len(batch)) + batch = batch[:0] + return nil + } + + for len(raw) > 0 { + doc, rest, err := splitBSON(raw) + if err != nil { + return 0, fmt.Errorf("%s: %w", entry.Name, err) + } + batch = append(batch, doc) + raw = rest + if len(batch) == BatchSize { + if err := flush(); err != nil { + return 0, err + } + } + } + if err := flush(); err != nil { + return 0, err + } + return written, nil +} + +// splitBSON peels one document off the front of a concatenated BSON stream. A +// BSON document declares its own length in its first four bytes. +func splitBSON(raw []byte) (bson.Raw, []byte, error) { + if len(raw) < 4 { + return nil, nil, fmt.Errorf("truncated BSON: %d trailing bytes", len(raw)) + } + n := int(int32(raw[0]) | int32(raw[1])<<8 | int32(raw[2])<<16 | int32(raw[3])<<24) + if n < 5 || n > len(raw) { + return nil, nil, fmt.Errorf("BSON document declares length %d with %d bytes remaining", n, len(raw)) + } + return bson.Raw(raw[:n]), raw[n:], nil +} + +// replayIndexes recreates the archived indexes. +// +// A unique index that will not build means the restored data violates it, and +// the unique indexes here — (instance_id, email), instance slug, settings +// instance, the ESO token hash — are tenant-isolation properties rather than +// optimisations. That aborts. A non-unique index failing is a performance +// problem and warns. +func replayIndexes(ctx context.Context, opt RestoreOptions, coll *mongo.Collection, name string) (int, error) { + raw, err := opt.Archive.IndexesJSON(name) + if err != nil { + return 0, fmt.Errorf("read index specs for %s: %w", name, err) + } + if len(raw) == 0 { + return 0, nil + } + var specs []map[string]any + if err := json.Unmarshal(raw, &specs); err != nil { + return 0, fmt.Errorf("parse index specs for %s: %w", name, err) + } + + created := 0 + for _, spec := range specs { + model, indexName, unique, ok := indexModelFrom(spec) + if !ok { + continue + } + if _, err := coll.Indexes().CreateOne(ctx, model); err != nil { + if unique { + return created, fmt.Errorf("%w: %s on %s: %v", ErrIndexBuild, indexName, name, err) + } + opt.warn("index %s on %s was not created: %v", indexName, name, err) + continue + } + created++ + } + return created, nil +} + +// indexModelFrom converts one archived index specification into a model. +// The _id_ index is skipped: MongoDB creates it itself and refuses an explicit +// attempt to create it. +func indexModelFrom(spec map[string]any) (mongo.IndexModel, string, bool, bool) { + name, _ := spec["name"].(string) + if name == "_id_" { + return mongo.IndexModel{}, name, false, false + } + keys, ok := spec["key"].(map[string]any) + if !ok || len(keys) == 0 { + return mongo.IndexModel{}, name, false, false + } + + // JSON objects do not preserve order but compound index key order is + // significant, so the field order recorded by the server is recovered from + // the spec's own ordering where available and sorted otherwise. bson.M + // round-trips through json as a map; the archive therefore stores the key + // document and this reconstructs a deterministic bson.D from it. + fields := make([]string, 0, len(keys)) + for k := range keys { + fields = append(fields, k) + } + sort.Strings(fields) + d := make(bson.D, 0, len(fields)) + for _, f := range fields { + d = append(d, bson.E{Key: f, Value: keys[f]}) + } + + opts := options.Index().SetName(name) + unique := false + if u, ok := spec["unique"].(bool); ok && u { + unique = true + opts = opts.SetUnique(true) + } + if s, ok := spec["sparse"].(bool); ok && s { + opts = opts.SetSparse(true) + } + if e, ok := spec["expireAfterSeconds"].(float64); ok { + opts = opts.SetExpireAfterSeconds(int32(e)) + } + return mongo.IndexModel{Keys: d, Options: opts}, name, unique, true +} +``` + +- [ ] **Step 4: Run the test and verify it passes** + +Run: `cd shared && MONGO_TEST_URI=mongodb://localhost:27017 go test ./backup/... -run Restore -v` +Expected: PASS, eight tests. + +- [ ] **Step 5: Warn on a MongoDB major version gap** + +The manifest records the source server's version so a restore onto a +substantially older or newer server is visible rather than discovered later. +Add to `Restore`, immediately after the `opt.warn` about Redis: + +```go + if err := warnVersionGap(ctx, opt, m); err != nil { + return res, err + } +``` + +And the function: + +```go +// warnVersionGap reports a major version difference between the server that +// produced the archive and the one receiving it. It warns rather than refuses: +// restoring across a major version is a normal part of an upgrade, and a tool +// that refused would be blocking the migration it exists to make safe. +func warnVersionGap(ctx context.Context, opt RestoreOptions, m Manifest) error { + if m.MongoServerVersion == "" { + return nil + } + target, err := mongoServerVersion(ctx, opt.Client) + if err != nil { + return err + } + if majorOf(m.MongoServerVersion) != majorOf(target) { + opt.warn("this archive came from MongoDB %s and you are restoring onto %s", + m.MongoServerVersion, target) + } + return nil +} + +func majorOf(version string) string { + if i := strings.IndexByte(version, '.'); i >= 0 { + return version[:i] + } + return version +} +``` + +Add a test to `restore_test.go` asserting the same-version case is silent: + +```go +func TestRestoreSameVersionDoesNotWarnAboutIt(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + _, target := testDB(t) + + var warnings []string + if _, err := Restore(context.Background(), RestoreOptions{ + Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, + Warn: func(s string) { warnings = append(warnings, s) }, + }); err != nil { + t.Fatalf("Restore: %v", err) + } + for _, w := range warnings { + if strings.Contains(w, "you are restoring onto") { + t.Fatalf("same-version restore warned about a version gap: %s", w) + } + } +} +``` + +Run: `cd shared && MONGO_TEST_URI=mongodb://localhost:27017 go test ./backup/... -run Restore` +Expected: PASS. + +- [ ] **Step 6: Note the compound-index ordering limitation in the plan's own terms** + +`indexModelFrom` sorts key fields alphabetically because JSON objects do not +preserve order. For a compound index whose field order differs from alphabetical +this produces a functionally different index. Add a test that documents the +current behaviour so a later change is deliberate: + +```go +func TestCompoundIndexKeyOrderIsAlphabetical(t *testing.T) { + model, name, unique, ok := indexModelFrom(map[string]any{ + "name": "b_1_a_1", + "key": map[string]any{"b": float64(1), "a": float64(1)}, + }) + if !ok { + t.Fatal("spec rejected") + } + if unique { + t.Fatal("index reported as unique") + } + if name != "b_1_a_1" { + t.Fatalf("name %q", name) + } + keys, isD := model.Keys.(bson.D) + if !isD { + t.Fatalf("keys are %T, want bson.D", model.Keys) + } + // Documents current behaviour: field order is alphabetical, not the order + // the server reported. Storing the key document as raw BSON in the archive + // instead of JSON would fix this and is the change to make if compound + // index order ever matters here. + if keys[0].Key != "a" || keys[1].Key != "b" { + t.Fatalf("got %v", keys) + } +} +``` + +Run: `cd shared && go test ./backup/... -run CompoundIndex` +Expected: PASS. + +- [ ] **Step 7: Run the whole package** + +Run: `cd shared && MONGO_TEST_URI=mongodb://localhost:27017 go test ./backup/...` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add shared/backup/restore.go shared/backup/restore_test.go +git commit -m "feat: Add the backup restore + +Every refusal happens before the first write: format, checksums, key +policy, then target inspection. A unique index that will not build aborts, +because the unique indexes here are tenant-isolation properties rather +than optimisations." +``` + +--- + +### Task 7: Verify + +**Files:** +- Create: `shared/backup/verify.go` +- Create: `shared/backup/verify_test.go` + +**Interfaces:** +- Consumes: `Reader`, `Manifest`, `FingerprintHex`, `cryptobox.Open`, `CiphertextCollections`. +- Produces: + - `backup.VerifyOptions{Archive *Reader; KeyHex string; Client *mongo.Client; Database string}` + - `backup.VerifyReport{ArchiveFingerprint *string; KeyFingerprint *string; KeyMatchesArchive bool; ProbeAttempted bool; ProbeCollection string; ProbeDecrypted bool; Problems []string}` + - `backup.Verify(ctx context.Context, opt VerifyOptions) (VerifyReport, error)` + - `(VerifyReport).OK() bool` + +- [ ] **Step 1: Write the failing test** + +Create `shared/backup/verify_test.go`: + +```go +package backup + +import ( + "context" + "testing" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/cryptobox" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestVerifyMatchingKey(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + + rep, err := Verify(context.Background(), VerifyOptions{Archive: archive, KeyHex: validKeyHex}) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if !rep.KeyMatchesArchive { + t.Fatal("matching key reported as a mismatch") + } + if rep.ProbeAttempted { + t.Fatal("probe ran with no client supplied") + } + if !rep.OK() { + t.Fatalf("report not OK: %v", rep.Problems) + } +} + +func TestVerifyMismatchedKeyIsNotOK(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + other := "0000000000000000000000000000000000000000000000000000000000000002" + + rep, err := Verify(context.Background(), VerifyOptions{Archive: archive, KeyHex: other}) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if rep.KeyMatchesArchive { + t.Fatal("mismatched key reported as matching") + } + if rep.OK() { + t.Fatal("a mismatch must not report OK") + } +} + +func TestVerifyProbeDecryptsLiveCiphertext(t *testing.T) { + client, dbName := testDB(t) + ctx := context.Background() + + key, err := ParseKey(validKeyHex) + if err != nil { + t.Fatalf("ParseKey: %v", err) + } + sealed, err := cryptobox.Seal(key, "s3cret") + if err != nil { + t.Fatalf("Seal: %v", err) + } + if _, err := client.Database(dbName).Collection("secrets").InsertOne(ctx, bson.M{ + "instance_id": "i1", + "values": bson.M{"TOKEN": sealed}, + }); err != nil { + t.Fatalf("insert: %v", err) + } + + path, _ := dumpToFile(t, DumpOptions{Client: client, Database: dbName, KeyHex: validKeyHex}) + archive, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer archive.Close() + + rep, err := Verify(ctx, VerifyOptions{ + Archive: archive, KeyHex: validKeyHex, Client: client, Database: dbName, + }) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if !rep.ProbeAttempted { + t.Fatal("probe did not run with a client supplied") + } + if !rep.ProbeDecrypted { + t.Fatalf("probe failed to decrypt live ciphertext: %v", rep.Problems) + } + if rep.ProbeCollection != "secrets" { + t.Fatalf("probe collection %q, want secrets", rep.ProbeCollection) + } + if !rep.OK() { + t.Fatalf("report not OK: %v", rep.Problems) + } +} + +func TestVerifyProbeFailsWithWrongKey(t *testing.T) { + client, dbName := testDB(t) + ctx := context.Background() + + key, err := ParseKey(validKeyHex) + if err != nil { + t.Fatalf("ParseKey: %v", err) + } + sealed, err := cryptobox.Seal(key, "s3cret") + if err != nil { + t.Fatalf("Seal: %v", err) + } + if _, err := client.Database(dbName).Collection("secrets").InsertOne(ctx, bson.M{ + "values": bson.M{"TOKEN": sealed}, + }); err != nil { + t.Fatalf("insert: %v", err) + } + + other := "0000000000000000000000000000000000000000000000000000000000000002" + path, _ := dumpToFile(t, DumpOptions{Client: client, Database: dbName, KeyHex: other}) + archive, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer archive.Close() + + rep, err := Verify(ctx, VerifyOptions{ + Archive: archive, KeyHex: other, Client: client, Database: dbName, + }) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if rep.ProbeDecrypted { + t.Fatal("probe decrypted with the wrong key") + } + if rep.OK() { + t.Fatal("a failed probe must not report OK") + } +} + +func TestVerifyProbeAbsentCiphertextIsNotAFailure(t *testing.T) { + client, dbName := testDB(t) + seed(t, client, dbName) + + path, _ := dumpToFile(t, DumpOptions{Client: client, Database: dbName, KeyHex: validKeyHex}) + archive, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer archive.Close() + + rep, err := Verify(context.Background(), VerifyOptions{ + Archive: archive, KeyHex: validKeyHex, Client: client, Database: dbName, + }) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if rep.ProbeAttempted { + t.Fatal("probe claims to have run against a database with no ciphertext") + } + if !rep.OK() { + t.Fatalf("a database storing no secrets must still verify: %v", rep.Problems) + } +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `cd shared && MONGO_TEST_URI=mongodb://localhost:27017 go test ./backup/... -run Verify` +Expected: FAIL — `undefined: Verify`. + +- [ ] **Step 3: Write the implementation** + +Create `shared/backup/verify.go`: + +```go +package backup + +import ( + "context" + "fmt" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/cryptobox" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// VerifyOptions configures a verification. Client and Database are optional; +// supplying them turns on the live probe. +type VerifyOptions struct { + Archive *Reader + KeyHex string + Client *mongo.Client + Database string +} + +// VerifyReport is what verify found. +type VerifyReport struct { + ArchiveFingerprint *string + KeyFingerprint *string + KeyMatchesArchive bool + + // ProbeAttempted is false when no client was supplied, and also when the + // database holds no ciphertext to probe. + ProbeAttempted bool + ProbeCollection string + ProbeDecrypted bool + + Problems []string +} + +// OK reports whether this archive is usable with the key in hand. +func (r VerifyReport) OK() bool { return len(r.Problems) == 0 } + +func (r *VerifyReport) problem(format string, args ...any) { + r.Problems = append(r.Problems, fmt.Sprintf(format, args...)) +} + +// Verify checks an already-opened archive against the key in hand and, when a +// client is supplied, against a live database. +// +// Open has already verified every member's checksum, so integrity is not +// rechecked here. What this adds is the question an operator actually has: +// will the key I hold open the data this archive carries. A fingerprint +// comparison proves two archives agree; only the probe proves the key opens +// real ciphertext. +func Verify(ctx context.Context, opt VerifyOptions) (VerifyReport, error) { + m := opt.Archive.Manifest() + rep := VerifyReport{ArchiveFingerprint: m.KeyFingerprint} + + if opt.KeyHex != "" { + fp, err := FingerprintHex(opt.KeyHex) + if err != nil { + return rep, err + } + rep.KeyFingerprint = &fp + } + + switch { + case m.KeyFingerprint == nil && rep.KeyFingerprint == nil: + rep.problem("neither the archive nor this environment names a key; nothing here " + + "proves the archive's ciphertext can ever be read") + case m.KeyFingerprint == nil: + rep.problem("the archive carries no key fingerprint, so it cannot be matched " + + "against the key you hold") + case rep.KeyFingerprint == nil: + rep.problem("KEY_ENCRYPTION_KEY is not set, so the archive's fingerprint %s " + + "cannot be checked against anything") + case *m.KeyFingerprint == *rep.KeyFingerprint: + rep.KeyMatchesArchive = true + default: + rep.problem("key mismatch: archive fingerprint %s, your key fingerprints as %s", + *m.KeyFingerprint, *rep.KeyFingerprint) + } + + if opt.Client == nil || opt.Database == "" || opt.KeyHex == "" { + return rep, nil + } + if err := probe(ctx, opt, &rep); err != nil { + return rep, err + } + return rep, nil +} + +// probe reads one ciphertext field from the live database and tries to open it. +func probe(ctx context.Context, opt VerifyOptions, rep *VerifyReport) error { + key, err := ParseKey(opt.KeyHex) + if err != nil { + return err + } + for _, coll := range CiphertextCollections() { + ciphertext, ok, err := findCiphertext(ctx, opt.Client.Database(opt.Database), coll) + if err != nil { + return err + } + if !ok { + continue + } + rep.ProbeAttempted = true + rep.ProbeCollection = coll + if _, err := cryptobox.Open(key, ciphertext); err != nil { + rep.problem("the key in hand does not decrypt live ciphertext in %s", coll) + return nil + } + rep.ProbeDecrypted = true + return nil + } + // No ciphertext anywhere is an ordinary state — a deployment that has + // stored no secrets, keys or SSO configuration yet — and is not a failure. + return nil +} + +// ciphertextFields names, per collection, the fields that hold hex ciphertext. +// A value is a candidate only if it is a hex string long enough to carry a GCM +// nonce and tag, which is what keeps this from probing a plaintext field. +var ciphertextFields = map[string][]string{ + "keys": {"private_key_enc", "passphrase_enc"}, + "secrets": {"values"}, + "auth_providers": {"client_secret_enc"}, + "console_sessions": {"rdp_password_enc", "vnc_password_enc"}, + "settings": {"secrets_token_hash_enc"}, +} + +func findCiphertext(ctx context.Context, db *mongo.Database, coll string) (string, bool, error) { + fields, ok := ciphertextFields[coll] + if !ok { + return "", false, nil + } + cur, err := db.Collection(coll).Find(ctx, bson.M{}) + if err != nil { + return "", false, fmt.Errorf("probe %s: %w", coll, err) + } + defer cur.Close(ctx) + + for cur.Next(ctx) { + var doc bson.M + if err := cur.Decode(&doc); err != nil { + return "", false, fmt.Errorf("probe %s: %w", coll, err) + } + for _, f := range fields { + if v, ok := looksLikeCiphertext(doc[f]); ok { + return v, true, nil + } + } + } + return "", false, cur.Err() +} + +// looksLikeCiphertext accepts a hex string long enough to be a sealed value, and +// descends one level into a map so secrets' values sub-document is reachable. +func looksLikeCiphertext(v any) (string, bool) { + switch t := v.(type) { + case string: + // 12-byte nonce plus a 16-byte tag is 56 hex characters before any + // plaintext at all, so anything shorter is not a sealed value. + if len(t) < 56 || !isHex(t) { + return "", false + } + return t, true + case bson.M: + for _, inner := range t { + if s, ok := looksLikeCiphertext(inner); ok { + return s, true + } + } + } + return "", false +} + +func isHex(s string) bool { + for _, c := range s { + switch { + case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F': + default: + return false + } + } + return true +} +``` + +- [ ] **Step 4: Fix the format-string bug the compiler will flag** + +`go vet` will report the `rep.problem("... fingerprint %s ...")` case that takes +no argument. Change that branch to: + +```go + case rep.KeyFingerprint == nil: + rep.problem("KEY_ENCRYPTION_KEY is not set, so the archive's fingerprint %s "+ + "cannot be checked against anything", *m.KeyFingerprint) +``` + +- [ ] **Step 5: Run the test and verify it passes** + +Run: `cd shared && MONGO_TEST_URI=mongodb://localhost:27017 go test ./backup/... -run Verify -v` +Expected: PASS, five tests. + +- [ ] **Step 6: Vet the package** + +Run: `cd shared && go vet ./backup/... ./cryptobox/...` +Expected: no output. + +- [ ] **Step 7: Commit** + +```bash +git add shared/backup/verify.go shared/backup/verify_test.go +git commit -m "feat: Add backup verify with a live decrypt probe + +A fingerprint comparison proves two archives agree about a key. Only +opening real ciphertext from the target proves the key in hand reads the +data, which is the question an operator actually has." +``` + +--- +### Task 8: The `vantagectl` module and cobra root + +**Files:** +- Create: `vantagectl/go.mod` +- Create: `vantagectl/main.go` +- Create: `vantagectl/internal/cmd/root.go` +- Create: `vantagectl/internal/cmd/root_test.go` +- Modify: `go.work` + +**Interfaces:** +- Consumes: nothing from `shared/backup` yet. +- Produces: + - `cmd.Execute(version string) error` + - `cmd.NewRoot(version string) *cobra.Command` + - `cmd.globalOpts{MongoURI, Database, KeyHex string}` populated by `cmd.resolveGlobals(c *cobra.Command) (*globalOpts, error)` + - `cmd.connect(ctx context.Context, g *globalOpts) (*mongo.Client, error)` + +- [ ] **Step 1: Create the module and wire it into the workspace** + +```bash +mkdir -p vantagectl/internal/cmd +cd vantagectl +cat > go.mod <<'MOD' +module gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl + +go 1.26 + +replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared +MOD +cd .. +``` + +Edit `go.work` so the `use` block reads: + +``` +use ( + ./admin + ./agent + ./server + ./shared + ./sitesvc + ./vantagectl +) +``` + +- [ ] **Step 2: Add the dependencies** + +```bash +cd vantagectl +go get github.com/spf13/cobra@latest +go get go.mongodb.org/mongo-driver/v2@v2.8.0 +go get golang.org/x/term@latest +go mod tidy +cd .. +``` + +Confirm `shared/go.mod` did **not** gain cobra: + +```bash +grep -c cobra shared/go.mod +``` +Expected: `0`. + +- [ ] **Step 3: Write the failing test** + +Create `vantagectl/internal/cmd/root_test.go`: + +```go +package cmd + +import ( + "bytes" + "strings" + "testing" +) + +func TestRootListsEverySubcommand(t *testing.T) { + root := NewRoot("test") + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"--help"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + for _, want := range []string{"backup", "restore", "inspect", "verify"} { + if !strings.Contains(buf.String(), want) { + t.Fatalf("help does not mention %q:\n%s", want, buf.String()) + } + } +} + +func TestGlobalFlagsFallBackToEnvironment(t *testing.T) { + t.Setenv("MONGO_URI", "mongodb://env:27017") + t.Setenv("MONGO_DB", "envdb") + t.Setenv("KEY_ENCRYPTION_KEY", "envkey") + + root := NewRoot("test") + g, err := resolveGlobals(root) + if err != nil { + t.Fatalf("resolveGlobals: %v", err) + } + if g.MongoURI != "mongodb://env:27017" { + t.Fatalf("MongoURI %q", g.MongoURI) + } + if g.Database != "envdb" { + t.Fatalf("Database %q", g.Database) + } + if g.KeyHex != "envkey" { + t.Fatalf("KeyHex %q", g.KeyHex) + } +} + +func TestExplicitFlagsBeatEnvironment(t *testing.T) { + t.Setenv("MONGO_URI", "mongodb://env:27017") + t.Setenv("MONGO_DB", "envdb") + + root := NewRoot("test") + if err := root.PersistentFlags().Set("mongo-uri", "mongodb://flag:27017"); err != nil { + t.Fatalf("set flag: %v", err) + } + if err := root.PersistentFlags().Set("db", "flagdb"); err != nil { + t.Fatalf("set flag: %v", err) + } + g, err := resolveGlobals(root) + if err != nil { + t.Fatalf("resolveGlobals: %v", err) + } + if g.MongoURI != "mongodb://flag:27017" { + t.Fatalf("MongoURI %q; the flag must win over the environment", g.MongoURI) + } + if g.Database != "flagdb" { + t.Fatalf("Database %q", g.Database) + } +} + +func TestDatabaseFallsBackToURIPath(t *testing.T) { + t.Setenv("MONGO_URI", "mongodb://host:27017/fromuri") + root := NewRoot("test") + g, err := resolveGlobals(root) + if err != nil { + t.Fatalf("resolveGlobals: %v", err) + } + if g.Database != "fromuri" { + t.Fatalf("Database %q, want fromuri", g.Database) + } +} + +func TestMissingURIIsAnError(t *testing.T) { + t.Setenv("MONGO_URI", "") + root := NewRoot("test") + if _, err := resolveGlobals(root); err == nil { + t.Fatal("resolveGlobals accepted an empty MONGO_URI") + } +} + +func TestVersionIsReported(t *testing.T) { + root := NewRoot("1.2.3") + if root.Version != "1.2.3" { + t.Fatalf("Version %q", root.Version) + } +} +``` + +- [ ] **Step 4: Run the test and verify it fails** + +Run: `cd vantagectl && go test ./internal/cmd/...` +Expected: FAIL — `undefined: NewRoot`. + +- [ ] **Step 5: Write the root command** + +Create `vantagectl/internal/cmd/root.go`: + +```go +// Package cmd is vantagectl's command tree. +// +// It holds argument parsing and operator-facing output only. Everything it does +// to a database goes through shared/backup, which the server can also import. +package cmd + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +const connectTimeout = 30 * time.Second + +// globalOpts is what every subcommand needs. +type globalOpts struct { + MongoURI string + Database string + KeyHex string +} + +// NewRoot builds the command tree. +func NewRoot(version string) *cobra.Command { + root := &cobra.Command{ + Use: "vantagectl", + Short: "Back up and restore a Vantage control plane", + Version: version, + Long: "vantagectl backs up and restores the MongoDB database behind a Vantage\n" + + "control plane.\n\n" + + "It talks to MongoDB directly and never to the Vantage API, so it works\n" + + "against a control plane that is down, half-migrated, or gone.\n\n" + + "KEY_ENCRYPTION_KEY is never written into an archive. What an archive\n" + + "records is a fingerprint of it, so a restore can tell you that the key\n" + + "you hold is the wrong one before it writes a database nobody can read.", + SilenceUsage: true, + } + + f := root.PersistentFlags() + f.String("mongo-uri", "", "MongoDB connection string (env MONGO_URI)") + f.String("db", "", "database name (env MONGO_DB, or the URI path)") + + root.AddCommand(newBackupCmd(), newRestoreCmd(), newInspectCmd(), newVerifyCmd()) + return root +} + +// Execute runs the tree. +func Execute(version string) error { + return NewRoot(version).Execute() +} + +// resolveGlobals applies the environment fallback. +// +// Explicit flags win. The check is on Changed rather than on emptiness, so +// `--db ""` is an explicit empty value rather than an invitation to read the +// environment behind the operator's back. +func resolveGlobals(c *cobra.Command) (*globalOpts, error) { + root := c.Root() + f := root.PersistentFlags() + + uri, err := f.GetString("mongo-uri") + if err != nil { + return nil, err + } + if !f.Changed("mongo-uri") { + uri = os.Getenv("MONGO_URI") + } + if uri == "" { + return nil, fmt.Errorf("no MongoDB URI: pass --mongo-uri or set MONGO_URI") + } + + db, err := f.GetString("db") + if err != nil { + return nil, err + } + if !f.Changed("db") { + db = os.Getenv("MONGO_DB") + } + if db == "" { + db = databaseFromURI(uri) + } + if db == "" { + return nil, fmt.Errorf("no database name: pass --db, set MONGO_DB, or put one in the URI path") + } + + return &globalOpts{ + MongoURI: uri, + Database: db, + KeyHex: strings.TrimSpace(os.Getenv("KEY_ENCRYPTION_KEY")), + }, nil +} + +// databaseFromURI reads the database out of the URI path. sitesvc takes its +// database name this way too, so an operator who has configured one has +// configured both. +func databaseFromURI(uri string) string { + u, err := url.Parse(uri) + if err != nil { + return "" + } + return strings.Trim(u.Path, "/") +} + +// connect dials MongoDB and proves the connection before a caller commits to +// anything. +func connect(ctx context.Context, g *globalOpts) (*mongo.Client, error) { + client, err := mongo.Connect(options.Client().ApplyURI(g.MongoURI)) + if err != nil { + return nil, fmt.Errorf("connect to MongoDB: %w", err) + } + pingCtx, cancel := context.WithTimeout(ctx, connectTimeout) + defer cancel() + if err := client.Ping(pingCtx, nil); err != nil { + _ = client.Disconnect(context.Background()) + return nil, fmt.Errorf("MongoDB did not answer: %w", err) + } + return client, nil +} +``` + +Create `vantagectl/main.go`: + +```go +// Command vantagectl backs up and restores a Vantage control plane. +package main + +import ( + "fmt" + "os" + + "gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl/internal/cmd" +) + +// Version is stamped at build time with -ldflags "-X main.Version=...". +var Version = "dev" + +func main() { + if err := cmd.Execute(Version); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} +``` + +- [ ] **Step 6: Add empty subcommand constructors so the package compiles** + +Create `vantagectl/internal/cmd/backup.go`, `restore.go`, `inspect.go` and +`verify.go`, each holding only its constructor for now. These are replaced in +full by Tasks 9 and 10. + +```go +package cmd + +import "github.com/spf13/cobra" + +func newBackupCmd() *cobra.Command { + return &cobra.Command{Use: "backup", Short: "Write an archive of the database"} +} +``` + +```go +package cmd + +import "github.com/spf13/cobra" + +func newRestoreCmd() *cobra.Command { + return &cobra.Command{Use: "restore ARCHIVE", Short: "Load an archive into a database"} +} +``` + +```go +package cmd + +import "github.com/spf13/cobra" + +func newInspectCmd() *cobra.Command { + return &cobra.Command{Use: "inspect ARCHIVE", Short: "Print an archive's manifest"} +} +``` + +```go +package cmd + +import "github.com/spf13/cobra" + +func newVerifyCmd() *cobra.Command { + return &cobra.Command{Use: "verify ARCHIVE", Short: "Check an archive against the key in hand"} +} +``` + +- [ ] **Step 7: Run the test and verify it passes** + +Run: `cd vantagectl && go test ./internal/cmd/...` +Expected: PASS, six tests. + +- [ ] **Step 8: Confirm the other modules are untouched** + +```bash +cd server && go build ./... && cd ../admin && go build ./... && cd ../sitesvc && go build ./... +git diff --stat server/go.sum admin/go.sum sitesvc/go.sum +``` +Expected: builds succeed, `git diff --stat` prints nothing — cobra stayed out of their module graphs. + +- [ ] **Step 9: Commit** + +```bash +git add go.work vantagectl +git commit -m "feat: Add the vantagectl module and its cobra root + +Its own module rather than a package under shared, so cobra and pflag stay +out of the module graphs of server, admin and sitesvc, which never use +them." +``` + +--- + +### Task 9: `backup` and `inspect` subcommands + +**Files:** +- Modify: `vantagectl/internal/cmd/backup.go` +- Modify: `vantagectl/internal/cmd/inspect.go` +- Create: `vantagectl/internal/cmd/inspect_test.go` + +**Interfaces:** +- Consumes: `resolveGlobals`, `connect`, `backup.Dump`, `backup.DumpOptions`, `backup.Open`, `backup.Manifest`. +- Produces: `cmd.archiveName(database string, at time.Time) string`, `cmd.renderManifest(w io.Writer, m backup.Manifest)`. + +- [ ] **Step 1: Write the failing test** + +Create `vantagectl/internal/cmd/inspect_test.go`: + +```go +package cmd + +import ( + "bytes" + "strings" + "testing" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/backup" +) + +func TestArchiveNameIsSortableAndNamesTheDatabase(t *testing.T) { + at := time.Date(2026, 9, 7, 14, 30, 5, 0, time.UTC) + got := archiveName("vantage", at) + if !strings.HasPrefix(got, "vantage-backup-vantage-") { + t.Fatalf("name %q does not name the database", got) + } + if !strings.HasSuffix(got, ".tar.gz") { + t.Fatalf("name %q has the wrong extension", got) + } + if strings.ContainsAny(got, ":") { + t.Fatalf("name %q contains a colon, which Windows will not accept", got) + } + if !strings.Contains(got, "20260907") { + t.Fatalf("name %q does not carry a sortable date", got) + } +} + +func TestRenderManifestShowsWhatMatters(t *testing.T) { + fp := "ab12" + m := backup.Manifest{ + FormatVersion: backup.FormatVersion, + CreatedAt: time.Date(2026, 9, 7, 14, 0, 0, 0, time.UTC), + VantageVersion: "1.4.0", + Hostname: "ops-box", + MongoDB: "vantage", + MongoServerVersion: "7.0.5", + KeyFingerprint: &fp, + Collections: []backup.CollectionEntry{ + {Name: "servers", Documents: 12, Bytes: 4096}, + {Name: "keys", Documents: 3, Bytes: 900}, + }, + Excluded: []string{"audit_logs"}, + } + + var buf bytes.Buffer + renderManifest(&buf, m) + out := buf.String() + + for _, want := range []string{ + "vantage", "1.4.0", "ops-box", "7.0.5", "ab12", + "servers", "12", "keys", "audit_logs", "2026-09-07", + } { + if !strings.Contains(out, want) { + t.Fatalf("inspect output missing %q:\n%s", want, out) + } + } +} + +func TestRenderManifestFlagsAMissingFingerprint(t *testing.T) { + var buf bytes.Buffer + renderManifest(&buf, backup.Manifest{FormatVersion: backup.FormatVersion}) + out := buf.String() + if !strings.Contains(out, "none recorded") { + t.Fatalf("a null fingerprint must be called out, got:\n%s", out) + } + if !strings.Contains(out, "cannot be checked") { + t.Fatalf("a null fingerprint must explain the consequence, got:\n%s", out) + } +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `cd vantagectl && go test ./internal/cmd/... -run 'ArchiveName|RenderManifest'` +Expected: FAIL — `undefined: archiveName`. + +- [ ] **Step 3: Write `inspect`** + +Replace `vantagectl/internal/cmd/inspect.go`: + +```go +package cmd + +import ( + "fmt" + "io" + "strings" + "text/tabwriter" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/backup" + "github.com/spf13/cobra" +) + +func newInspectCmd() *cobra.Command { + return &cobra.Command{ + Use: "inspect ARCHIVE", + Short: "Print an archive's manifest", + Long: "inspect reads an archive and prints what it holds. It contacts no\n" + + "database, so it is safe to run against an archive of unknown origin\n" + + "and is the fastest way to find out whether one is worth anything.", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + archive, err := backup.Open(args[0]) + if err != nil { + return err + } + defer archive.Close() + renderManifest(c.OutOrStdout(), archive.Manifest()) + return nil + }, + } +} + +// renderManifest prints a manifest for a human. +func renderManifest(w io.Writer, m backup.Manifest) { + fmt.Fprintf(w, "Created %s\n", m.CreatedAt.UTC().Format("2006-01-02 15:04:05 MST")) + fmt.Fprintf(w, "Database %s\n", m.MongoDB) + fmt.Fprintf(w, "MongoDB %s\n", m.MongoServerVersion) + fmt.Fprintf(w, "Written by vantagectl %s on %s\n", m.VantageVersion, m.Hostname) + fmt.Fprintf(w, "Format version %d\n", m.FormatVersion) + + if m.KeyFingerprint == nil { + fmt.Fprintf(w, "Key none recorded — this archive cannot be checked "+ + "against any KEY_ENCRYPTION_KEY\n") + } else { + fmt.Fprintf(w, "Key %s\n", *m.KeyFingerprint) + } + if len(m.Excluded) > 0 { + fmt.Fprintf(w, "Excluded %s\n", strings.Join(m.Excluded, ", ")) + } + + var docs, bytes int64 + for _, c := range m.Collections { + docs += c.Documents + bytes += c.Bytes + } + fmt.Fprintf(w, "\n%d collections, %d documents, %s\n\n", + len(m.Collections), docs, humanBytes(bytes)) + + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "COLLECTION\tDOCUMENTS\tSIZE") + for _, c := range m.Collections { + fmt.Fprintf(tw, "%s\t%d\t%s\n", c.Name, c.Documents, humanBytes(c.Bytes)) + } + tw.Flush() +} + +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for v := n / unit; v >= unit; v /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTP"[exp]) +} +``` + +- [ ] **Step 4: Write `backup`** + +Replace `vantagectl/internal/cmd/backup.go`: + +```go +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/backup" + "github.com/spf13/cobra" +) + +func newBackupCmd() *cobra.Command { + var ( + out string + exclude []string + allowNoKey bool + ) + + c := &cobra.Command{ + Use: "backup", + Short: "Write an archive of the database", + Long: "backup writes every collection in the database to a gzipped tar\n" + + "archive, along with a fingerprint of KEY_ENCRYPTION_KEY.\n\n" + + "The key itself is never written. The fingerprint is what lets a later\n" + + "restore refuse rather than produce a database whose secrets nobody\n" + + "can read.\n\n" + + "Pass --out - to stream to stdout, which is how this composes with\n" + + "restic, age, or aws s3 cp -.", + Args: cobra.NoArgs, + RunE: func(c *cobra.Command, _ []string) error { + ctx := c.Context() + g, err := resolveGlobals(c) + if err != nil { + return err + } + client, err := connect(ctx, g) + if err != nil { + return err + } + defer client.Disconnect(context.Background()) + + w, closeOut, name, err := backupDestination(out, g.Database) + if err != nil { + return err + } + defer closeOut() + + m, err := backup.Dump(ctx, backup.DumpOptions{ + Client: client, + Database: g.Database, + Exclude: exclude, + KeyHex: g.KeyHex, + AllowNoKey: allowNoKey, + VantageVersion: c.Root().Version, + Out: w, + }) + if err != nil { + return err + } + + // Progress goes to stderr so --out - stays a clean pipe. + var docs int64 + for _, coll := range m.Collections { + docs += coll.Documents + } + fmt.Fprintf(c.ErrOrStderr(), "wrote %s: %d collections, %d documents\n", + name, len(m.Collections), docs) + if m.KeyFingerprint == nil { + fmt.Fprintln(c.ErrOrStderr(), + "warning: no key recorded; nothing in this archive proves its "+ + "ciphertext can ever be read") + } + return nil + }, + } + + c.Flags().StringVar(&out, "out", ".", "directory to write the archive into, or - for stdout") + c.Flags().StringSliceVar(&exclude, "exclude", nil, + "collections to leave out, comma separated (recorded in the manifest)") + c.Flags().BoolVar(&allowNoKey, "allow-no-key", false, + "back up without KEY_ENCRYPTION_KEY set; only for a deployment storing no encrypted data") + return c +} + +// backupDestination resolves --out to a writer, a closer and a name to print. +func backupDestination(out, database string) (io.Writer, func(), string, error) { + if out == "-" { + return os.Stdout, func() {}, "stdout", nil + } + name := archiveName(database, time.Now().UTC()) + path := filepath.Join(out, name) + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return nil, nil, "", fmt.Errorf("create %s: %w", path, err) + } + return f, func() { f.Close() }, path, nil +} + +// archiveName is sortable and carries no colon, because an operator will copy +// these onto a Windows share sooner or later and a colon is not a legal +// filename character there. +func archiveName(database string, at time.Time) string { + return fmt.Sprintf("vantage-backup-%s-%s.tar.gz", database, at.Format("20060102T150405Z")) +} +``` + +- [ ] **Step 5: Run the tests** + +Run: `cd vantagectl && go test ./internal/cmd/...` +Expected: PASS, nine tests. + +- [ ] **Step 6: Exercise both commands end to end against a real database** + +```bash +docker run -d --rm -p 27017:27017 --name vantage-test-mongo mongo:7 +cd vantagectl +export MONGO_URI=mongodb://localhost:27017 +export MONGO_DB=vantage_smoke +export KEY_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000001 +go run . backup --out /tmp +go run . inspect /tmp/vantage-backup-vantage_smoke-*.tar.gz +``` + +Expected: `backup` reports what it wrote; `inspect` prints the manifest with a +key fingerprint and a collection table. An empty database is fine — the point +here is that both commands run. + +Then confirm the refusal: + +```bash +KEY_ENCRYPTION_KEY= go run . backup --out /tmp +``` +Expected: exits non-zero with `KEY_ENCRYPTION_KEY is not set`. + +- [ ] **Step 7: Commit** + +```bash +git add vantagectl/internal/cmd/backup.go vantagectl/internal/cmd/inspect.go vantagectl/internal/cmd/inspect_test.go +git commit -m "feat: Add the vantagectl backup and inspect subcommands + +Progress output goes to stderr so --out - stays a clean pipe into restic, +age or aws s3 cp. Archive names carry no colon, because these get copied +onto Windows shares." +``` + +--- + +### Task 10: `restore` and `verify` subcommands + +**Files:** +- Modify: `vantagectl/internal/cmd/restore.go` +- Modify: `vantagectl/internal/cmd/verify.go` +- Create: `vantagectl/internal/cmd/restore_test.go` + +**Interfaces:** +- Consumes: `resolveGlobals`, `connect`, `backup.Open`, `backup.Restore`, `backup.RestoreOptions`, `backup.Verify`, `backup.VerifyOptions`. +- Produces: `cmd.confirmDestruction(in io.Reader, out io.Writer, isTTY bool, confirmDB, database string) error`, `cmd.ErrNotConfirmed`. + +- [ ] **Step 1: Write the failing test** + +Create `vantagectl/internal/cmd/restore_test.go`: + +```go +package cmd + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +func TestConfirmDestructionNonTTYRequiresMatchingFlag(t *testing.T) { + var out bytes.Buffer + err := confirmDestruction(strings.NewReader(""), &out, false, "", "vantage") + if !errors.Is(err, ErrNotConfirmed) { + t.Fatalf("got %v, want ErrNotConfirmed", err) + } + if !strings.Contains(err.Error(), "--confirm-db vantage") { + t.Fatalf("the error must tell the operator exactly what to pass, got: %v", err) + } +} + +func TestConfirmDestructionNonTTYRejectsWrongDatabase(t *testing.T) { + var out bytes.Buffer + err := confirmDestruction(strings.NewReader(""), &out, false, "staging", "production") + if !errors.Is(err, ErrNotConfirmed) { + t.Fatalf("got %v, want ErrNotConfirmed", err) + } + if !strings.Contains(err.Error(), "production") { + t.Fatalf("the error must name the real target, got: %v", err) + } +} + +func TestConfirmDestructionNonTTYAcceptsMatchingFlag(t *testing.T) { + var out bytes.Buffer + if err := confirmDestruction(strings.NewReader(""), &out, false, "vantage", "vantage"); err != nil { + t.Fatalf("matching --confirm-db rejected: %v", err) + } +} + +func TestConfirmDestructionTTYRequiresTypedName(t *testing.T) { + var out bytes.Buffer + if err := confirmDestruction(strings.NewReader("vantage\n"), &out, true, "", "vantage"); err != nil { + t.Fatalf("typed name rejected: %v", err) + } + if !strings.Contains(out.String(), "vantage") { + t.Fatalf("the prompt must name the database, got: %s", out.String()) + } +} + +func TestConfirmDestructionTTYRejectsWrongTypedName(t *testing.T) { + var out bytes.Buffer + err := confirmDestruction(strings.NewReader("something else\n"), &out, true, "", "vantage") + if !errors.Is(err, ErrNotConfirmed) { + t.Fatalf("got %v, want ErrNotConfirmed", err) + } +} + +func TestConfirmDestructionTTYFlagSkipsThePrompt(t *testing.T) { + var out bytes.Buffer + if err := confirmDestruction(strings.NewReader(""), &out, true, "vantage", "vantage"); err != nil { + t.Fatalf("matching --confirm-db rejected on a TTY: %v", err) + } + if out.Len() != 0 { + t.Fatalf("--confirm-db must skip the prompt, got: %s", out.String()) + } +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `cd vantagectl && go test ./internal/cmd/... -run Confirm` +Expected: FAIL — `undefined: confirmDestruction`. + +- [ ] **Step 3: Write `restore`** + +Replace `vantagectl/internal/cmd/restore.go`: + +```go +package cmd + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/backup" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// ErrNotConfirmed is returned when a destructive restore was not confirmed. +var ErrNotConfirmed = errors.New("restore not confirmed") + +func newRestoreCmd() *cobra.Command { + var ( + force bool + confirmDB string + ignoreKeyErr bool + ) + + c := &cobra.Command{ + Use: "restore ARCHIVE", + Short: "Load an archive into a database", + Long: "restore loads an archive into a MongoDB database.\n\n" + + "The target is expected to be empty. A database that already holds data\n" + + "is refused unless --force is given, which drops each collection in the\n" + + "archive before loading it. There are no merge semantics: merging two\n" + + "control planes reconciles nothing, and upserting would resurrect\n" + + "revoked keys and deleted users.\n\n" + + "Restore does not touch Redis. Sessions are all it holds, so everyone\n" + + "signs in again.", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + ctx := c.Context() + g, err := resolveGlobals(c) + if err != nil { + return err + } + + archive, err := backup.Open(args[0]) + if err != nil { + return err + } + defer archive.Close() + + if force { + isTTY := term.IsTerminal(int(os.Stdin.Fd())) + if err := confirmDestruction(c.InOrStdin(), c.OutOrStdout(), isTTY, + confirmDB, g.Database); err != nil { + return err + } + } + + client, err := connect(ctx, g) + if err != nil { + return err + } + defer client.Disconnect(context.Background()) + + res, err := backup.Restore(ctx, backup.RestoreOptions{ + Client: client, + Database: g.Database, + Archive: archive, + Force: force, + KeyHex: g.KeyHex, + IgnoreKeyMismatch: ignoreKeyErr, + Warn: func(s string) { + fmt.Fprintln(c.ErrOrStderr(), "warning:", s) + }, + }) + if err != nil { + return err + } + + tw := tabwriter.NewWriter(c.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "COLLECTION\tDOCUMENTS\tINDEXES") + for _, coll := range res.Collections { + fmt.Fprintf(tw, "%s\t%d\t%d\n", coll.Name, coll.Documents, coll.Indexes) + } + tw.Flush() + fmt.Fprintf(c.OutOrStdout(), "\nrestored %d collections into %s\n", + len(res.Collections), g.Database) + return nil + }, + } + + c.Flags().BoolVar(&force, "force", false, + "drop each collection in the archive before loading it") + c.Flags().StringVar(&confirmDB, "confirm-db", "", + "name of the database being overwritten; required with --force when there is no terminal") + c.Flags().BoolVar(&ignoreKeyErr, "ignore-key-mismatch", false, + "restore even though KEY_ENCRYPTION_KEY does not match the archive") + return c +} + +// confirmDestruction gates a --force restore. +// +// On a terminal the operator types the database name. Without one — a +// Kubernetes Job, a CI step, a cron entry — the same assurance comes from +// --confirm-db, whose value must equal the target. Naming the database in the +// argument means a copy-pasted command carries its intended target with it and +// cannot destroy a different one. +func confirmDestruction(in io.Reader, out io.Writer, isTTY bool, confirmDB, database string) error { + if confirmDB != "" { + if confirmDB != database { + return fmt.Errorf("%w: --confirm-db says %q but the target is %q", + ErrNotConfirmed, confirmDB, database) + } + return nil + } + if !isTTY { + return fmt.Errorf("%w: --force with no terminal needs --confirm-db %s", + ErrNotConfirmed, database) + } + + fmt.Fprintf(out, "This drops every collection in the archive from %q and reloads it.\n", database) + fmt.Fprintf(out, "Type the database name to continue: ") + + line, err := bufio.NewReader(in).ReadString('\n') + if err != nil && err != io.EOF { + return fmt.Errorf("%w: %v", ErrNotConfirmed, err) + } + if strings.TrimSpace(line) != database { + return fmt.Errorf("%w: that is not %q", ErrNotConfirmed, database) + } + return nil +} +``` + +- [ ] **Step 4: Write `verify`** + +Replace `vantagectl/internal/cmd/verify.go`: + +```go +package cmd + +import ( + "context" + "fmt" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/backup" + "github.com/spf13/cobra" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +func newVerifyCmd() *cobra.Command { + return &cobra.Command{ + Use: "verify ARCHIVE", + Short: "Check an archive against the key in hand", + Long: "verify checks that an archive is intact and that the\n" + + "KEY_ENCRYPTION_KEY in this environment matches the one it was made\n" + + "with.\n\n" + + "Given --mongo-uri it goes further and opens a real ciphertext value\n" + + "from that database. A fingerprint proves two archives agree about a\n" + + "key; only the probe proves the key you hold reads the data.\n\n" + + "Exit status is non-zero when anything is wrong, so this is the command\n" + + "to put on a schedule.", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + ctx := c.Context() + + archive, err := backup.Open(args[0]) + if err != nil { + return err + } + defer archive.Close() + + opt := backup.VerifyOptions{Archive: archive} + + // A database is optional here. resolveGlobals fails without a URI, + // so its error is a signal to verify the archive alone rather than + // a reason to stop. + var client *mongo.Client + if g, gerr := resolveGlobals(c); gerr == nil { + client, err = connect(ctx, g) + if err != nil { + return err + } + defer client.Disconnect(context.Background()) + opt.Client = client + opt.Database = g.Database + opt.KeyHex = g.KeyHex + } else { + fmt.Fprintln(c.ErrOrStderr(), + "note: no MongoDB URI, so this checks the archive and the key only") + } + + rep, err := backup.Verify(ctx, opt) + if err != nil { + return err + } + + out := c.OutOrStdout() + fmt.Fprintln(out, "Archive intact, every member matches its checksum") + if rep.ArchiveFingerprint != nil { + fmt.Fprintf(out, "Archive key %s\n", *rep.ArchiveFingerprint) + } + if rep.KeyFingerprint != nil { + fmt.Fprintf(out, "Your key %s\n", *rep.KeyFingerprint) + } + if rep.KeyMatchesArchive { + fmt.Fprintln(out, "Key match yes") + } + switch { + case rep.ProbeDecrypted: + fmt.Fprintf(out, "Live probe decrypted a value from %s\n", rep.ProbeCollection) + case rep.ProbeAttempted: + fmt.Fprintf(out, "Live probe FAILED against %s\n", rep.ProbeCollection) + case opt.Client != nil: + fmt.Fprintln(out, "Live probe skipped; this database stores no ciphertext yet") + } + + if rep.OK() { + fmt.Fprintln(out, "\nThis archive will restore.") + return nil + } + fmt.Fprintln(out) + for _, p := range rep.Problems { + fmt.Fprintln(out, "problem:", p) + } + return fmt.Errorf("verification failed") + }, + } +} +``` + +- [ ] **Step 5: Run the tests** + +Run: `cd vantagectl && go test ./internal/cmd/...` +Expected: PASS, fifteen tests. + +- [ ] **Step 6: Exercise the full cycle against a real database** + +```bash +cd vantagectl +export MONGO_URI=mongodb://localhost:27017 +export KEY_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000001 + +MONGO_DB=vantage_smoke go run . backup --out /tmp +ARCHIVE=$(ls -t /tmp/vantage-backup-vantage_smoke-*.tar.gz | head -1) + +MONGO_DB=vantage_restored go run . restore "$ARCHIVE" +MONGO_DB=vantage_restored go run . verify "$ARCHIVE" + +# The refusal path: a second restore over a populated database. +MONGO_DB=vantage_restored go run . restore "$ARCHIVE" +``` + +Expected: the first restore succeeds, `verify` reports the archive will +restore, and the second restore exits non-zero with `target database is not +empty`. + +Then the non-TTY confirmation: + +```bash +MONGO_DB=vantage_restored go run . restore "$ARCHIVE" --force < /dev/null +``` +Expected: exits non-zero, telling you to pass `--confirm-db vantage_restored`. + +```bash +MONGO_DB=vantage_restored go run . restore "$ARCHIVE" --force --confirm-db vantage_restored < /dev/null +``` +Expected: succeeds. + +- [ ] **Step 7: Commit** + +```bash +git add vantagectl/internal/cmd/restore.go vantagectl/internal/cmd/verify.go vantagectl/internal/cmd/restore_test.go +git commit -m "feat: Add the vantagectl restore and verify subcommands + +--force requires a typed database name on a terminal and --confirm-db +without one, so a copy-pasted restore command carries its intended target +and cannot destroy a different database." +``` + +--- +### Task 11: Container image and CI + +**Files:** +- Create: `vantagectl/Dockerfile` +- Create: `.gitea/workflows/vantagectl-release.yml` +- Modify: `.gitea/workflows/server-deploy.yml` + +**Interfaces:** +- Consumes: the built `vantagectl` module from Tasks 8 to 10. +- Produces: image `${DOCKER_HOST}//vantage/vantagectl:latest`, and release assets `vantagectl-{linux-amd64,linux-arm64,darwin-arm64,windows-amd64.exe}` plus `checksums.txt`. + +- [ ] **Step 1: Write the Dockerfile** + +Create `vantagectl/Dockerfile`: + +```dockerfile +# Build stage +# +# Context is the repository root, not vantagectl/, because vantagectl depends on +# the shared module through a replace directive. +FROM golang:1.26 AS builder + +WORKDIR /src + +# Manifests first so the dependency layer caches independently of source edits. +COPY shared/go.mod shared/go.sum ./shared/ +COPY vantagectl/go.mod vantagectl/go.sum ./vantagectl/ +RUN cd vantagectl && go mod download + +COPY shared/ ./shared/ +COPY vantagectl/ ./vantagectl/ + +ARG VERSION=dev +RUN cd vantagectl && CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w -X main.Version=${VERSION}" -o /vantagectl . + +# Staged so the scratch image below can have a /tmp. It cannot mkdir one +# itself — scratch has no shell. +RUN mkdir -p /staging/tmp && chmod 1777 /staging/tmp + +# Runtime stage +FROM scratch + +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ + +# restore extracts an archive here before verifying its checksums, and backup +# stages nothing but still inherits os.MkdirTemp's requirements. Without this +# every restore stops at "temp dir: stat /tmp: no such file or directory". +COPY --from=builder /staging/tmp /tmp +COPY --from=builder /vantagectl /vantagectl + +ENTRYPOINT ["/vantagectl"] +``` + +- [ ] **Step 2: Build the image and run it** + +```bash +docker build -t vantagectl:test -f vantagectl/Dockerfile . +docker run --rm vantagectl:test --help +``` +Expected: the help text lists `backup`, `restore`, `inspect` and `verify`. + +- [ ] **Step 3: Prove the `/tmp` copy is load-bearing** + +Temporarily comment out the `COPY --from=builder /staging/tmp /tmp` line, +rebuild as `vantagectl:notmp`, and run a restore against any archive. It must +fail with a `/tmp` error. Restore the line and rebuild. This is a manual check, +not a committed test — the point is that the next person to trim the Dockerfile +learns why the line is there. + +- [ ] **Step 4: Add the release workflow** + +Create `.gitea/workflows/vantagectl-release.yml`: + +```yaml +name: vantagectl Release + +on: + push: + tags: + - "vantagectl/v*" + +jobs: + build: + runs-on: ubuntu-docker + container: node:26 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26" + cache: true + cache-dependency-path: vantagectl/go.sum + + - name: Extract version + id: version + run: echo "VERSION=${GITHUB_REF_NAME#vantagectl/}" >> $GITHUB_OUTPUT + + - name: Test + working-directory: vantagectl + run: go test ./... + + - name: Build + working-directory: vantagectl + env: + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + mkdir -p dist + for target in linux/amd64 linux/arm64 darwin/arm64 windows/amd64; do + goos="${target%/*}" + goarch="${target#*/}" + out="dist/vantagectl-${goos}-${goarch}" + if [ "$goos" = "windows" ]; then out="${out}.exe"; fi + CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o "$out" . + done + + - name: Checksums + working-directory: vantagectl/dist + run: sha256sum vantagectl-* > checksums.txt + + - name: Create release + uses: https://gitea.com/actions/gitea-release-action@v1 + with: + token: ${{ secrets.RELEASE_TOKEN }} + files: | + vantagectl/dist/vantagectl-linux-amd64 + vantagectl/dist/vantagectl-linux-arm64 + vantagectl/dist/vantagectl-darwin-arm64 + vantagectl/dist/vantagectl-windows-amd64.exe + vantagectl/dist/checksums.txt +``` + +- [ ] **Step 5: Add the image to `server-deploy.yml`** + +In the change-detection block, add a line after the three existing Go flags: + +```bash + flag vantagectl '^(vantagectl/|shared/|go\.work)' +``` + +Update the comment above those flags so it stays true: + +```bash + # The four Go images build from the repo root and COPY + # shared/ plus their own directory, so shared/ rebuilds all + # four. proto/ is in server's list as insurance: the +``` + +Add a build step alongside the others: + +```yaml + - name: Build and push vantagectl image + if: steps.changed.outputs.vantagectl == 'true' + run: | + IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/vantagectl:latest" + # Root context: vantagectl depends on the shared module. + docker build -t "$IMAGE" -f vantagectl/Dockerfile . + docker push "$IMAGE" +``` + +- [ ] **Step 6: Check the workflow parses** + +Run: `python3 -c "import yaml,sys; [yaml.safe_load(open(p)) for p in ['.gitea/workflows/server-deploy.yml','.gitea/workflows/vantagectl-release.yml']]; print('ok')"` +Expected: `ok`. + +- [ ] **Step 7: Commit** + +```bash +git add vantagectl/Dockerfile .gitea/workflows/vantagectl-release.yml .gitea/workflows/server-deploy.yml +git commit -m "feat: Build and publish vantagectl + +The scratch runtime stage copies an explicit /tmp: restore extracts an +archive there before verifying it, and a scratch image has none. + +shared/ now fans out to four Go images rather than three." +``` + +--- + +### Task 12: Helm CronJob + +**Files:** +- Create: `deploy/chart/vantage/templates/backup-cronjob.yaml` +- Modify: `deploy/chart/vantage/values.yaml` +- Modify: `deploy/chart/vantage/templates/NOTES.txt` + +**Interfaces:** +- Consumes: the image from Task 11. +- Produces: values `backup.enabled`, `backup.schedule`, `backup.image`, `backup.pvcName`, `backup.exclude`, `backup.successfulJobsHistoryLimit`, `backup.failedJobsHistoryLimit`, `backup.resources`. + +- [ ] **Step 1: Read how the existing templates reference secrets** + +Run: `sed -n '1,80p' deploy/chart/vantage/templates/server.yaml` + +Match whatever that file does for `MONGO_URI` and `KEY_ENCRYPTION_KEY` exactly. +The CronJob must reference the same secret keys rather than declaring its own — +a backup job with its own copy of the encryption key is a second place for it to +be wrong. + +- [ ] **Step 2: Add the values** + +Append to `deploy/chart/vantage/values.yaml`: + +```yaml +# Scheduled backups. +# +# Off by default, deliberately. A backup with nowhere durable to land is a +# false sense of safety, and the chart cannot know where that is — pvcName +# must name a volume you have decided will outlive the cluster. +# +# There is no restore manifest here on purpose: a restore is an operator +# decision with a confirmation attached, and must never be something a +# `helm upgrade` can trigger. Run one as a `kubectl run` Job with +# --confirm-db. +backup: + enabled: false + schedule: "0 2 * * *" + image: "" + pvcName: "" + # Collections to leave out. Recorded in each archive's manifest, so an + # archive can never claim to be complete when it is not. + exclude: [] + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + resources: {} +``` + +- [ ] **Step 3: Write the template** + +Create `deploy/chart/vantage/templates/backup-cronjob.yaml`: + +```yaml +{{- if .Values.backup.enabled }} +{{- if not .Values.backup.pvcName }} +{{- fail "backup.enabled requires backup.pvcName: a backup needs somewhere durable to land, and the chart cannot guess where that is" }} +{{- end }} +{{- if not .Values.backup.image }} +{{- fail "backup.enabled requires backup.image: the vantagectl image to run" }} +{{- end }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "vantage.fullname" . }}-backup + labels: + {{- include "vantage.labels" . | nindent 4 }} + app.kubernetes.io/component: backup +spec: + schedule: {{ .Values.backup.schedule | quote }} + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: {{ .Values.backup.successfulJobsHistoryLimit }} + failedJobsHistoryLimit: {{ .Values.backup.failedJobsHistoryLimit }} + jobTemplate: + spec: + backoffLimit: 2 + template: + metadata: + labels: + {{- include "vantage.selectorLabels" . | nindent 12 }} + app.kubernetes.io/component: backup + spec: + restartPolicy: Never + containers: + - name: vantagectl + image: {{ .Values.backup.image | quote }} + args: + - backup + - --out + - /backups + {{- with .Values.backup.exclude }} + - --exclude + - {{ join "," . | quote }} + {{- end }} + env: + # Referenced, never redeclared. A backup job holding its own + # copy of KEY_ENCRYPTION_KEY is a second place for it to be + # wrong, and the fingerprint it stamps would then be a + # fingerprint of the wrong key. + {{- include "vantage.mongoEnv" . | nindent 16 }} + {{- include "vantage.encryptionKeyEnv" . | nindent 16 }} + volumeMounts: + - name: backups + mountPath: /backups + resources: + {{- toYaml .Values.backup.resources | nindent 16 }} + volumes: + - name: backups + persistentVolumeClaim: + claimName: {{ .Values.backup.pvcName | quote }} +{{- end }} +``` + +If `_helpers.tpl` has no `vantage.mongoEnv` or `vantage.encryptionKeyEnv`, add +them there by lifting the exact env blocks `server.yaml` already uses, and change +`server.yaml` to call the helpers too. Two copies of an env block that must agree +is the drift this chart already avoids elsewhere. + +- [ ] **Step 4: Render the chart four ways** + +```bash +cd deploy/chart +helm lint vantage +helm template vantage vantage > /dev/null +helm template vantage vantage --set backup.enabled=true --set backup.image=img --set backup.pvcName=pvc > /dev/null +``` + +Then the refusals, which must fail: + +```bash +helm template vantage vantage --set backup.enabled=true --set backup.image=img 2>&1 | grep -q "backup.pvcName" && echo "refused without a PVC" +helm template vantage vantage --set backup.enabled=true --set backup.pvcName=pvc 2>&1 | grep -q "backup.image" && echo "refused without an image" +``` +Expected: both echo their message. `helm lint` accepts a chart whose templates +never execute, so rendering is what proves the `fail` calls still fire. + +- [ ] **Step 5: Add the CI render cases** + +In `.gitea/workflows/chart-release.yml`, add the backup-enabled render to the +existing set of four, and add the two refusals to the must-be-refused set. +Follow whatever shape that file already uses; do not restructure it. + +- [ ] **Step 6: Add the NOTES.txt warning** + +Append to `deploy/chart/vantage/templates/NOTES.txt`: + +``` +{{- if not .Values.backup.enabled }} + +No backups are scheduled. Vantage encrypts SSH private keys, vault secrets and +SSO client secrets with KEY_ENCRYPTION_KEY, and that key is not stored anywhere +but your own configuration — a database restored without it is permanently +unreadable. + +Set backup.enabled, backup.image and backup.pvcName, and store +KEY_ENCRYPTION_KEY somewhere that survives this cluster. +{{- end }} +``` + +- [ ] **Step 7: Commit** + +```bash +git add deploy/chart/vantage +git commit -m "feat: Add an optional scheduled backup CronJob to the chart + +Off by default: a backup with nowhere durable to land is a false sense of +safety and the chart cannot know where that is. NOTES.txt says so when it +is off. + +No restore manifest ships: a restore must never be something a helm +upgrade can trigger." +``` + +--- + +### Task 13: Documentation + +**Files:** +- Create: `docsite/docs/operations/backup-and-restore.md` +- Modify: `docsite/sidebars.ts` +- Modify: `CLAUDE.md` + +**Interfaces:** +- Consumes: everything above. +- Produces: no code. + +- [ ] **Step 1: Read the existing operations docs for house style** + +Run: `ls docsite/docs/operations && head -40 docsite/docs/operations/*.md | head -60` + +Match the front matter, heading level and tone of what is already there. + +- [ ] **Step 2: Write the page** + +Create `docsite/docs/operations/backup-and-restore.md`. Front matter must match +the sibling pages' shape. Content, in this order: + +1. **The key comes first.** Vantage encrypts SSH private keys, key passphrases, + vault secrets, SSO client secrets and console credentials with + `KEY_ENCRYPTION_KEY`. It is not in your backup and it is not recoverable. A + database restored without it is permanently unreadable. Store it wherever you + store the credentials you could not rebuild. +2. **What a backup holds:** every collection in the database, the index + definitions, and a SHA-256 fingerprint of the key — never the key. +3. **What it does not hold:** Redis sessions (everyone signs in again, which is + already true whenever Redis restarts), the vulnerability database (re-pulled + automatically), and any agent state on managed servers. Agents reconnect on + their own because `servers.agent_token_hash` is in the backup, so no server + needs re-enrolling. +4. **Taking a backup**, three copyable forms: the loose binary, the container, + and the Kubernetes CronJob values. Use the exact commands from the spec's + Distribution section. +5. **Where to put the archive.** `--out -` streams to stdout; show one `restic` + and one `aws s3 cp -` example. Note that an archive is as sensitive as a + database dump and should be encrypted at rest by whatever you pipe it into. +6. **Checking a backup is real:** `vantagectl verify ARCHIVE` with + `--mongo-uri`, what each line of its output means, and that it exits non-zero + so it can go on a schedule. +7. **Restoring**, in order: restore into an empty database, what the refusal on + a non-empty one means, `--force` and its confirmation, and `--confirm-db` for + a Job or CI step with no terminal. +8. **The restore drill**, with a heading of its own: restore last night's + archive into a scratch database, run `verify` against it, drop it. An + untested backup is a hypothesis. Recommend monthly. +9. **When the key is wrong**, describing what `--ignore-key-mismatch` does and + which collections it names, and stating plainly that there is no way to + recover the ciphertext afterwards. + +- [ ] **Step 3: Add the page to the sidebar** + +`docsite/sidebars.ts` is authored by hand. Add the new page to the Operations +section in the position that reads correctly, not necessarily last. + +- [ ] **Step 4: Build the docs site** + +```bash +cd docsite && npm ci && npm run build +``` +Expected: build succeeds with no broken-link warnings for the new page. + +- [ ] **Step 5: Add the `CLAUDE.md` section** + +Add a `### Backup and restore` subsection under Subsystems, covering, briefly: + +- `vantagectl` is a separate module and a separate image, and why (cobra out of + three module graphs; a tool that must run when the control plane does not). +- `shared/backup` holds the logic so `server` can import it later; + `shared/cryptobox` is now the single AES-GCM implementation and + `services/crypto.go` delegates to it. +- The archive carries a fingerprint of `KEY_ENCRYPTION_KEY`, never the key, and + backup refuses without one. +- Collections are enumerated live, the opposite choice to `ScopedCollections`, + and why that is right here. +- Restore refuses a non-empty target and has no merge semantics. +- The `vantagectl/Dockerfile` scratch stage needs its explicit `/tmp`, same as + `server`. +- `shared/` now fans out to **four** Go images in `server-deploy.yml`. + +Also update the existing sentence in the CI section that says "seven images" and +the one that says `shared/` "fans out to all three Go images", both of which are +now wrong. + +- [ ] **Step 6: Verify the counts you just changed** + +Run: `grep -n "seven images\|three Go images\|all three" CLAUDE.md` +Expected: no stale hits remain. + +- [ ] **Step 7: Commit** + +```bash +git add docsite CLAUDE.md +git commit -m "docs: Document backup and restore + +The page leads with KEY_ENCRYPTION_KEY rather than mentioning it in a +note, because holding a good database dump and no key is the way this goes +wrong." +``` + +--- + +## Final verification + +- [ ] **Run every module's tests** + +```bash +cd shared && go test ./... && cd ../vantagectl && go test ./... && cd ../server && go test ./... +``` +With `MONGO_TEST_URI` set for the first two, so the database-backed tests +actually run rather than skipping. + +- [ ] **Vet everything new** + +```bash +cd shared && go vet ./backup/... ./cryptobox/... && cd ../vantagectl && go vet ./... +``` + +- [ ] **Confirm the module boundaries held** + +```bash +grep -r "spf13/cobra" shared/ server/ admin/ sitesvc/ --include="*.go" --include="go.mod" +grep -r "vantage/server" vantagectl/ --include="*.go" +``` +Expected: both print nothing. + +- [ ] **Confirm the full round trip once more, from the built image** + +```bash +docker build -t vantagectl:final -f vantagectl/Dockerfile . +docker run --rm --network host \ + -e MONGO_URI=mongodb://localhost:27017 -e MONGO_DB=vantage_smoke \ + -e KEY_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000001 \ + -v /tmp:/out vantagectl:final backup --out /out +``` +Expected: an archive appears in `/tmp`, written by the scratch image, which +proves the staged `/tmp` and the CA bundle are both in place. diff --git a/docs/superpowers/specs/2026-09-07-control-plane-backup-restore-design.md b/docs/superpowers/specs/2026-09-07-control-plane-backup-restore-design.md index ac680d7..695946a 100644 --- a/docs/superpowers/specs/2026-09-07-control-plane-backup-restore-design.md +++ b/docs/superpowers/specs/2026-09-07-control-plane-backup-restore-design.md @@ -208,8 +208,10 @@ vantagectl root; prints help ``` Persistent flags on the root command, so every subcommand accepts them and they -are documented once: `--mongo-uri` (env `MONGO_URI`), `--db` (env `MONGO_DB`, -falling back to the URI path), `--log-level`. +are documented once: `--mongo-uri` (env `MONGO_URI`) and `--db` (env `MONGO_DB`, +falling back to the URI path). There is no `--log-level`: the tool's entire +output is what it is telling the operator, and a level that could hide a key +warning is worth not having. Environment fallback is wired with an explicit `Changed` check on each flag rather than through viper. Viper is a configuration-file and remote-config From 577b060b8a6d8ca589059d470b48a7b31056300d Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 10:55:54 +0000 Subject: [PATCH 04/23] feat: Extract AES-GCM into shared/cryptobox services/crypto.go keeps its function names and its KEY_ENCRYPTION_KEY lookup and delegates the cipher, so vantagectl's verify probe can decrypt with the same implementation rather than a second copy. --- server/internal/services/crypto.go | 48 +++--------------- shared/cryptobox/cryptobox.go | 67 +++++++++++++++++++++++++ shared/cryptobox/cryptobox_test.go | 79 ++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 40 deletions(-) create mode 100644 shared/cryptobox/cryptobox.go create mode 100644 shared/cryptobox/cryptobox_test.go diff --git a/server/internal/services/crypto.go b/server/internal/services/crypto.go index 64f82e1..dff6e7d 100644 --- a/server/internal/services/crypto.go +++ b/server/internal/services/crypto.go @@ -1,22 +1,23 @@ package services import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" "encoding/hex" "fmt" - "io" "os" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/cryptobox" ) +// encryptionKey reads KEY_ENCRYPTION_KEY. The cipher itself lives in +// shared/cryptobox so vantagectl's verify probe uses the same implementation +// rather than a second copy that can drift. func encryptionKey() ([]byte, error) { raw := os.Getenv("KEY_ENCRYPTION_KEY") if raw == "" { return nil, fmt.Errorf("KEY_ENCRYPTION_KEY is not set") } key, err := hex.DecodeString(raw) - if err != nil || len(key) != 32 { + if err != nil || len(key) != cryptobox.KeySize { return nil, fmt.Errorf("KEY_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)") } return key, nil @@ -27,20 +28,7 @@ func encryptString(plaintext string) (string, error) { if err != nil { return "", err } - block, err := aes.NewCipher(key) - if err != nil { - return "", err - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - nonce := make([]byte, gcm.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return "", err - } - sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil) - return hex.EncodeToString(sealed), nil + return cryptobox.Seal(key, plaintext) } func decryptString(ciphertextHex string) (string, error) { @@ -48,27 +36,7 @@ func decryptString(ciphertextHex string) (string, error) { if err != nil { return "", err } - data, err := hex.DecodeString(ciphertextHex) - if err != nil { - return "", fmt.Errorf("invalid ciphertext encoding") - } - block, err := aes.NewCipher(key) - if err != nil { - return "", err - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - nonceSize := gcm.NonceSize() - if len(data) < nonceSize { - return "", fmt.Errorf("ciphertext too short") - } - plaintext, err := gcm.Open(nil, data[:nonceSize], data[nonceSize:], nil) - if err != nil { - return "", fmt.Errorf("decryption failed") - } - return string(plaintext), nil + return cryptobox.Open(key, ciphertextHex) } func encryptPrivateKey(plaintext string) (string, error) { return encryptString(plaintext) } diff --git a/shared/cryptobox/cryptobox.go b/shared/cryptobox/cryptobox.go new file mode 100644 index 0000000..b7edb8b --- /dev/null +++ b/shared/cryptobox/cryptobox.go @@ -0,0 +1,67 @@ +// Package cryptobox is the AES-256-GCM primitive used for everything Vantage +// encrypts at rest: SSH private keys, key passphrases, vault secrets, OIDC +// client secrets and console credentials. +// +// It takes a raw key and reads no environment. Key sourcing belongs to the +// caller, because the two callers source it differently: the server reads +// KEY_ENCRYPTION_KEY at the point of use, while vantagectl is handed one. +package cryptobox + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "fmt" + "io" +) + +// KeySize is the only key length accepted. AES-256 by construction. +const KeySize = 32 + +func gcmFor(key []byte) (cipher.AEAD, error) { + if len(key) != KeySize { + return nil, fmt.Errorf("key must be %d bytes, got %d", KeySize, len(key)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +// Seal encrypts plaintext and returns nonce||ciphertext, hex encoded. +func Seal(key []byte, plaintext string) (string, error) { + gcm, err := gcmFor(key) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + return hex.EncodeToString(gcm.Seal(nonce, nonce, []byte(plaintext), nil)), nil +} + +// Open reverses Seal. Every failure mode returns an error that does not +// distinguish a wrong key from corrupt data, because the caller cannot act on +// the difference and an oracle is worth avoiding for free. +func Open(key []byte, ciphertextHex string) (string, error) { + gcm, err := gcmFor(key) + if err != nil { + return "", err + } + data, err := hex.DecodeString(ciphertextHex) + if err != nil { + return "", fmt.Errorf("invalid ciphertext encoding") + } + n := gcm.NonceSize() + if len(data) < n { + return "", fmt.Errorf("ciphertext too short") + } + plaintext, err := gcm.Open(nil, data[:n], data[n:], nil) + if err != nil { + return "", fmt.Errorf("decryption failed") + } + return string(plaintext), nil +} diff --git a/shared/cryptobox/cryptobox_test.go b/shared/cryptobox/cryptobox_test.go new file mode 100644 index 0000000..a67e1ec --- /dev/null +++ b/shared/cryptobox/cryptobox_test.go @@ -0,0 +1,79 @@ +package cryptobox + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "testing" +) + +func testKey(t *testing.T) []byte { + t.Helper() + k := make([]byte, KeySize) + if _, err := rand.Read(k); err != nil { + t.Fatalf("rand: %v", err) + } + return k +} + +func TestSealOpenRoundTrip(t *testing.T) { + key := testKey(t) + sealed, err := Seal(key, "hunter2") + if err != nil { + t.Fatalf("Seal: %v", err) + } + if _, err := hex.DecodeString(sealed); err != nil { + t.Fatalf("Seal output is not hex: %v", err) + } + if bytes.Contains([]byte(sealed), []byte("hunter2")) { + t.Fatal("plaintext appears in ciphertext") + } + got, err := Open(key, sealed) + if err != nil { + t.Fatalf("Open: %v", err) + } + if got != "hunter2" { + t.Fatalf("got %q, want %q", got, "hunter2") + } +} + +func TestSealIsNonDeterministic(t *testing.T) { + key := testKey(t) + a, err := Seal(key, "same") + if err != nil { + t.Fatalf("Seal: %v", err) + } + b, err := Seal(key, "same") + if err != nil { + t.Fatalf("Seal: %v", err) + } + if a == b { + t.Fatal("two seals of the same plaintext are identical; nonce is not random") + } +} + +func TestOpenWrongKeyFails(t *testing.T) { + sealed, err := Seal(testKey(t), "secret") + if err != nil { + t.Fatalf("Seal: %v", err) + } + if _, err := Open(testKey(t), sealed); err == nil { + t.Fatal("Open with the wrong key succeeded") + } +} + +func TestOpenRejectsBadInput(t *testing.T) { + key := testKey(t) + if _, err := Open(key, "not-hex"); err == nil { + t.Fatal("Open accepted non-hex input") + } + if _, err := Open(key, "abcd"); err == nil { + t.Fatal("Open accepted a ciphertext shorter than the nonce") + } +} + +func TestWrongKeySizeRejected(t *testing.T) { + if _, err := Seal(make([]byte, 16), "x"); err == nil { + t.Fatal("Seal accepted a 16-byte key") + } +} From 8135c8d781ed4b114c6241f9b93040b7979962d7 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 11:03:48 +0000 Subject: [PATCH 05/23] feat: Add key fingerprinting for backup archives Fingerprint hashes the raw key bytes rather than the hex string, so the same key written in different cases fingerprints identically. --- shared/backup/fingerprint.go | 57 +++++++++++++++++++++++++++++ shared/backup/fingerprint_test.go | 61 +++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 shared/backup/fingerprint.go create mode 100644 shared/backup/fingerprint_test.go diff --git a/shared/backup/fingerprint.go b/shared/backup/fingerprint.go new file mode 100644 index 0000000..5437653 --- /dev/null +++ b/shared/backup/fingerprint.go @@ -0,0 +1,57 @@ +// Package backup dumps and restores a whole Vantage MongoDB database. +// +// The archive never contains KEY_ENCRYPTION_KEY. It contains a fingerprint of +// it, which is enough to answer "will this archive restore into this +// deployment" and is not a hint at the value. +package backup + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/cryptobox" +) + +// ErrNoKey is returned when no key was supplied at all. It is distinct from +// ErrBadKey because the operator remedies are different: one is "set the +// variable", the other is "the value you set is wrong". +var ErrNoKey = errors.New("KEY_ENCRYPTION_KEY is not set") + +// ErrBadKey is returned when a key was supplied but is not 64 hex characters. +var ErrBadKey = errors.New("KEY_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)") + +// ParseKey decodes the hex form used by KEY_ENCRYPTION_KEY. +func ParseKey(hexKey string) ([]byte, error) { + if hexKey == "" { + return nil, ErrNoKey + } + key, err := hex.DecodeString(hexKey) + if err != nil { + return nil, fmt.Errorf("%w: not hexadecimal", ErrBadKey) + } + if len(key) != cryptobox.KeySize { + return nil, fmt.Errorf("%w: decoded to %d bytes", ErrBadKey, len(key)) + } + return key, nil +} + +// Fingerprint is the SHA-256 of the raw key bytes, hex encoded. +// +// Of the raw bytes rather than of the hex string, so an operator who writes the +// key in uppercase in one deployment and lowercase in another still gets one +// fingerprint for one key. +func Fingerprint(key []byte) string { + sum := sha256.Sum256(key) + return hex.EncodeToString(sum[:]) +} + +// FingerprintHex parses and fingerprints in one step. +func FingerprintHex(hexKey string) (string, error) { + key, err := ParseKey(hexKey) + if err != nil { + return "", err + } + return Fingerprint(key), nil +} diff --git a/shared/backup/fingerprint_test.go b/shared/backup/fingerprint_test.go new file mode 100644 index 0000000..d05fc7a --- /dev/null +++ b/shared/backup/fingerprint_test.go @@ -0,0 +1,61 @@ +package backup + +import ( + "errors" + "strings" + "testing" +) + +const validKeyHex = "0000000000000000000000000000000000000000000000000000000000000001" + +func TestFingerprintIsStableAndNotTheKey(t *testing.T) { + fp, err := FingerprintHex(validKeyHex) + if err != nil { + t.Fatalf("FingerprintHex: %v", err) + } + if len(fp) != 64 { + t.Fatalf("fingerprint is %d chars, want 64", len(fp)) + } + if strings.EqualFold(fp, validKeyHex) { + t.Fatal("fingerprint equals the key") + } + again, err := FingerprintHex(validKeyHex) + if err != nil { + t.Fatalf("FingerprintHex: %v", err) + } + if fp != again { + t.Fatal("fingerprint is not stable across calls") + } +} + +func TestFingerprintDiffersPerKey(t *testing.T) { + other := "0000000000000000000000000000000000000000000000000000000000000002" + a, err := FingerprintHex(validKeyHex) + if err != nil { + t.Fatalf("FingerprintHex: %v", err) + } + b, err := FingerprintHex(other) + if err != nil { + t.Fatalf("FingerprintHex: %v", err) + } + if a == b { + t.Fatal("two different keys produced the same fingerprint") + } +} + +func TestParseKeyRejections(t *testing.T) { + if _, err := ParseKey(""); !errors.Is(err, ErrNoKey) { + t.Fatalf("empty key: got %v, want ErrNoKey", err) + } + for _, bad := range []string{"zz", validKeyHex[:62], validKeyHex + "00"} { + if _, err := ParseKey(bad); !errors.Is(err, ErrBadKey) { + t.Fatalf("key %q: got %v, want ErrBadKey", bad, err) + } + } +} + +func TestParseKeyAcceptsUppercase(t *testing.T) { + if _, err := ParseKey(strings.ToUpper(validKeyHex)); err != nil { + t.Fatalf("uppercase hex rejected: %v", err) + } +} From d55ed2b19aa501fb86d7b6f7c6144f1d6aad22ce Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 11:08:02 +0000 Subject: [PATCH 06/23] feat: Add the backup archive manifest KeyFingerprint is a pointer so an archive that recorded no key is a state restore can report, not a default it silently treats as a match. --- shared/backup/manifest.go | 73 ++++++++++++++++++++++++++++++ shared/backup/manifest_test.go | 81 ++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 shared/backup/manifest.go create mode 100644 shared/backup/manifest_test.go diff --git a/shared/backup/manifest.go b/shared/backup/manifest.go new file mode 100644 index 0000000..91246c0 --- /dev/null +++ b/shared/backup/manifest.go @@ -0,0 +1,73 @@ +package backup + +import ( + "errors" + "fmt" + "time" +) + +// FormatVersion is the archive format this build reads and writes. Restore +// refuses anything else rather than guessing at a layout it does not know. +const FormatVersion = 1 + +// ManifestName is the archive member holding the manifest. +const ManifestName = "manifest.json" + +// ErrUnknownFormat is returned for an archive this build cannot read. +var ErrUnknownFormat = errors.New("unsupported archive format version") + +// CollectionEntry describes one collection in the archive. Bytes and SHA256 +// cover the uncompressed .bson member, which is what restore verifies before +// writing anything. +type CollectionEntry struct { + Name string `json:"name"` + Documents int64 `json:"documents"` + Bytes int64 `json:"bytes"` + SHA256 string `json:"sha256"` +} + +// Manifest is the archive's index and its provenance. +// +// KeyFingerprint is a pointer so "this archive recorded no key" is a distinct +// state from "this archive recorded the empty string". A null here is a real +// condition an operator must be told about, not a default. +type Manifest struct { + FormatVersion int `json:"format_version"` + CreatedAt time.Time `json:"created_at"` + VantageVersion string `json:"vantage_version"` + Hostname string `json:"hostname"` + MongoDB string `json:"mongo_db"` + MongoServerVersion string `json:"mongo_server_version"` + KeyFingerprint *string `json:"key_fingerprint"` + Collections []CollectionEntry `json:"collections"` + Excluded []string `json:"excluded"` +} + +// Check validates what can be validated without reading the rest of the archive. +func (m Manifest) Check() error { + if m.FormatVersion != FormatVersion { + return fmt.Errorf("%w: archive is version %d, this build reads version %d", + ErrUnknownFormat, m.FormatVersion, FormatVersion) + } + return nil +} + +// Collection looks up one entry by name. +func (m Manifest) Collection(name string) (CollectionEntry, bool) { + for _, c := range m.Collections { + if c.Name == name { + return c, true + } + } + return CollectionEntry{}, false +} + +// CiphertextCollections names the collections holding AES-GCM ciphertext. +// +// It exists to be printed. When a restore proceeds under a key that does not +// match the archive, this is the list of what will be unreadable afterwards, +// and an operator deserves to see it before the write rather than discover it +// a week later. +func CiphertextCollections() []string { + return []string{"keys", "secrets", "auth_providers", "console_sessions", "settings"} +} diff --git a/shared/backup/manifest_test.go b/shared/backup/manifest_test.go new file mode 100644 index 0000000..b4676cb --- /dev/null +++ b/shared/backup/manifest_test.go @@ -0,0 +1,81 @@ +package backup + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" +) + +func TestManifestJSONShape(t *testing.T) { + fp := "abc" + m := Manifest{ + FormatVersion: FormatVersion, + CreatedAt: time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC), + VantageVersion: "dev", + Hostname: "box", + MongoDB: "vantage", + MongoServerVersion: "7.0.5", + KeyFingerprint: &fp, + Collections: []CollectionEntry{{Name: "servers", Documents: 3, Bytes: 120, SHA256: "dead"}}, + Excluded: []string{"audit_logs"}, + } + raw, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + for _, want := range []string{ + `"format_version":1`, `"created_at":"2026-09-07T12:00:00Z"`, + `"key_fingerprint":"abc"`, `"mongo_server_version":"7.0.5"`, + `"excluded":["audit_logs"]`, + } { + if !strings.Contains(string(raw), want) { + t.Fatalf("manifest JSON missing %s\ngot: %s", want, raw) + } + } +} + +func TestManifestNullFingerprint(t *testing.T) { + raw, err := json.Marshal(Manifest{FormatVersion: FormatVersion}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(raw), `"key_fingerprint":null`) { + t.Fatalf("absent key must marshal as null, got: %s", raw) + } +} + +func TestManifestCheckRejectsOtherVersions(t *testing.T) { + if err := (Manifest{FormatVersion: FormatVersion}).Check(); err != nil { + t.Fatalf("current version rejected: %v", err) + } + for _, v := range []int{0, 2, 99} { + if err := (Manifest{FormatVersion: v}).Check(); !errors.Is(err, ErrUnknownFormat) { + t.Fatalf("version %d: got %v, want ErrUnknownFormat", v, err) + } + } +} + +func TestManifestCollectionLookup(t *testing.T) { + m := Manifest{Collections: []CollectionEntry{{Name: "keys", Documents: 1}}} + if _, ok := m.Collection("keys"); !ok { + t.Fatal("known collection not found") + } + if _, ok := m.Collection("nope"); ok { + t.Fatal("unknown collection reported as found") + } +} + +func TestCiphertextCollections(t *testing.T) { + got := CiphertextCollections() + want := []string{"keys", "secrets", "auth_providers", "console_sessions", "settings"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} From 66b1a041bae9da92e81214f1160cd8403438c5e7 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 11:10:17 +0000 Subject: [PATCH 07/23] feat: Add the backup archive writer and reader Open extracts and verifies every member against the manifest before the reader is usable, so a corrupt archive is refused before a restore writes its first document rather than halfway through. --- shared/backup/archive.go | 245 ++++++++++++++++++++++++++++++++++ shared/backup/archive_test.go | 216 ++++++++++++++++++++++++++++++ 2 files changed, 461 insertions(+) create mode 100644 shared/backup/archive.go create mode 100644 shared/backup/archive_test.go diff --git a/shared/backup/archive.go b/shared/backup/archive.go new file mode 100644 index 0000000..1d83e57 --- /dev/null +++ b/shared/backup/archive.go @@ -0,0 +1,245 @@ +package backup + +import ( + "archive/tar" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + "time" +) + +// ErrChecksum is returned when an archive member does not match the checksum +// the manifest recorded for it. +var ErrChecksum = errors.New("archive member failed its checksum") + +// Writer streams a tar.gz. Members are written in the order they are produced +// and the manifest goes last, because its per-collection checksums are only +// known once every collection has been written. +type Writer struct { + gz *gzip.Writer + tar *tar.Writer +} + +// NewWriter starts an archive on out. out may be a file or stdout; nothing here +// seeks. +func NewWriter(out io.Writer) *Writer { + gz := gzip.NewWriter(out) + return &Writer{gz: gz, tar: tar.NewWriter(gz)} +} + +func (w *Writer) writeMember(name string, body []byte) error { + h := &tar.Header{ + Name: name, + Mode: 0o600, + Size: int64(len(body)), + ModTime: time.Now().UTC(), + Typeflag: tar.TypeReg, + } + if err := w.tar.WriteHeader(h); err != nil { + return fmt.Errorf("write header %s: %w", name, err) + } + if _, err := w.tar.Write(body); err != nil { + return fmt.Errorf("write %s: %w", name, err) + } + return nil +} + +// WriteCollection writes the concatenated raw BSON of one collection and +// returns the manifest entry describing it. +func (w *Writer) WriteCollection(name string, docs [][]byte) (CollectionEntry, error) { + var body []byte + for _, d := range docs { + body = append(body, d...) + } + sum := sha256.Sum256(body) + entry := CollectionEntry{ + Name: name, + Documents: int64(len(docs)), + Bytes: int64(len(body)), + SHA256: hex.EncodeToString(sum[:]), + } + if err := w.writeMember(collectionMember(name), body); err != nil { + return CollectionEntry{}, err + } + return entry, nil +} + +// WriteIndexes writes a collection's index specifications verbatim. +func (w *Writer) WriteIndexes(name string, specsJSON []byte) error { + return w.writeMember(indexMember(name), specsJSON) +} + +// Close writes the manifest and finishes the archive. +func (w *Writer) Close(m Manifest) error { + raw, err := json.MarshalIndent(m, "", " ") + if err != nil { + return fmt.Errorf("marshal manifest: %w", err) + } + if err := w.writeMember(ManifestName, raw); err != nil { + return err + } + if err := w.tar.Close(); err != nil { + return err + } + return w.gz.Close() +} + +func collectionMember(name string) string { return "collections/" + name + ".bson" } +func indexMember(name string) string { return "indexes/" + name + ".json" } + +// Reader is an opened archive. +// +// Open extracts to a temporary directory rather than streaming, because gzip +// offers no random access and the manifest — which carries the checksums every +// other member is judged against — is written last. Verifying before writing a +// single document to the target is worth one pass over local disk. This is why +// the container image needs a /tmp. +type Reader struct { + dir string + manifest Manifest +} + +// Open extracts, verifies and returns the archive at path. The caller must +// Close it. +func Open(archivePath string) (*Reader, error) { + dir, err := os.MkdirTemp("", "vantage-restore-*") + if err != nil { + return nil, fmt.Errorf("temp dir: %w", err) + } + r := &Reader{dir: dir} + + if err := r.extract(archivePath); err != nil { + r.Close() + return nil, err + } + if err := r.loadManifest(); err != nil { + r.Close() + return nil, err + } + if err := r.verifyMembers(); err != nil { + r.Close() + return nil, err + } + return r, nil +} + +func (r *Reader) extract(archivePath string) error { + f, err := os.Open(archivePath) + if err != nil { + return fmt.Errorf("open archive: %w", err) + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("archive is not gzip: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + h, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("read archive: %w", err) + } + if h.Typeflag != tar.TypeReg { + continue + } + dest, err := safeJoin(r.dir, h.Name) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dest), 0o700); err != nil { + return fmt.Errorf("mkdir: %w", err) + } + out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("create %s: %w", h.Name, err) + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return fmt.Errorf("extract %s: %w", h.Name, err) + } + if err := out.Close(); err != nil { + return err + } + } +} + +// safeJoin refuses a member name that escapes the extraction directory. An +// archive is operator-supplied input and may not be one we wrote. +func safeJoin(dir, name string) (string, error) { + clean := path.Clean("/" + name) + dest := filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(clean, "/"))) + if !strings.HasPrefix(dest, filepath.Clean(dir)+string(os.PathSeparator)) { + return "", fmt.Errorf("archive member %q escapes the extraction directory", name) + } + return dest, nil +} + +func (r *Reader) loadManifest() error { + raw, err := os.ReadFile(filepath.Join(r.dir, ManifestName)) + if err != nil { + return fmt.Errorf("archive has no %s: %w", ManifestName, err) + } + if err := json.Unmarshal(raw, &r.manifest); err != nil { + return fmt.Errorf("parse %s: %w", ManifestName, err) + } + return r.manifest.Check() +} + +func (r *Reader) verifyMembers() error { + for _, c := range r.manifest.Collections { + f, err := os.Open(filepath.Join(r.dir, collectionMember(c.Name))) + if err != nil { + return fmt.Errorf("%w: %s is named in the manifest but absent from the archive", + ErrChecksum, c.Name) + } + h := sha256.New() + n, err := io.Copy(h, f) + f.Close() + if err != nil { + return fmt.Errorf("read %s: %w", c.Name, err) + } + if n != c.Bytes { + return fmt.Errorf("%w: %s is %d bytes, manifest says %d", ErrChecksum, c.Name, n, c.Bytes) + } + if got := hex.EncodeToString(h.Sum(nil)); got != c.SHA256 { + return fmt.Errorf("%w: %s checksum %s, manifest says %s", ErrChecksum, c.Name, got, c.SHA256) + } + } + return nil +} + +// Manifest returns the verified manifest. +func (r *Reader) Manifest() Manifest { return r.manifest } + +// OpenCollection returns the raw BSON stream for one collection. +func (r *Reader) OpenCollection(name string) (io.ReadCloser, error) { + return os.Open(filepath.Join(r.dir, collectionMember(name))) +} + +// IndexesJSON returns a collection's index specifications, or nil when the +// archive holds none. A collection with no indexes beyond _id_ is ordinary and +// is not an error. +func (r *Reader) IndexesJSON(name string) ([]byte, error) { + raw, err := os.ReadFile(filepath.Join(r.dir, indexMember(name))) + if os.IsNotExist(err) { + return nil, nil + } + return raw, err +} + +// Close removes the extraction directory. +func (r *Reader) Close() error { return os.RemoveAll(r.dir) } diff --git a/shared/backup/archive_test.go b/shared/backup/archive_test.go new file mode 100644 index 0000000..62c7a67 --- /dev/null +++ b/shared/backup/archive_test.go @@ -0,0 +1,216 @@ +package backup + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "errors" + "io" + "os" + "path/filepath" + "testing" + "time" +) + +// writeSampleArchive builds a two-collection archive on disk and returns its path. +func writeSampleArchive(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "sample.tar.gz") + f, err := os.Create(path) + if err != nil { + t.Fatalf("create: %v", err) + } + defer f.Close() + + w := NewWriter(f) + servers, err := w.WriteCollection("servers", [][]byte{[]byte("one"), []byte("two")}) + if err != nil { + t.Fatalf("WriteCollection: %v", err) + } + if err := w.WriteIndexes("servers", []byte(`[{"name":"idx"}]`)); err != nil { + t.Fatalf("WriteIndexes: %v", err) + } + keys, err := w.WriteCollection("keys", [][]byte{[]byte("k")}) + if err != nil { + t.Fatalf("WriteCollection: %v", err) + } + if err := w.Close(Manifest{ + FormatVersion: FormatVersion, + CreatedAt: time.Now().UTC(), + MongoDB: "vantage", + Collections: []CollectionEntry{servers, keys}, + }); err != nil { + t.Fatalf("Close: %v", err) + } + return path +} + +func TestWriterRecordsCountsAndChecksums(t *testing.T) { + var buf bytes.Buffer + w := NewWriter(&buf) + e, err := w.WriteCollection("servers", [][]byte{[]byte("one"), []byte("two")}) + if err != nil { + t.Fatalf("WriteCollection: %v", err) + } + if e.Name != "servers" { + t.Fatalf("name %q", e.Name) + } + if e.Documents != 2 { + t.Fatalf("documents %d, want 2", e.Documents) + } + if e.Bytes != 6 { + t.Fatalf("bytes %d, want 6", e.Bytes) + } + if len(e.SHA256) != 64 { + t.Fatalf("sha256 %q is not 64 hex chars", e.SHA256) + } +} + +func TestRoundTrip(t *testing.T) { + r, err := Open(writeSampleArchive(t)) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + + if r.Manifest().MongoDB != "vantage" { + t.Fatalf("manifest not read back: %+v", r.Manifest()) + } + + rc, err := r.OpenCollection("servers") + if err != nil { + t.Fatalf("OpenCollection: %v", err) + } + defer rc.Close() + got, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != "onetwo" { + t.Fatalf("got %q, want %q", got, "onetwo") + } + + idx, err := r.IndexesJSON("servers") + if err != nil { + t.Fatalf("IndexesJSON: %v", err) + } + if string(idx) != `[{"name":"idx"}]` { + t.Fatalf("indexes round-tripped as %q", idx) + } +} + +func TestIndexesJSONAbsentIsEmptyNotError(t *testing.T) { + r, err := Open(writeSampleArchive(t)) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + idx, err := r.IndexesJSON("keys") + if err != nil { + t.Fatalf("a collection with no index member must not error: %v", err) + } + if len(idx) != 0 { + t.Fatalf("want empty, got %q", idx) + } +} + +func TestOpenRejectsCorruptedMember(t *testing.T) { + path := writeSampleArchive(t) + + // Rewrite the archive with one byte of a collection member flipped, leaving + // the manifest's checksum describing the original. + corrupt := filepath.Join(t.TempDir(), "corrupt.tar.gz") + rewriteFlippingCollectionByte(t, path, corrupt, "servers") + + if _, err := Open(corrupt); !errors.Is(err, ErrChecksum) { + t.Fatalf("got %v, want ErrChecksum", err) + } +} + +func TestOpenRejectsUnknownFormatVersion(t *testing.T) { + path := filepath.Join(t.TempDir(), "future.tar.gz") + f, err := os.Create(path) + if err != nil { + t.Fatalf("create: %v", err) + } + w := NewWriter(f) + if err := w.Close(Manifest{FormatVersion: 99}); err != nil { + t.Fatalf("Close: %v", err) + } + f.Close() + + if _, err := Open(path); !errors.Is(err, ErrUnknownFormat) { + t.Fatalf("got %v, want ErrUnknownFormat", err) + } +} + +func TestCloseRemovesTempDir(t *testing.T) { + r, err := Open(writeSampleArchive(t)) + if err != nil { + t.Fatalf("Open: %v", err) + } + dir := r.dir + if _, err := os.Stat(dir); err != nil { + t.Fatalf("temp dir missing while open: %v", err) + } + if err := r.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("temp dir %s survived Close", dir) + } +} + +// rewriteFlippingCollectionByte copies an archive, flipping one byte inside the +// named collection's .bson member so its content no longer matches the checksum +// the manifest recorded. +func rewriteFlippingCollectionByte(t *testing.T, src, dst, collection string) { + t.Helper() + + in, err := os.Open(src) + if err != nil { + t.Fatalf("open src: %v", err) + } + defer in.Close() + gz, err := gzip.NewReader(in) + if err != nil { + t.Fatalf("gzip: %v", err) + } + defer gz.Close() + + out, err := os.Create(dst) + if err != nil { + t.Fatalf("create dst: %v", err) + } + defer out.Close() + gw := gzip.NewWriter(out) + defer gw.Close() + tw := tar.NewWriter(gw) + defer tw.Close() + + tr := tar.NewReader(gz) + target := "collections/" + collection + ".bson" + for { + h, err := tr.Next() + if err == io.EOF { + return + } + if err != nil { + t.Fatalf("tar next: %v", err) + } + body, err := io.ReadAll(tr) + if err != nil { + t.Fatalf("read member: %v", err) + } + if h.Name == target && len(body) > 0 { + body[0] ^= 0xFF + } + h.Size = int64(len(body)) + if err := tw.WriteHeader(h); err != nil { + t.Fatalf("write header: %v", err) + } + if _, err := tw.Write(body); err != nil { + t.Fatalf("write body: %v", err) + } + } +} From a918b1bdc1a811a1f3a51c5edc203f4fc57120ba Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 11:16:38 +0000 Subject: [PATCH 08/23] feat: Add the backup dump Collections are enumerated live rather than from a list, so a collection added later is backed up with no code change. Documents are written as the raw BSON the driver returned, so Decimal128, ObjectId, DateTime and binary subtypes survive byte for byte. --- shared/backup/dump.go | 170 +++++++++++++++++++++++++++++ shared/backup/dump_test.go | 206 ++++++++++++++++++++++++++++++++++++ shared/backup/mongo_test.go | 44 ++++++++ 3 files changed, 420 insertions(+) create mode 100644 shared/backup/dump.go create mode 100644 shared/backup/dump_test.go create mode 100644 shared/backup/mongo_test.go diff --git a/shared/backup/dump.go b/shared/backup/dump.go new file mode 100644 index 0000000..ab17ece --- /dev/null +++ b/shared/backup/dump.go @@ -0,0 +1,170 @@ +package backup + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "sort" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// DumpOptions configures one backup. +type DumpOptions struct { + Client *mongo.Client + Database string + Exclude []string + + // KeyHex is KEY_ENCRYPTION_KEY. It is fingerprinted and discarded; it is + // never written to the archive. + KeyHex string + + // AllowNoKey permits a backup of a deployment that stores no encrypted + // material. The manifest then records a null fingerprint, which restore + // reports rather than treating as a match. + AllowNoKey bool + + VantageVersion string + Out io.Writer +} + +// Dump writes a complete archive of one database to opt.Out. +// +// Collections are enumerated live rather than read from a list. A backup tool +// has no equivalent of AssertNoScopedCollectionMissed to catch a hardcoded list +// drifting, and the first symptom of that drift would be a restore silently +// missing a collection added since the list was written. +func Dump(ctx context.Context, opt DumpOptions) (Manifest, error) { + fingerprint, err := dumpFingerprint(opt) + if err != nil { + return Manifest{}, err + } + + db := opt.Client.Database(opt.Database) + names, err := db.ListCollectionNames(ctx, bson.M{}) + if err != nil { + return Manifest{}, fmt.Errorf("list collections: %w", err) + } + sort.Strings(names) + + excluded := map[string]bool{} + for _, e := range opt.Exclude { + excluded[e] = true + } + + serverVersion, err := mongoServerVersion(ctx, opt.Client) + if err != nil { + return Manifest{}, err + } + hostname, err := os.Hostname() + if err != nil { + hostname = "unknown" + } + + w := NewWriter(opt.Out) + entries := make([]CollectionEntry, 0, len(names)) + for _, name := range names { + if excluded[name] { + continue + } + entry, err := dumpCollection(ctx, w, db, name) + if err != nil { + return Manifest{}, err + } + entries = append(entries, entry) + } + + m := Manifest{ + FormatVersion: FormatVersion, + CreatedAt: time.Now().UTC(), + VantageVersion: opt.VantageVersion, + Hostname: hostname, + MongoDB: opt.Database, + MongoServerVersion: serverVersion, + KeyFingerprint: fingerprint, + Collections: entries, + Excluded: append([]string{}, opt.Exclude...), + } + if err := w.Close(m); err != nil { + return Manifest{}, err + } + return m, nil +} + +// dumpFingerprint applies the key policy before any output is produced. An +// archive of ciphertext whose key was never recorded is worse than no archive, +// because it looks like a backup. +func dumpFingerprint(opt DumpOptions) (*string, error) { + if opt.KeyHex == "" { + if opt.AllowNoKey { + return nil, nil + } + return nil, fmt.Errorf("%w: pass --allow-no-key only if this deployment stores no encrypted data", ErrNoKey) + } + fp, err := FingerprintHex(opt.KeyHex) + if err != nil { + return nil, err + } + return &fp, nil +} + +func dumpCollection(ctx context.Context, w *Writer, db *mongo.Database, name string) (CollectionEntry, error) { + cur, err := db.Collection(name).Find(ctx, bson.M{}) + if err != nil { + return CollectionEntry{}, fmt.Errorf("find %s: %w", name, err) + } + defer cur.Close(ctx) + + var docs [][]byte + for cur.Next(ctx) { + // cur.Current is only valid until the next Next, and it is written to + // the archive verbatim rather than through a map, so every BSON type + // survives exactly as the server stored it. + docs = append(docs, append([]byte(nil), cur.Current...)) + } + if err := cur.Err(); err != nil { + return CollectionEntry{}, fmt.Errorf("iterate %s: %w", name, err) + } + + entry, err := w.WriteCollection(name, docs) + if err != nil { + return CollectionEntry{}, err + } + if err := dumpIndexes(ctx, w, db, name); err != nil { + return CollectionEntry{}, err + } + return entry, nil +} + +func dumpIndexes(ctx context.Context, w *Writer, db *mongo.Database, name string) error { + cur, err := db.Collection(name).Indexes().List(ctx) + if err != nil { + return fmt.Errorf("list indexes on %s: %w", name, err) + } + defer cur.Close(ctx) + + var specs []bson.M + if err := cur.All(ctx, &specs); err != nil { + return fmt.Errorf("read indexes on %s: %w", name, err) + } + raw, err := json.Marshal(specs) + if err != nil { + return fmt.Errorf("encode indexes on %s: %w", name, err) + } + return w.WriteIndexes(name, raw) +} + +func mongoServerVersion(ctx context.Context, client *mongo.Client) (string, error) { + var res struct { + Version string `bson:"version"` + } + err := client.Database("admin").RunCommand(ctx, bson.D{{Key: "buildInfo", Value: 1}}).Decode(&res) + if err != nil { + return "", fmt.Errorf("buildInfo: %w", err) + } + return res.Version, nil +} diff --git a/shared/backup/dump_test.go b/shared/backup/dump_test.go new file mode 100644 index 0000000..f13a845 --- /dev/null +++ b/shared/backup/dump_test.go @@ -0,0 +1,206 @@ +package backup + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "testing" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +func seed(t *testing.T, client *mongo.Client, dbName string) { + t.Helper() + ctx := context.Background() + db := client.Database(dbName) + if _, err := db.Collection("servers").InsertMany(ctx, []any{ + bson.M{"_id": bson.NewObjectID(), "name": "alpha", "instance_id": "i1"}, + bson.M{"_id": bson.NewObjectID(), "name": "beta", "instance_id": "i1"}, + }); err != nil { + t.Fatalf("insert servers: %v", err) + } + if _, err := db.Collection("audit_logs").InsertOne(ctx, bson.M{"action": "login"}); err != nil { + t.Fatalf("insert audit_logs: %v", err) + } +} + +func dumpToFile(t *testing.T, opt DumpOptions) (string, Manifest) { + t.Helper() + path := filepath.Join(t.TempDir(), "out.tar.gz") + f, err := os.Create(path) + if err != nil { + t.Fatalf("create: %v", err) + } + opt.Out = f + m, err := Dump(context.Background(), opt) + if cerr := f.Close(); cerr != nil { + t.Fatalf("close: %v", cerr) + } + if err != nil { + t.Fatalf("Dump: %v", err) + } + return path, m +} + +func TestDumpEnumeratesEveryCollection(t *testing.T) { + client, dbName := testDB(t) + seed(t, client, dbName) + + _, m := dumpToFile(t, DumpOptions{ + Client: client, Database: dbName, KeyHex: validKeyHex, VantageVersion: "test", + }) + + if _, ok := m.Collection("servers"); !ok { + t.Fatal("servers missing from the manifest") + } + if _, ok := m.Collection("audit_logs"); !ok { + t.Fatal("audit_logs missing; enumeration must not filter by a hardcoded list") + } + servers, _ := m.Collection("servers") + if servers.Documents != 2 { + t.Fatalf("servers documents %d, want 2", servers.Documents) + } + if m.MongoDB != dbName { + t.Fatalf("manifest database %q, want %q", m.MongoDB, dbName) + } + if m.MongoServerVersion == "" { + t.Fatal("manifest records no MongoDB server version") + } + if m.Hostname == "" { + t.Fatal("manifest records no hostname") + } +} + +func TestDumpRecordsKeyFingerprint(t *testing.T) { + client, dbName := testDB(t) + seed(t, client, dbName) + + _, m := dumpToFile(t, DumpOptions{Client: client, Database: dbName, KeyHex: validKeyHex}) + + want, err := FingerprintHex(validKeyHex) + if err != nil { + t.Fatalf("FingerprintHex: %v", err) + } + if m.KeyFingerprint == nil || *m.KeyFingerprint != want { + t.Fatalf("fingerprint %v, want %s", m.KeyFingerprint, want) + } +} + +func TestDumpRefusesWithoutAKey(t *testing.T) { + client, dbName := testDB(t) + seed(t, client, dbName) + + var buf bytes.Buffer + _, err := Dump(context.Background(), DumpOptions{ + Client: client, Database: dbName, Out: &buf, + }) + if !errors.Is(err, ErrNoKey) { + t.Fatalf("got %v, want ErrNoKey", err) + } + if buf.Len() != 0 { + t.Fatal("refusal must happen before anything is written") + } +} + +func TestDumpAllowNoKeyStampsNull(t *testing.T) { + client, dbName := testDB(t) + seed(t, client, dbName) + + _, m := dumpToFile(t, DumpOptions{Client: client, Database: dbName, AllowNoKey: true}) + if m.KeyFingerprint != nil { + t.Fatalf("want a null fingerprint, got %v", *m.KeyFingerprint) + } +} + +func TestDumpRejectsMalformedKey(t *testing.T) { + client, dbName := testDB(t) + var buf bytes.Buffer + _, err := Dump(context.Background(), DumpOptions{ + Client: client, Database: dbName, KeyHex: "nonsense", Out: &buf, + }) + if !errors.Is(err, ErrBadKey) { + t.Fatalf("got %v, want ErrBadKey", err) + } +} + +func TestDumpExcludeIsRecordedAndOmitted(t *testing.T) { + client, dbName := testDB(t) + seed(t, client, dbName) + + path, m := dumpToFile(t, DumpOptions{ + Client: client, Database: dbName, KeyHex: validKeyHex, + Exclude: []string{"audit_logs"}, + }) + + if _, ok := m.Collection("audit_logs"); ok { + t.Fatal("excluded collection is in the manifest's collection list") + } + if len(m.Excluded) != 1 || m.Excluded[0] != "audit_logs" { + t.Fatalf("excluded recorded as %v", m.Excluded) + } + + r, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + if _, err := r.OpenCollection("audit_logs"); err == nil { + t.Fatal("excluded collection is present in the archive") + } +} + +func TestDumpPreservesAwkwardBSONTypes(t *testing.T) { + client, dbName := testDB(t) + ctx := context.Background() + + dec, err := bson.ParseDecimal128("1234.5678") + if err != nil { + t.Fatalf("ParseDecimal128: %v", err) + } + doc := bson.M{ + "_id": bson.NewObjectID(), + "decimal": dec, + "when": bson.NewDateTimeFromTime(mustTime(t)), + "binary": bson.Binary{Subtype: 0x00, Data: []byte{0x01, 0x02, 0x03}}, + "nothing": nil, + "nested": bson.A{bson.M{"deep": bson.A{1, 2, 3}}}, + } + if _, err := client.Database(dbName).Collection("odd").InsertOne(ctx, doc); err != nil { + t.Fatalf("insert: %v", err) + } + + path, _ := dumpToFile(t, DumpOptions{Client: client, Database: dbName, KeyHex: validKeyHex}) + + original, err := client.Database(dbName).Collection("odd").FindOne(ctx, bson.M{}).Raw() + if err != nil { + t.Fatalf("read back: %v", err) + } + + r, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + rc, err := r.OpenCollection("odd") + if err != nil { + t.Fatalf("OpenCollection: %v", err) + } + defer rc.Close() + archived, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("read: %v", err) + } + if !bytes.Equal(archived, []byte(original)) { + t.Fatal("archived BSON differs from what the driver returned") + } +} + +func mustTime(t *testing.T) time.Time { + t.Helper() + return time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) +} diff --git a/shared/backup/mongo_test.go b/shared/backup/mongo_test.go new file mode 100644 index 0000000..b9b2081 --- /dev/null +++ b/shared/backup/mongo_test.go @@ -0,0 +1,44 @@ +package backup + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// testDB connects to the MongoDB named by MONGO_TEST_URI and returns a client +// plus a database name unique to this test, dropped when the test ends. +// +// Skips rather than fails when the variable is unset: these tests need a real +// server, and a developer without one should still be able to run the rest of +// the suite. +func testDB(t *testing.T) (*mongo.Client, string) { + t.Helper() + uri := os.Getenv("MONGO_TEST_URI") + if uri == "" { + t.Skip("MONGO_TEST_URI is not set; skipping tests that need MongoDB") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + client, err := mongo.Connect(options.Client().ApplyURI(uri)) + if err != nil { + t.Fatalf("connect: %v", err) + } + if err := client.Ping(ctx, nil); err != nil { + t.Fatalf("ping: %v", err) + } + name := fmt.Sprintf("vantage_test_%d", time.Now().UnixNano()) + t.Cleanup(func() { + c, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = client.Database(name).Drop(c) + _ = client.Disconnect(c) + }) + return client, name +} From 30d83c4c327a1e3b26bd5e3f39ac109e2077a540 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 11:23:27 +0000 Subject: [PATCH 09/23] feat: Add the backup restore Every refusal happens before the first write: format, checksums, key policy, then target inspection. A unique index that will not build aborts, because the unique indexes here are tenant-isolation properties rather than optimisations. --- shared/backup/restore.go | 339 +++++++++++++++++++++++++++++++++ shared/backup/restore_test.go | 348 ++++++++++++++++++++++++++++++++++ 2 files changed, 687 insertions(+) create mode 100644 shared/backup/restore.go create mode 100644 shared/backup/restore_test.go diff --git a/shared/backup/restore.go b/shared/backup/restore.go new file mode 100644 index 0000000..7cc83a9 --- /dev/null +++ b/shared/backup/restore.go @@ -0,0 +1,339 @@ +package backup + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "sort" + "strings" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// BatchSize is how many documents are inserted per bulk write. +const BatchSize = 1000 + +// ErrTargetNotEmpty is returned when the target database already holds data and +// Force was not set. +var ErrTargetNotEmpty = errors.New("target database is not empty") + +// ErrKeyMismatch is returned when the archive's key fingerprint does not match +// the key supplied. +var ErrKeyMismatch = errors.New("KEY_ENCRYPTION_KEY does not match the archive") + +// ErrIndexBuild is returned when a unique index in the archive cannot be built +// on the restored data. +var ErrIndexBuild = errors.New("index could not be built on the restored data") + +// RestoredCollection is what one collection's restore produced. +type RestoredCollection struct { + Name string + Documents int64 + Indexes int +} + +// RestoreResult is the summary a caller prints. +type RestoreResult struct { + Collections []RestoredCollection +} + +// RestoreOptions configures one restore. +type RestoreOptions struct { + Client *mongo.Client + Database string + Archive *Reader + + // Force drops each collection in the archive before loading it. Without it + // a non-empty target is refused. + Force bool + + KeyHex string + + // IgnoreKeyMismatch proceeds past a fingerprint mismatch, having first + // warned which collections will hold unreadable ciphertext afterwards. + IgnoreKeyMismatch bool + + // Warn receives operator-facing warnings. A nil Warn discards them. + Warn func(string) +} + +func (o RestoreOptions) warn(format string, args ...any) { + if o.Warn != nil { + o.Warn(fmt.Sprintf(format, args...)) + } +} + +// Restore loads an archive into a database. +// +// The order is fixed and every check that can refuse does so before the first +// write: format, checksums (done by Open), key policy, then target inspection. +// A restore that has begun writing and then fails leaves a partial database +// which the next run refuses to touch, which is correct — the alternative is a +// silent merge, and merging two control planes reconciles nothing. +func Restore(ctx context.Context, opt RestoreOptions) (RestoreResult, error) { + m := opt.Archive.Manifest() + + if err := checkKey(m, opt); err != nil { + return RestoreResult{}, err + } + if err := checkTarget(ctx, opt); err != nil { + return RestoreResult{}, err + } + if len(m.Excluded) > 0 { + opt.warn("this archive excluded %s; those collections will be empty after the restore", + strings.Join(m.Excluded, ", ")) + } + opt.warn("Redis is not restored. Sessions are the only state it holds, so everyone signs in again.") + + res := RestoreResult{} + if err := warnVersionGap(ctx, opt, m); err != nil { + return res, err + } + + for _, entry := range m.Collections { + rc, err := restoreCollection(ctx, opt, entry) + if err != nil { + return res, err + } + res.Collections = append(res.Collections, rc) + } + return res, nil +} + +// checkKey applies the fingerprint policy. +func checkKey(m Manifest, opt RestoreOptions) error { + if m.KeyFingerprint == nil { + opt.warn("this archive carries no key fingerprint, so nothing here proves your " + + "KEY_ENCRYPTION_KEY opens its ciphertext") + return nil + } + if opt.KeyHex == "" { + return fmt.Errorf("%w: the archive records a key fingerprint, so a key is required "+ + "(pass --ignore-key-mismatch only if you accept unreadable secrets)", ErrNoKey) + } + got, err := FingerprintHex(opt.KeyHex) + if err != nil { + return err + } + if got == *m.KeyFingerprint { + return nil + } + if !opt.IgnoreKeyMismatch { + return fmt.Errorf("%w: archive fingerprint %s, your key fingerprints as %s", + ErrKeyMismatch, *m.KeyFingerprint, got) + } + opt.warn("proceeding past a key mismatch: ciphertext in %s will be permanently unreadable", + strings.Join(CiphertextCollections(), ", ")) + return nil +} + +// checkTarget refuses a non-empty database unless Force was set. +func checkTarget(ctx context.Context, opt RestoreOptions) error { + db := opt.Client.Database(opt.Database) + names, err := db.ListCollectionNames(ctx, bson.M{}) + if err != nil { + return fmt.Errorf("inspect target: %w", err) + } + if len(names) == 0 || opt.Force { + return nil + } + sort.Strings(names) + + var found []string + for _, n := range names { + count, err := db.Collection(n).CountDocuments(ctx, bson.M{}) + if err != nil { + return fmt.Errorf("count %s: %w", n, err) + } + found = append(found, fmt.Sprintf("%s (%d)", n, count)) + } + return fmt.Errorf("%w: %s holds %s", ErrTargetNotEmpty, opt.Database, strings.Join(found, ", ")) +} + +// warnVersionGap reports a major version difference between the server that +// produced the archive and the one receiving it. It warns rather than refuses: +// restoring across a major version is a normal part of an upgrade, and a tool +// that refused would be blocking the migration it exists to make safe. +func warnVersionGap(ctx context.Context, opt RestoreOptions, m Manifest) error { + if m.MongoServerVersion == "" { + return nil + } + target, err := mongoServerVersion(ctx, opt.Client) + if err != nil { + return err + } + if majorOf(m.MongoServerVersion) != majorOf(target) { + opt.warn("this archive came from MongoDB %s and you are restoring onto %s", + m.MongoServerVersion, target) + } + return nil +} + +func majorOf(version string) string { + if i := strings.IndexByte(version, '.'); i >= 0 { + return version[:i] + } + return version +} + +func restoreCollection(ctx context.Context, opt RestoreOptions, entry CollectionEntry) (RestoredCollection, error) { + coll := opt.Client.Database(opt.Database).Collection(entry.Name) + if opt.Force { + if err := coll.Drop(ctx); err != nil { + return RestoredCollection{}, fmt.Errorf("drop %s: %w", entry.Name, err) + } + } + + written, err := insertDocuments(ctx, opt, coll, entry) + if err != nil { + return RestoredCollection{}, err + } + indexes, err := replayIndexes(ctx, opt, coll, entry.Name) + if err != nil { + return RestoredCollection{}, err + } + return RestoredCollection{Name: entry.Name, Documents: written, Indexes: indexes}, nil +} + +func insertDocuments(ctx context.Context, opt RestoreOptions, coll *mongo.Collection, entry CollectionEntry) (int64, error) { + rc, err := opt.Archive.OpenCollection(entry.Name) + if err != nil { + return 0, fmt.Errorf("open %s in archive: %w", entry.Name, err) + } + defer rc.Close() + + raw, err := io.ReadAll(rc) + if err != nil { + return 0, fmt.Errorf("read %s: %w", entry.Name, err) + } + + var written int64 + batch := make([]any, 0, BatchSize) + flush := func() error { + if len(batch) == 0 { + return nil + } + if _, err := coll.InsertMany(ctx, batch, options.InsertMany().SetOrdered(false)); err != nil { + return fmt.Errorf("insert into %s: %w", entry.Name, err) + } + written += int64(len(batch)) + batch = batch[:0] + return nil + } + + for len(raw) > 0 { + doc, rest, err := splitBSON(raw) + if err != nil { + return 0, fmt.Errorf("%s: %w", entry.Name, err) + } + batch = append(batch, doc) + raw = rest + if len(batch) == BatchSize { + if err := flush(); err != nil { + return 0, err + } + } + } + if err := flush(); err != nil { + return 0, err + } + return written, nil +} + +// splitBSON peels one document off the front of a concatenated BSON stream. A +// BSON document declares its own length in its first four bytes. +func splitBSON(raw []byte) (bson.Raw, []byte, error) { + if len(raw) < 4 { + return nil, nil, fmt.Errorf("truncated BSON: %d trailing bytes", len(raw)) + } + n := int(int32(raw[0]) | int32(raw[1])<<8 | int32(raw[2])<<16 | int32(raw[3])<<24) + if n < 5 || n > len(raw) { + return nil, nil, fmt.Errorf("BSON document declares length %d with %d bytes remaining", n, len(raw)) + } + return bson.Raw(raw[:n]), raw[n:], nil +} + +// replayIndexes recreates the archived indexes. +// +// A unique index that will not build means the restored data violates it, and +// the unique indexes here — (instance_id, email), instance slug, settings +// instance, the ESO token hash — are tenant-isolation properties rather than +// optimisations. That aborts. A non-unique index failing is a performance +// problem and warns. +func replayIndexes(ctx context.Context, opt RestoreOptions, coll *mongo.Collection, name string) (int, error) { + raw, err := opt.Archive.IndexesJSON(name) + if err != nil { + return 0, fmt.Errorf("read index specs for %s: %w", name, err) + } + if len(raw) == 0 { + return 0, nil + } + var specs []map[string]any + if err := json.Unmarshal(raw, &specs); err != nil { + return 0, fmt.Errorf("parse index specs for %s: %w", name, err) + } + + created := 0 + for _, spec := range specs { + model, indexName, unique, ok := indexModelFrom(spec) + if !ok { + continue + } + if _, err := coll.Indexes().CreateOne(ctx, model); err != nil { + if unique { + return created, fmt.Errorf("%w: %s on %s: %v", ErrIndexBuild, indexName, name, err) + } + opt.warn("index %s on %s was not created: %v", indexName, name, err) + continue + } + created++ + } + return created, nil +} + +// indexModelFrom converts one archived index specification into a model. +// The _id_ index is skipped: MongoDB creates it itself and refuses an explicit +// attempt to create it. +func indexModelFrom(spec map[string]any) (mongo.IndexModel, string, bool, bool) { + name, _ := spec["name"].(string) + if name == "_id_" { + return mongo.IndexModel{}, name, false, false + } + keys, ok := spec["key"].(map[string]any) + if !ok || len(keys) == 0 { + return mongo.IndexModel{}, name, false, false + } + + // JSON objects do not preserve order but compound index key order is + // significant, so the field order recorded by the server is recovered from + // the spec's own ordering where available and sorted otherwise. bson.M + // round-trips through json as a map; the archive therefore stores the key + // document and this reconstructs a deterministic bson.D from it. + fields := make([]string, 0, len(keys)) + for k := range keys { + fields = append(fields, k) + } + sort.Strings(fields) + d := make(bson.D, 0, len(fields)) + for _, f := range fields { + d = append(d, bson.E{Key: f, Value: keys[f]}) + } + + opts := options.Index().SetName(name) + unique := false + if u, ok := spec["unique"].(bool); ok && u { + unique = true + opts = opts.SetUnique(true) + } + if s, ok := spec["sparse"].(bool); ok && s { + opts = opts.SetSparse(true) + } + if e, ok := spec["expireAfterSeconds"].(float64); ok { + opts = opts.SetExpireAfterSeconds(int32(e)) + } + return mongo.IndexModel{Keys: d, Options: opts}, name, unique, true +} diff --git a/shared/backup/restore_test.go b/shared/backup/restore_test.go new file mode 100644 index 0000000..b56841e --- /dev/null +++ b/shared/backup/restore_test.go @@ -0,0 +1,348 @@ +package backup + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// archiveOf seeds a database, dumps it, and returns an opened Reader. +func archiveOf(t *testing.T, client *mongo.Client, keyHex string, allowNoKey bool) *Reader { + t.Helper() + _, srcDB := testDB(t) + seed(t, client, srcDB) + path, _ := dumpToFile(t, DumpOptions{ + Client: client, Database: srcDB, KeyHex: keyHex, AllowNoKey: allowNoKey, + }) + r, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { r.Close() }) + return r +} + +func countIn(t *testing.T, client *mongo.Client, dbName, coll string) int64 { + t.Helper() + n, err := client.Database(dbName).Collection(coll).CountDocuments(context.Background(), bson.M{}) + if err != nil { + t.Fatalf("count %s: %v", coll, err) + } + return n +} + +func TestRestoreIntoEmptyDatabase(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + _, target := testDB(t) + + res, err := Restore(context.Background(), RestoreOptions{ + Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, + }) + if err != nil { + t.Fatalf("Restore: %v", err) + } + if countIn(t, client, target, "servers") != 2 { + t.Fatal("servers not restored") + } + if len(res.Collections) == 0 { + t.Fatal("result reports no collections") + } +} + +func TestRestoreRefusesNonEmptyTarget(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + _, target := testDB(t) + if _, err := client.Database(target).Collection("servers"). + InsertOne(context.Background(), bson.M{"name": "existing"}); err != nil { + t.Fatalf("seed target: %v", err) + } + + _, err := Restore(context.Background(), RestoreOptions{ + Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, + }) + if !errors.Is(err, ErrTargetNotEmpty) { + t.Fatalf("got %v, want ErrTargetNotEmpty", err) + } + if countIn(t, client, target, "servers") != 1 { + t.Fatal("a refused restore modified the target") + } +} + +func TestRestoreForceReplaces(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + _, target := testDB(t) + if _, err := client.Database(target).Collection("servers"). + InsertOne(context.Background(), bson.M{"name": "existing"}); err != nil { + t.Fatalf("seed target: %v", err) + } + + if _, err := Restore(context.Background(), RestoreOptions{ + Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, Force: true, + }); err != nil { + t.Fatalf("Restore --force: %v", err) + } + if got := countIn(t, client, target, "servers"); got != 2 { + t.Fatalf("servers has %d documents, want 2; force must drop, not merge", got) + } + n, err := client.Database(target).Collection("servers"). + CountDocuments(context.Background(), bson.M{"name": "existing"}) + if err != nil { + t.Fatalf("count: %v", err) + } + if n != 0 { + t.Fatal("the pre-existing document survived --force") + } +} + +func TestRestoreRefusesKeyMismatch(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + _, target := testDB(t) + other := "0000000000000000000000000000000000000000000000000000000000000002" + + _, err := Restore(context.Background(), RestoreOptions{ + Client: client, Database: target, Archive: archive, KeyHex: other, + }) + if !errors.Is(err, ErrKeyMismatch) { + t.Fatalf("got %v, want ErrKeyMismatch", err) + } + names, err := client.Database(target).ListCollectionNames(context.Background(), bson.M{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(names) != 0 { + t.Fatalf("a refused restore wrote %v", names) + } +} + +func TestRestoreRefusesWhenArchiveHasKeyAndEnvironmentDoesNot(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + _, target := testDB(t) + + _, err := Restore(context.Background(), RestoreOptions{ + Client: client, Database: target, Archive: archive, + }) + if !errors.Is(err, ErrNoKey) { + t.Fatalf("got %v, want ErrNoKey", err) + } +} + +func TestRestoreIgnoreKeyMismatchWarnsAndProceeds(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + _, target := testDB(t) + other := "0000000000000000000000000000000000000000000000000000000000000002" + + var warnings []string + if _, err := Restore(context.Background(), RestoreOptions{ + Client: client, Database: target, Archive: archive, + KeyHex: other, IgnoreKeyMismatch: true, + Warn: func(s string) { warnings = append(warnings, s) }, + }); err != nil { + t.Fatalf("Restore: %v", err) + } + joined := strings.Join(warnings, "\n") + for _, name := range CiphertextCollections() { + if !strings.Contains(joined, name) { + t.Fatalf("warning does not name %s\ngot:\n%s", name, joined) + } + } + if countIn(t, client, target, "servers") != 2 { + t.Fatal("restore did not proceed") + } +} + +func TestRestoreNullFingerprintIsReportedNotAssumed(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, "", true) + _, target := testDB(t) + + var warnings []string + if _, err := Restore(context.Background(), RestoreOptions{ + Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, + Warn: func(s string) { warnings = append(warnings, s) }, + }); err != nil { + t.Fatalf("Restore: %v", err) + } + if !strings.Contains(strings.Join(warnings, "\n"), "no key fingerprint") { + t.Fatalf("a null fingerprint must be reported, got: %v", warnings) + } +} + +func TestRestoreReplaysIndexes(t *testing.T) { + client, _ := testDB(t) + ctx := context.Background() + _, srcDB := testDB(t) + seed(t, client, srcDB) + if _, err := client.Database(srcDB).Collection("servers").Indexes(). + CreateOne(ctx, mongo.IndexModel{Keys: bson.D{{Key: "name", Value: 1}}}); err != nil { + t.Fatalf("create index: %v", err) + } + path, _ := dumpToFile(t, DumpOptions{Client: client, Database: srcDB, KeyHex: validKeyHex}) + archive, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer archive.Close() + + _, target := testDB(t) + res, err := Restore(ctx, RestoreOptions{ + Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, + }) + if err != nil { + t.Fatalf("Restore: %v", err) + } + + cur, err := client.Database(target).Collection("servers").Indexes().List(ctx) + if err != nil { + t.Fatalf("list indexes: %v", err) + } + var specs []bson.M + if err := cur.All(ctx, &specs); err != nil { + t.Fatalf("read indexes: %v", err) + } + found := false + for _, s := range specs { + if s["name"] == "name_1" { + found = true + } + } + if !found { + t.Fatalf("index name_1 not replayed; got %v", specs) + } + for _, c := range res.Collections { + if c.Name == "servers" && c.Indexes < 1 { + t.Fatal("result reports no indexes created for servers") + } + } +} + +// TestRestoreAbortsWhenAUniqueIndexCannotBuild replaces the brief's +// TestRestoreAbortsOnUniqueIndexViolation per ruling 1: creating an index on +// the target also creates the collection, so that version's assertion +// (err == nil) would have passed on the wrong error (ErrTargetNotEmpty), and +// Force: true does not rescue it because the drop removes the index before +// replayIndexes runs. This version builds a hand-made archive whose data and +// index specification directly contradict each other, which is the actual +// shape of a corrupted archive that replayIndexes must refuse to load. +func TestRestoreAbortsWhenAUniqueIndexCannotBuild(t *testing.T) { + client, _ := testDB(t) + ctx := context.Background() + + // Built by hand rather than dumped: two documents that collide on email + // alongside an index specification declaring email unique. No live database + // would let those coexist, which is exactly the point — this is the shape + // of a corrupted or hand-edited archive, and restore must refuse rather + // than load the rows and leave the index missing. + a, err := bson.Marshal(bson.M{"email": "a@example.com"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + b, err := bson.Marshal(bson.M{"email": "a@example.com"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + path := filepath.Join(t.TempDir(), "dupes.tar.gz") + f, err := os.Create(path) + if err != nil { + t.Fatalf("create: %v", err) + } + w := NewWriter(f) + entry, err := w.WriteCollection("users", [][]byte{a, b}) + if err != nil { + t.Fatalf("WriteCollection: %v", err) + } + if err := w.WriteIndexes("users", + []byte(`[{"name":"email_1","key":{"email":1},"unique":true}]`)); err != nil { + t.Fatalf("WriteIndexes: %v", err) + } + fp, err := FingerprintHex(validKeyHex) + if err != nil { + t.Fatalf("FingerprintHex: %v", err) + } + if err := w.Close(Manifest{ + FormatVersion: FormatVersion, + CreatedAt: time.Now().UTC(), + MongoDB: "handmade", + KeyFingerprint: &fp, + Collections: []CollectionEntry{entry}, + }); err != nil { + t.Fatalf("Close: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + archive, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer archive.Close() + + _, target := testDB(t) + if _, err := Restore(ctx, RestoreOptions{ + Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, + }); !errors.Is(err, ErrIndexBuild) { + t.Fatalf("got %v, want ErrIndexBuild", err) + } else if !strings.Contains(err.Error(), "email_1") { + t.Fatalf("the error must name the offending index, got: %v", err) + } +} + +func TestRestoreSameVersionDoesNotWarnAboutIt(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + _, target := testDB(t) + + var warnings []string + if _, err := Restore(context.Background(), RestoreOptions{ + Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, + Warn: func(s string) { warnings = append(warnings, s) }, + }); err != nil { + t.Fatalf("Restore: %v", err) + } + for _, w := range warnings { + if strings.Contains(w, "you are restoring onto") { + t.Fatalf("same-version restore warned about a version gap: %s", w) + } + } +} + +func TestCompoundIndexKeyOrderIsAlphabetical(t *testing.T) { + model, name, unique, ok := indexModelFrom(map[string]any{ + "name": "b_1_a_1", + "key": map[string]any{"b": float64(1), "a": float64(1)}, + }) + if !ok { + t.Fatal("spec rejected") + } + if unique { + t.Fatal("index reported as unique") + } + if name != "b_1_a_1" { + t.Fatalf("name %q", name) + } + keys, isD := model.Keys.(bson.D) + if !isD { + t.Fatalf("keys are %T, want bson.D", model.Keys) + } + // Documents current behaviour: field order is alphabetical, not the order + // the server reported. Storing the key document as raw BSON in the archive + // instead of JSON would fix this and is the change to make if compound + // index order ever matters here. + if keys[0].Key != "a" || keys[1].Key != "b" { + t.Fatalf("got %v", keys) + } +} From 060fa6433913a864c2886b59b35c2d0a90e95175 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 11:26:34 +0000 Subject: [PATCH 10/23] feat: Add backup verify with a live decrypt probe A fingerprint comparison proves two archives agree about a key. Only opening real ciphertext from the target proves the key in hand reads the data, which is the question an operator actually has. --- shared/backup/verify.go | 183 +++++++++++++++++++++++++++++++++++ shared/backup/verify_test.go | 156 +++++++++++++++++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 shared/backup/verify.go create mode 100644 shared/backup/verify_test.go diff --git a/shared/backup/verify.go b/shared/backup/verify.go new file mode 100644 index 0000000..9234d9a --- /dev/null +++ b/shared/backup/verify.go @@ -0,0 +1,183 @@ +package backup + +import ( + "context" + "fmt" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/cryptobox" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// VerifyOptions configures a verification. Client and Database are optional; +// supplying them turns on the live probe. +type VerifyOptions struct { + Archive *Reader + KeyHex string + Client *mongo.Client + Database string +} + +// VerifyReport is what verify found. +type VerifyReport struct { + ArchiveFingerprint *string + KeyFingerprint *string + KeyMatchesArchive bool + + // ProbeAttempted is false when no client was supplied, and also when the + // database holds no ciphertext to probe. + ProbeAttempted bool + ProbeCollection string + ProbeDecrypted bool + + Problems []string +} + +// OK reports whether this archive is usable with the key in hand. +func (r VerifyReport) OK() bool { return len(r.Problems) == 0 } + +func (r *VerifyReport) problem(format string, args ...any) { + r.Problems = append(r.Problems, fmt.Sprintf(format, args...)) +} + +// Verify checks an already-opened archive against the key in hand and, when a +// client is supplied, against a live database. +// +// Open has already verified every member's checksum, so integrity is not +// rechecked here. What this adds is the question an operator actually has: +// will the key I hold open the data this archive carries. A fingerprint +// comparison proves two archives agree; only the probe proves the key opens +// real ciphertext. +func Verify(ctx context.Context, opt VerifyOptions) (VerifyReport, error) { + m := opt.Archive.Manifest() + rep := VerifyReport{ArchiveFingerprint: m.KeyFingerprint} + + if opt.KeyHex != "" { + fp, err := FingerprintHex(opt.KeyHex) + if err != nil { + return rep, err + } + rep.KeyFingerprint = &fp + } + + switch { + case m.KeyFingerprint == nil && rep.KeyFingerprint == nil: + rep.problem("neither the archive nor this environment names a key; nothing here " + + "proves the archive's ciphertext can ever be read") + case m.KeyFingerprint == nil: + rep.problem("the archive carries no key fingerprint, so it cannot be matched " + + "against the key you hold") + case rep.KeyFingerprint == nil: + rep.problem("KEY_ENCRYPTION_KEY is not set, so the archive's fingerprint %s "+ + "cannot be checked against anything", *m.KeyFingerprint) + case *m.KeyFingerprint == *rep.KeyFingerprint: + rep.KeyMatchesArchive = true + default: + rep.problem("key mismatch: archive fingerprint %s, your key fingerprints as %s", + *m.KeyFingerprint, *rep.KeyFingerprint) + } + + if opt.Client == nil || opt.Database == "" || opt.KeyHex == "" { + return rep, nil + } + if err := probe(ctx, opt, &rep); err != nil { + return rep, err + } + return rep, nil +} + +// probe reads one ciphertext field from the live database and tries to open it. +func probe(ctx context.Context, opt VerifyOptions, rep *VerifyReport) error { + key, err := ParseKey(opt.KeyHex) + if err != nil { + return err + } + for _, coll := range CiphertextCollections() { + ciphertext, ok, err := findCiphertext(ctx, opt.Client.Database(opt.Database), coll) + if err != nil { + return err + } + if !ok { + continue + } + rep.ProbeAttempted = true + rep.ProbeCollection = coll + if _, err := cryptobox.Open(key, ciphertext); err != nil { + rep.problem("the key in hand does not decrypt live ciphertext in %s", coll) + return nil + } + rep.ProbeDecrypted = true + return nil + } + // No ciphertext anywhere is an ordinary state — a deployment that has + // stored no secrets, keys or SSO configuration yet — and is not a failure. + return nil +} + +// ciphertextFields names, per collection, the fields that hold hex ciphertext. +// A value is a candidate only if it is a hex string long enough to carry a GCM +// nonce and tag, which is what keeps this from probing a plaintext field. +var ciphertextFields = map[string][]string{ + "keys": {"private_key_enc", "passphrase_enc"}, + "secrets": {"values"}, + "auth_providers": {"client_secret_enc"}, + "console_sessions": {"rdp_password_enc", "vnc_password_enc"}, + "settings": {"secrets_token_hash_enc"}, +} + +func findCiphertext(ctx context.Context, db *mongo.Database, coll string) (string, bool, error) { + fields, ok := ciphertextFields[coll] + if !ok { + return "", false, nil + } + cur, err := db.Collection(coll).Find(ctx, bson.M{}) + if err != nil { + return "", false, fmt.Errorf("probe %s: %w", coll, err) + } + defer cur.Close(ctx) + + for cur.Next(ctx) { + var doc bson.M + if err := cur.Decode(&doc); err != nil { + return "", false, fmt.Errorf("probe %s: %w", coll, err) + } + for _, f := range fields { + if v, ok := looksLikeCiphertext(doc[f]); ok { + return v, true, nil + } + } + } + return "", false, cur.Err() +} + +// looksLikeCiphertext accepts a hex string long enough to be a sealed value, and +// descends one level into a map so secrets' values sub-document is reachable. +func looksLikeCiphertext(v any) (string, bool) { + switch t := v.(type) { + case string: + // 12-byte nonce plus a 16-byte tag is 56 hex characters before any + // plaintext at all, so anything shorter is not a sealed value. + if len(t) < 56 || !isHex(t) { + return "", false + } + return t, true + case bson.M: + for _, inner := range t { + if s, ok := looksLikeCiphertext(inner); ok { + return s, true + } + } + } + return "", false +} + +func isHex(s string) bool { + for _, c := range s { + switch { + case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F': + default: + return false + } + } + return true +} diff --git a/shared/backup/verify_test.go b/shared/backup/verify_test.go new file mode 100644 index 0000000..a0a63c8 --- /dev/null +++ b/shared/backup/verify_test.go @@ -0,0 +1,156 @@ +package backup + +import ( + "context" + "testing" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/cryptobox" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestVerifyMatchingKey(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + + rep, err := Verify(context.Background(), VerifyOptions{Archive: archive, KeyHex: validKeyHex}) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if !rep.KeyMatchesArchive { + t.Fatal("matching key reported as a mismatch") + } + if rep.ProbeAttempted { + t.Fatal("probe ran with no client supplied") + } + if !rep.OK() { + t.Fatalf("report not OK: %v", rep.Problems) + } +} + +func TestVerifyMismatchedKeyIsNotOK(t *testing.T) { + client, _ := testDB(t) + archive := archiveOf(t, client, validKeyHex, false) + other := "0000000000000000000000000000000000000000000000000000000000000002" + + rep, err := Verify(context.Background(), VerifyOptions{Archive: archive, KeyHex: other}) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if rep.KeyMatchesArchive { + t.Fatal("mismatched key reported as matching") + } + if rep.OK() { + t.Fatal("a mismatch must not report OK") + } +} + +func TestVerifyProbeDecryptsLiveCiphertext(t *testing.T) { + client, dbName := testDB(t) + ctx := context.Background() + + key, err := ParseKey(validKeyHex) + if err != nil { + t.Fatalf("ParseKey: %v", err) + } + sealed, err := cryptobox.Seal(key, "s3cret") + if err != nil { + t.Fatalf("Seal: %v", err) + } + if _, err := client.Database(dbName).Collection("secrets").InsertOne(ctx, bson.M{ + "instance_id": "i1", + "values": bson.M{"TOKEN": sealed}, + }); err != nil { + t.Fatalf("insert: %v", err) + } + + path, _ := dumpToFile(t, DumpOptions{Client: client, Database: dbName, KeyHex: validKeyHex}) + archive, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer archive.Close() + + rep, err := Verify(ctx, VerifyOptions{ + Archive: archive, KeyHex: validKeyHex, Client: client, Database: dbName, + }) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if !rep.ProbeAttempted { + t.Fatal("probe did not run with a client supplied") + } + if !rep.ProbeDecrypted { + t.Fatalf("probe failed to decrypt live ciphertext: %v", rep.Problems) + } + if rep.ProbeCollection != "secrets" { + t.Fatalf("probe collection %q, want secrets", rep.ProbeCollection) + } + if !rep.OK() { + t.Fatalf("report not OK: %v", rep.Problems) + } +} + +func TestVerifyProbeFailsWithWrongKey(t *testing.T) { + client, dbName := testDB(t) + ctx := context.Background() + + key, err := ParseKey(validKeyHex) + if err != nil { + t.Fatalf("ParseKey: %v", err) + } + sealed, err := cryptobox.Seal(key, "s3cret") + if err != nil { + t.Fatalf("Seal: %v", err) + } + if _, err := client.Database(dbName).Collection("secrets").InsertOne(ctx, bson.M{ + "values": bson.M{"TOKEN": sealed}, + }); err != nil { + t.Fatalf("insert: %v", err) + } + + other := "0000000000000000000000000000000000000000000000000000000000000002" + path, _ := dumpToFile(t, DumpOptions{Client: client, Database: dbName, KeyHex: other}) + archive, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer archive.Close() + + rep, err := Verify(ctx, VerifyOptions{ + Archive: archive, KeyHex: other, Client: client, Database: dbName, + }) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if rep.ProbeDecrypted { + t.Fatal("probe decrypted with the wrong key") + } + if rep.OK() { + t.Fatal("a failed probe must not report OK") + } +} + +func TestVerifyProbeAbsentCiphertextIsNotAFailure(t *testing.T) { + client, dbName := testDB(t) + seed(t, client, dbName) + + path, _ := dumpToFile(t, DumpOptions{Client: client, Database: dbName, KeyHex: validKeyHex}) + archive, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer archive.Close() + + rep, err := Verify(context.Background(), VerifyOptions{ + Archive: archive, KeyHex: validKeyHex, Client: client, Database: dbName, + }) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if rep.ProbeAttempted { + t.Fatal("probe claims to have run against a database with no ciphertext") + } + if !rep.OK() { + t.Fatalf("a database storing no secrets must still verify: %v", rep.Problems) + } +} From d9f7fa6993e415ac74b2a4753145e7126ac77d2b Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 13:45:20 +0000 Subject: [PATCH 11/23] feat: Add the vantagectl module and its cobra root Its own module rather than a package under shared, so cobra and pflag stay out of the module graphs of server, admin and sitesvc, which never use them. --- go.work | 1 + vantagectl/go.mod | 23 +++++ vantagectl/go.sum | 56 ++++++++++++ vantagectl/internal/cmd/backup.go | 7 ++ vantagectl/internal/cmd/inspect.go | 7 ++ vantagectl/internal/cmd/restore.go | 7 ++ vantagectl/internal/cmd/root.go | 124 +++++++++++++++++++++++++++ vantagectl/internal/cmd/root_test.go | 93 ++++++++++++++++++++ vantagectl/internal/cmd/verify.go | 7 ++ vantagectl/main.go | 19 ++++ 10 files changed, 344 insertions(+) create mode 100644 vantagectl/go.mod create mode 100644 vantagectl/go.sum create mode 100644 vantagectl/internal/cmd/backup.go create mode 100644 vantagectl/internal/cmd/inspect.go create mode 100644 vantagectl/internal/cmd/restore.go create mode 100644 vantagectl/internal/cmd/root.go create mode 100644 vantagectl/internal/cmd/root_test.go create mode 100644 vantagectl/internal/cmd/verify.go create mode 100644 vantagectl/main.go diff --git a/go.work b/go.work index ab47347..ed44a2f 100644 --- a/go.work +++ b/go.work @@ -6,4 +6,5 @@ use ( ./server ./shared ./sitesvc + ./vantagectl ) diff --git a/vantagectl/go.mod b/vantagectl/go.mod new file mode 100644 index 0000000..fe0d83f --- /dev/null +++ b/vantagectl/go.mod @@ -0,0 +1,23 @@ +module gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl + +go 1.26 + +replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared + +require ( + github.com/spf13/cobra v1.10.2 + go.mongodb.org/mongo-driver/v2 v2.8.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.17.6 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/text v0.22.0 // indirect +) diff --git a/vantagectl/go.sum b/vantagectl/go.sum new file mode 100644 index 0000000..0583637 --- /dev/null +++ b/vantagectl/go.sum @@ -0,0 +1,56 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= +github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8= +go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/vantagectl/internal/cmd/backup.go b/vantagectl/internal/cmd/backup.go new file mode 100644 index 0000000..e256bc1 --- /dev/null +++ b/vantagectl/internal/cmd/backup.go @@ -0,0 +1,7 @@ +package cmd + +import "github.com/spf13/cobra" + +func newBackupCmd() *cobra.Command { + return &cobra.Command{Use: "backup", Short: "Write an archive of the database"} +} diff --git a/vantagectl/internal/cmd/inspect.go b/vantagectl/internal/cmd/inspect.go new file mode 100644 index 0000000..1cdcfd7 --- /dev/null +++ b/vantagectl/internal/cmd/inspect.go @@ -0,0 +1,7 @@ +package cmd + +import "github.com/spf13/cobra" + +func newInspectCmd() *cobra.Command { + return &cobra.Command{Use: "inspect ARCHIVE", Short: "Print an archive's manifest"} +} diff --git a/vantagectl/internal/cmd/restore.go b/vantagectl/internal/cmd/restore.go new file mode 100644 index 0000000..2a98555 --- /dev/null +++ b/vantagectl/internal/cmd/restore.go @@ -0,0 +1,7 @@ +package cmd + +import "github.com/spf13/cobra" + +func newRestoreCmd() *cobra.Command { + return &cobra.Command{Use: "restore ARCHIVE", Short: "Load an archive into a database"} +} diff --git a/vantagectl/internal/cmd/root.go b/vantagectl/internal/cmd/root.go new file mode 100644 index 0000000..d1eeb45 --- /dev/null +++ b/vantagectl/internal/cmd/root.go @@ -0,0 +1,124 @@ +// Package cmd is vantagectl's command tree. +// +// It holds argument parsing and operator-facing output only. Everything it does +// to a database goes through shared/backup, which the server can also import. +package cmd + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +const connectTimeout = 30 * time.Second + +// globalOpts is what every subcommand needs. +type globalOpts struct { + MongoURI string + Database string + KeyHex string +} + +// NewRoot builds the command tree. +func NewRoot(version string) *cobra.Command { + root := &cobra.Command{ + Use: "vantagectl", + Short: "Back up and restore a Vantage control plane", + Version: version, + Long: "vantagectl backs up and restores the MongoDB database behind a Vantage\n" + + "control plane.\n\n" + + "It talks to MongoDB directly and never to the Vantage API, so it works\n" + + "against a control plane that is down, half-migrated, or gone.\n\n" + + "KEY_ENCRYPTION_KEY is never written into an archive. What an archive\n" + + "records is a fingerprint of it, so a restore can tell you that the key\n" + + "you hold is the wrong one before it writes a database nobody can read.", + SilenceUsage: true, + } + + f := root.PersistentFlags() + f.String("mongo-uri", "", "MongoDB connection string (env MONGO_URI)") + f.String("db", "", "database name (env MONGO_DB, or the URI path)") + + root.AddCommand(newBackupCmd(), newRestoreCmd(), newInspectCmd(), newVerifyCmd()) + return root +} + +// Execute runs the tree. +func Execute(version string) error { + return NewRoot(version).Execute() +} + +// resolveGlobals applies the environment fallback. +// +// Explicit flags win. The check is on Changed rather than on emptiness, so +// `--db ""` is an explicit empty value rather than an invitation to read the +// environment behind the operator's back. +func resolveGlobals(c *cobra.Command) (*globalOpts, error) { + root := c.Root() + f := root.PersistentFlags() + + uri, err := f.GetString("mongo-uri") + if err != nil { + return nil, err + } + if !f.Changed("mongo-uri") { + uri = os.Getenv("MONGO_URI") + } + if uri == "" { + return nil, fmt.Errorf("no MongoDB URI: pass --mongo-uri or set MONGO_URI") + } + + db, err := f.GetString("db") + if err != nil { + return nil, err + } + if !f.Changed("db") { + db = os.Getenv("MONGO_DB") + } + if db == "" { + db = databaseFromURI(uri) + } + if db == "" { + return nil, fmt.Errorf("no database name: pass --db, set MONGO_DB, or put one in the URI path") + } + + return &globalOpts{ + MongoURI: uri, + Database: db, + KeyHex: strings.TrimSpace(os.Getenv("KEY_ENCRYPTION_KEY")), + }, nil +} + +// databaseFromURI reads the database out of the URI path. sitesvc takes its +// database name this way too, so an operator who has configured one has +// configured both. +func databaseFromURI(uri string) string { + u, err := url.Parse(uri) + if err != nil { + return "" + } + return strings.Trim(u.Path, "/") +} + +// connect dials MongoDB and proves the connection before a caller commits to +// anything. +func connect(ctx context.Context, g *globalOpts) (*mongo.Client, error) { + client, err := mongo.Connect(options.Client().ApplyURI(g.MongoURI)) + if err != nil { + return nil, fmt.Errorf("connect to MongoDB: %w", err) + } + pingCtx, cancel := context.WithTimeout(ctx, connectTimeout) + defer cancel() + if err := client.Ping(pingCtx, nil); err != nil { + _ = client.Disconnect(context.Background()) + return nil, fmt.Errorf("MongoDB did not answer: %w", err) + } + return client, nil +} diff --git a/vantagectl/internal/cmd/root_test.go b/vantagectl/internal/cmd/root_test.go new file mode 100644 index 0000000..b15285c --- /dev/null +++ b/vantagectl/internal/cmd/root_test.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" +) + +func TestRootListsEverySubcommand(t *testing.T) { + root := NewRoot("test") + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"--help"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + for _, want := range []string{"backup", "restore", "inspect", "verify"} { + if !strings.Contains(buf.String(), want) { + t.Fatalf("help does not mention %q:\n%s", want, buf.String()) + } + } +} + +func TestGlobalFlagsFallBackToEnvironment(t *testing.T) { + t.Setenv("MONGO_URI", "mongodb://env:27017") + t.Setenv("MONGO_DB", "envdb") + t.Setenv("KEY_ENCRYPTION_KEY", "envkey") + + root := NewRoot("test") + g, err := resolveGlobals(root) + if err != nil { + t.Fatalf("resolveGlobals: %v", err) + } + if g.MongoURI != "mongodb://env:27017" { + t.Fatalf("MongoURI %q", g.MongoURI) + } + if g.Database != "envdb" { + t.Fatalf("Database %q", g.Database) + } + if g.KeyHex != "envkey" { + t.Fatalf("KeyHex %q", g.KeyHex) + } +} + +func TestExplicitFlagsBeatEnvironment(t *testing.T) { + t.Setenv("MONGO_URI", "mongodb://env:27017") + t.Setenv("MONGO_DB", "envdb") + + root := NewRoot("test") + if err := root.PersistentFlags().Set("mongo-uri", "mongodb://flag:27017"); err != nil { + t.Fatalf("set flag: %v", err) + } + if err := root.PersistentFlags().Set("db", "flagdb"); err != nil { + t.Fatalf("set flag: %v", err) + } + g, err := resolveGlobals(root) + if err != nil { + t.Fatalf("resolveGlobals: %v", err) + } + if g.MongoURI != "mongodb://flag:27017" { + t.Fatalf("MongoURI %q; the flag must win over the environment", g.MongoURI) + } + if g.Database != "flagdb" { + t.Fatalf("Database %q", g.Database) + } +} + +func TestDatabaseFallsBackToURIPath(t *testing.T) { + t.Setenv("MONGO_URI", "mongodb://host:27017/fromuri") + root := NewRoot("test") + g, err := resolveGlobals(root) + if err != nil { + t.Fatalf("resolveGlobals: %v", err) + } + if g.Database != "fromuri" { + t.Fatalf("Database %q, want fromuri", g.Database) + } +} + +func TestMissingURIIsAnError(t *testing.T) { + t.Setenv("MONGO_URI", "") + root := NewRoot("test") + if _, err := resolveGlobals(root); err == nil { + t.Fatal("resolveGlobals accepted an empty MONGO_URI") + } +} + +func TestVersionIsReported(t *testing.T) { + root := NewRoot("1.2.3") + if root.Version != "1.2.3" { + t.Fatalf("Version %q", root.Version) + } +} diff --git a/vantagectl/internal/cmd/verify.go b/vantagectl/internal/cmd/verify.go new file mode 100644 index 0000000..893ba24 --- /dev/null +++ b/vantagectl/internal/cmd/verify.go @@ -0,0 +1,7 @@ +package cmd + +import "github.com/spf13/cobra" + +func newVerifyCmd() *cobra.Command { + return &cobra.Command{Use: "verify ARCHIVE", Short: "Check an archive against the key in hand"} +} diff --git a/vantagectl/main.go b/vantagectl/main.go new file mode 100644 index 0000000..5fbf96f --- /dev/null +++ b/vantagectl/main.go @@ -0,0 +1,19 @@ +// Command vantagectl backs up and restores a Vantage control plane. +package main + +import ( + "fmt" + "os" + + "gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl/internal/cmd" +) + +// Version is stamped at build time with -ldflags "-X main.Version=...". +var Version = "dev" + +func main() { + if err := cmd.Execute(Version); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} From 461a79277d9ded1c9e7fadcd8fda1c564173b929 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 13:57:04 +0000 Subject: [PATCH 12/23] feat: Add the vantagectl backup and inspect subcommands Progress output goes to stderr so --out - stays a clean pipe into restic, age or aws s3 cp. Archive names carry no colon, because these get copied onto Windows shares. --- vantagectl/go.mod | 7 +- vantagectl/go.sum | 12 +-- vantagectl/internal/cmd/backup.go | 104 +++++++++++++++++++++++- vantagectl/internal/cmd/inspect.go | 75 ++++++++++++++++- vantagectl/internal/cmd/inspect_test.go | 70 ++++++++++++++++ 5 files changed, 255 insertions(+), 13 deletions(-) create mode 100644 vantagectl/internal/cmd/inspect_test.go diff --git a/vantagectl/go.mod b/vantagectl/go.mod index fe0d83f..c09a1cb 100644 --- a/vantagectl/go.mod +++ b/vantagectl/go.mod @@ -5,6 +5,7 @@ go 1.26 replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared require ( + gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0-00010101000000-000000000000 github.com/spf13/cobra v1.10.2 go.mongodb.org/mongo-driver/v2 v2.8.0 ) @@ -17,7 +18,7 @@ require ( github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - golang.org/x/crypto v0.33.0 // indirect - golang.org/x/sync v0.11.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.40.0 // indirect ) diff --git a/vantagectl/go.sum b/vantagectl/go.sum index 0583637..91f6299 100644 --- a/vantagectl/go.sum +++ b/vantagectl/go.sum @@ -26,16 +26,16 @@ go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzyb go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -47,8 +47,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/vantagectl/internal/cmd/backup.go b/vantagectl/internal/cmd/backup.go index e256bc1..538f71f 100644 --- a/vantagectl/internal/cmd/backup.go +++ b/vantagectl/internal/cmd/backup.go @@ -1,7 +1,107 @@ package cmd -import "github.com/spf13/cobra" +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/backup" + "github.com/spf13/cobra" +) func newBackupCmd() *cobra.Command { - return &cobra.Command{Use: "backup", Short: "Write an archive of the database"} + var ( + out string + exclude []string + allowNoKey bool + ) + + c := &cobra.Command{ + Use: "backup", + Short: "Write an archive of the database", + Long: "backup writes every collection in the database to a gzipped tar\n" + + "archive, along with a fingerprint of KEY_ENCRYPTION_KEY.\n\n" + + "The key itself is never written. The fingerprint is what lets a later\n" + + "restore refuse rather than produce a database whose secrets nobody\n" + + "can read.\n\n" + + "Pass --out - to stream to stdout, which is how this composes with\n" + + "restic, age, or aws s3 cp -.", + Args: cobra.NoArgs, + RunE: func(c *cobra.Command, _ []string) error { + ctx := c.Context() + g, err := resolveGlobals(c) + if err != nil { + return err + } + client, err := connect(ctx, g) + if err != nil { + return err + } + defer client.Disconnect(context.Background()) + + w, closeOut, name, err := backupDestination(out, g.Database) + if err != nil { + return err + } + defer closeOut() + + m, err := backup.Dump(ctx, backup.DumpOptions{ + Client: client, + Database: g.Database, + Exclude: exclude, + KeyHex: g.KeyHex, + AllowNoKey: allowNoKey, + VantageVersion: c.Root().Version, + Out: w, + }) + if err != nil { + return err + } + + // Progress goes to stderr so --out - stays a clean pipe. + var docs int64 + for _, coll := range m.Collections { + docs += coll.Documents + } + fmt.Fprintf(c.ErrOrStderr(), "wrote %s: %d collections, %d documents\n", + name, len(m.Collections), docs) + if m.KeyFingerprint == nil { + fmt.Fprintln(c.ErrOrStderr(), + "warning: no key recorded; nothing in this archive proves its "+ + "ciphertext can ever be read") + } + return nil + }, + } + + c.Flags().StringVar(&out, "out", ".", "directory to write the archive into, or - for stdout") + c.Flags().StringSliceVar(&exclude, "exclude", nil, + "collections to leave out, comma separated (recorded in the manifest)") + c.Flags().BoolVar(&allowNoKey, "allow-no-key", false, + "back up without KEY_ENCRYPTION_KEY set; only for a deployment storing no encrypted data") + return c +} + +// backupDestination resolves --out to a writer, a closer and a name to print. +func backupDestination(out, database string) (io.Writer, func(), string, error) { + if out == "-" { + return os.Stdout, func() {}, "stdout", nil + } + name := archiveName(database, time.Now().UTC()) + path := filepath.Join(out, name) + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return nil, nil, "", fmt.Errorf("create %s: %w", path, err) + } + return f, func() { f.Close() }, path, nil +} + +// archiveName is sortable and carries no colon, because an operator will copy +// these onto a Windows share sooner or later and a colon is not a legal +// filename character there. +func archiveName(database string, at time.Time) string { + return fmt.Sprintf("vantage-backup-%s-%s.tar.gz", database, at.Format("20060102T150405Z")) } diff --git a/vantagectl/internal/cmd/inspect.go b/vantagectl/internal/cmd/inspect.go index 1cdcfd7..5f6580b 100644 --- a/vantagectl/internal/cmd/inspect.go +++ b/vantagectl/internal/cmd/inspect.go @@ -1,7 +1,78 @@ package cmd -import "github.com/spf13/cobra" +import ( + "fmt" + "io" + "strings" + "text/tabwriter" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/backup" + "github.com/spf13/cobra" +) func newInspectCmd() *cobra.Command { - return &cobra.Command{Use: "inspect ARCHIVE", Short: "Print an archive's manifest"} + return &cobra.Command{ + Use: "inspect ARCHIVE", + Short: "Print an archive's manifest", + Long: "inspect reads an archive and prints what it holds. It contacts no\n" + + "database, so it is safe to run against an archive of unknown origin\n" + + "and is the fastest way to find out whether one is worth anything.", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + archive, err := backup.Open(args[0]) + if err != nil { + return err + } + defer archive.Close() + renderManifest(c.OutOrStdout(), archive.Manifest()) + return nil + }, + } +} + +// renderManifest prints a manifest for a human. +func renderManifest(w io.Writer, m backup.Manifest) { + fmt.Fprintf(w, "Created %s\n", m.CreatedAt.UTC().Format("2006-01-02 15:04:05 MST")) + fmt.Fprintf(w, "Database %s\n", m.MongoDB) + fmt.Fprintf(w, "MongoDB %s\n", m.MongoServerVersion) + fmt.Fprintf(w, "Written by vantagectl %s on %s\n", m.VantageVersion, m.Hostname) + fmt.Fprintf(w, "Format version %d\n", m.FormatVersion) + + if m.KeyFingerprint == nil { + fmt.Fprintf(w, "Key none recorded — this archive cannot be checked "+ + "against any KEY_ENCRYPTION_KEY\n") + } else { + fmt.Fprintf(w, "Key %s\n", *m.KeyFingerprint) + } + if len(m.Excluded) > 0 { + fmt.Fprintf(w, "Excluded %s\n", strings.Join(m.Excluded, ", ")) + } + + var docs, bytes int64 + for _, c := range m.Collections { + docs += c.Documents + bytes += c.Bytes + } + fmt.Fprintf(w, "\n%d collections, %d documents, %s\n\n", + len(m.Collections), docs, humanBytes(bytes)) + + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "COLLECTION\tDOCUMENTS\tSIZE") + for _, c := range m.Collections { + fmt.Fprintf(tw, "%s\t%d\t%s\n", c.Name, c.Documents, humanBytes(c.Bytes)) + } + tw.Flush() +} + +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for v := n / unit; v >= unit; v /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTP"[exp]) } diff --git a/vantagectl/internal/cmd/inspect_test.go b/vantagectl/internal/cmd/inspect_test.go new file mode 100644 index 0000000..81125fc --- /dev/null +++ b/vantagectl/internal/cmd/inspect_test.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/backup" +) + +func TestArchiveNameIsSortableAndNamesTheDatabase(t *testing.T) { + at := time.Date(2026, 9, 7, 14, 30, 5, 0, time.UTC) + got := archiveName("vantage", at) + if !strings.HasPrefix(got, "vantage-backup-vantage-") { + t.Fatalf("name %q does not name the database", got) + } + if !strings.HasSuffix(got, ".tar.gz") { + t.Fatalf("name %q has the wrong extension", got) + } + if strings.ContainsAny(got, ":") { + t.Fatalf("name %q contains a colon, which Windows will not accept", got) + } + if !strings.Contains(got, "20260907") { + t.Fatalf("name %q does not carry a sortable date", got) + } +} + +func TestRenderManifestShowsWhatMatters(t *testing.T) { + fp := "ab12" + m := backup.Manifest{ + FormatVersion: backup.FormatVersion, + CreatedAt: time.Date(2026, 9, 7, 14, 0, 0, 0, time.UTC), + VantageVersion: "1.4.0", + Hostname: "ops-box", + MongoDB: "vantage", + MongoServerVersion: "7.0.5", + KeyFingerprint: &fp, + Collections: []backup.CollectionEntry{ + {Name: "servers", Documents: 12, Bytes: 4096}, + {Name: "keys", Documents: 3, Bytes: 900}, + }, + Excluded: []string{"audit_logs"}, + } + + var buf bytes.Buffer + renderManifest(&buf, m) + out := buf.String() + + for _, want := range []string{ + "vantage", "1.4.0", "ops-box", "7.0.5", "ab12", + "servers", "12", "keys", "audit_logs", "2026-09-07", + } { + if !strings.Contains(out, want) { + t.Fatalf("inspect output missing %q:\n%s", want, out) + } + } +} + +func TestRenderManifestFlagsAMissingFingerprint(t *testing.T) { + var buf bytes.Buffer + renderManifest(&buf, backup.Manifest{FormatVersion: backup.FormatVersion}) + out := buf.String() + if !strings.Contains(out, "none recorded") { + t.Fatalf("a null fingerprint must be called out, got:\n%s", out) + } + if !strings.Contains(out, "cannot be checked") { + t.Fatalf("a null fingerprint must explain the consequence, got:\n%s", out) + } +} From 3ce963b0cd942257cd7f56c5f3b27da18c99a457 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 14:04:25 +0000 Subject: [PATCH 13/23] feat: Add the vantagectl restore and verify subcommands --force requires a typed database name on a terminal and --confirm-db without one, so a copy-pasted restore command carries its intended target and cannot destroy a different database. Also silences cobra's own error print (root.go) so a failure is reported once by main.go instead of twice, and pins the Changed()-based env fallback in resolveGlobals with a test for an explicitly empty --db. --- vantagectl/go.mod | 2 + vantagectl/go.sum | 4 + vantagectl/internal/cmd/restore.go | 131 +++++++++++++++++++++++- vantagectl/internal/cmd/restore_test.go | 65 ++++++++++++ vantagectl/internal/cmd/root.go | 3 +- vantagectl/internal/cmd/root_test.go | 24 +++++ vantagectl/internal/cmd/verify.go | 87 +++++++++++++++- 7 files changed, 311 insertions(+), 5 deletions(-) create mode 100644 vantagectl/internal/cmd/restore_test.go diff --git a/vantagectl/go.mod b/vantagectl/go.mod index c09a1cb..761d620 100644 --- a/vantagectl/go.mod +++ b/vantagectl/go.mod @@ -8,6 +8,7 @@ require ( gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0-00010101000000-000000000000 github.com/spf13/cobra v1.10.2 go.mongodb.org/mongo-driver/v2 v2.8.0 + golang.org/x/term v0.45.0 ) require ( @@ -20,5 +21,6 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect golang.org/x/crypto v0.54.0 // indirect golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect ) diff --git a/vantagectl/go.sum b/vantagectl/go.sum index 91f6299..258b7c5 100644 --- a/vantagectl/go.sum +++ b/vantagectl/go.sum @@ -41,8 +41,12 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= diff --git a/vantagectl/internal/cmd/restore.go b/vantagectl/internal/cmd/restore.go index 2a98555..a74d452 100644 --- a/vantagectl/internal/cmd/restore.go +++ b/vantagectl/internal/cmd/restore.go @@ -1,7 +1,134 @@ package cmd -import "github.com/spf13/cobra" +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/backup" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// ErrNotConfirmed is returned when a destructive restore was not confirmed. +var ErrNotConfirmed = errors.New("restore not confirmed") func newRestoreCmd() *cobra.Command { - return &cobra.Command{Use: "restore ARCHIVE", Short: "Load an archive into a database"} + var ( + force bool + confirmDB string + ignoreKeyErr bool + ) + + c := &cobra.Command{ + Use: "restore ARCHIVE", + Short: "Load an archive into a database", + Long: "restore loads an archive into a MongoDB database.\n\n" + + "The target is expected to be empty. A database that already holds data\n" + + "is refused unless --force is given, which drops each collection in the\n" + + "archive before loading it. There are no merge semantics: merging two\n" + + "control planes reconciles nothing, and upserting would resurrect\n" + + "revoked keys and deleted users.\n\n" + + "Restore does not touch Redis. Sessions are all it holds, so everyone\n" + + "signs in again.", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + ctx := c.Context() + g, err := resolveGlobals(c) + if err != nil { + return err + } + + archive, err := backup.Open(args[0]) + if err != nil { + return err + } + defer archive.Close() + + if force { + isTTY := term.IsTerminal(int(os.Stdin.Fd())) + if err := confirmDestruction(c.InOrStdin(), c.OutOrStdout(), isTTY, + confirmDB, g.Database); err != nil { + return err + } + } + + client, err := connect(ctx, g) + if err != nil { + return err + } + defer client.Disconnect(context.Background()) + + res, err := backup.Restore(ctx, backup.RestoreOptions{ + Client: client, + Database: g.Database, + Archive: archive, + Force: force, + KeyHex: g.KeyHex, + IgnoreKeyMismatch: ignoreKeyErr, + Warn: func(s string) { + fmt.Fprintln(c.ErrOrStderr(), "warning:", s) + }, + }) + if err != nil { + return err + } + + tw := tabwriter.NewWriter(c.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "COLLECTION\tDOCUMENTS\tINDEXES") + for _, coll := range res.Collections { + fmt.Fprintf(tw, "%s\t%d\t%d\n", coll.Name, coll.Documents, coll.Indexes) + } + tw.Flush() + fmt.Fprintf(c.OutOrStdout(), "\nrestored %d collections into %s\n", + len(res.Collections), g.Database) + return nil + }, + } + + c.Flags().BoolVar(&force, "force", false, + "drop each collection in the archive before loading it") + c.Flags().StringVar(&confirmDB, "confirm-db", "", + "name of the database being overwritten; required with --force when there is no terminal") + c.Flags().BoolVar(&ignoreKeyErr, "ignore-key-mismatch", false, + "restore even though KEY_ENCRYPTION_KEY does not match the archive") + return c +} + +// confirmDestruction gates a --force restore. +// +// On a terminal the operator types the database name. Without one — a +// Kubernetes Job, a CI step, a cron entry — the same assurance comes from +// --confirm-db, whose value must equal the target. Naming the database in the +// argument means a copy-pasted command carries its intended target with it and +// cannot destroy a different one. +func confirmDestruction(in io.Reader, out io.Writer, isTTY bool, confirmDB, database string) error { + if confirmDB != "" { + if confirmDB != database { + return fmt.Errorf("%w: --confirm-db says %q but the target is %q", + ErrNotConfirmed, confirmDB, database) + } + return nil + } + if !isTTY { + return fmt.Errorf("%w: --force with no terminal needs --confirm-db %s", + ErrNotConfirmed, database) + } + + fmt.Fprintf(out, "This drops every collection in the archive from %q and reloads it.\n", database) + fmt.Fprintf(out, "Type the database name to continue: ") + + line, err := bufio.NewReader(in).ReadString('\n') + if err != nil && err != io.EOF { + return fmt.Errorf("%w: %v", ErrNotConfirmed, err) + } + if strings.TrimSpace(line) != database { + return fmt.Errorf("%w: that is not %q", ErrNotConfirmed, database) + } + return nil } diff --git a/vantagectl/internal/cmd/restore_test.go b/vantagectl/internal/cmd/restore_test.go new file mode 100644 index 0000000..ebd7f90 --- /dev/null +++ b/vantagectl/internal/cmd/restore_test.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +func TestConfirmDestructionNonTTYRequiresMatchingFlag(t *testing.T) { + var out bytes.Buffer + err := confirmDestruction(strings.NewReader(""), &out, false, "", "vantage") + if !errors.Is(err, ErrNotConfirmed) { + t.Fatalf("got %v, want ErrNotConfirmed", err) + } + if !strings.Contains(err.Error(), "--confirm-db vantage") { + t.Fatalf("the error must tell the operator exactly what to pass, got: %v", err) + } +} + +func TestConfirmDestructionNonTTYRejectsWrongDatabase(t *testing.T) { + var out bytes.Buffer + err := confirmDestruction(strings.NewReader(""), &out, false, "staging", "production") + if !errors.Is(err, ErrNotConfirmed) { + t.Fatalf("got %v, want ErrNotConfirmed", err) + } + if !strings.Contains(err.Error(), "production") { + t.Fatalf("the error must name the real target, got: %v", err) + } +} + +func TestConfirmDestructionNonTTYAcceptsMatchingFlag(t *testing.T) { + var out bytes.Buffer + if err := confirmDestruction(strings.NewReader(""), &out, false, "vantage", "vantage"); err != nil { + t.Fatalf("matching --confirm-db rejected: %v", err) + } +} + +func TestConfirmDestructionTTYRequiresTypedName(t *testing.T) { + var out bytes.Buffer + if err := confirmDestruction(strings.NewReader("vantage\n"), &out, true, "", "vantage"); err != nil { + t.Fatalf("typed name rejected: %v", err) + } + if !strings.Contains(out.String(), "vantage") { + t.Fatalf("the prompt must name the database, got: %s", out.String()) + } +} + +func TestConfirmDestructionTTYRejectsWrongTypedName(t *testing.T) { + var out bytes.Buffer + err := confirmDestruction(strings.NewReader("something else\n"), &out, true, "", "vantage") + if !errors.Is(err, ErrNotConfirmed) { + t.Fatalf("got %v, want ErrNotConfirmed", err) + } +} + +func TestConfirmDestructionTTYFlagSkipsThePrompt(t *testing.T) { + var out bytes.Buffer + if err := confirmDestruction(strings.NewReader(""), &out, true, "vantage", "vantage"); err != nil { + t.Fatalf("matching --confirm-db rejected on a TTY: %v", err) + } + if out.Len() != 0 { + t.Fatalf("--confirm-db must skip the prompt, got: %s", out.String()) + } +} diff --git a/vantagectl/internal/cmd/root.go b/vantagectl/internal/cmd/root.go index d1eeb45..7d0b859 100644 --- a/vantagectl/internal/cmd/root.go +++ b/vantagectl/internal/cmd/root.go @@ -39,7 +39,8 @@ func NewRoot(version string) *cobra.Command { "KEY_ENCRYPTION_KEY is never written into an archive. What an archive\n" + "records is a fingerprint of it, so a restore can tell you that the key\n" + "you hold is the wrong one before it writes a database nobody can read.", - SilenceUsage: true, + SilenceUsage: true, + SilenceErrors: true, } f := root.PersistentFlags() diff --git a/vantagectl/internal/cmd/root_test.go b/vantagectl/internal/cmd/root_test.go index b15285c..7396c89 100644 --- a/vantagectl/internal/cmd/root_test.go +++ b/vantagectl/internal/cmd/root_test.go @@ -91,3 +91,27 @@ func TestVersionIsReported(t *testing.T) { t.Fatalf("Version %q", root.Version) } } + +func TestExplicitlyEmptyFlagDoesNotFallBackToEnvironment(t *testing.T) { + t.Setenv("MONGO_URI", "mongodb://env:27017") + t.Setenv("MONGO_DB", "envdb") + + root := NewRoot("test") + // An operator who writes --db "" means the empty string. Reading the + // environment behind their back would be a different database than the one + // they named, which for a restore is the difference between the right + // machine and the wrong one. + if err := root.PersistentFlags().Set("db", ""); err != nil { + t.Fatalf("set flag: %v", err) + } + + // The URI carries no database path either, so nothing can rescue the empty + // value and resolveGlobals must refuse rather than reach for MONGO_DB. + if err := root.PersistentFlags().Set("mongo-uri", "mongodb://host:27017"); err != nil { + t.Fatalf("set flag: %v", err) + } + + if _, err := resolveGlobals(root); err == nil { + t.Fatal("an explicitly empty --db fell back to MONGO_DB instead of being refused") + } +} diff --git a/vantagectl/internal/cmd/verify.go b/vantagectl/internal/cmd/verify.go index 893ba24..f8e23ae 100644 --- a/vantagectl/internal/cmd/verify.go +++ b/vantagectl/internal/cmd/verify.go @@ -1,7 +1,90 @@ package cmd -import "github.com/spf13/cobra" +import ( + "context" + "fmt" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/backup" + "github.com/spf13/cobra" + "go.mongodb.org/mongo-driver/v2/mongo" +) func newVerifyCmd() *cobra.Command { - return &cobra.Command{Use: "verify ARCHIVE", Short: "Check an archive against the key in hand"} + return &cobra.Command{ + Use: "verify ARCHIVE", + Short: "Check an archive against the key in hand", + Long: "verify checks that an archive is intact and that the\n" + + "KEY_ENCRYPTION_KEY in this environment matches the one it was made\n" + + "with.\n\n" + + "Given --mongo-uri it goes further and opens a real ciphertext value\n" + + "from that database. A fingerprint proves two archives agree about a\n" + + "key; only the probe proves the key you hold reads the data.\n\n" + + "Exit status is non-zero when anything is wrong, so this is the command\n" + + "to put on a schedule.", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + ctx := c.Context() + + archive, err := backup.Open(args[0]) + if err != nil { + return err + } + defer archive.Close() + + opt := backup.VerifyOptions{Archive: archive} + + // A database is optional here. resolveGlobals fails without a URI, + // so its error is a signal to verify the archive alone rather than + // a reason to stop. + var client *mongo.Client + if g, gerr := resolveGlobals(c); gerr == nil { + client, err = connect(ctx, g) + if err != nil { + return err + } + defer client.Disconnect(context.Background()) + opt.Client = client + opt.Database = g.Database + opt.KeyHex = g.KeyHex + } else { + fmt.Fprintln(c.ErrOrStderr(), + "note: no MongoDB URI, so this checks the archive and the key only") + } + + rep, err := backup.Verify(ctx, opt) + if err != nil { + return err + } + + out := c.OutOrStdout() + fmt.Fprintln(out, "Archive intact, every member matches its checksum") + if rep.ArchiveFingerprint != nil { + fmt.Fprintf(out, "Archive key %s\n", *rep.ArchiveFingerprint) + } + if rep.KeyFingerprint != nil { + fmt.Fprintf(out, "Your key %s\n", *rep.KeyFingerprint) + } + if rep.KeyMatchesArchive { + fmt.Fprintln(out, "Key match yes") + } + switch { + case rep.ProbeDecrypted: + fmt.Fprintf(out, "Live probe decrypted a value from %s\n", rep.ProbeCollection) + case rep.ProbeAttempted: + fmt.Fprintf(out, "Live probe FAILED against %s\n", rep.ProbeCollection) + case opt.Client != nil: + fmt.Fprintln(out, "Live probe skipped; this database stores no ciphertext yet") + } + + if rep.OK() { + fmt.Fprintln(out, "\nThis archive will restore.") + return nil + } + fmt.Fprintln(out) + for _, p := range rep.Problems { + fmt.Fprintln(out, "problem:", p) + } + return fmt.Errorf("verification failed") + }, + } } From e08cc2f9288847de4035cbf8914c511c37f7289f Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 14:09:00 +0000 Subject: [PATCH 14/23] feat: Build and publish vantagectl The scratch runtime stage copies an explicit /tmp: restore extracts an archive there before verifying it, and a scratch image has none. shared/ now fans out to four Go images rather than three. --- .gitea/workflows/server-deploy.yml | 13 +++++- .gitea/workflows/vantagectl-release.yml | 60 +++++++++++++++++++++++++ vantagectl/Dockerfile | 36 +++++++++++++++ 3 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 .gitea/workflows/vantagectl-release.yml create mode 100644 vantagectl/Dockerfile diff --git a/.gitea/workflows/server-deploy.yml b/.gitea/workflows/server-deploy.yml index 17a41b8..09f4959 100644 --- a/.gitea/workflows/server-deploy.yml +++ b/.gitea/workflows/server-deploy.yml @@ -71,15 +71,16 @@ jobs: fi } - # The three Go images build from the repo root and COPY + # The four Go images build from the repo root and COPY # shared/ plus their own directory, so shared/ rebuilds all - # three. proto/ is in server's list as insurance: the + # four. proto/ is in server's list as insurance: the # generated pb is committed under server/, but a proto change # that someone regenerates in the same push should not depend # on that ordering. flag server '^(server/|shared/|proto/|default_steps/|go\.work)' flag sitesvc '^(sitesvc/|shared/|go\.work)' flag admin '^(admin/|shared/|go\.work)' + flag vantagectl '^(vantagectl/|shared/|go\.work)' # The three Next images and the docs site use their own # directory as the build context, so nothing outside it can @@ -159,6 +160,14 @@ jobs: docker build -t "$IMAGE" -f admin/Dockerfile . docker push "$IMAGE" + - name: Build and push vantagectl image + if: steps.changed.outputs.vantagectl == 'true' + run: | + IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/vantagectl:latest" + # Root context: vantagectl depends on the shared module. + docker build -t "$IMAGE" -f vantagectl/Dockerfile . + docker push "$IMAGE" + - name: Build and push adminsite image if: steps.changed.outputs.adminsite == 'true' run: | diff --git a/.gitea/workflows/vantagectl-release.yml b/.gitea/workflows/vantagectl-release.yml new file mode 100644 index 0000000..6b8ea57 --- /dev/null +++ b/.gitea/workflows/vantagectl-release.yml @@ -0,0 +1,60 @@ +name: vantagectl Release + +on: + push: + tags: + - "vantagectl/v*" + +jobs: + build: + runs-on: ubuntu-docker + container: node:26 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26" + cache: true + cache-dependency-path: vantagectl/go.sum + + - name: Extract version + id: version + run: echo "VERSION=${GITHUB_REF_NAME#vantagectl/}" >> $GITHUB_OUTPUT + + - name: Test + working-directory: vantagectl + run: go test ./... + + - name: Build + working-directory: vantagectl + env: + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + mkdir -p dist + for target in linux/amd64 linux/arm64 darwin/arm64 windows/amd64; do + goos="${target%/*}" + goarch="${target#*/}" + out="dist/vantagectl-${goos}-${goarch}" + if [ "$goos" = "windows" ]; then out="${out}.exe"; fi + CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o "$out" . + done + + - name: Checksums + working-directory: vantagectl/dist + run: sha256sum vantagectl-* > checksums.txt + + - name: Create release + uses: https://gitea.com/actions/gitea-release-action@v1 + with: + token: ${{ secrets.RELEASE_TOKEN }} + files: | + vantagectl/dist/vantagectl-linux-amd64 + vantagectl/dist/vantagectl-linux-arm64 + vantagectl/dist/vantagectl-darwin-arm64 + vantagectl/dist/vantagectl-windows-amd64.exe + vantagectl/dist/checksums.txt diff --git a/vantagectl/Dockerfile b/vantagectl/Dockerfile new file mode 100644 index 0000000..437c1ad --- /dev/null +++ b/vantagectl/Dockerfile @@ -0,0 +1,36 @@ +# Build stage +# +# Context is the repository root, not vantagectl/, because vantagectl depends on +# the shared module through a replace directive. +FROM golang:1.26 AS builder + +WORKDIR /src + +# Manifests first so the dependency layer caches independently of source edits. +COPY shared/go.mod shared/go.sum ./shared/ +COPY vantagectl/go.mod vantagectl/go.sum ./vantagectl/ +RUN cd vantagectl && go mod download + +COPY shared/ ./shared/ +COPY vantagectl/ ./vantagectl/ + +ARG VERSION=dev +RUN cd vantagectl && CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w -X main.Version=${VERSION}" -o /vantagectl . + +# Staged so the scratch image below can have a /tmp. It cannot mkdir one +# itself — scratch has no shell. +RUN mkdir -p /staging/tmp && chmod 1777 /staging/tmp + +# Runtime stage +FROM scratch + +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ + +# restore extracts an archive here before verifying its checksums, and backup +# stages nothing but still inherits os.MkdirTemp's requirements. Without this +# every restore stops at "temp dir: stat /tmp: no such file or directory". +COPY --from=builder /staging/tmp /tmp +COPY --from=builder /vantagectl /vantagectl + +ENTRYPOINT ["/vantagectl"] From 884fd189fb53a7fd26664aad118ff6060e8dc1fc Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 14:11:58 +0000 Subject: [PATCH 15/23] fix: Report the real reason verify falls back to archive-only checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveGlobals can fail for two distinct reasons — no MongoDB URI, or no resolvable database name — and verify.go was printing a hardcoded no-URI note regardless of which one occurred, misleading an operator whose URI was fine but whose database name could not be resolved. --- vantagectl/internal/cmd/verify.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/vantagectl/internal/cmd/verify.go b/vantagectl/internal/cmd/verify.go index f8e23ae..758947b 100644 --- a/vantagectl/internal/cmd/verify.go +++ b/vantagectl/internal/cmd/verify.go @@ -33,9 +33,9 @@ func newVerifyCmd() *cobra.Command { opt := backup.VerifyOptions{Archive: archive} - // A database is optional here. resolveGlobals fails without a URI, - // so its error is a signal to verify the archive alone rather than - // a reason to stop. + // A database is optional here. resolveGlobals fails without a URI or + // without a resolvable database name, and either error is a signal to + // verify the archive alone rather than a reason to stop. var client *mongo.Client if g, gerr := resolveGlobals(c); gerr == nil { client, err = connect(ctx, g) @@ -47,8 +47,8 @@ func newVerifyCmd() *cobra.Command { opt.Database = g.Database opt.KeyHex = g.KeyHex } else { - fmt.Fprintln(c.ErrOrStderr(), - "note: no MongoDB URI, so this checks the archive and the key only") + fmt.Fprintf(c.ErrOrStderr(), + "note: %v, so this checks the archive and the key only\n", gerr) } rep, err := backup.Verify(ctx, opt) From f0f2600bed4f30a48df58b29fe8fc4fe1ef12d3f Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 14:17:42 +0000 Subject: [PATCH 16/23] feat: Add an optional scheduled backup CronJob to the chart Off by default: a backup with nowhere durable to land is a false sense of safety and the chart cannot know where that is. NOTES.txt says so when it is off. No restore manifest ships: a restore must never be something a helm upgrade can trigger. --- .gitea/workflows/chart-release.yml | 13 +++++ deploy/chart/vantage/templates/NOTES.txt | 10 ++++ deploy/chart/vantage/templates/_helpers.tpl | 15 +++++ .../vantage/templates/backup-cronjob.yaml | 56 +++++++++++++++++++ deploy/chart/vantage/values.yaml | 22 ++++++++ 5 files changed, 116 insertions(+) create mode 100644 deploy/chart/vantage/templates/backup-cronjob.yaml diff --git a/.gitea/workflows/chart-release.yml b/.gitea/workflows/chart-release.yml index 3c8bbe0..a94810e 100644 --- a/.gitea/workflows/chart-release.yml +++ b/.gitea/workflows/chart-release.yml @@ -76,6 +76,13 @@ jobs: fi echo "ok: reaper configured in cloud mode only" + - name: Render with backups enabled + run: | + helm template test "$CHART_DIR" \ + --set backup.enabled=true \ + --set backup.image=gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl:latest \ + --set backup.pvcName=vantage-backups > /dev/null + - name: Render against external Redis and MongoDB run: | helm template test "$CHART_DIR" \ @@ -149,6 +156,12 @@ jobs: --set ingress.enabled=true \ --set ingress.web.host=vantage.example.com \ --set ingress.grpc.host=agents.example.com + refuses "backup enabled with no pvcName" \ + --set backup.enabled=true \ + --set backup.image=gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl:latest + refuses "backup enabled with no image" \ + --set backup.enabled=true \ + --set backup.pvcName=vantage-backups - name: Read the chart version id: chart diff --git a/deploy/chart/vantage/templates/NOTES.txt b/deploy/chart/vantage/templates/NOTES.txt index 09c39fe..617aefa 100644 --- a/deploy/chart/vantage/templates/NOTES.txt +++ b/deploy/chart/vantage/templates/NOTES.txt @@ -67,3 +67,13 @@ or add an Ingress on top of the -web and -server services. Quick access via port-forward, e.g.: kubectl port-forward svc/{{ .Release.Name }}-web {{ .Values.web.service.port }}:{{ .Values.web.service.port }} kubectl port-forward svc/{{ .Release.Name }}-server {{ .Values.server.service.httpPort }}:{{ .Values.server.service.httpPort }} +{{- if not .Values.backup.enabled }} + +No backups are scheduled. Vantage encrypts SSH private keys, vault secrets and +SSO client secrets with KEY_ENCRYPTION_KEY, and that key is not stored anywhere +but your own configuration — a database restored without it is permanently +unreadable. + +Set backup.enabled, backup.image and backup.pvcName, and store +KEY_ENCRYPTION_KEY somewhere that survives this cluster. +{{- end }} diff --git a/deploy/chart/vantage/templates/_helpers.tpl b/deploy/chart/vantage/templates/_helpers.tpl index 842cf5b..2ada665 100644 --- a/deploy/chart/vantage/templates/_helpers.tpl +++ b/deploy/chart/vantage/templates/_helpers.tpl @@ -85,3 +85,18 @@ both read it. fieldRef: fieldPath: status.podIP {{- end -}} + +{{/* +vantage.backup.env renders the environment vantagectl needs. + +It reads the SAME values the server does rather than taking its own, because a +backup that connected to a different database, or stamped a fingerprint of a +different key, than the deployment it is backing up would be worse than no +backup: it would look like one. +*/}} +{{- define "vantage.backup.env" -}} +- name: MONGO_URI + value: {{ tpl .Values.server.env.mongoUri . | quote }} +- name: KEY_ENCRYPTION_KEY + value: {{ .Values.server.env.keyEncryptionKey | quote }} +{{- end -}} diff --git a/deploy/chart/vantage/templates/backup-cronjob.yaml b/deploy/chart/vantage/templates/backup-cronjob.yaml new file mode 100644 index 0000000..0a43656 --- /dev/null +++ b/deploy/chart/vantage/templates/backup-cronjob.yaml @@ -0,0 +1,56 @@ +{{- if .Values.backup.enabled }} +{{- if not .Values.backup.pvcName }} +{{- fail "backup.enabled requires backup.pvcName: a backup needs somewhere durable to land, and the chart cannot guess where that is" }} +{{- end }} +{{- if not .Values.backup.image }} +{{- fail "backup.enabled requires backup.image: the vantagectl image to run" }} +{{- end }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "vantage.fullname" . }}-backup + labels: + {{- include "vantage.labels" . | nindent 4 }} + app.kubernetes.io/component: backup +spec: + schedule: {{ .Values.backup.schedule | quote }} + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: {{ .Values.backup.successfulJobsHistoryLimit }} + failedJobsHistoryLimit: {{ .Values.backup.failedJobsHistoryLimit }} + jobTemplate: + spec: + backoffLimit: 2 + template: + metadata: + labels: + {{- include "vantage.labels" . | nindent 12 }} + app.kubernetes.io/component: backup + spec: + restartPolicy: Never + containers: + - name: vantagectl + image: {{ .Values.backup.image | quote }} + args: + - backup + - --out + - /backups + {{- with .Values.backup.exclude }} + - --exclude + - {{ join "," . | quote }} + {{- end }} + env: + # Referenced, never redeclared. A backup job holding its own + # copy of KEY_ENCRYPTION_KEY is a second place for it to be + # wrong, and the fingerprint it stamps would then be a + # fingerprint of the wrong key. + {{- include "vantage.backup.env" . | nindent 16 }} + volumeMounts: + - name: backups + mountPath: /backups + resources: + {{- toYaml .Values.backup.resources | nindent 16 }} + volumes: + - name: backups + persistentVolumeClaim: + claimName: {{ .Values.backup.pvcName | quote }} +{{- end }} diff --git a/deploy/chart/vantage/values.yaml b/deploy/chart/vantage/values.yaml index 4ba7f6a..32a1e0f 100644 --- a/deploy/chart/vantage/values.yaml +++ b/deploy/chart/vantage/values.yaml @@ -111,3 +111,25 @@ ingress: certResolver: "" imagePullSecrets: [] + +# Scheduled backups. +# +# Off by default, deliberately. A backup with nowhere durable to land is a +# false sense of safety, and the chart cannot know where that is — pvcName +# must name a volume you have decided will outlive the cluster. +# +# There is no restore manifest here on purpose: a restore is an operator +# decision with a confirmation attached, and must never be something a +# `helm upgrade` can trigger. Run one as a `kubectl run` Job with +# --confirm-db. +backup: + enabled: false + schedule: "0 2 * * *" + image: "" + pvcName: "" + # Collections to leave out. Recorded in each archive's manifest, so an + # archive can never claim to be complete when it is not. + exclude: [] + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + resources: {} From 89cc524f3f633419c683aa442c2850eb3bab529f Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 14:29:03 +0000 Subject: [PATCH 17/23] docs: Document backup and restore The page leads with KEY_ENCRYPTION_KEY rather than mentioning it in a note, because holding a good database dump and no key is the way this goes wrong. --- CLAUDE.md | 70 ++++++- docsite/docs/operations/backup-and-restore.md | 193 ++++++++++++++++++ docsite/sidebars.ts | 2 +- 3 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 docsite/docs/operations/backup-and-restore.md diff --git a/CLAUDE.md b/CLAUDE.md index 4e92fec..776bfe6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -563,6 +563,65 @@ inserted in front. The same setting also decides the address recorded in `UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself. +### Backup and restore + +`vantagectl` is a standalone Go module (`vantagectl/`), not a subcommand of +`server`. It needs its own module rather than living inside `server`'s for the +same reason `admin` and `sitesvc` already do: `server` imports the rest of +`server`'s dependency graph, and `spf13/cobra` has no business in a process +that also terminates gRPC streams and serves the REST API. More to the point, +`vantagectl` has to run when the control plane **does not** — a backup or +restore against a database with no server container alive at all — so it +cannot be a mode of the binary whose crash is the reason you need it. + +The actual logic lives in `shared/backup` (dump, restore, verify, manifest, +fingerprint), not in `vantagectl/internal/cmd`, which holds only argument +parsing and operator-facing output. That split is what lets `server` import +`shared/backup` later — a scheduled in-process backup, say — without a second +implementation to keep in sync. `shared/cryptobox` is the same move one layer +down: it is now the **single** AES-256-GCM implementation, and +`server/internal/services/crypto.go` delegates to it rather than keeping its +own copy that `shared/backup` would otherwise have had to duplicate to decrypt +a probe value during `verify`. + +**The archive stores a SHA-256 fingerprint of `KEY_ENCRYPTION_KEY`, never the +key.** `backup` refuses to run without the key set in the environment unless +`--allow-no-key` is passed, because an archive with no fingerprint at all +cannot later tell a restore that the wrong key is in hand — it can only find +that out when the data comes back as noise. The fingerprint is what turns that +failure into a refusal at `restore` time instead. + +**Collections are enumerated live** — `shared/backup` lists what the database +actually holds rather than reading `services.ScopedCollections`, the opposite +choice from the one instance-deletion purge makes. Purge must never miss a +tenant-scoped collection, so it keeps one hand-maintained registry; a backup +must never miss **any** collection, tenant-scoped or not (`migrations`, +`vulndb_meta`), so a static list is the wrong shape twice over — once for the +collections it would still owe `instance_id` deletion but not a backup, and +once for the two singleton collections that carry neither `instance_id` nor a +release note. + +**Restore refuses a non-empty target database and has no merge semantics.** +There is no code path that upserts an archive's documents over existing ones: +merging two control planes' data reconciles nothing about which SSH keys are +still valid or which users still exist, and an upsert would resurrect a +revoked key or a deleted member from the older side. `--force` drops each +collection in the archive first, and is gated behind a typed confirmation +(the target database's name, typed back) on a terminal, or `--confirm-db NAME` +matching the target exactly with none. Naming the target in the command itself +means a copied command carries its intended target with it and cannot destroy +a different one by accident. + +**`vantagectl/Dockerfile`'s runtime stage is `scratch`, and needs the same +explicit `/tmp` as `server/Dockerfile`.** `restore` extracts an archive to a +temporary directory before verifying its checksums, and a scratch image has no +`/tmp` for `os.MkdirTemp` to find — the same failure mode `vulnsched` hits on +`server`, but here it would break every restore rather than only vulnerability +scanning. + +**`shared/` now fans out to four Go images** in `server-deploy.yml`: +`server`, `sitesvc`, `admin` and `vantagectl` — see the CI section below. + ### API tokens and OpenAPI A token is `vt_` plus 32 random bytes hex, shown once at creation and stored @@ -1221,7 +1280,7 @@ GOOS=linux GOARCH=amd64 go build \ ### `server-deploy.yml` — triggered on every push to `main` -Builds and pushes seven images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin`, `adminsite` and `docsite`. +Builds and pushes eight images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin`, `adminsite`, `docsite` and `vantagectl`. Note that despite the name, **this workflow does not deploy** — it only builds and pushes. There is no SSH step. Rolling images out is a separate manual step on the host: @@ -1237,9 +1296,16 @@ cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.y | `server` | `server/`, `shared/`, `proto/`, `go.work` | | `admin` | `admin/`, `shared/`, `go.work` | | `sitesvc` | `sitesvc/`, `shared/`, `go.work` | +| `vantagectl` | `vantagectl/`, `shared/`, `go.work` | | `web` · `site` · `adminsite` · `docsite` | their own directory only | -`shared/` fans out to all three Go images because each of their Dockerfiles copies `shared/` from a root context — **if a fourth service ever imports `shared/`, add it to that list or it will ship stale**. A change to the workflow file rebuilds everything, since a build arg is baked into the image. So does anything that leaves no trustworthy base commit: a manual `workflow_dispatch`, a new branch, or a force-push whose old head is gone. +`shared/` fans out to **four** Go images (`server`, `sitesvc`, `admin`, +`vantagectl`) because each of their Dockerfiles copies `shared/` from a root +context — **if a fifth service ever imports `shared/`, add it to that list or +it will ship stale**. A change to the workflow file rebuilds everything, since +a build arg is baked into the image. So does anything that leaves no +trustworthy base commit: a manual `workflow_dispatch`, a new branch, or a +force-push whose old head is gone. The gap this leaves: **changing a repo variable pushes no commit, so nothing rebuilds.** After editing `ADMIN_API_URL`, `HQ_URL` or `ADMIN_ENV`, run the workflow manually — that is what `workflow_dispatch` is there for. Base images also stop being refreshed on a service nobody touches; a periodic manual run covers that. diff --git a/docsite/docs/operations/backup-and-restore.md b/docsite/docs/operations/backup-and-restore.md new file mode 100644 index 0000000..b319155 --- /dev/null +++ b/docsite/docs/operations/backup-and-restore.md @@ -0,0 +1,193 @@ +--- +id: backup-and-restore +title: Backup and restore +sidebar_label: Backup and restore +--- + +`vantagectl` is a separate command-line tool that backs up and restores the +MongoDB database behind a Vantage control plane. It talks to MongoDB directly, +never to the Vantage API, so it works against a control plane that is down, +half-migrated, or gone — exactly the situation a backup tool has to survive. + +:::danger The key comes first +Vantage encrypts SSH private keys, key passphrases, vault secrets, SSO client +secrets and console credentials with `KEY_ENCRYPTION_KEY`. **It is not in your +backup, and it is not recoverable.** A database restored without it is +permanently unreadable — not degraded, not partially readable, unreadable. + +Store it wherever you store the credentials you could not rebuild: a password +manager, a secrets vault outside this control plane, a piece of paper in a +safe. Anywhere but next to the archive. +::: + +## What a backup holds + +Every collection in the database, the index definitions each one needs to be +useful again, and a SHA-256 **fingerprint** of `KEY_ENCRYPTION_KEY` — never the +key itself. The fingerprint is what lets a later `restore` or `verify` tell you +that the key you are holding is the wrong one, before it writes a database +nobody can read. + +## What it does not hold + +- **Redis sessions.** Everyone signs in again after a restore, which is already + true whenever Redis itself restarts. +- **The vulnerability database.** It is re-pulled automatically on next boot. +- **Agent state on managed servers.** Nothing needs re-enrolling: agents + reconnect on their own, because `servers.agent_token_hash` — the thing an + agent authenticates with — is itself in the backup. + +## Taking a backup + +The loose binary: + +```bash +export MONGO_URI=mongodb://localhost:27017 +export MONGO_DB=vantage +export KEY_ENCRYPTION_KEY= +vantagectl backup --out /backups +``` + +The container: + +```bash +docker run --rm \ + -e MONGO_URI=mongodb://mongo:27017 \ + -e MONGO_DB=vantage \ + -e KEY_ENCRYPTION_KEY= \ + -v /backups:/backups \ + gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl:latest backup --out /backups +``` + +Kubernetes, as a scheduled `CronJob` the Helm chart can render for you: + +```yaml +backup: + enabled: true + schedule: "0 2 * * *" + image: "gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl:latest" + pvcName: "vantage-backups" +``` + +`backup.enabled` defaults to `false`, and the chart refuses to render if it is +turned on without both `backup.image` and `backup.pvcName` — a backup needs a +known image and somewhere durable to land, and guessing at either is worse than +refusing to start. `backup.exclude` names collections to leave out (recorded in +the archive's manifest, so an archive never claims to be complete when it is +not), and `backup.successfulJobsHistoryLimit` / `backup.failedJobsHistoryLimit` +/ `backup.resources` behave exactly as they do on any other `CronJob`. + +`backup` refuses to run without `KEY_ENCRYPTION_KEY` set in the environment, +unless you pass `--allow-no-key` — for a deployment that genuinely stores no +encrypted data. Everywhere else, treat the refusal as the tool doing its job. + +## Where to put the archive + +`--out -` streams the tarball to stdout instead of writing a file, and every +line of progress output goes to stderr — so piping the archive into something +else is always safe, nothing progress-related lands in the stream. + +Into `restic`: + +```bash +vantagectl backup --out - | restic backup --stdin --stdin-filename vantage.tar.gz +``` + +Into S3: + +```bash +vantagectl backup --out - | aws s3 cp - s3://my-backups/vantage-$(date +%F).tar.gz +``` + +An archive is as sensitive as a raw database dump — it carries every SSH key +assignment, every secret group, every session-adjacent setting, in a form the +right `KEY_ENCRYPTION_KEY` can decrypt. Whatever you pipe it into should +encrypt it at rest; `vantagectl` itself does not. + +## Checking a backup is real + +```bash +vantagectl verify /backups/vantage-backup-vantage-20260907T020000Z.tar.gz \ + --mongo-uri mongodb://localhost:27017 --db vantage +``` + +Each line of output answers a different question: + +- **`Archive`** — every member's checksum still matches; the tarball has not + been truncated or corrupted. +- **`Archive key`** / **`Your key`** — the fingerprint stored in the archive + next to the fingerprint of the `KEY_ENCRYPTION_KEY` in your environment. +- **`Key match`** — whether those two fingerprints agree. +- **`Live probe`** — given `--mongo-uri`, `verify` goes one step further and + decrypts a real ciphertext value from that database with the key you hold. + A fingerprint match proves two archives agree about a key; only the probe + proves the key in your hand actually reads the data. + +`verify` exits non-zero the moment anything above is wrong, which is what makes +it worth putting on a schedule — a backup job that "succeeded" last night is +not the same claim as a backup that will actually restore. + +## Restoring + +`restore` expects the target database to be empty. Pointed at one that already +holds data, it refuses outright: there are no merge semantics, because merging +two control planes reconciles nothing and upserting old data over new would +resurrect revoked keys and deleted users. + +```bash +vantagectl restore /backups/vantage-backup-vantage-20260907T020000Z.tar.gz \ + --mongo-uri mongodb://localhost:27017 --db vantage_restore +``` + +To overwrite a database that is not empty, add `--force`, which drops each +collection named in the archive before loading it. On a terminal, `--force` +alone prompts you to type the target database's name back — a deliberate pause +before something destructive. With no terminal — a Kubernetes Job, a CI step, a +cron entry — that prompt cannot happen, so `--force` instead requires +`--confirm-db NAME` naming the target exactly; a mismatch is refused. Naming +the database in the command itself means a copy-pasted invocation carries its +intended target with it and cannot destroy a different one by accident. + +`restore` also refuses when the archive's key fingerprint does not match the +`KEY_ENCRYPTION_KEY` in your environment — see "When the key is wrong" below. + +## The restore drill + +An untested backup is a hypothesis, not a backup. Rehearse the whole path, +monthly: + +1. Restore last night's archive into a scratch database: + ```bash + vantagectl restore /backups/vantage-backup-vantage-.tar.gz \ + --mongo-uri mongodb://localhost:27017 --db vantage_drill + ``` +2. Run `verify` against the result to confirm the data that landed is actually + readable with your current key: + ```bash + vantagectl verify /backups/vantage-backup-vantage-.tar.gz \ + --mongo-uri mongodb://localhost:27017 --db vantage_drill + ``` +3. Drop the scratch database. It served its purpose. + +The failure this catches is not "the archive is corrupt" — `verify` alone +catches that. It is "the archive is fine but nobody can actually stand a +control plane back up from it," which only a real restore proves. + +## When the key is wrong + +If `restore` finds the archive's key fingerprint does not match the +`KEY_ENCRYPTION_KEY` you are running with, it stops. Passing +`--ignore-key-mismatch` proceeds anyway, but says plainly which collections +will come back with ciphertext nobody can read: + +- `keys` — SSH private keys and passphrases +- `secrets` — the vault +- `auth_providers` — OIDC/SSO client secrets +- `console_sessions` — RDP/VNC credentials +- `settings` — anything encrypted at the instance level + +There is no way to recover that ciphertext afterwards. If you have reached +this point, the right key was lost along with the chance to read those rows — +the fix is to re-enter each of them by hand (re-upload SSH keys, re-save vault +secrets, reconfigure SSO), not to keep searching for a way to decrypt what is +already in the database. diff --git a/docsite/sidebars.ts b/docsite/sidebars.ts index 925e5a9..0cab3d0 100644 --- a/docsite/sidebars.ts +++ b/docsite/sidebars.ts @@ -49,7 +49,7 @@ const sidebars: SidebarsConfig = { { type: "category", label: "Operations", - items: ["operations/upgrading", "operations/backups", "operations/agent-updates"], + items: ["operations/upgrading", "operations/backups", "operations/backup-and-restore", "operations/agent-updates"], }, ], }; From d328d3aaca5dfab136cd21f3f690e10b5b38186e Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 14:32:31 +0000 Subject: [PATCH 18/23] docs: Reconcile backups and backup-and-restore pages backups.md kept its store-level table and danger note but now points to vantagectl as the supported path, with mongodump/mongorestore demoted to an explicit fallback and a warning that a plain dump records no key fingerprint. backup-and-restore.md links back for the store-level overview. --- docsite/docs/operations/backup-and-restore.md | 3 ++ docsite/docs/operations/backups.md | 30 +++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/docsite/docs/operations/backup-and-restore.md b/docsite/docs/operations/backup-and-restore.md index b319155..6db3026 100644 --- a/docsite/docs/operations/backup-and-restore.md +++ b/docsite/docs/operations/backup-and-restore.md @@ -9,6 +9,9 @@ MongoDB database behind a Vantage control plane. It talks to MongoDB directly, never to the Vantage API, so it works against a control plane that is down, half-migrated, or gone — exactly the situation a backup tool has to survive. +For the store-level overview — what holds what, and why the database alone is +not a backup — see [Backups](./backups.md). This page covers the tool. + :::danger The key comes first Vantage encrypts SSH private keys, key passphrases, vault secrets, SSO client secrets and console credentials with `KEY_ENCRYPTION_KEY`. **It is not in your diff --git a/docsite/docs/operations/backups.md b/docsite/docs/operations/backups.md index 481966a..897bfa5 100644 --- a/docsite/docs/operations/backups.md +++ b/docsite/docs/operations/backups.md @@ -24,7 +24,23 @@ values is permanently unreadable. Store the key somewhere other than the server it protects. ::: -## Backing up MongoDB +:::info Use `vantagectl` +[**Backup and restore**](./backup-and-restore.md) is the supported way to take +and restore a backup. It writes an archive that carries a fingerprint of +`KEY_ENCRYPTION_KEY` — never the key — so a restore taken with the wrong key +**refuses** rather than silently producing a database whose secrets nobody can +read. It also checksums every archive member before writing anything, and +refuses to restore into a database that already holds data. A plain +`mongodump` does none of that: it records nothing about which key the data was +encrypted under, so a restore from one succeeds even when the key is wrong and +the failure only shows up later, as unreadable secrets. + +The rest of this page, past the table above, describes the `mongodump` / +`mongorestore` fallback for an operator who does not have `vantagectl` +available. Prefer the linked page. +::: + +## Backing up MongoDB (fallback, without `vantagectl`) With the bundled Mongo container: @@ -33,6 +49,13 @@ docker compose exec -T mongo mongodump --archive --gzip --db vantage \ > /backups/vantage-$(date +%F).archive.gz ``` +:::warning +This archive records nothing about which `KEY_ENCRYPTION_KEY` it was taken +under. Restoring it with the wrong key produces a database that looks intact +and is not — every secret in it is silently unreadable until something tries +to decrypt one. +::: + Restoring: ```bash @@ -69,12 +92,13 @@ What it does **not** do is reconcile the world. After a restore: | What | When | | ----------------- | ----------------------------------------------------- | -| MongoDB dump | Nightly, retained per your policy | +| Backup | Nightly, retained per your policy | | Environment file | On change, held in a password manager or secret store | | Restore rehearsal | Occasionally, into a throwaway host | Rehearse a restore now and again. It is the step most often skipped, and the one -that finds the problems. +that finds the problems. See [Backup and restore](./backup-and-restore.md) for +the drill, and for `verify`, which checks a backup is real without a restore. ## Cloud instances From 2e4bc687d43676fa4f44bbbec630cdc8aa002f20 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 14:45:36 +0000 Subject: [PATCH 19/23] fix: replay index specs verbatim instead of reconstructing them --- shared/backup/dump.go | 18 ++++- shared/backup/restore.go | 132 ++++++++++++++++++++++++---------- shared/backup/restore_test.go | 119 ++++++++++++++++++++++++++---- 3 files changed, 214 insertions(+), 55 deletions(-) diff --git a/shared/backup/dump.go b/shared/backup/dump.go index ab17ece..1374d6c 100644 --- a/shared/backup/dump.go +++ b/shared/backup/dump.go @@ -147,11 +147,25 @@ func dumpIndexes(ctx context.Context, w *Writer, db *mongo.Database, name string } defer cur.Close(ctx) - var specs []bson.M + // The specs are read as raw BSON and re-encoded as extended JSON, one + // element per index, so key order and every option the server reported — + // partialFilterExpression, collation, weights and the rest — survive + // verbatim. Decoding into bson.M would lose compound key order, and + // reconstructing an index from a hand-picked set of options would drop + // whatever was not picked. + var specs []bson.Raw if err := cur.All(ctx, &specs); err != nil { return fmt.Errorf("read indexes on %s: %w", name, err) } - raw, err := json.Marshal(specs) + encoded := make([]json.RawMessage, 0, len(specs)) + for _, spec := range specs { + ej, err := bson.MarshalExtJSON(spec, false, false) + if err != nil { + return fmt.Errorf("encode indexes on %s: %w", name, err) + } + encoded = append(encoded, ej) + } + raw, err := json.Marshal(encoded) if err != nil { return fmt.Errorf("encode indexes on %s: %w", name, err) } diff --git a/shared/backup/restore.go b/shared/backup/restore.go index 7cc83a9..31ed217 100644 --- a/shared/backup/restore.go +++ b/shared/backup/restore.go @@ -83,6 +83,9 @@ func Restore(ctx context.Context, opt RestoreOptions) (RestoreResult, error) { if err := checkTarget(ctx, opt); err != nil { return RestoreResult{}, err } + if err := warnLeftovers(ctx, opt, m); err != nil { + return RestoreResult{}, err + } if len(m.Excluded) > 0 { opt.warn("this archive excluded %s; those collections will be empty after the restore", strings.Join(m.Excluded, ", ")) @@ -154,6 +157,43 @@ func checkTarget(ctx context.Context, opt RestoreOptions) error { return fmt.Errorf("%w: %s holds %s", ErrTargetNotEmpty, opt.Database, strings.Join(found, ", ")) } +// warnLeftovers names the collections already in the target that the archive +// does not carry. +// +// They are named rather than dropped. A --force restore of an archive taken +// with --exclude workflow_log_lines leaves the old logs joined to restored +// runs, which the operator must know; but dropping a collection the archive +// never mentioned would delete data nobody asked to delete, and there is no +// way back from that. +func warnLeftovers(ctx context.Context, opt RestoreOptions, m Manifest) error { + if !opt.Force { + // Without Force the target was already proven empty. + return nil + } + names, err := opt.Client.Database(opt.Database).ListCollectionNames(ctx, bson.M{}) + if err != nil { + return fmt.Errorf("inspect target: %w", err) + } + inArchive := map[string]bool{} + for _, c := range m.Collections { + inArchive[c.Name] = true + } + var leftover []string + for _, n := range names { + if !inArchive[n] { + leftover = append(leftover, n) + } + } + if len(leftover) == 0 { + return nil + } + sort.Strings(leftover) + opt.warn("this archive does not carry %s, which already exist in %s and are left "+ + "untouched: their contents will sit alongside the restored data", + strings.Join(leftover, ", "), opt.Database) + return nil +} + // warnVersionGap reports a major version difference between the server that // produced the archive and the one receiving it. It warns rather than refuses: // restoring across a major version is a normal part of an upgrade, and a tool @@ -259,6 +299,14 @@ func splitBSON(raw []byte) (bson.Raw, []byte, error) { // replayIndexes recreates the archived indexes. // +// The specs are handed to the createIndexes command exactly as the source +// server reported them, rather than reconstructed into a mongo.IndexModel from +// a hand-picked set of options. Reconstruction dropped every option nobody had +// thought to pick — partialFilterExpression above all, which this codebase +// relies on for partial unique indexes, and which replayed as a full unique +// index fails on any real database. It also lost compound key order, which is +// significant. +// // A unique index that will not build means the restored data violates it, and // the unique indexes here — (instance_id, email), instance slug, settings // instance, the ESO token hash — are tenant-isolation properties rather than @@ -272,18 +320,26 @@ func replayIndexes(ctx context.Context, opt RestoreOptions, coll *mongo.Collecti if len(raw) == 0 { return 0, nil } - var specs []map[string]any - if err := json.Unmarshal(raw, &specs); err != nil { + var encoded []json.RawMessage + if err := json.Unmarshal(raw, &encoded); err != nil { return 0, fmt.Errorf("parse index specs for %s: %w", name, err) } + db := coll.Database() created := 0 - for _, spec := range specs { - model, indexName, unique, ok := indexModelFrom(spec) + for _, ej := range encoded { + spec, indexName, unique, ok, err := indexSpecFrom(ej) + if err != nil { + return created, fmt.Errorf("parse index specs for %s: %w", name, err) + } if !ok { continue } - if _, err := coll.Indexes().CreateOne(ctx, model); err != nil { + cmd := bson.D{ + {Key: "createIndexes", Value: name}, + {Key: "indexes", Value: bson.A{spec}}, + } + if err := db.RunCommand(ctx, cmd).Err(); err != nil { if unique { return created, fmt.Errorf("%w: %s on %s: %v", ErrIndexBuild, indexName, name, err) } @@ -295,45 +351,43 @@ func replayIndexes(ctx context.Context, opt RestoreOptions, coll *mongo.Collecti return created, nil } -// indexModelFrom converts one archived index specification into a model. +// droppedIndexSpecFields are the fields the server reports on an existing index +// but rejects when creating one. Everything else is passed through untouched. +var droppedIndexSpecFields = map[string]bool{"v": true, "ns": true} + +// indexSpecFrom decodes one archived extended-JSON index specification into an +// ordered bson.D suitable for createIndexes. +// // The _id_ index is skipped: MongoDB creates it itself and refuses an explicit // attempt to create it. -func indexModelFrom(spec map[string]any) (mongo.IndexModel, string, bool, bool) { - name, _ := spec["name"].(string) - if name == "_id_" { - return mongo.IndexModel{}, name, false, false - } - keys, ok := spec["key"].(map[string]any) - if !ok || len(keys) == 0 { - return mongo.IndexModel{}, name, false, false +func indexSpecFrom(ej []byte) (bson.D, string, bool, bool, error) { + var d bson.D + if err := bson.UnmarshalExtJSON(ej, false, &d); err != nil { + return nil, "", false, false, err } - // JSON objects do not preserve order but compound index key order is - // significant, so the field order recorded by the server is recovered from - // the spec's own ordering where available and sorted otherwise. bson.M - // round-trips through json as a map; the archive therefore stores the key - // document and this reconstructs a deterministic bson.D from it. - fields := make([]string, 0, len(keys)) - for k := range keys { - fields = append(fields, k) - } - sort.Strings(fields) - d := make(bson.D, 0, len(fields)) - for _, f := range fields { - d = append(d, bson.E{Key: f, Value: keys[f]}) - } - - opts := options.Index().SetName(name) + out := make(bson.D, 0, len(d)) + var name string unique := false - if u, ok := spec["unique"].(bool); ok && u { - unique = true - opts = opts.SetUnique(true) + hasKey := false + for _, e := range d { + switch e.Key { + case "name": + name, _ = e.Value.(string) + case "unique": + if u, ok := e.Value.(bool); ok { + unique = u + } + case "key": + hasKey = true + } + if droppedIndexSpecFields[e.Key] { + continue + } + out = append(out, e) } - if s, ok := spec["sparse"].(bool); ok && s { - opts = opts.SetSparse(true) + if name == "_id_" || !hasKey { + return nil, name, false, false, nil } - if e, ok := spec["expireAfterSeconds"].(float64); ok { - opts = opts.SetExpireAfterSeconds(int32(e)) - } - return mongo.IndexModel{Keys: d, Options: opts}, name, unique, true + return out, name, unique, true, nil } diff --git a/shared/backup/restore_test.go b/shared/backup/restore_test.go index b56841e..0272d26 100644 --- a/shared/backup/restore_test.go +++ b/shared/backup/restore_test.go @@ -11,6 +11,7 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" ) // archiveOf seeds a database, dumps it, and returns an opened Reader. @@ -320,11 +321,12 @@ func TestRestoreSameVersionDoesNotWarnAboutIt(t *testing.T) { } } -func TestCompoundIndexKeyOrderIsAlphabetical(t *testing.T) { - model, name, unique, ok := indexModelFrom(map[string]any{ - "name": "b_1_a_1", - "key": map[string]any{"b": float64(1), "a": float64(1)}, - }) +func TestCompoundIndexKeyOrderIsPreserved(t *testing.T) { + spec, name, unique, ok, err := indexSpecFrom([]byte( + `{"v":2,"key":{"b":1,"a":1},"name":"b_1_a_1","ns":"db.c"}`)) + if err != nil { + t.Fatalf("indexSpecFrom: %v", err) + } if !ok { t.Fatal("spec rejected") } @@ -334,15 +336,104 @@ func TestCompoundIndexKeyOrderIsAlphabetical(t *testing.T) { if name != "b_1_a_1" { t.Fatalf("name %q", name) } - keys, isD := model.Keys.(bson.D) - if !isD { - t.Fatalf("keys are %T, want bson.D", model.Keys) + + var keys bson.D + for _, e := range spec { + switch e.Key { + case "v", "ns": + t.Fatalf("%q must be stripped before createIndexes, got %v", e.Key, spec) + case "key": + d, isD := e.Value.(bson.D) + if !isD { + t.Fatalf("key is %T, want bson.D", e.Value) + } + keys = d + } } - // Documents current behaviour: field order is alphabetical, not the order - // the server reported. Storing the key document as raw BSON in the archive - // instead of JSON would fix this and is the change to make if compound - // index order ever matters here. - if keys[0].Key != "a" || keys[1].Key != "b" { - t.Fatalf("got %v", keys) + // Compound index key order is significant, so it is carried through + // verbatim rather than reconstructed from an unordered map. + if len(keys) != 2 || keys[0].Key != "b" || keys[1].Key != "a" { + t.Fatalf("key order not preserved, got %v", keys) } } + +func TestIdIndexIsSkipped(t *testing.T) { + _, name, _, ok, err := indexSpecFrom([]byte(`{"v":2,"key":{"_id":1},"name":"_id_"}`)) + if err != nil { + t.Fatalf("indexSpecFrom: %v", err) + } + if ok { + t.Fatal("_id_ must be skipped; MongoDB creates it itself") + } + if name != "_id_" { + t.Fatalf("name %q", name) + } +} + +// TestRestoreReplaysPartialUniqueIndex is the regression guard for the defect +// that made a restore abort on any real database: a partial unique index — +// this codebase has them on workflow_steps and settings — replayed as a full +// unique index hits duplicate keys, and a failing unique index is fatal. +func TestRestoreReplaysPartialUniqueIndex(t *testing.T) { + client, _ := testDB(t) + ctx := context.Background() + _, srcDB := testDB(t) + seed(t, client, srcDB) + + coll := client.Database(srcDB).Collection("workflow_steps") + docs := []any{ + bson.M{"instance_id": "i1", "slug": "same", "source": "default"}, + bson.M{"instance_id": "i1", "slug": "same", "source": "custom"}, + bson.M{"instance_id": "i1", "slug": "same", "source": "custom"}, + } + if _, err := coll.InsertMany(ctx, docs); err != nil { + t.Fatalf("insert: %v", err) + } + if _, err := coll.Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "slug", Value: 1}}, + Options: options.Index().SetName("default_step_slug").SetUnique(true). + SetPartialFilterExpression(bson.M{"source": "default"}), + }); err != nil { + t.Fatalf("create partial index: %v", err) + } + + path, _ := dumpToFile(t, DumpOptions{Client: client, Database: srcDB, KeyHex: validKeyHex}) + archive, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer archive.Close() + + _, target := testDB(t) + if _, err := Restore(ctx, RestoreOptions{ + Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, + }); err != nil { + t.Fatalf("Restore: %v", err) + } + + cur, err := client.Database(target).Collection("workflow_steps").Indexes().List(ctx) + if err != nil { + t.Fatalf("list indexes: %v", err) + } + var specs []bson.M + if err := cur.All(ctx, &specs); err != nil { + t.Fatalf("read indexes: %v", err) + } + for _, s := range specs { + if s["name"] != "default_step_slug" { + continue + } + if s["unique"] != true { + t.Fatalf("index lost its uniqueness: %v", s) + } + if s["partialFilterExpression"] == nil { + t.Fatalf("partialFilterExpression was dropped: %v", s) + } + keys, isD := s["key"].(bson.D) + if isD && (len(keys) != 2 || keys[0].Key != "instance_id" || keys[1].Key != "slug") { + t.Fatalf("compound key order not preserved: %v", keys) + } + return + } + t.Fatalf("partial unique index not replayed; got %v", specs) +} From b6f45390c4855c827808ac1ca60c05843b2be1de Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 14:45:36 +0000 Subject: [PATCH 20/23] fix: correct verify's ciphertext field map against the models --- shared/backup/manifest.go | 31 ++++++++++++++++++++++++++++++- shared/backup/manifest_test.go | 24 +++++++++++++++++++++++- shared/backup/verify.go | 24 +++++++++++++++++++----- 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/shared/backup/manifest.go b/shared/backup/manifest.go index 91246c0..0251892 100644 --- a/shared/backup/manifest.go +++ b/shared/backup/manifest.go @@ -3,6 +3,7 @@ package backup import ( "errors" "fmt" + "strings" "time" ) @@ -16,6 +17,10 @@ const ManifestName = "manifest.json" // ErrUnknownFormat is returned for an archive this build cannot read. var ErrUnknownFormat = errors.New("unsupported archive format version") +// ErrBadCollectionName is returned for a manifest naming a collection that +// cannot safely be used as a path component. +var ErrBadCollectionName = errors.New("manifest names an unusable collection") + // CollectionEntry describes one collection in the archive. Bytes and SHA256 // cover the uncompressed .bson member, which is what restore verifies before // writing anything. @@ -49,6 +54,27 @@ func (m Manifest) Check() error { return fmt.Errorf("%w: archive is version %d, this build reads version %d", ErrUnknownFormat, m.FormatVersion, FormatVersion) } + // Collection names become path components inside the extraction directory, + // and an archive is operator-supplied input that may not be one we wrote. + for _, c := range m.Collections { + if err := checkCollectionName(c.Name); err != nil { + return err + } + } + return nil +} + +// checkCollectionName refuses a name that could escape a directory when joined +// as a path component. +func checkCollectionName(name string) error { + switch { + case name == "": + return fmt.Errorf("%w: a collection entry has no name", ErrBadCollectionName) + case name == "." || name == "..": + return fmt.Errorf("%w: %q", ErrBadCollectionName, name) + case strings.ContainsAny(name, "/\\"), strings.Contains(name, ".."): + return fmt.Errorf("%w: %q", ErrBadCollectionName, name) + } return nil } @@ -68,6 +94,9 @@ func (m Manifest) Collection(name string) (CollectionEntry, bool) { // match the archive, this is the list of what will be unreadable afterwards, // and an operator deserves to see it before the write rather than discover it // a week later. +// +// settings is not in the list: it holds no encrypted material. Its ESO read +// token is a SHA-256 hash, not ciphertext. func CiphertextCollections() []string { - return []string{"keys", "secrets", "auth_providers", "console_sessions", "settings"} + return []string{"keys", "secrets", "auth_providers", "console_sessions"} } diff --git a/shared/backup/manifest_test.go b/shared/backup/manifest_test.go index b4676cb..1b1bff2 100644 --- a/shared/backup/manifest_test.go +++ b/shared/backup/manifest_test.go @@ -69,7 +69,7 @@ func TestManifestCollectionLookup(t *testing.T) { func TestCiphertextCollections(t *testing.T) { got := CiphertextCollections() - want := []string{"keys", "secrets", "auth_providers", "console_sessions", "settings"} + want := []string{"keys", "secrets", "auth_providers", "console_sessions"} if len(got) != len(want) { t.Fatalf("got %v, want %v", got, want) } @@ -79,3 +79,25 @@ func TestCiphertextCollections(t *testing.T) { } } } + +// TestManifestRefusesUnusableCollectionNames covers names being used as path +// components inside the extraction directory. An archive is operator-supplied +// input and may not be one we wrote. +func TestManifestRefusesUnusableCollectionNames(t *testing.T) { + for _, name := range []string{"", ".", "..", "../etc/passwd", "a/b", "a..b"} { + m := Manifest{ + FormatVersion: FormatVersion, + Collections: []CollectionEntry{{Name: name}}, + } + if err := m.Check(); !errors.Is(err, ErrBadCollectionName) { + t.Fatalf("collection name %q was accepted (err %v)", name, err) + } + } + m := Manifest{ + FormatVersion: FormatVersion, + Collections: []CollectionEntry{{Name: "workflow_log_lines"}}, + } + if err := m.Check(); err != nil { + t.Fatalf("an ordinary collection name was refused: %v", err) + } +} diff --git a/shared/backup/verify.go b/shared/backup/verify.go index 9234d9a..e32bf92 100644 --- a/shared/backup/verify.go +++ b/shared/backup/verify.go @@ -117,12 +117,25 @@ func probe(ctx context.Context, opt VerifyOptions, rep *VerifyReport) error { // ciphertextFields names, per collection, the fields that hold hex ciphertext. // A value is a candidate only if it is a hex string long enough to carry a GCM // nonce and tag, which is what keeps this from probing a plaintext field. +// +// This map MIRRORS BY HAND the bson tags in server/internal/models, which this +// package cannot import: shared/ is a separate module and models is under +// server/internal. It must change in the same commit as any rename of the +// fields below — the same mirrored-constant hazard as web/lib/targets.ts and +// services.MaxWorkloadLogLines. The sources are: +// +// keys — models/key.go: private_key_enc, passphrase_enc +// secrets — models/secret.go: encrypted_value +// auth_providers — models/auth_provider.go: client_secret_enc +// console_sessions — models/console_session.go: rdp_user_enc, rdp_pass_enc +// +// settings is deliberately absent: it holds no ciphertext at all. The ESO read +// token is stored as a SHA-256 hash, which no key opens. var ciphertextFields = map[string][]string{ "keys": {"private_key_enc", "passphrase_enc"}, - "secrets": {"values"}, + "secrets": {"encrypted_value"}, "auth_providers": {"client_secret_enc"}, - "console_sessions": {"rdp_password_enc", "vnc_password_enc"}, - "settings": {"secrets_token_hash_enc"}, + "console_sessions": {"rdp_user_enc", "rdp_pass_enc"}, } func findCiphertext(ctx context.Context, db *mongo.Database, coll string) (string, bool, error) { @@ -150,8 +163,9 @@ func findCiphertext(ctx context.Context, db *mongo.Database, coll string) (strin return "", false, cur.Err() } -// looksLikeCiphertext accepts a hex string long enough to be a sealed value, and -// descends one level into a map so secrets' values sub-document is reachable. +// looksLikeCiphertext accepts a hex string long enough to be a sealed value. It +// descends into a sub-document so a field that holds a map of sealed values is +// still reachable. func looksLikeCiphertext(v any) (string, bool) { switch t := v.(type) { case string: From b28a2263bb314231bf0bf87c30b3d9067d447b06 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 14:45:36 +0000 Subject: [PATCH 21/23] fix: read KEY_ENCRYPTION_KEY in archive-only verify; never leave a partial archive --- vantagectl/internal/cmd/backup.go | 63 +++++++++++++++-- vantagectl/internal/cmd/inspect_test.go | 46 +++++++++++++ vantagectl/internal/cmd/root.go | 8 ++- vantagectl/internal/cmd/verify.go | 8 ++- vantagectl/internal/cmd/verify_test.go | 90 +++++++++++++++++++++++++ 5 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 vantagectl/internal/cmd/verify_test.go diff --git a/vantagectl/internal/cmd/backup.go b/vantagectl/internal/cmd/backup.go index 538f71f..c70ffdc 100644 --- a/vantagectl/internal/cmd/backup.go +++ b/vantagectl/internal/cmd/backup.go @@ -42,11 +42,16 @@ func newBackupCmd() *cobra.Command { } defer client.Disconnect(context.Background()) - w, closeOut, name, err := backupDestination(out, g.Database) + w, dest, name, err := backupDestination(out, g.Database) if err != nil { return err } - defer closeOut() + committed := false + defer func() { + if !committed { + dest.Cleanup() + } + }() m, err := backup.Dump(ctx, backup.DumpOptions{ Client: client, @@ -60,6 +65,10 @@ func newBackupCmd() *cobra.Command { if err != nil { return err } + if err := dest.Commit(); err != nil { + return err + } + committed = true // Progress goes to stderr so --out - stays a clean pipe. var docs int64 @@ -86,19 +95,59 @@ func newBackupCmd() *cobra.Command { } // backupDestination resolves --out to a writer, a closer and a name to print. -func backupDestination(out, database string) (io.Writer, func(), string, error) { +func backupDestination(out, database string) (io.Writer, destination, string, error) { if out == "-" { - return os.Stdout, func() {}, "stdout", nil + return os.Stdout, stdoutDestination{}, "stdout", nil } name := archiveName(database, time.Now().UTC()) path := filepath.Join(out, name) - f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + // Written under a temporary name and renamed on success, the same + // discipline the agent uses for authorized_keys: a failed backup must not + // leave a partial file named exactly like a good archive. + tmp := path + ".partial" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err != nil { - return nil, nil, "", fmt.Errorf("create %s: %w", path, err) + return nil, nil, "", fmt.Errorf("create %s: %w", tmp, err) } - return f, func() { f.Close() }, path, nil + d := &fileDestination{f: f, tmp: tmp, final: path} + return f, d, path, nil } +// fileDestination finishes a file-backed backup. Commit renames the temporary +// file into place; Cleanup removes it if Commit was never called. +type fileDestination struct { + f *os.File + tmp string + final string +} + +func (d *fileDestination) Commit() error { + if err := d.f.Close(); err != nil { + return fmt.Errorf("close %s: %w", d.tmp, err) + } + if err := os.Rename(d.tmp, d.final); err != nil { + return fmt.Errorf("rename %s: %w", d.tmp, err) + } + return nil +} + +func (d *fileDestination) Cleanup() { + d.f.Close() + os.Remove(d.tmp) +} + +// destination is how the two --out modes finish. stdout commits by doing +// nothing; there is no partial file to clean up either. +type destination interface { + Commit() error + Cleanup() +} + +type stdoutDestination struct{} + +func (stdoutDestination) Commit() error { return nil } +func (stdoutDestination) Cleanup() {} + // archiveName is sortable and carries no colon, because an operator will copy // these onto a Windows share sooner or later and a colon is not a legal // filename character there. diff --git a/vantagectl/internal/cmd/inspect_test.go b/vantagectl/internal/cmd/inspect_test.go index 81125fc..a6bed5a 100644 --- a/vantagectl/internal/cmd/inspect_test.go +++ b/vantagectl/internal/cmd/inspect_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "os" "strings" "testing" "time" @@ -68,3 +69,48 @@ func TestRenderManifestFlagsAMissingFingerprint(t *testing.T) { t.Fatalf("a null fingerprint must explain the consequence, got:\n%s", out) } } + +// TestBackupDestinationDoesNotLeaveAPartialArchive covers the failure path: a +// backup that errors must not leave a file named exactly like a good archive. +func TestBackupDestinationDoesNotLeaveAPartialArchive(t *testing.T) { + dir := t.TempDir() + w, dest, path, err := backupDestination(dir, "vantage") + if err != nil { + t.Fatalf("backupDestination: %v", err) + } + if _, err := w.Write([]byte("half an archive")); err != nil { + t.Fatalf("write: %v", err) + } + dest.Cleanup() + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("readdir: %v", err) + } + if len(entries) != 0 { + t.Fatalf("a failed backup left %v behind", entries) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("%s exists after a failed backup", path) + } +} + +func TestBackupDestinationRenamesOnCommit(t *testing.T) { + dir := t.TempDir() + w, dest, path, err := backupDestination(dir, "vantage") + if err != nil { + t.Fatalf("backupDestination: %v", err) + } + if _, err := w.Write([]byte("a whole archive")); err != nil { + t.Fatalf("write: %v", err) + } + if err := dest.Commit(); err != nil { + t.Fatalf("Commit: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("committed archive is not at %s: %v", path, err) + } + if _, err := os.Stat(path + ".partial"); !os.IsNotExist(err) { + t.Fatal("the temporary file was left behind") + } +} diff --git a/vantagectl/internal/cmd/root.go b/vantagectl/internal/cmd/root.go index 7d0b859..c4f9b11 100644 --- a/vantagectl/internal/cmd/root.go +++ b/vantagectl/internal/cmd/root.go @@ -93,10 +93,16 @@ func resolveGlobals(c *cobra.Command) (*globalOpts, error) { return &globalOpts{ MongoURI: uri, Database: db, - KeyHex: strings.TrimSpace(os.Getenv("KEY_ENCRYPTION_KEY")), + KeyHex: keyFromEnv(), }, nil } +// keyFromEnv reads KEY_ENCRYPTION_KEY. It is separate from resolveGlobals +// because verify needs the key even when there is no database to resolve. +func keyFromEnv() string { + return strings.TrimSpace(os.Getenv("KEY_ENCRYPTION_KEY")) +} + // databaseFromURI reads the database out of the URI path. sitesvc takes its // database name this way too, so an operator who has configured one has // configured both. diff --git a/vantagectl/internal/cmd/verify.go b/vantagectl/internal/cmd/verify.go index 758947b..d1a55c8 100644 --- a/vantagectl/internal/cmd/verify.go +++ b/vantagectl/internal/cmd/verify.go @@ -31,7 +31,12 @@ func newVerifyCmd() *cobra.Command { } defer archive.Close() - opt := backup.VerifyOptions{Archive: archive} + // The key is read unconditionally. Archive-only mode — no MONGO_URI, + // which is what a scheduled check uses — must still compare the key + // in hand against the archive's fingerprint; leaving it unset there + // reported "KEY_ENCRYPTION_KEY is not set" for a key that was set + // and correct. + opt := backup.VerifyOptions{Archive: archive, KeyHex: keyFromEnv()} // A database is optional here. resolveGlobals fails without a URI or // without a resolvable database name, and either error is a signal to @@ -45,7 +50,6 @@ func newVerifyCmd() *cobra.Command { defer client.Disconnect(context.Background()) opt.Client = client opt.Database = g.Database - opt.KeyHex = g.KeyHex } else { fmt.Fprintf(c.ErrOrStderr(), "note: %v, so this checks the archive and the key only\n", gerr) diff --git a/vantagectl/internal/cmd/verify_test.go b/vantagectl/internal/cmd/verify_test.go new file mode 100644 index 0000000..72e66e3 --- /dev/null +++ b/vantagectl/internal/cmd/verify_test.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/backup" +) + +const testKeyHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +// writeArchive builds a minimal, valid archive without touching a database. +func writeArchive(t *testing.T, keyHex string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "archive.tar.gz") + f, err := os.Create(path) + if err != nil { + t.Fatalf("create: %v", err) + } + defer f.Close() + + w := backup.NewWriter(f) + entry, err := w.WriteCollection("servers", nil) + if err != nil { + t.Fatalf("WriteCollection: %v", err) + } + fp, err := backup.FingerprintHex(keyHex) + if err != nil { + t.Fatalf("FingerprintHex: %v", err) + } + if err := w.Close(backup.Manifest{ + FormatVersion: backup.FormatVersion, + CreatedAt: time.Now().UTC(), + MongoDB: "vantage", + KeyFingerprint: &fp, + Collections: []backup.CollectionEntry{entry}, + }); err != nil { + t.Fatalf("Close: %v", err) + } + return path +} + +// TestVerifyReadsTheKeyWithoutADatabase is the regression guard for the key +// being read only on the branch that resolved a database: archive-only mode is +// what a scheduled check runs, and it reported "KEY_ENCRYPTION_KEY is not set" +// for a key that was set and correct. +func TestVerifyReadsTheKeyWithoutADatabase(t *testing.T) { + path := writeArchive(t, testKeyHex) + t.Setenv("MONGO_URI", "") + t.Setenv("MONGO_DB", "") + t.Setenv("KEY_ENCRYPTION_KEY", testKeyHex) + + root := NewRoot("test") + var out, errBuf bytes.Buffer + root.SetOut(&out) + root.SetErr(&errBuf) + root.SetArgs([]string{"verify", path}) + + if err := root.Execute(); err != nil { + t.Fatalf("verify failed with the correct key and no database: %v\n%s%s", + err, out.String(), errBuf.String()) + } + if !strings.Contains(out.String(), "Key match yes") { + t.Fatalf("archive-only verify did not compare the key:\n%s", out.String()) + } +} + +func TestVerifyReportsAKeyMismatchWithoutADatabase(t *testing.T) { + path := writeArchive(t, testKeyHex) + t.Setenv("MONGO_URI", "") + t.Setenv("MONGO_DB", "") + t.Setenv("KEY_ENCRYPTION_KEY", strings.Repeat("ab", 32)) + + root := NewRoot("test") + var out, errBuf bytes.Buffer + root.SetOut(&out) + root.SetErr(&errBuf) + root.SetArgs([]string{"verify", path}) + + if err := root.Execute(); err == nil { + t.Fatalf("verify accepted the wrong key:\n%s", out.String()) + } + if !strings.Contains(out.String(), "key mismatch") { + t.Fatalf("output does not name the mismatch:\n%s", out.String()) + } +} From d83061786ca6bbe0a1ac90e4580b28d12806ad35 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 14:45:37 +0000 Subject: [PATCH 22/23] fix: validate manifest collection names and route archive accessors through safeJoin --- shared/backup/archive.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/shared/backup/archive.go b/shared/backup/archive.go index 1d83e57..752111b 100644 --- a/shared/backup/archive.go +++ b/shared/backup/archive.go @@ -201,7 +201,7 @@ func (r *Reader) loadManifest() error { func (r *Reader) verifyMembers() error { for _, c := range r.manifest.Collections { - f, err := os.Open(filepath.Join(r.dir, collectionMember(c.Name))) + f, err := r.OpenCollection(c.Name) if err != nil { return fmt.Errorf("%w: %s is named in the manifest but absent from the archive", ErrChecksum, c.Name) @@ -227,14 +227,22 @@ func (r *Reader) Manifest() Manifest { return r.manifest } // OpenCollection returns the raw BSON stream for one collection. func (r *Reader) OpenCollection(name string) (io.ReadCloser, error) { - return os.Open(filepath.Join(r.dir, collectionMember(name))) + p, err := safeJoin(r.dir, collectionMember(name)) + if err != nil { + return nil, err + } + return os.Open(p) } // IndexesJSON returns a collection's index specifications, or nil when the // archive holds none. A collection with no indexes beyond _id_ is ordinary and // is not an error. func (r *Reader) IndexesJSON(name string) ([]byte, error) { - raw, err := os.ReadFile(filepath.Join(r.dir, indexMember(name))) + p, err := safeJoin(r.dir, indexMember(name)) + if err != nil { + return nil, err + } + raw, err := os.ReadFile(p) if os.IsNotExist(err) { return nil, nil } From be299845caee03a57793489011f95de04c82639d Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 7 Sep 2026 14:45:37 +0000 Subject: [PATCH 23/23] fix: document inspect and --confirm-db's actual behaviour --- CLAUDE.md | 48 +++++++++++++++++-- docsite/docs/operations/backup-and-restore.md | 46 ++++++++++++++---- 2 files changed, 81 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 776bfe6..29d254f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -606,11 +606,49 @@ There is no code path that upserts an archive's documents over existing ones: merging two control planes' data reconciles nothing about which SSH keys are still valid or which users still exist, and an upsert would resurrect a revoked key or a deleted member from the older side. `--force` drops each -collection in the archive first, and is gated behind a typed confirmation -(the target database's name, typed back) on a terminal, or `--confirm-db NAME` -matching the target exactly with none. Naming the target in the command itself -means a copied command carries its intended target with it and cannot destroy -a different one by accident. +collection in the archive first, and is gated behind a second assurance: +`--confirm-db NAME` matching the target exactly, which works everywhere, or — +on a terminal only, and only when `--confirm-db` was not given — the target +database's name typed back at a prompt. `--confirm-db` is accepted on a +terminal too: it is the stronger of the two, because naming the target in the +command itself means a copied command carries its intended target with it and +cannot destroy a different one by accident. Without a terminal and without +`--confirm-db`, `--force` is refused. + +**`--force` drops only what the archive names.** Collections already in the +target that the archive does not carry are left untouched and **named in a +warning** — an archive taken with `--exclude workflow_log_lines` restored over +a live database leaves the old lines joined to restored runs, which the +operator must be told. Dropping them instead would delete data nobody asked to +delete, and there is no way back from that. + +**Index specifications are replayed verbatim, never reconstructed.** +`dumpIndexes` stores each spec as extended JSON over the raw BSON the server +reported, and `replayIndexes` hands it back to `createIndexes` through +`RunCommand` with only `v` and `ns` stripped and `_id_` skipped. Rebuilding a +`mongo.IndexModel` from a hand-picked set of options dropped +`partialFilterExpression` — which this codebase relies on in +`services/workflows.go` and `services/settings.go` — so a partial unique index +came back as a full one, failed on duplicate keys, and aborted the restore +mid-write. Reconstructing the key document from JSON also lost compound key +order, which is significant. + +**`backup.ciphertextFields` mirrors `server/internal/models` by hand.** +`shared/` is a separate module and `models` is under `server/internal`, so +`shared/backup` cannot import it; the map naming each collection's `*_enc` +fields (`keys`, `secrets`, `auth_providers`, `console_sessions`) must change in +the same commit as any of those bson tags, the same hazard as +`web/lib/targets.ts` and `services.MaxWorkloadLogLines`. Wrong field names are +silent: `verify`'s live probe simply finds no ciphertext and reports "this +database stores no ciphertext yet", so the one gate that catches what a +fingerprint cannot no-ops. `settings` is deliberately in neither that map nor +`CiphertextCollections()` — its ESO read token is a SHA-256 hash, not +ciphertext. + +**A file-backed `backup` writes to `.tar.gz.partial` and renames on +success**, the same discipline the agent uses for `authorized_keys`. A failed +dump must not leave a partial file named exactly like a good archive; `--out -` +is untouched, since a broken pipe has no file to mislead anyone. **`vantagectl/Dockerfile`'s runtime stage is `scratch`, and needs the same explicit `/tmp` as `server/Dockerfile`.** `restore` extracts an archive to a diff --git a/docsite/docs/operations/backup-and-restore.md b/docsite/docs/operations/backup-and-restore.md index 6db3026..57f60e8 100644 --- a/docsite/docs/operations/backup-and-restore.md +++ b/docsite/docs/operations/backup-and-restore.md @@ -130,6 +130,25 @@ Each line of output answers a different question: it worth putting on a schedule — a backup job that "succeeded" last night is not the same claim as a backup that will actually restore. +## Looking inside an archive + +`inspect` prints an archive's manifest and touches no database at all — no +`--mongo-uri`, no key. It is what to run against an archive of unknown origin, +before deciding whether it is the one you want: + +```bash +vantagectl inspect /backups/vantage-backup-vantage-20260907T020000Z.tar.gz +``` + +It reports when the archive was taken and on which host, the Vantage and +MongoDB versions behind it, the database it came from, the key fingerprint (or +that it carries none), every collection with its document count and size, and +anything `--exclude` left out. Opening the archive verifies every member's +checksum on the way, so a corrupt archive fails here too. + +Reach for `verify` instead when the question is whether the key you hold opens +it; reach for `inspect` when the question is what it is. + ## Restoring `restore` expects the target database to be empty. Pointed at one that already @@ -143,13 +162,25 @@ vantagectl restore /backups/vantage-backup-vantage-20260907T020000Z.tar.gz \ ``` To overwrite a database that is not empty, add `--force`, which drops each -collection named in the archive before loading it. On a terminal, `--force` -alone prompts you to type the target database's name back — a deliberate pause -before something destructive. With no terminal — a Kubernetes Job, a CI step, a -cron entry — that prompt cannot happen, so `--force` instead requires -`--confirm-db NAME` naming the target exactly; a mismatch is refused. Naming -the database in the command itself means a copy-pasted invocation carries its -intended target with it and cannot destroy a different one by accident. +collection named in the archive before loading it. `--force` always needs a +second assurance, in one of two forms: + +- `--confirm-db NAME`, naming the target exactly. A mismatch is refused. This + works everywhere — on a terminal and in a Kubernetes Job, a CI step or a cron + entry alike — and is the form to script. +- Nothing, on a terminal: `--force` alone prompts you to type the target + database's name back, a deliberate pause before something destructive. + +Without a terminal and without `--confirm-db`, `--force` is refused: there is +nobody there to prompt. Naming the database in the command itself means a +copy-pasted invocation carries its intended target with it and cannot destroy a +different one by accident. + +`--force` drops only the collections the archive carries. Anything else already +in the target is left alone and named in a warning, so an archive taken with +`--exclude workflow_log_lines` restored over a live database tells you the old +log lines are still there, joined to freshly restored runs. Dropping them +instead would delete data you never asked to delete. `restore` also refuses when the archive's key fingerprint does not match the `KEY_ENCRYPTION_KEY` in your environment — see "When the key is wrong" below. @@ -187,7 +218,6 @@ will come back with ciphertext nobody can read: - `secrets` — the vault - `auth_providers` — OIDC/SSO client secrets - `console_sessions` — RDP/VNC credentials -- `settings` — anything encrypted at the instance level There is no way to recover that ciphertext afterwards. If you have reached this point, the right key was lost along with the chance to read those rows —