Load a config that has no age recipient (closes #221)
check / check (push) Successful in 13m47s
check / check (push) Successful in 13m47s
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
This commit was merged in pull request #244.
This commit is contained in:
@@ -530,7 +530,7 @@ complete annotated example also lives in
|
|||||||
|
|
||||||
| Field | Default | Description |
|
| 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. |
|
| `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 |
|
| `snapshots` | (required) | Named snapshot definitions with paths and excludes |
|
||||||
| `storage_url` | | Storage backend URL (`s3://`, `file://`, `rclone://`) |
|
| `storage_url` | | Storage backend URL (`s3://`, `file://`, `rclone://`) |
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ the tag exists and is exercised; what is left is merging `next` to
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 2026-10-06: Made `snapshot restore --skip-errors` skip the files that
|
||||||
need a blob it cannot download
|
need a blob it cannot download
|
||||||
([issue #218](https://git.eeqj.de/sneak/vaultik/issues/218)). A missing
|
([issue #218](https://git.eeqj.de/sneak/vaultik/issues/218)). A missing
|
||||||
|
|||||||
+2
-1
@@ -3,7 +3,8 @@
|
|||||||
# Copy this file and uncomment/modify the values you need
|
# Copy this file and uncomment/modify the values you need
|
||||||
|
|
||||||
# Age recipient public keys for encryption
|
# 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"
|
# Generate with: age-keygen | grep "public key"
|
||||||
age_recipients:
|
age_recipients:
|
||||||
- age1cj2k2addawy294f6k2gr2mf9gps9r3syplryxca3nvxj3daqm96qfp84tz
|
- age1cj2k2addawy294f6k2gr2mf9gps9r3syplryxca3nvxj3daqm96qfp84tz
|
||||||
|
|||||||
@@ -45,16 +45,19 @@ const defaultConfigTemplate = `# vaultik configuration
|
|||||||
|
|
||||||
# ─── REQUIRED ────────────────────────────────────────────────────────────────
|
# ─── 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
|
# 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
|
# private keys can decrypt. Adding a recipient later does not re-encrypt data
|
||||||
# already stored: deduplicated chunks and existing blobs stay encrypted to the
|
# 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
|
# 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
|
# age-keygen -o vaultik_backup_private_key.txt
|
||||||
# grep 'public key' vaultik_backup_private_key.txt
|
# grep 'public key' vaultik_backup_private_key.txt
|
||||||
age_recipients:
|
# vaultik config set age_recipients.0 age1...
|
||||||
- age1REPLACE_WITH_YOUR_PUBLIC_KEY
|
age_recipients: []
|
||||||
|
|
||||||
# Named snapshots. Each snapshot backs up one or more paths and can have its
|
# Named snapshots. Each snapshot backs up one or more paths and can have its
|
||||||
# own exclude patterns in addition to the global excludes below.
|
# own exclude patterns in addition to the global excludes below.
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ func TestDefaultConfigTemplateParses(t *testing.T) {
|
|||||||
t.Fatalf("default config template is not valid YAML: %v", err)
|
t.Fatalf("default config template is not valid YAML: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.AgeRecipients) != 1 {
|
// A placeholder recipient would fail config.Load, so the template
|
||||||
t.Errorf("expected 1 placeholder age recipient, got %d", len(cfg.AgeRecipients))
|
// 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"]
|
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
|
const testYAML = `# top comment
|
||||||
compression_level: 3
|
compression_level: 3
|
||||||
age_recipients:
|
age_recipients:
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ const (
|
|||||||
|
|
||||||
// Sentinel validation errors.
|
// Sentinel validation errors.
|
||||||
var (
|
var (
|
||||||
errNoConfigPath = errors.New("config path not provided")
|
errNoConfigPath = errors.New("config path not provided")
|
||||||
errNoAgeRecipients = errors.New(
|
|
||||||
"at least one age_recipient is required (generate with: age-keygen)")
|
|
||||||
errRecipientIsSecretKey = errors.New(
|
errRecipientIsSecretKey = errors.New(
|
||||||
"an age secret key was given where a public key (age1...) belongs")
|
"an age secret key was given where a public key (age1...) belongs")
|
||||||
errRecipientNotX25519 = errors.New(
|
errRecipientNotX25519 = errors.New(
|
||||||
@@ -323,9 +321,10 @@ func Load(path string) (*Config, error) {
|
|||||||
|
|
||||||
// Validate checks if the configuration is valid and complete.
|
// Validate checks if the configuration is valid and complete.
|
||||||
// It ensures all required fields are present and have valid values:
|
// It ensures all required fields are present and have valid values:
|
||||||
// - At least one age recipient must be specified, and every recipient must
|
// - Every age recipient must parse as an X25519 age1... public key (so a
|
||||||
// parse as an X25519 age1... public key (so a bad entry fails at load, not
|
// bad entry fails at load, not mid-backup); errors name the position,
|
||||||
// mid-backup); errors name the position, never the value
|
// 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
|
// - At least one snapshot must be configured with at least one path
|
||||||
// - Storage must be configured (either storage_url or s3.* fields)
|
// - Storage must be configured (either storage_url or s3.* fields)
|
||||||
// - Chunk size must be at least 1MB
|
// - 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.
|
// Returns an error describing the first validation failure encountered.
|
||||||
func (c *Config) Validate() error {
|
func (c *Config) Validate() error {
|
||||||
if len(c.AgeRecipients) == 0 {
|
|
||||||
return errNoAgeRecipients
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, recipient := range c.AgeRecipients {
|
for i, recipient := range c.AgeRecipients {
|
||||||
err := validateAgeRecipient(recipient)
|
err := validateAgeRecipient(recipient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -223,7 +223,8 @@ func TestValidateBlobSizeLimit(t *testing.T) {
|
|||||||
|
|
||||||
// TestValidateAgeRecipients checks that recipients are parsed at config load
|
// TestValidateAgeRecipients checks that recipients are parsed at config load
|
||||||
// (a bad entry fails immediately, not mid-backup) and that no invalid entry —
|
// (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) {
|
func TestValidateAgeRecipients(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -244,7 +245,12 @@ func TestValidateAgeRecipients(t *testing.T) {
|
|||||||
wantErr bool
|
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"},
|
recipients: []string{"age1REPLACE_WITH_YOUR_PUBLIC_KEY"},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/cli"
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
@@ -27,10 +28,12 @@ import (
|
|||||||
//
|
//
|
||||||
// The backup half writes a snapshot with one index and hostname. The
|
// The backup half writes a snapshot with one index and hostname. The
|
||||||
// restore half throws that index away entirely: a fresh, empty index and
|
// restore half throws that index away entirely: a fresh, empty index and
|
||||||
// a config that shares nothing with the original but the storage location
|
// a config written by `config init` and `config set storage_url`, as in
|
||||||
// and the secret key. If restore or verify needed the original local
|
// the README's steps for restoring on another machine, which shares
|
||||||
// index — or the human snapshot ID that only that index holds — this test
|
// nothing with the original but the storage location and the secret key.
|
||||||
// could not run, because the recovery host can know neither.
|
// 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) {
|
func TestRestoreOnAnotherMachine(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -58,7 +61,12 @@ func TestRestoreOnAnotherMachine(t *testing.T) {
|
|||||||
|
|
||||||
// Recovery host: a fresh empty index, a different hostname, and no
|
// Recovery host: a fresh empty index, a different hostname, and no
|
||||||
// age_recipients — only the secret key and the same storage location.
|
// 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 recovery index really is empty. This is the assertion that makes
|
||||||
// the test a guard against restore quietly depending on the original
|
// 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}))
|
remote.RemoteKey, &vaultik.VerifyOptions{Deep: true}))
|
||||||
|
|
||||||
assertRestoredTreeMatches(t, fs, restoreDir, sourceFiles)
|
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
|
// writeRecoverySourceTree writes a small source tree spanning several
|
||||||
@@ -118,14 +130,24 @@ func writeRecoverySourceTree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newRecoveryHost builds the Vaultik a replacement machine would run: an
|
// newRecoveryHost builds the Vaultik a replacement machine would run: an
|
||||||
// empty in-memory index, a hostname different from the backup host, no
|
// empty in-memory index, a hostname different from the backup host, and
|
||||||
// age_recipients, and only the secret key plus the shared storer. It
|
// only the secret key plus the shared storer. Its config is read from
|
||||||
// returns the instance and the buffer its stdout is wired to.
|
// configPath by config.Load, as every command reads it. It returns the
|
||||||
|
// instance and the buffer its stdout is wired to.
|
||||||
func newRecoveryHost(
|
func newRecoveryHost(
|
||||||
ctx context.Context, t *testing.T, fs afero.Fs, storer storage.Storer,
|
ctx context.Context, t *testing.T, fs afero.Fs, storer storage.Storer,
|
||||||
|
configPath string,
|
||||||
) (*vaultik.Vaultik, *bytes.Buffer) {
|
) (*vaultik.Vaultik, *bytes.Buffer) {
|
||||||
t.Helper()
|
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:")
|
recoveryDB, err := database.New(ctx, ":memory:")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _ = recoveryDB.Close() })
|
t.Cleanup(func() { _ = recoveryDB.Close() })
|
||||||
@@ -133,10 +155,7 @@ func newRecoveryHost(
|
|||||||
stdout := &bytes.Buffer{}
|
stdout := &bytes.Buffer{}
|
||||||
|
|
||||||
recovery := &vaultik.Vaultik{
|
recovery := &vaultik.Vaultik{
|
||||||
Config: &config.Config{
|
Config: cfg,
|
||||||
AgeSecretKey: testAgeSecretKey,
|
|
||||||
Hostname: "recovery-host",
|
|
||||||
},
|
|
||||||
Storage: storer,
|
Storage: storer,
|
||||||
Fs: fs,
|
Fs: fs,
|
||||||
Repositories: database.NewRepositories(recoveryDB),
|
Repositories: database.NewRepositories(recoveryDB),
|
||||||
@@ -150,6 +169,20 @@ func newRecoveryHost(
|
|||||||
return recovery, stdout
|
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
|
// assertRestoredTreeMatches byte-compares every restored file against its
|
||||||
// source content.
|
// source content.
|
||||||
func assertRestoredTreeMatches(
|
func assertRestoredTreeMatches(
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ var (
|
|||||||
errSnapshotVerifyFailed = errors.New("verification failed")
|
errSnapshotVerifyFailed = errors.New("verification failed")
|
||||||
errRemoveAllNeedsForce = errors.New("--all requires --force")
|
errRemoveAllNeedsForce = errors.New("--all requires --force")
|
||||||
errInvalidTableName = errors.New("invalid table name")
|
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
|
// listRecentLimit caps how many snapshot rows are fetched from the
|
||||||
@@ -42,6 +45,12 @@ type SnapshotCreateOptions struct {
|
|||||||
|
|
||||||
// CreateSnapshot executes the snapshot creation operation
|
// CreateSnapshot executes the snapshot creation operation
|
||||||
func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
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()
|
overallStartTime := time.Now()
|
||||||
|
|
||||||
log.Info("Starting snapshot creation",
|
log.Info("Starting snapshot creation",
|
||||||
|
|||||||
Reference in New Issue
Block a user