diff --git a/config.example.yml b/config.example.yml index b23252f..c0d526b 100644 --- a/config.example.yml +++ b/config.example.yml @@ -1,16 +1,30 @@ # vaultik configuration file example -# This file shows all available configuration options with their default values -# Copy this file and uncomment/modify the values you need +# This file shows every configuration option. The values are examples, not +# defaults; where an option has a default, its comment gives it. +# Copy this file and change the values you need # Age recipient public keys for encryption # 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 a key pair, and show its public key, with: +# age-keygen -o vaultik_backup_private_key.txt +# grep 'public key' vaultik_backup_private_key.txt +# Keep the private key file: restoring needs it. Do not pipe age-keygen +# straight into grep, which would discard the private key age_recipients: - age1cj2k2addawy294f6k2gr2mf9gps9r3syplryxca3nvxj3daqm96qfp84tz +# Age private key for decryption +# Only snapshot restore and snapshot verify --deep need it. The +# VAULTIK_AGE_SECRET_KEY environment variable overrides this setting +# Do not set it on a machine you back up: prefer the environment variable, +# supplied only on the machine you restore on +# Default: not set +#age_secret_key: YOUR_AGE_PRIVATE_KEY + # Named snapshots - each snapshot can contain multiple paths # Each snapshot gets its own ID and can have snapshot-specific excludes +# Required: every command needs at least one snapshot with at least one path snapshots: testing: paths: @@ -258,6 +272,11 @@ exclude: # Storage URL - use either this OR the s3 section below # Supports: s3://bucket/prefix, file:///path, rclone://remote/path +# An s3:// URL also needs s3.access_key_id and s3.secret_access_key below +# A file:// path must be absolute, and the directory must already exist: +# vaultik never creates it +# An rclone:// URL needs the remote to be defined in rclone's own config +# (rclone config) storage_url: "rclone://myremote/path/to/backups" # S3-compatible storage configuration @@ -283,6 +302,8 @@ storage_url: "rclone://myremote/path/to/backups" # #region: us-east-1 # # # Use SSL/TLS for S3 connections +# # Only read when storage_url is not set and the endpoint has no scheme; +# # with an s3:// storage_url, use ?ssl=false in the URL instead # # Default: true # #use_ssl: true # @@ -295,6 +316,7 @@ storage_url: "rclone://myremote/path/to/backups" # Path to local SQLite index database # This database tracks file state for incremental backups +# The VAULTIK_INDEX_PATH environment variable overrides this setting # Default: the platform data directory, e.g. # macOS: ~/Library/Application Support/vaultik/index.sqlite # Linux: ~/.local/share/vaultik/index.sqlite diff --git a/internal/cli/config.go b/internal/cli/config.go index 2a18fc1..b99f731 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -289,12 +289,14 @@ func newConfigInitCommand() *cobra.Command { Use: "init", Short: "Write a default config file", Long: `Creates a default configuration file with commented explanations -for every setting. If a config file already exists at the target path, -the command refuses to overwrite it. +for the settings it lists. If a config file already exists at the target +path, the command refuses to overwrite it. The file is created with mode +0600, because it can hold credentials. The config is written to the path from --config, $VAULTIK_CONFIG, or -the platform default config directory (e.g. ~/Library/Application Support/ -on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`, +config.yml in the platform default config directory +(~/Library/Application Support/vaultik/ on macOS, ~/.config/vaultik/ on +Linux, /etc/vaultik/ as root).`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { path := configPathForInit() @@ -332,7 +334,7 @@ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`, func newConfigEditCommand() *cobra.Command { return &cobra.Command{ Use: "edit", - Short: "Open the config file in $EDITOR", + Short: "Open the config file in $EDITOR (vi when it is not set)", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { path, err := ResolveConfigPath() diff --git a/internal/cli/prune.go b/internal/cli/prune.go index 6b6843a..44e00f8 100644 --- a/internal/cli/prune.go +++ b/internal/cli/prune.go @@ -17,7 +17,11 @@ func NewPruneCommand() *cobra.Command { unreferenced blobs and directories under metadata/ with no manifest from the backup destination store. -Local cleanup drops incomplete snapshots and any files, chunks, or +Needs permission to list, read and delete objects on the destination +store. It does not need the age private key. + +Local cleanup drops snapshots whose metadata is gone from the +destination store, incomplete snapshots, and any files, chunks, or blobs no longer referenced by a completed snapshot. Remote cleanup scans every snapshot manifest in the destination store, builds the set of still-referenced blob hashes, and deletes any blob not in that diff --git a/internal/cli/remote.go b/internal/cli/remote.go index 4bf403f..2167cdb 100644 --- a/internal/cli/remote.go +++ b/internal/cli/remote.go @@ -40,11 +40,13 @@ func newRemoteNukeCommand() *cobra.Command { cmd := &cobra.Command{ Use: "nuke", Short: "Delete ALL snapshot metadata and blobs from the backup destination store", - Long: `Removes every snapshot's metadata and every blob from remote -storage. After this command completes successfully the bucket prefix is + Long: `Removes every snapshot's metadata, every blob and the destination +marker from remote storage, and each removed snapshot from the local +index. After this command completes successfully the bucket prefix is empty and the next backup starts from scratch. -This is destructive and irreversible. Requires --force.`, +This is destructive and irreversible. Requires --force, and permission +to list and delete objects on the destination store.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { if !force { @@ -75,7 +77,10 @@ func newRemoteInfoCommand() *cobra.Command { - Size of all snapshot metadata (per snapshot and total) - Count and total size of all blobs - Count and size of referenced blobs (from all manifests) -- Count and size of orphaned blobs (not referenced by any manifest)`, +- Count and size of orphaned blobs (not referenced by any manifest) + +Needs permission to list and read the destination store. It does not +need the age private key.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { // Use unified config resolution diff --git a/internal/cli/root.go b/internal/cli/root.go index ba1fcbc..843d3ca 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -35,9 +35,10 @@ func NewRootCommand() *cobra.Command { cmd := &cobra.Command{ Use: "vaultik", Short: "Secure incremental backup tool with asymmetric encryption", - Long: `vaultik is a secure incremental backup tool that encrypts data using age -public keys and uploads to S3-compatible storage. No private keys are needed -on the source system.`, + Long: `vaultik is an incremental backup tool that encrypts data to age public +keys and uploads it to an S3-compatible object store, a local directory or +an rclone remote. The backed-up machine needs no private key: only +'snapshot restore' and 'snapshot verify --deep' need one.`, SilenceUsage: true, // Bare 'vaultik' (no subcommand): print help. The banner is // printed once at process startup by Entry, before cobra @@ -50,13 +51,15 @@ on the source system.`, // Add global flags cmd.PersistentFlags().StringVar(&rootFlags.ConfigPath, "config", "", - "Path to config file (default: $VAULTIK_CONFIG or platform config dir)") + "Path to config file (default: $VAULTIK_CONFIG, then the platform "+ + "config directory, then /etc/vaultik/config.yml)") cmd.PersistentFlags().BoolVarP(&rootFlags.Verbose, "verbose", "v", false, "Enable verbose output") cmd.PersistentFlags().BoolVar(&rootFlags.Debug, "debug", false, "Enable debug output") cmd.PersistentFlags().BoolVarP(&rootFlags.Quiet, "quiet", "q", false, - "Suppress non-error output") + "Suppress status output (warnings, errors, JSON documents, "+ + "'config get' values and confirmation prompts are still printed)") cmd.PersistentFlags().BoolVar(&rootFlags.SkipErrors, "skip-errors", false, "Skip files that cannot be read when creating a snapshot, or "+ "that cannot be restored when restoring, instead of aborting "+ diff --git a/internal/cli/snapshot.go b/internal/cli/snapshot.go index 8996d35..e50ba08 100644 --- a/internal/cli/snapshot.go +++ b/internal/cli/snapshot.go @@ -60,6 +60,21 @@ func NewSnapshotCommand() *cobra.Command { return cmd } +// snapshotCreateLong is the long help text of 'snapshot create'. +const snapshotCreateLong = `Creates new snapshots of the configured directories. + +If snapshot names are provided, only those snapshots are created. +If no names are provided, all configured snapshots are created. + +Needs at least one public key in age_recipients, and permission to write +objects at the backup destination store. It does not need the age private +key. With --prune it also needs permission to list, read and delete objects +there. + +The config is read from the path given by --config or VAULTIK_CONFIG; +otherwise from the platform config directory (~/.config/vaultik/config.yml +on Linux), then /etc/vaultik/config.yml.` + // newSnapshotCreateCommand creates the 'snapshot create' subcommand func newSnapshotCreateCommand() *cobra.Command { opts := &vaultik.SnapshotCreateOptions{} @@ -67,15 +82,8 @@ func newSnapshotCreateCommand() *cobra.Command { cmd := &cobra.Command{ Use: "create [snapshot-names...]", Short: "Create new snapshots", - Long: `Creates new snapshots of the configured directories. - -If snapshot names are provided, only those snapshots are created. -If no names are provided, all configured snapshots are created. - -The config is read from the path given by --config or VAULTIK_CONFIG; -otherwise from the platform config directory (~/.config/vaultik/config.yml -on Linux), then /etc/vaultik/config.yml.`, - Args: cobra.ArbitraryArgs, + Long: snapshotCreateLong, + Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { err := opts.Validate() if err != nil { @@ -156,8 +164,15 @@ func newSnapshotListCommand() *cobra.Command { Use: "list", Aliases: []string{"ls"}, Short: "List all snapshots", - Long: "Lists all snapshots with their ID, timestamp, and compressed size", - Args: cobra.NoArgs, + Long: `Lists the snapshots in the local index and on the backup destination +store, with their ID, timestamp, and sizes. + +Needs permission to list and read the destination store. It does not need +the age private key. A snapshot that is only on the destination store is +listed by its remote key, because its ID cannot be read without that key. +If the destination store cannot be listed, the command warns and lists the +local index alone.`, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return runVaultikApp(cmd, readOnly, false, "Failed to list snapshots", @@ -179,11 +194,19 @@ func newSnapshotPurgeCommand() *cobra.Command { cmd := &cobra.Command{ Use: "purge", Short: "Purge old snapshots", - Long: `Removes snapshots based on age or count criteria. + Long: `Removes snapshots based on age or count criteria. Exactly one of +--keep-latest and --older-than is required. -Retention is per-snapshot-name: --keep-latest keeps the latest of each -configured snapshot name, not the latest globally. Use --snapshot to -restrict the operation to specific snapshot names.`, +Retention is per-snapshot-name: --keep-latest keeps the latest snapshot of +each name, not the latest globally. Use --snapshot to restrict the +operation to specific snapshot names. + +Only snapshots in the local index are removed. Each one is removed from +the local index and its metadata is deleted from the backup destination +store. Blobs are not touched: run 'vaultik prune' afterwards. + +Needs permission to list and delete objects on the destination store. If +the destination store cannot be listed, nothing is removed.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { // Validate flags @@ -222,14 +245,26 @@ func newSnapshotVerifyCommand() *cobra.Command { cmd := &cobra.Command{ Use: "verify ", Short: "Check a snapshot's blobs are present with the listed size", - Long: "Checks that every blob the snapshot's manifest lists is present\n" + - "in storage with the size the manifest records, and that the\n" + - "snapshot's encrypted database is present. It does not read blob\n" + - "contents; use --deep to download, decrypt, and re-hash every blob\n" + - "to detect corruption -- integrity, not who wrote it.\n\n" + - "The snapshot may be named by its ID or, on a host with no local\n" + - "index, by the remote key that 'snapshot list' prints for a\n" + - "remote-only snapshot (an unambiguous leading part is enough).", + Long: `Checks that every blob the snapshot's manifest lists is present +in storage with the size the manifest records, and that the +snapshot's encrypted database is present. It does not read blob +contents and does not need the age private key. + +Use --deep to download, decrypt, and re-hash every blob to detect +corruption -- integrity, not who wrote it. + +--deep requires the age private key, in the VAULTIK_AGE_SECRET_KEY +environment variable or as age_secret_key in the config file; the +environment variable wins. The variable may hold the whole age-keygen +file (comments and all of its identities are accepted); read it from +the file rather than typing the key, so it does not land in your shell +history: + + export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)" + +The snapshot may be named by its ID or, on a host with no local +index, by the remote key that 'snapshot list' prints for a +remote-only snapshot (an unambiguous leading part is enough).`, Args: requireSnapshotIDArg, RunE: func(cmd *cobra.Command, args []string) error { snapshotID := args[0] @@ -264,7 +299,10 @@ func newSnapshotVerifyCommand() *cobra.Command { }, } - cmd.Flags().BoolVar(&opts.Deep, "deep", false, "Download and verify blob hashes") + cmd.Flags().BoolVar(&opts.Deep, "deep", false, + "Download, decrypt and re-hash every blob; requires the age private "+ + "key in VAULTIK_AGE_SECRET_KEY, or as age_secret_key in the config "+ + "file") cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output verification results as JSON") return cmd @@ -288,8 +326,10 @@ know about), which is what 'vaultik prune' does. The command prints the 'vaultik prune' invocation to run as a follow-up only when it removed the snapshot's metadata from the destination store, and never under --json. -An ID that names no snapshot in the local index or on the destination -store is an error. +The argument must be a snapshot ID: a remote key is not accepted. An ID +that names no snapshot in the local index or on the destination store is +an error. Needs permission to list and delete objects on the destination +store, unless --local-only is given. Use --local-only to skip the remote half (e.g. when you want to forget a snapshot locally without touching the destination store); the snapshot diff --git a/internal/cli/snapshot_restore.go b/internal/cli/snapshot_restore.go index 8609b90..1018766 100644 --- a/internal/cli/snapshot_restore.go +++ b/internal/cli/snapshot_restore.go @@ -37,13 +37,21 @@ The snapshot may be named by its ID or, when restoring on a host with no local index, by the remote key that 'snapshot list' prints for a remote-only snapshot (an unambiguous leading part is enough). -Requires the age private key in the VAULTIK_AGE_SECRET_KEY environment -variable. The variable may hold the whole age-keygen file (comments and -all of its identities are accepted); read it from the file rather than -typing the key, so it does not land in your shell history: +Each file is restored under its original absolute path inside the target +directory. File owners are restored only when the command runs as root. + +Requires the age private key, in the VAULTIK_AGE_SECRET_KEY environment +variable or as age_secret_key in the config file; the environment +variable wins. The variable may hold the whole age-keygen file (comments +and all of its identities are accepted); read it from the file rather +than typing the key, so it does not land in your shell history: export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)" +Also needs permission to read the backup destination store (and to list +it when a remote key is given), and free space in the temporary directory +for the decrypted blobs. + Examples: # Restore entire snapshot vaultik snapshot restore myhost_docs_2025-01-01T12:00:00Z /restore