From 14fc4c9893e37b25fba4622f613c1f19f830d49e Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 15:46:13 +0200 Subject: [PATCH] Load a config that has no age recipient (closes #221) The README's steps for restoring on another machine failed at the first command: `config init` wrote a placeholder recipient, and `config.Load` rejects any recipient that does not parse. `config init` now writes an empty `age_recipients` list, `config.Load` accepts an empty list, and `snapshot create` refuses to start without a recipient. A malformed recipient is still rejected at load. The recovery-host test now builds its config with `config init` and `config set` and reads it through `config.Load`, so it imports `internal/cli`. On a fresh file, `config set age_recipients.0` writes the list in flow style (`[age1...]`). Model: opus-5-5 --- README.md | 2 +- TODO.md | 8 +++ config.example.yml | 3 +- internal/cli/config.go | 11 ++-- internal/cli/config_test.go | 43 +++++++++++++- internal/config/config.go | 15 ++--- internal/config/config_test.go | 10 +++- .../vaultik/restore_another_machine_test.go | 57 +++++++++++++++---- internal/vaultik/snapshot.go | 9 +++ 9 files changed, 126 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 6592d9f..425d569 100644 --- a/README.md +++ b/README.md @@ -530,7 +530,7 @@ complete annotated example also lives in | Field | Default | Description | |-------|---------|-------------| -| `age_recipients` | (required) | Age public keys for encryption | +| `age_recipients` | (required by `snapshot create`) | Age public keys for encryption. Other commands run without one, so a machine that only restores can leave it empty | | `age_secret_key` | (unset) | Age private key for decryption (`snapshot restore`, `snapshot verify --deep`). Setting it in the config file places the private key on the backed-up host, defeating the public-key-only design (see "why" above). Prefer the `VAULTIK_AGE_SECRET_KEY` environment variable, supplied only on the machine you restore from. | | `snapshots` | (required) | Named snapshot definitions with paths and excludes | | `storage_url` | | Storage backend URL (`s3://`, `file://`, `rclone://`) | diff --git a/TODO.md b/TODO.md index a59deca..b7958a5 100644 --- a/TODO.md +++ b/TODO.md @@ -22,6 +22,14 @@ the tag exists and is exercised; what is left is merging `next` to # Completed Steps +- 2026-10-06: Made the README's steps for restoring on another machine + work ([issue #221](https://git.eeqj.de/sneak/vaultik/issues/221)). + `config init` wrote a placeholder recipient that `config.Load` + rejects, so every command on the new machine failed before reaching + the store. The file now has an empty `age_recipients` list, + `config.Load` accepts an empty list, and `snapshot create` refuses to + run without a recipient. + - 2026-10-06: Made `snapshot restore --skip-errors` skip the files that need a blob it cannot download ([issue #218](https://git.eeqj.de/sneak/vaultik/issues/218)). A missing diff --git a/config.example.yml b/config.example.yml index 199c3d1..6dc50de 100644 --- a/config.example.yml +++ b/config.example.yml @@ -3,7 +3,8 @@ # Copy this file and uncomment/modify the values you need # Age recipient public keys for encryption -# This is REQUIRED - backups are encrypted to these public keys +# Backups are encrypted to these public keys. snapshot create needs at least +# one; listing, verifying and restoring do not # Generate with: age-keygen | grep "public key" age_recipients: - age1cj2k2addawy294f6k2gr2mf9gps9r3syplryxca3nvxj3daqm96qfp84tz diff --git a/internal/cli/config.go b/internal/cli/config.go index ae8aad9..a7444f8 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -45,16 +45,19 @@ const defaultConfigTemplate = `# vaultik configuration # ─── REQUIRED ──────────────────────────────────────────────────────────────── -# Age recipient public keys for encryption. +# Age recipient public keys for encryption. snapshot create needs at least +# one; listing, verifying and restoring do not, so a machine that only +# restores can leave this empty. # Backups are encrypted to ALL listed recipients; any one of the corresponding # private keys can decrypt. Adding a recipient later does not re-encrypt data # already stored: deduplicated chunks and existing blobs stay encrypted to the # earlier recipients, so a newly added key cannot restore them on its own (see -# docs/REPOSTRUCTURE.md, Accepted Risks). Generate a keypair with: +# docs/REPOSTRUCTURE.md, Accepted Risks). Generate a keypair and add its +# public key with: # age-keygen -o vaultik_backup_private_key.txt # grep 'public key' vaultik_backup_private_key.txt -age_recipients: - - age1REPLACE_WITH_YOUR_PUBLIC_KEY +# vaultik config set age_recipients.0 age1... +age_recipients: [] # Named snapshots. Each snapshot backs up one or more paths and can have its # own exclude patterns in addition to the global excludes below. diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index b537946..4e3f7e2 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -24,8 +24,10 @@ func TestDefaultConfigTemplateParses(t *testing.T) { t.Fatalf("default config template is not valid YAML: %v", err) } - if len(cfg.AgeRecipients) != 1 { - t.Errorf("expected 1 placeholder age recipient, got %d", len(cfg.AgeRecipients)) + // A placeholder recipient would fail config.Load, so the template + // leaves the list empty. + if len(cfg.AgeRecipients) != 0 { + t.Errorf("expected no age recipients, got %d", len(cfg.AgeRecipients)) } home, ok := cfg.Snapshots["home"] @@ -55,6 +57,43 @@ func TestDefaultConfigTemplateParses(t *testing.T) { } } +// TestConfigSetRecipientOnFreshConfig follows the README quickstart: on the +// file `config init` writes, `config set age_recipients.0` and +// `config set storage_url` give a config that loads with that recipient. +func TestConfigSetRecipientOnFreshConfig(t *testing.T) { + t.Parallel() + + const recipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj" + + path := filepath.Join(t.TempDir(), "config.yml") + + err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode) + if err != nil { + t.Fatalf("write config: %v", err) + } + + out := ui.NewWithColor(&bytes.Buffer{}, false) + + err = writeConfigSet(out, path, "age_recipients.0", recipient) + if err != nil { + t.Fatalf("config set age_recipients.0: %v", err) + } + + err = writeConfigSet(out, path, "storage_url", "file:///mnt/backups") + if err != nil { + t.Fatalf("config set storage_url: %v", err) + } + + cfg, err := config.Load(path) + if err != nil { + t.Fatalf("config.Load: %v", err) + } + + if len(cfg.AgeRecipients) != 1 || cfg.AgeRecipients[0] != recipient { + t.Errorf("age_recipients = %v, want [%s]", cfg.AgeRecipients, recipient) + } +} + const testYAML = `# top comment compression_level: 3 age_recipients: diff --git a/internal/config/config.go b/internal/config/config.go index 2608d9d..1448a32 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,9 +42,7 @@ const ( // Sentinel validation errors. var ( - errNoConfigPath = errors.New("config path not provided") - errNoAgeRecipients = errors.New( - "at least one age_recipient is required (generate with: age-keygen)") + errNoConfigPath = errors.New("config path not provided") errRecipientIsSecretKey = errors.New( "an age secret key was given where a public key (age1...) belongs") errRecipientNotX25519 = errors.New( @@ -323,9 +321,10 @@ func Load(path string) (*Config, error) { // Validate checks if the configuration is valid and complete. // It ensures all required fields are present and have valid values: -// - At least one age recipient must be specified, and every recipient must -// parse as an X25519 age1... public key (so a bad entry fails at load, not -// mid-backup); errors name the position, never the value +// - Every age recipient must parse as an X25519 age1... public key (so a +// bad entry fails at load, not mid-backup); errors name the position, +// never the value. An empty list is accepted, because only snapshot +// create needs a recipient and it checks for one itself // - At least one snapshot must be configured with at least one path // - Storage must be configured (either storage_url or s3.* fields) // - Chunk size must be at least 1MB @@ -336,10 +335,6 @@ func Load(path string) (*Config, error) { // // Returns an error describing the first validation failure encountered. func (c *Config) Validate() error { - if len(c.AgeRecipients) == 0 { - return errNoAgeRecipients - } - for i, recipient := range c.AgeRecipients { err := validateAgeRecipient(recipient) if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 44e9fe6..52b46ad 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -223,7 +223,8 @@ func TestValidateBlobSizeLimit(t *testing.T) { // TestValidateAgeRecipients checks that recipients are parsed at config load // (a bad entry fails immediately, not mid-backup) and that no invalid entry — -// least of all a pasted secret key — is echoed in the error. +// least of all a pasted secret key — is echoed in the error. An empty list +// loads, because only snapshot create needs a recipient. func TestValidateAgeRecipients(t *testing.T) { t.Parallel() @@ -244,7 +245,12 @@ func TestValidateAgeRecipients(t *testing.T) { wantErr bool }{ { - name: "config init placeholder is rejected", + name: "no recipients is accepted", + recipients: nil, + wantErr: false, + }, + { + name: "placeholder recipient is rejected", recipients: []string{"age1REPLACE_WITH_YOUR_PUBLIC_KEY"}, wantErr: true, }, diff --git a/internal/vaultik/restore_another_machine_test.go b/internal/vaultik/restore_another_machine_test.go index 59268a4..3f53202 100644 --- a/internal/vaultik/restore_another_machine_test.go +++ b/internal/vaultik/restore_another_machine_test.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "sneak.berlin/go/vaultik/internal/cli" "sneak.berlin/go/vaultik/internal/config" "sneak.berlin/go/vaultik/internal/database" "sneak.berlin/go/vaultik/internal/log" @@ -27,10 +28,12 @@ import ( // // The backup half writes a snapshot with one index and hostname. The // restore half throws that index away entirely: a fresh, empty index and -// a config that shares nothing with the original but the storage location -// and the secret key. If restore or verify needed the original local -// index — or the human snapshot ID that only that index holds — this test -// could not run, because the recovery host can know neither. +// a config written by `config init` and `config set storage_url`, as in +// the README's steps for restoring on another machine, which shares +// nothing with the original but the storage location and the secret key. +// If restore or verify needed the original local index — or +// the human snapshot ID that only that index holds — this test could not +// run, because the recovery host can know neither. func TestRestoreOnAnotherMachine(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() @@ -58,7 +61,12 @@ func TestRestoreOnAnotherMachine(t *testing.T) { // Recovery host: a fresh empty index, a different hostname, and no // age_recipients — only the secret key and the same storage location. - recovery, stdout := newRecoveryHost(ctx, t, fs, storer) + configPath := filepath.Join(tempDir, "config.yml") + runVaultikCommand(t, "--config", configPath, "config", "init") + runVaultikCommand(t, "--config", configPath, + "config", "set", "storage_url", "file://"+storeDir) + + recovery, stdout := newRecoveryHost(ctx, t, fs, storer, configPath) // The recovery index really is empty. This is the assertion that makes // the test a guard against restore quietly depending on the original @@ -93,6 +101,10 @@ func TestRestoreOnAnotherMachine(t *testing.T) { remote.RemoteKey, &vaultik.VerifyOptions{Deep: true})) assertRestoredTreeMatches(t, fs, restoreDir, sourceFiles) + + // With no public key configured, a backup must refuse to start. + err = recovery.CreateSnapshot(&vaultik.SnapshotCreateOptions{Cron: true}) + require.ErrorContains(t, err, "age_recipients") } // writeRecoverySourceTree writes a small source tree spanning several @@ -118,14 +130,24 @@ func writeRecoverySourceTree( } // newRecoveryHost builds the Vaultik a replacement machine would run: an -// empty in-memory index, a hostname different from the backup host, no -// age_recipients, and only the secret key plus the shared storer. It -// returns the instance and the buffer its stdout is wired to. +// empty in-memory index, a hostname different from the backup host, and +// only the secret key plus the shared storer. Its config is read from +// configPath by config.Load, as every command reads it. It returns the +// instance and the buffer its stdout is wired to. func newRecoveryHost( ctx context.Context, t *testing.T, fs afero.Fs, storer storage.Storer, + configPath string, ) (*vaultik.Vaultik, *bytes.Buffer) { t.Helper() + cfg, err := config.Load(configPath) + require.NoError(t, err) + + // Set directly rather than through VAULTIK_AGE_SECRET_KEY, which a + // parallel test cannot change. + cfg.AgeSecretKey = testAgeSecretKey + cfg.Hostname = "recovery-host" + recoveryDB, err := database.New(ctx, ":memory:") require.NoError(t, err) t.Cleanup(func() { _ = recoveryDB.Close() }) @@ -133,10 +155,7 @@ func newRecoveryHost( stdout := &bytes.Buffer{} recovery := &vaultik.Vaultik{ - Config: &config.Config{ - AgeSecretKey: testAgeSecretKey, - Hostname: "recovery-host", - }, + Config: cfg, Storage: storer, Fs: fs, Repositories: database.NewRepositories(recoveryDB), @@ -150,6 +169,20 @@ func newRecoveryHost( return recovery, stdout } +// runVaultikCommand runs one vaultik command line in-process and fails the +// test if it returns an error. The command writes the cli package's global +// flag variables, so it must not be called from two tests that run at the +// same time. +func runVaultikCommand(t *testing.T, args ...string) { + t.Helper() + + cmd := cli.NewRootCommand() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + require.NoError(t, cmd.Execute()) +} + // assertRestoredTreeMatches byte-compares every restored file against its // source content. func assertRestoredTreeMatches( diff --git a/internal/vaultik/snapshot.go b/internal/vaultik/snapshot.go index 9f76fdf..cb61ba5 100644 --- a/internal/vaultik/snapshot.go +++ b/internal/vaultik/snapshot.go @@ -23,6 +23,9 @@ var ( errSnapshotVerifyFailed = errors.New("verification failed") errRemoveAllNeedsForce = errors.New("--all requires --force") errInvalidTableName = errors.New("invalid table name") + errNoAgeRecipients = errors.New( + "creating a snapshot needs at least one public key in " + + "age_recipients (generate a keypair with: age-keygen)") ) // listRecentLimit caps how many snapshot rows are fetched from the @@ -42,6 +45,12 @@ type SnapshotCreateOptions struct { // CreateSnapshot executes the snapshot creation operation func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error { + // config.Load accepts an empty list, since listing, verifying and + // restoring need no public key. + if len(v.Config.AgeRecipients) == 0 { + return errNoAgeRecipients + } + overallStartTime := time.Now() log.Info("Starting snapshot creation",