feat: Add vantagectl, the control plane backup and restore CLI
Vantage encrypts SSH private keys, vault secrets, SSO client secrets and console credentials with KEY_ENCRYPTION_KEY. That key is in no backup and is not recoverable, so restoring a database without it produces a control plane whose every secret is permanently unreadable. vantagectl dumps and restores a whole database and stamps a SHA-256 fingerprint of the key into every archive, so a restore refuses rather than producing that database. The key itself is never written. - shared/cryptobox: AES-GCM extracted so the server and the CLI share one implementation rather than a copy that can drift - shared/backup: manifest, tar+gzip archive with per-member checksums verified before the first write, dump, restore, verify - vantagectl: its own module, so cobra stays out of the server, admin and sitesvc module graphs - container image, tagged release workflow, CI rebuild trigger - optional Helm CronJob, off by default - operator documentation, reconciled with the existing backups page
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
@@ -563,6 +563,103 @@ 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 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 `<name>.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
|
||||
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 +1318,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 +1334,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.
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 -}}
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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: {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,330 @@
|
||||
# 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-<db>-<RFC3339>.tar.gz`:
|
||||
|
||||
```
|
||||
manifest.json
|
||||
collections/<name>.bson concatenated raw BSON documents
|
||||
indexes/<name>.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/<name>.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 <name>`, 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`) 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
|
||||
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 — 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
|
||||
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.** `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
|
||||
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.
|
||||
@@ -0,0 +1,226 @@
|
||||
---
|
||||
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.
|
||||
|
||||
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
|
||||
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=<your 64-char hex 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=<your 64-char hex 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.
|
||||
|
||||
## 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
|
||||
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. `--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.
|
||||
|
||||
## 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-<date>.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-<date>.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
|
||||
|
||||
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.
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
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 := 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)
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
return raw, err
|
||||
}
|
||||
|
||||
// Close removes the extraction directory.
|
||||
func (r *Reader) Close() error { return os.RemoveAll(r.dir) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
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)
|
||||
|
||||
// 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"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")
|
||||
|
||||
// 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.
|
||||
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)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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"}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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"}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
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 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, ", "))
|
||||
}
|
||||
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, ", "))
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// 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 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 _, 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
opt.warn("index %s on %s was not created: %v", indexName, name, err)
|
||||
continue
|
||||
}
|
||||
created++
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// 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 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
|
||||
}
|
||||
|
||||
out := make(bson.D, 0, len(d))
|
||||
var name string
|
||||
unique := false
|
||||
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 name == "_id_" || !hasKey {
|
||||
return nil, name, false, false, nil
|
||||
}
|
||||
return out, name, unique, true, nil
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
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"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// 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 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")
|
||||
}
|
||||
if unique {
|
||||
t.Fatal("index reported as unique")
|
||||
}
|
||||
if name != "b_1_a_1" {
|
||||
t.Fatalf("name %q", name)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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.
|
||||
//
|
||||
// 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": {"encrypted_value"},
|
||||
"auth_providers": {"client_secret_enc"},
|
||||
"console_sessions": {"rdp_user_enc", "rdp_pass_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. 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:
|
||||
// 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,26 @@
|
||||
module gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl
|
||||
|
||||
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
|
||||
golang.org/x/term v0.45.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.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
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
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.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.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=
|
||||
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=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
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=
|
||||
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=
|
||||
@@ -0,0 +1,156 @@
|
||||
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, dest, name, err := backupDestination(out, g.Database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
dest.Cleanup()
|
||||
}
|
||||
}()
|
||||
|
||||
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
|
||||
}
|
||||
if err := dest.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
committed = true
|
||||
|
||||
// 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, destination, string, error) {
|
||||
if out == "-" {
|
||||
return os.Stdout, stdoutDestination{}, "stdout", nil
|
||||
}
|
||||
name := archiveName(database, time.Now().UTC())
|
||||
path := filepath.Join(out, name)
|
||||
// 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", tmp, err)
|
||||
}
|
||||
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.
|
||||
func archiveName(database string, at time.Time) string {
|
||||
return fmt.Sprintf("vantage-backup-%s-%s.tar.gz", database, at.Format("20060102T150405Z"))
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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])
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// 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,
|
||||
SilenceErrors: 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: 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.
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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()
|
||||
|
||||
// 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
|
||||
// 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
|
||||
} else {
|
||||
fmt.Fprintf(c.ErrOrStderr(),
|
||||
"note: %v, so this checks the archive and the key only\n", gerr)
|
||||
}
|
||||
|
||||
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")
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user