All checks were successful
check / check (push) Successful in 2m38s
Scanner now writes all user-facing output to an io.Writer (os.Stdout when progress is enabled, io.Discard in --cron mode). This fixes the long-standing issue where --cron still printed progress lines. S3 HeadObject now properly distinguishes not-found from other errors instead of swallowing all errors as not-found. Config/CLI error messages include actionable hints (where to find the config, how to generate keys, what storage options exist).
340 lines
11 KiB
Go
340 lines
11 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"filippo.io/age"
|
|
"git.eeqj.de/sneak/smartconfig"
|
|
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
"github.com/adrg/xdg"
|
|
"go.uber.org/fx"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const appName = "berlin.sneak.app.vaultik"
|
|
|
|
// expandTilde expands ~ at the start of a path to the user's home directory.
|
|
func expandTilde(path string) string {
|
|
if path == "~" {
|
|
home, _ := os.UserHomeDir()
|
|
return home
|
|
}
|
|
if strings.HasPrefix(path, "~/") {
|
|
home, _ := os.UserHomeDir()
|
|
return filepath.Join(home, path[2:])
|
|
}
|
|
return path
|
|
}
|
|
|
|
// expandTildeInURL expands ~ in file:// URLs.
|
|
func expandTildeInURL(url string) string {
|
|
if strings.HasPrefix(url, "file://~/") {
|
|
home, _ := os.UserHomeDir()
|
|
return "file://" + filepath.Join(home, url[9:])
|
|
}
|
|
return url
|
|
}
|
|
|
|
// SnapshotConfig represents configuration for a named snapshot.
|
|
// Each snapshot backs up one or more paths and can have its own exclude patterns
|
|
// in addition to the global excludes.
|
|
type SnapshotConfig struct {
|
|
Paths []string `yaml:"paths"`
|
|
Exclude []string `yaml:"exclude"` // Additional excludes for this snapshot
|
|
}
|
|
|
|
// GetExcludes returns the combined exclude patterns for a named snapshot.
|
|
// It merges global excludes with the snapshot-specific excludes.
|
|
func (c *Config) GetExcludes(snapshotName string) []string {
|
|
snap, ok := c.Snapshots[snapshotName]
|
|
if !ok {
|
|
return c.Exclude
|
|
}
|
|
|
|
if len(snap.Exclude) == 0 {
|
|
return c.Exclude
|
|
}
|
|
|
|
// Combine global and snapshot-specific excludes
|
|
combined := make([]string, 0, len(c.Exclude)+len(snap.Exclude))
|
|
combined = append(combined, c.Exclude...)
|
|
combined = append(combined, snap.Exclude...)
|
|
return combined
|
|
}
|
|
|
|
// SnapshotNames returns the names of all configured snapshots in sorted order.
|
|
func (c *Config) SnapshotNames() []string {
|
|
names := make([]string, 0, len(c.Snapshots))
|
|
for name := range c.Snapshots {
|
|
names = append(names, name)
|
|
}
|
|
// Sort for deterministic order
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
// Config represents the application configuration for Vaultik.
|
|
// It defines all settings for backup operations, including source directories,
|
|
// encryption recipients, storage configuration, and performance tuning parameters.
|
|
// Configuration is typically loaded from a YAML file.
|
|
type Config struct {
|
|
AgeRecipients []string `yaml:"age_recipients"`
|
|
AgeSecretKey string `yaml:"age_secret_key"`
|
|
BlobSizeLimit Size `yaml:"blob_size_limit"`
|
|
ChunkSize Size `yaml:"chunk_size"`
|
|
Exclude []string `yaml:"exclude"` // Global excludes applied to all snapshots
|
|
Hostname string `yaml:"hostname"`
|
|
IndexPath string `yaml:"index_path"`
|
|
S3 S3Config `yaml:"s3"`
|
|
Snapshots map[string]SnapshotConfig `yaml:"snapshots"`
|
|
CompressionLevel int `yaml:"compression_level"`
|
|
|
|
// StorageURL specifies the storage backend using a URL format.
|
|
// Takes precedence over S3Config if set.
|
|
// Supported formats:
|
|
// - s3://bucket/prefix?endpoint=host®ion=us-east-1
|
|
// - file:///path/to/backup
|
|
// For S3 URLs, credentials are still read from s3.access_key_id and s3.secret_access_key.
|
|
StorageURL string `yaml:"storage_url"`
|
|
}
|
|
|
|
// S3Config represents S3 storage configuration for backup storage.
|
|
// It supports both AWS S3 and S3-compatible storage services.
|
|
// All fields except UseSSL and PartSize are required.
|
|
type S3Config struct {
|
|
Endpoint string `yaml:"endpoint"`
|
|
Bucket string `yaml:"bucket"`
|
|
Prefix string `yaml:"prefix"`
|
|
AccessKeyID string `yaml:"access_key_id"`
|
|
SecretAccessKey string `yaml:"secret_access_key"`
|
|
Region string `yaml:"region"`
|
|
UseSSL bool `yaml:"use_ssl"`
|
|
PartSize Size `yaml:"part_size"`
|
|
}
|
|
|
|
// ConfigPath wraps the config file path for fx dependency injection.
|
|
// This type allows the config file path to be injected as a distinct type
|
|
// rather than a plain string, avoiding conflicts with other string dependencies.
|
|
type ConfigPath string
|
|
|
|
// New creates a new Config instance by loading from the specified path.
|
|
// This function is used by the fx dependency injection framework.
|
|
// Returns an error if the path is empty or if loading fails.
|
|
func New(path ConfigPath) (*Config, error) {
|
|
if path == "" {
|
|
return nil, fmt.Errorf("config path not provided")
|
|
}
|
|
|
|
cfg, err := Load(string(path))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load config: %w", err)
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// Load reads and parses the configuration file from the specified path.
|
|
// It applies default values for optional fields, performs environment variable
|
|
// substitution using smartconfig, and validates the configuration.
|
|
// The configuration file should be in YAML format. Returns an error if the file
|
|
// cannot be read, parsed, or if validation fails.
|
|
func Load(path string) (*Config, error) {
|
|
// Load config using smartconfig for interpolation
|
|
sc, err := smartconfig.NewFromConfigPath(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load config file: %w", err)
|
|
}
|
|
|
|
cfg := &Config{
|
|
// Set defaults
|
|
BlobSizeLimit: Size(10 * 1024 * 1024 * 1024), // 10GB
|
|
ChunkSize: Size(10 * 1024 * 1024), // 10MB
|
|
IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"),
|
|
CompressionLevel: 3,
|
|
}
|
|
|
|
// Convert smartconfig data to YAML then unmarshal
|
|
configData := sc.Data()
|
|
yamlBytes, err := yaml.Marshal(configData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal config data: %w", err)
|
|
}
|
|
|
|
if err := yaml.Unmarshal(yamlBytes, cfg); err != nil {
|
|
return nil, fmt.Errorf("failed to parse config: %w", err)
|
|
}
|
|
|
|
// Expand tilde in all path fields
|
|
cfg.IndexPath = expandTilde(cfg.IndexPath)
|
|
cfg.StorageURL = expandTildeInURL(cfg.StorageURL)
|
|
|
|
// Expand tildes in snapshot paths
|
|
for name, snap := range cfg.Snapshots {
|
|
for i, path := range snap.Paths {
|
|
snap.Paths[i] = expandTilde(path)
|
|
}
|
|
cfg.Snapshots[name] = snap
|
|
}
|
|
|
|
// Check for environment variable override for IndexPath
|
|
if envIndexPath := os.Getenv("VAULTIK_INDEX_PATH"); envIndexPath != "" {
|
|
cfg.IndexPath = expandTilde(envIndexPath)
|
|
}
|
|
|
|
// Check for environment variable override for AgeSecretKey
|
|
if envAgeSecretKey := os.Getenv("VAULTIK_AGE_SECRET_KEY"); envAgeSecretKey != "" {
|
|
cfg.AgeSecretKey = extractAgeSecretKey(envAgeSecretKey)
|
|
}
|
|
|
|
// Get hostname if not set
|
|
if cfg.Hostname == "" {
|
|
hostname, err := os.Hostname()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get hostname: %w", err)
|
|
}
|
|
cfg.Hostname = hostname
|
|
}
|
|
|
|
// Set default S3 settings
|
|
if cfg.S3.Region == "" {
|
|
cfg.S3.Region = "us-east-1"
|
|
}
|
|
if cfg.S3.PartSize == 0 {
|
|
cfg.S3.PartSize = Size(5 * 1024 * 1024) // 5MB
|
|
}
|
|
|
|
// Check config file permissions (warn if world or group readable)
|
|
if info, err := os.Stat(path); err == nil {
|
|
mode := info.Mode().Perm()
|
|
if mode&0044 != 0 { // group or world readable
|
|
log.Warn("Config file has insecure permissions (contains S3 credentials)",
|
|
"path", path,
|
|
"mode", fmt.Sprintf("%04o", mode),
|
|
"recommendation", "chmod 600 "+path)
|
|
}
|
|
}
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, fmt.Errorf("invalid config: %w", err)
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// 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
|
|
// - 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
|
|
// - Blob size limit must be at least the chunk size
|
|
// - Compression level must be between 1 and 19
|
|
// Returns an error describing the first validation failure encountered.
|
|
func (c *Config) Validate() error {
|
|
if len(c.AgeRecipients) == 0 {
|
|
return fmt.Errorf("at least one age_recipient is required (generate with: age-keygen)")
|
|
}
|
|
|
|
if len(c.Snapshots) == 0 {
|
|
return fmt.Errorf("at least one snapshot must be configured (see config.example.yml)")
|
|
}
|
|
|
|
for name, snap := range c.Snapshots {
|
|
if len(snap.Paths) == 0 {
|
|
return fmt.Errorf("snapshot %q must have at least one path", name)
|
|
}
|
|
}
|
|
|
|
// Validate storage configuration
|
|
if err := c.validateStorage(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if c.ChunkSize.Int64() < 1024*1024 { // 1MB minimum
|
|
return fmt.Errorf("chunk_size must be at least 1MB")
|
|
}
|
|
|
|
if c.BlobSizeLimit.Int64() < c.ChunkSize.Int64() {
|
|
return fmt.Errorf("blob_size_limit must be at least chunk_size")
|
|
}
|
|
|
|
if c.CompressionLevel < 1 || c.CompressionLevel > 19 {
|
|
return fmt.Errorf("compression_level must be between 1 and 19")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateStorage validates storage configuration.
|
|
// If StorageURL is set, it takes precedence. S3 URLs require credentials.
|
|
// File URLs don't require any S3 configuration.
|
|
// If StorageURL is not set, legacy S3 configuration is required.
|
|
func (c *Config) validateStorage() error {
|
|
if c.StorageURL != "" {
|
|
// URL-based configuration
|
|
if strings.HasPrefix(c.StorageURL, "file://") {
|
|
// File storage doesn't need S3 credentials
|
|
return nil
|
|
}
|
|
if strings.HasPrefix(c.StorageURL, "s3://") {
|
|
// S3 storage needs credentials
|
|
if c.S3.AccessKeyID == "" {
|
|
return fmt.Errorf("s3.access_key_id is required for s3:// URLs")
|
|
}
|
|
if c.S3.SecretAccessKey == "" {
|
|
return fmt.Errorf("s3.secret_access_key is required for s3:// URLs")
|
|
}
|
|
return nil
|
|
}
|
|
if strings.HasPrefix(c.StorageURL, "rclone://") {
|
|
// Rclone storage uses rclone's own config
|
|
return nil
|
|
}
|
|
return fmt.Errorf("storage_url must start with s3://, file://, or rclone://")
|
|
}
|
|
|
|
// Legacy S3 configuration
|
|
if c.S3.Endpoint == "" {
|
|
return fmt.Errorf("storage not configured; set storage_url or provide s3.endpoint + s3.bucket + credentials")
|
|
}
|
|
|
|
if c.S3.Bucket == "" {
|
|
return fmt.Errorf("s3.bucket is required (or set storage_url)")
|
|
}
|
|
|
|
if c.S3.AccessKeyID == "" {
|
|
return fmt.Errorf("s3.access_key_id is required")
|
|
}
|
|
|
|
if c.S3.SecretAccessKey == "" {
|
|
return fmt.Errorf("s3.secret_access_key is required")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// extractAgeSecretKey extracts the AGE-SECRET-KEY from the input using
|
|
// the age library's parser, which handles comments and whitespace.
|
|
func extractAgeSecretKey(input string) string {
|
|
identities, err := age.ParseIdentities(strings.NewReader(input))
|
|
if err != nil || len(identities) == 0 {
|
|
// Fall back to trimmed input if parsing fails
|
|
return strings.TrimSpace(input)
|
|
}
|
|
// Return the string representation of the first identity
|
|
if id, ok := identities[0].(*age.X25519Identity); ok {
|
|
return id.String()
|
|
}
|
|
return strings.TrimSpace(input)
|
|
}
|
|
|
|
// Module exports the config module for fx dependency injection.
|
|
// It provides the Config type to other modules in the application.
|
|
var Module = fx.Module("config",
|
|
fx.Provide(New),
|
|
)
|