The config command group manages the config file: config init - write default config (moved from top-level init) config edit - open the config in $EDITOR (falls back to vi) config get - print a value by dotted YAML path (s3.bucket) config set - set a scalar value by dotted YAML path get/set operate on the yaml.Node tree so comments and formatting in the config file are preserved across edits. set creates intermediate maps as needed.
479 lines
14 KiB
Go
479 lines
14 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
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.
|
|
#
|
|
# Exclude pattern semantics:
|
|
# - Patterns starting with / are anchored to the snapshot path root
|
|
# (e.g. "/Library/Caches" matches only ~/Library/Caches in a ~ snapshot)
|
|
# - Patterns without a leading / match anywhere in the tree
|
|
# (e.g. ".cache" matches any directory named .cache at any depth)
|
|
# - Globs are supported: *, **, ?
|
|
snapshots:
|
|
home:
|
|
paths:
|
|
- "~"
|
|
exclude:
|
|
# Trash, temp, and filesystem metadata
|
|
- "/.Trash"
|
|
- "/.Trashes"
|
|
- "/.fseventsd"
|
|
- "/.Spotlight-V100"
|
|
- "/.TemporaryItems"
|
|
- "/tmp"
|
|
- "/.rnd"
|
|
- ".DS_Store"
|
|
# Caches and package manager state (rebuildable)
|
|
- ".cache"
|
|
- ".bundle"
|
|
- "/.cpan/build"
|
|
- "/.cpan/sources"
|
|
- "/.gradle/caches"
|
|
- "/.dropbox"
|
|
- "/.minikube/cache"
|
|
- "/.local/share/containers/podman/machine"
|
|
- "/.persepolis"
|
|
- "/Library/Caches"
|
|
- "/Library/Logs"
|
|
- "/Library/Cookies"
|
|
- "/Library/Metadata"
|
|
- "/Library/Suggestions"
|
|
- "/Library/PubSub"
|
|
- "/Library/Homebrew"
|
|
- "/Library/Developer"
|
|
- "/Library/Google/GoogleSoftwareUpdate"
|
|
- "/Library/Preferences/Macromedia/Flash Player"
|
|
- "/Library/Preferences/SDMHelpData"
|
|
- "/Library/VoiceTrigger/SAT"
|
|
# Language/toolchain package caches (rebuildable from registries)
|
|
- "/.npm"
|
|
- "/.cargo/registry"
|
|
- "/.cargo/git"
|
|
- "/.rustup/toolchains"
|
|
- "/go/pkg/mod"
|
|
- "/.m2/repository"
|
|
- "/.vagrant.d/boxes"
|
|
- "node_modules"
|
|
- "__pycache__"
|
|
- ".venv"
|
|
# Virtual machine disk images (huge; remove these lines to back them up)
|
|
- "/Parallels"
|
|
- "/Virtual Machines.localized"
|
|
- "/VirtualBox VMs"
|
|
- "/.orbstack"
|
|
- "/Library/Containers/com.utmapp.UTM"
|
|
# Downloaded LLM models (huge, re-downloadable)
|
|
- "/.ollama/models"
|
|
- "/.lmstudio/models"
|
|
# Cloud-synced storage. These are synced to a provider already, and on
|
|
# modern macOS may contain dataless placeholder files that the backup
|
|
# would force-download in full.
|
|
- "/Library/CloudStorage"
|
|
- "/Library/Mobile Documents"
|
|
# Android SDK and emulator images (re-downloadable)
|
|
- "/Library/Android/sdk"
|
|
- "/.android/avd"
|
|
# Cloud-synced or restorable-from-server data
|
|
- "/Library/Mail"
|
|
- "/Library/Mail Downloads"
|
|
- "/Library/Safari"
|
|
- "/Library/Application Support/Evernote"
|
|
- "/Library/Application Support/MobileSync"
|
|
- "/Library/Application Support/SyncServices"
|
|
- "/Library/Application Support/protonmail/bridge/cache"
|
|
- "/Library/Application Support/Syncthing/index-*"
|
|
- "/Library/Syncthing/folders"
|
|
- "/Documents/Dropbox/.dropbox.cache"
|
|
# Large rebuildable app data (games, media caches, device backups)
|
|
- "/Applications/Fortnite"
|
|
- "/Documents/Steam Content"
|
|
- "/Library/Application Support/Ableton"
|
|
- "/Library/Application Support/CrossOver Games"
|
|
- "/Library/Application Support/SecondLife/cache"
|
|
- "/Library/Application Support/Steam/SteamApps"
|
|
- "/Library/Containers/com.docker.docker"
|
|
- "/Library/Group Containers/group.com.apple.secure-control-center-preferences"
|
|
- "/Library/iTunes/iPad Software Updates"
|
|
- "/Library/iTunes/iPhone Software Updates"
|
|
- "/Movies/CacheClip"
|
|
- "/Movies/ProxyMedia"
|
|
- "/Music/iTunes/Album Artwork"
|
|
- "/Pictures/iPod Photo Cache"
|
|
|
|
# Third-party applications. OS-provided apps live in /System/Applications
|
|
# on modern macOS and are never in /Applications, but Apple-installed
|
|
# App Store apps (Safari, GarageBand, iWork, iMovie) are excluded since
|
|
# they are re-downloadable.
|
|
apps:
|
|
paths:
|
|
- /Applications
|
|
exclude:
|
|
- ".DS_Store"
|
|
- "/Safari.app"
|
|
- "/GarageBand.app"
|
|
- "/iMovie.app"
|
|
- "/Keynote.app"
|
|
- "/Numbers.app"
|
|
- "/Pages.app"
|
|
- "/Xcode.app"
|
|
- "/Spotify.app"
|
|
- "/Steam.app"
|
|
- "/VirtualBox.app"
|
|
- "/Utilities/Adobe Installers"
|
|
|
|
# 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
|
|
`
|
|
|
|
// NewConfigCommand creates the config command group.
|
|
func NewConfigCommand() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "config",
|
|
Short: "Manage the configuration file",
|
|
Long: "Commands for creating, editing, and querying the vaultik config file.",
|
|
}
|
|
|
|
cmd.AddCommand(newConfigInitCommand())
|
|
cmd.AddCommand(newConfigEditCommand())
|
|
cmd.AddCommand(newConfigGetCommand())
|
|
cmd.AddCommand(newConfigSetCommand())
|
|
|
|
return cmd
|
|
}
|
|
|
|
// newConfigInitCommand creates the 'config init' subcommand.
|
|
func newConfigInitCommand() *cobra.Command {
|
|
return &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
|
|
},
|
|
}
|
|
}
|
|
|
|
// newConfigEditCommand creates the 'config edit' subcommand.
|
|
func newConfigEditCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "edit",
|
|
Short: "Open the config file in $EDITOR",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
path, err := ResolveConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
editor := os.Getenv("EDITOR")
|
|
if editor == "" {
|
|
editor = "vi"
|
|
}
|
|
|
|
ed := exec.Command(editor, path)
|
|
ed.Stdin = os.Stdin
|
|
ed.Stdout = os.Stdout
|
|
ed.Stderr = os.Stderr
|
|
return ed.Run()
|
|
},
|
|
}
|
|
}
|
|
|
|
// newConfigGetCommand creates the 'config get' subcommand.
|
|
func newConfigGetCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "get <key>",
|
|
Short: "Print a config value by dotted path (e.g. s3.bucket)",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
path, err := ResolveConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
root, err := loadYAMLFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
node, err := yamlPathGet(root, strings.Split(args[0], "."))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if node.Kind == yaml.ScalarNode {
|
|
fmt.Println(node.Value)
|
|
return nil
|
|
}
|
|
|
|
out, err := yaml.Marshal(node)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling value: %w", err)
|
|
}
|
|
fmt.Print(string(out))
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// newConfigSetCommand creates the 'config set' subcommand.
|
|
func newConfigSetCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "set <key> <value>",
|
|
Short: "Set a config value by dotted path (e.g. compression_level 5)",
|
|
Long: `Sets a scalar config value addressed by dotted YAML path and writes
|
|
the file back, preserving comments and formatting. Intermediate maps
|
|
are created as needed.
|
|
|
|
Examples:
|
|
vaultik config set compression_level 9
|
|
vaultik config set s3.bucket mybucket
|
|
vaultik config set storage_url "file:///mnt/backups"`,
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
path, err := ResolveConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
root, err := loadYAMLFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := yamlPathSet(root, strings.Split(args[0], "."), args[1]); err != nil {
|
|
return err
|
|
}
|
|
|
|
out, err := yaml.Marshal(root)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling config: %w", err)
|
|
}
|
|
|
|
mode := os.FileMode(0o600)
|
|
if info, err := os.Stat(path); err == nil {
|
|
mode = info.Mode().Perm()
|
|
}
|
|
|
|
if err := os.WriteFile(path, out, mode); err != nil {
|
|
return fmt.Errorf("writing config file: %w", err)
|
|
}
|
|
|
|
fmt.Printf("%s = %s\n", args[0], args[1])
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// loadYAMLFile parses a YAML file into a yaml.Node document tree,
|
|
// which preserves comments and ordering for round-tripping.
|
|
func loadYAMLFile(path string) (*yaml.Node, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading config file: %w", err)
|
|
}
|
|
|
|
var root yaml.Node
|
|
if err := yaml.Unmarshal(data, &root); err != nil {
|
|
return nil, fmt.Errorf("parsing config file: %w", err)
|
|
}
|
|
|
|
// An empty file yields a zero node; normalize to an empty mapping document.
|
|
if root.Kind == 0 {
|
|
root = yaml.Node{
|
|
Kind: yaml.DocumentNode,
|
|
Content: []*yaml.Node{{Kind: yaml.MappingNode}},
|
|
}
|
|
}
|
|
|
|
return &root, nil
|
|
}
|
|
|
|
// yamlPathGet navigates a dotted key path through mapping nodes and
|
|
// returns the value node.
|
|
func yamlPathGet(root *yaml.Node, keys []string) (*yaml.Node, error) {
|
|
node := root
|
|
if node.Kind == yaml.DocumentNode {
|
|
if len(node.Content) == 0 {
|
|
return nil, fmt.Errorf("empty config file")
|
|
}
|
|
node = node.Content[0]
|
|
}
|
|
|
|
for i, key := range keys {
|
|
if node.Kind != yaml.MappingNode {
|
|
return nil, fmt.Errorf("key %q is not a map", strings.Join(keys[:i], "."))
|
|
}
|
|
found := false
|
|
for j := 0; j+1 < len(node.Content); j += 2 {
|
|
if node.Content[j].Value == key {
|
|
node = node.Content[j+1]
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, fmt.Errorf("key not found: %s", strings.Join(keys[:i+1], "."))
|
|
}
|
|
}
|
|
|
|
return node, nil
|
|
}
|
|
|
|
// yamlPathSet navigates a dotted key path, creating intermediate maps as
|
|
// needed, and sets the final key to the given scalar value.
|
|
func yamlPathSet(root *yaml.Node, keys []string, value string) error {
|
|
node := root
|
|
if node.Kind == yaml.DocumentNode {
|
|
if len(node.Content) == 0 {
|
|
node.Content = []*yaml.Node{{Kind: yaml.MappingNode}}
|
|
}
|
|
node = node.Content[0]
|
|
}
|
|
|
|
for i, key := range keys {
|
|
if node.Kind != yaml.MappingNode {
|
|
return fmt.Errorf("key %q is not a map", strings.Join(keys[:i], "."))
|
|
}
|
|
|
|
last := i == len(keys)-1
|
|
|
|
var valueNode *yaml.Node
|
|
for j := 0; j+1 < len(node.Content); j += 2 {
|
|
if node.Content[j].Value == key {
|
|
valueNode = node.Content[j+1]
|
|
break
|
|
}
|
|
}
|
|
|
|
if valueNode == nil {
|
|
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key}
|
|
valueNode = &yaml.Node{Kind: yaml.MappingNode}
|
|
if last {
|
|
valueNode = &yaml.Node{Kind: yaml.ScalarNode, Value: value}
|
|
}
|
|
node.Content = append(node.Content, keyNode, valueNode)
|
|
} else if last {
|
|
valueNode.Kind = yaml.ScalarNode
|
|
valueNode.Tag = ""
|
|
valueNode.Value = value
|
|
valueNode.Content = nil
|
|
valueNode.Style = 0
|
|
}
|
|
|
|
node = valueNode
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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()
|
|
}
|