diff --git a/README.md b/README.md index dcdaa21..46c90a3 100644 --- a/README.md +++ b/README.md @@ -46,66 +46,34 @@ go install git.eeqj.de/sneak/vaultik@latest ## quick start -1. **generate keypair** +```sh +# 1. Install +go install git.eeqj.de/sneak/vaultik@latest - ```sh - age-keygen -o agekey.txt - grep 'public key:' agekey.txt - ``` +# 2. Generate an age keypair (store the private key somewhere safe, offline) +age-keygen -o key.txt +# the public key is printed to stdout and also in key.txt -2. **write config** (see `config.example.yml` for all options) +# 3. Create a default config file +vaultik init +# Writes to the platform config directory with commented defaults: +# macOS: ~/Library/Application Support/vaultik/config.yml +# Linux: ~/.config/vaultik/config.yml +# root: /etc/vaultik/config.yml - ```yaml - snapshots: - system: - paths: - - /etc - - /var/lib - exclude: - - '*.cache' - home: - paths: - - /home/user/documents - - /home/user/photos +# 4. Edit the config: set age_recipients, snapshots, and storage_url +# (init prints the path it wrote to) - exclude: - - '*.log' - - '*.tmp' - - '.git' - - 'node_modules' +# 5. Run your first backup +vaultik snapshot create - age_recipients: - - age1YOUR_PUBLIC_KEY_HERE +# 6. Verify it worked +vaultik snapshot list +vaultik snapshot verify - # Storage backend (pick one): - storage_url: "s3://mybucket/backups?endpoint=s3.example.com®ion=us-east-1" - # storage_url: "file:///mnt/backups" - # storage_url: "rclone://myremote/path/to/backups" - - # For s3:// URLs, credentials are still required: - s3: - access_key_id: ... - secret_access_key: ... - ``` - -3. **run** - - ```sh - # Back up all configured snapshots - vaultik --config /etc/vaultik.yml snapshot create - - # Back up specific snapshots by name - vaultik --config /etc/vaultik.yml snapshot create home system - - # Silent mode for cron - vaultik --config /etc/vaultik.yml snapshot create --cron - - # Back up and clean up old snapshots + orphan blobs in one shot - vaultik --config /etc/vaultik.yml snapshot create --prune - - # Daily cron: back up, keep last 4 weeks of snapshots - vaultik --config /etc/vaultik.yml snapshot create --cron --prune --keep-newer-than 4w - ``` +# 7. Set up a daily cron job (keeps last 4 weeks of snapshots) +# 0 3 * * * vaultik snapshot create --cron --prune --keep-newer-than 4w +``` --- @@ -114,6 +82,7 @@ go install git.eeqj.de/sneak/vaultik@latest ### commands ```sh +vaultik [--config ] init vaultik [--config ] snapshot create [snapshot-names...] [--cron] [--prune] [--keep-newer-than ] [--skip-errors] vaultik [--config ] snapshot list [--json] vaultik [--config ] snapshot verify [--deep] [--json] @@ -132,7 +101,7 @@ vaultik version ### global flags -* `--config `: Path to config file (default: `$VAULTIK_CONFIG` or `/etc/vaultik/config.yml`) +* `--config `: Path to config file (default: `$VAULTIK_CONFIG`, then platform config dir, then `/etc/vaultik/config.yml`) * `--verbose`, `-v`: Enable verbose output * `--debug`: Enable debug output * `--quiet`, `-q`: Suppress non-error output @@ -145,6 +114,12 @@ vaultik version ### command details +**init**: Write a default config file with commented explanations for every +setting. Writes to the path from `--config`, `$VAULTIK_CONFIG`, or the +platform config directory (`~/Library/Application Support/vaultik/` on macOS, +`~/.config/vaultik/` on Linux, `/etc/vaultik/` as root). Refuses to overwrite an +existing file. Created with mode `0600` since it will contain credentials. + **snapshot create**: Perform incremental backup of configured snapshots. * Optional snapshot names argument to create specific snapshots (default: all) * `--cron`: Silent unless error (for crontab) diff --git a/internal/cli/entry_test.go b/internal/cli/entry_test.go index 041c1b7..06b959e 100644 --- a/internal/cli/entry_test.go +++ b/internal/cli/entry_test.go @@ -18,7 +18,7 @@ func TestCLIEntry(t *testing.T) { } // Verify all subcommands are registered - expectedCommands := []string{"snapshot", "store", "restore", "prune", "info", "version", "remote", "database"} + expectedCommands := []string{"init", "snapshot", "store", "restore", "prune", "info", "version", "remote", "database"} for _, expected := range expectedCommands { found := false for _, cmd := range cmd.Commands() { diff --git a/internal/cli/init.go b/internal/cli/init.go new file mode 100644 index 0000000..27884af --- /dev/null +++ b/internal/cli/init.go @@ -0,0 +1,136 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +const defaultConfigTemplate = `# vaultik configuration +# Documentation: https://git.eeqj.de/sneak/vaultik + +# ─── REQUIRED ──────────────────────────────────────────────────────────────── + +# Age recipient public keys for encryption. +# Backups are encrypted to ALL listed recipients. Any one of the corresponding +# private keys can decrypt. Generate a keypair with: +# age-keygen -o key.txt && grep 'public key' key.txt +age_recipients: + - age1REPLACE_WITH_YOUR_PUBLIC_KEY + +# Named snapshots. Each snapshot backs up one or more paths and can have its +# own exclude patterns in addition to the global excludes below. +snapshots: + home: + paths: + - ~/Documents + - ~/Pictures + # exclude: + # - "*.cache" + +# Storage backend (pick ONE of the three forms below). +# +# S3-compatible: +# storage_url: "s3://mybucket/backups?endpoint=s3.example.com®ion=us-east-1" +# (also set s3.access_key_id and s3.secret_access_key below) +# +# Local filesystem: +# storage_url: "file:///mnt/backups/vaultik" +# +# Rclone (requires rclone configured separately): +# storage_url: "rclone://myremote/path/to/backups" +storage_url: "" + +# ─── S3 CREDENTIALS (required for s3:// storage_url) ──────────────────────── + +# s3: +# access_key_id: YOUR_ACCESS_KEY +# secret_access_key: YOUR_SECRET_KEY +# # region: us-east-1 # Default: us-east-1 +# # use_ssl: true # Default: true +# # part_size: 5MB # Multipart upload part size. Default: 5MB + +# ─── OPTIONAL ──────────────────────────────────────────────────────────────── + +# Global exclude patterns applied to ALL snapshots. +# Snapshot-specific excludes are additive. +# exclude: +# - "*.log" +# - "*.tmp" +# - ".git" +# - "node_modules" + +# Average chunk size for content-defined chunking (FastCDC). +# Smaller = better deduplication but more metadata overhead. +# Accepts: 1MB, 10M, 64KB, etc. +# Default: 10MB +# chunk_size: 10MB + +# Maximum blob size before splitting into a new blob. +# Accepts: 1GB, 10G, 500MB, etc. +# Default: 10GB +# blob_size_limit: 10GB + +# Zstd compression level (1-19). Higher = better ratio but slower. +# Default: 3 +# compression_level: 3 + +# Hostname used in snapshot IDs. Default: system hostname. +# hostname: myserver + +# Path to the local SQLite index database. +# Default: ~/.local/share/berlin.sneak.app.vaultik/index.sqlite +# index_path: /path/to/index.sqlite +` + +// NewInitCommand creates the init command that writes a default config file. +func NewInitCommand() *cobra.Command { + cmd := &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. + +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).`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + path := configPathForInit() + + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("config file already exists: %s", path) + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("creating config directory %s: %w", dir, err) + } + + if err := os.WriteFile(path, []byte(defaultConfigTemplate), 0o600); err != nil { + return fmt.Errorf("writing config file: %w", err) + } + + fmt.Printf("Config written to %s\n", path) + fmt.Println("Edit it to set your age_recipients, snapshots, and storage_url.") + return nil + }, + } + + return cmd +} + +// configPathForInit returns the config path to write, checking --config flag, +// VAULTIK_CONFIG env, and the platform default. +func configPathForInit() string { + if rootFlags.ConfigPath != "" { + return rootFlags.ConfigPath + } + if envPath := os.Getenv("VAULTIK_CONFIG"); envPath != "" { + return envPath + } + return DefaultConfigPath() +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 774b7a3..48686c1 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -3,7 +3,9 @@ package cli import ( "fmt" "os" + "path/filepath" + "github.com/adrg/xdg" "github.com/spf13/cobra" ) @@ -32,13 +34,14 @@ on the source system.`, } // Add global flags - cmd.PersistentFlags().StringVar(&rootFlags.ConfigPath, "config", "", "Path to config file (default: $VAULTIK_CONFIG or /etc/vaultik/config.yml)") + cmd.PersistentFlags().StringVar(&rootFlags.ConfigPath, "config", "", "Path to config file (default: $VAULTIK_CONFIG or platform config dir)") 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") // Add subcommands cmd.AddCommand( + NewInitCommand(), NewRestoreCommand(), NewPruneCommand(), NewStoreCommand(), @@ -59,25 +62,41 @@ func GetRootFlags() RootFlags { } // ResolveConfigPath resolves the config file path from flags, environment, or default. -// It checks in order: 1) --config flag, 2) VAULTIK_CONFIG environment variable, -// 3) default location /etc/vaultik/config.yml. Returns an error if no valid -// config file can be found through any of these methods. +// Search order: --config flag, VAULTIK_CONFIG env, XDG config dir, /etc/vaultik/config.yml. func ResolveConfigPath() (string, error) { - // First check global flag if rootFlags.ConfigPath != "" { return rootFlags.ConfigPath, nil } - // Then check environment variable if envPath := os.Getenv("VAULTIK_CONFIG"); envPath != "" { return envPath, nil } - // Finally check default location - defaultPath := "/etc/vaultik/config.yml" - if _, err := os.Stat(defaultPath); err == nil { - return defaultPath, nil + for _, path := range defaultConfigPaths() { + if _, err := os.Stat(path); err == nil { + return path, nil + } } - return "", fmt.Errorf("no config file found; specify one with --config, set VAULTIK_CONFIG, or create %s", defaultPath) + return "", fmt.Errorf("no config file found; run 'vaultik init' to create one, or specify with --config") +} + +// defaultConfigPaths returns the ordered list of config paths to search. +// On macOS: ~/Library/Application Support/vaultik/config.yml +// On Linux: ~/.config/vaultik/config.yml +// Fallback: /etc/vaultik/config.yml +func defaultConfigPaths() []string { + return []string{ + filepath.Join(xdg.ConfigHome, "vaultik", "config.yml"), + "/etc/vaultik/config.yml", + } +} + +// DefaultConfigPath returns the platform-appropriate default config path. +// Used by the init command and in help text. +func DefaultConfigPath() string { + if os.Getuid() == 0 { + return "/etc/vaultik/config.yml" + } + return filepath.Join(xdg.ConfigHome, "vaultik", "config.yml") }