All checks were successful
check / check (push) Successful in 5s
Updates golangci-lint to v2.12.2 everywhere it is pinned and installs the canonical `.golangci.yml`, then remediates every finding the new linter/config surfaces so `make check` is green. ## Version bump - `Dockerfile` lint stage: `golangci/golangci-lint:v2.11.3-alpine` -> `v2.12.2-alpine` (digest-pinned, date comment updated) - `Makefile` `deps` target: `go install` moved from the old v1 module path at `@latest` to the pinned `github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2` - `.golangci.yml` replaced with the canonical config (v2 schema; settings under `linters.settings` so the thresholds actually apply; `default: all` with the standard six disables) - `script/bootstrap` installs golangci-lint via the system package manager and carries no version pin, so it is unchanged - CI (`.gitea/workflows/check.yml`) only runs `script/cibuild`, so it needed no change ## Lint remediation The canonical config surfaced ~3,300 findings across 56k lines. All are fixed, behavior-preserving; incorporates and supersedes the per-package mechanical passes already merged to `main` (refs #61). Highlights: - `err113`: dynamic errors replaced with package sentinels + `%w` wrapping; comparisons via `errors.Is` - `goprintffuncname`: printf-style helpers renamed with an `f` suffix (`ui.Writer` message methods, `cli.ReportErrorf`, `database.Fatalf`) and all call sites updated - `revive` stutter renames: `blob.Handler`, `blob.WithReader`, `blob.ChunkPosition`, `storage.URL`, `storage.Info`; missing doc comments added - `contextcheck`/`noctx`: `context.Context` threaded through `blob.Packer` and the scanner call sites; context-aware `exec`/`sql` variants - `funlen`/`cyclop`/`gocognit`/`dupl`: oversized and duplicated functions split into focused helpers (production and test code) - tests: `t.Parallel()` added where safe (global logger init kept in the serial phase for `-race`), `t.TempDir()`/`t.Helper()` adopted, several suites converted to external test packages - `gosec`: bounded integer conversions, `ReadHeaderTimeout` on the test HTTP server; remaining warnings suppressed per-site with justifications - remaining `nolint` directives are rare, targeted, and each carries a reason (e.g. `nilnil` not-found contract in the repository layer, fx module globals, on-disk snake_case struct tags) - removed the deprecated `log.LogOptions` alias (callers migrated to `log.Options`) `make check` (tests with `-race`, lint, fmt-check) passes. Co-authored-by: sneak <sneak@sneak.berlin> Reviewed-on: #62 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
411 lines
12 KiB
Go
411 lines
12 KiB
Go
// Package config loads, validates, and provides the vaultik YAML
|
|
// configuration, including snapshot definitions, encryption recipients,
|
|
// and storage settings.
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"filippo.io/age"
|
|
"git.eeqj.de/sneak/smartconfig"
|
|
"github.com/adrg/xdg"
|
|
"go.uber.org/fx"
|
|
"gopkg.in/yaml.v3"
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
)
|
|
|
|
const appName = "vaultik"
|
|
|
|
// Defaults and validation bounds for tunable settings.
|
|
const (
|
|
defaultBlobSizeLimit = Size(10 * 1024 * 1024 * 1024) // 10GB
|
|
defaultChunkSize = Size(10 * 1024 * 1024) // 10MB
|
|
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MB
|
|
defaultCompressionLevel = 3
|
|
minChunkSize = 1024 * 1024 // 1MB
|
|
minCompressionLevel = 1
|
|
maxCompressionLevel = 19
|
|
)
|
|
|
|
// Sentinel validation errors.
|
|
var (
|
|
errNoConfigPath = errors.New("config path not provided")
|
|
errNoAgeRecipients = errors.New(
|
|
"at least one age_recipient is required (generate with: age-keygen)")
|
|
errNoSnapshots = errors.New(
|
|
"at least one snapshot must be configured (see config.example.yml)")
|
|
errSnapshotNoPaths = errors.New("snapshot must have at least one path")
|
|
errChunkSizeTooSmall = errors.New("chunk_size must be at least 1MB")
|
|
errBlobSizeTooSmall = errors.New("blob_size_limit must be at least chunk_size")
|
|
errBadCompression = errors.New("compression_level must be between 1 and 19")
|
|
errBadStorageScheme = errors.New(
|
|
"storage_url must start with s3://, file://, or rclone://")
|
|
errStorageNotConfigured = errors.New(
|
|
"storage not configured; set storage_url or provide s3.endpoint + " +
|
|
"s3.bucket + credentials")
|
|
errS3BucketRequired = errors.New("s3.bucket is required (or set storage_url)")
|
|
errS3KeyIDRequired = errors.New("s3.access_key_id is required")
|
|
errS3SecretRequired = errors.New("s3.secret_access_key is required")
|
|
)
|
|
|
|
// 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.
|
|
//
|
|
//nolint:tagliatelle // snake_case is the established config-file format
|
|
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 holds global excludes applied to all snapshots.
|
|
Exclude []string `yaml:"exclude"`
|
|
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.
|
|
//
|
|
//nolint:tagliatelle // snake_case is the established config-file format
|
|
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"`
|
|
}
|
|
|
|
// Path 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 Path 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 Path) (*Config, error) {
|
|
if path == "" {
|
|
return nil, errNoConfigPath
|
|
}
|
|
|
|
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: defaultBlobSizeLimit,
|
|
ChunkSize: defaultChunkSize,
|
|
IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"),
|
|
CompressionLevel: defaultCompressionLevel,
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
err = yaml.Unmarshal(yamlBytes, cfg)
|
|
if 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 = defaultS3PartSize
|
|
}
|
|
|
|
// Check config file permissions (warn if world or group readable)
|
|
//nolint:gosec // G703: config path is operator-supplied by design
|
|
info, statErr := os.Stat(path)
|
|
if statErr == 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)
|
|
}
|
|
}
|
|
|
|
err = cfg.Validate()
|
|
if 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 errNoAgeRecipients
|
|
}
|
|
|
|
if len(c.Snapshots) == 0 {
|
|
return errNoSnapshots
|
|
}
|
|
|
|
for name, snap := range c.Snapshots {
|
|
if len(snap.Paths) == 0 {
|
|
return fmt.Errorf("%w: %q", errSnapshotNoPaths, name)
|
|
}
|
|
}
|
|
|
|
// Validate storage configuration
|
|
err := c.validateStorage()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if c.ChunkSize.Int64() < minChunkSize {
|
|
return errChunkSizeTooSmall
|
|
}
|
|
|
|
if c.BlobSizeLimit.Int64() < c.ChunkSize.Int64() {
|
|
return errBlobSizeTooSmall
|
|
}
|
|
|
|
if c.CompressionLevel < minCompressionLevel ||
|
|
c.CompressionLevel > maxCompressionLevel {
|
|
return errBadCompression
|
|
}
|
|
|
|
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 != "" {
|
|
return c.validateStorageURL()
|
|
}
|
|
|
|
// Legacy S3 configuration
|
|
if c.S3.Endpoint == "" {
|
|
return errStorageNotConfigured
|
|
}
|
|
|
|
if c.S3.Bucket == "" {
|
|
return errS3BucketRequired
|
|
}
|
|
|
|
if c.S3.AccessKeyID == "" {
|
|
return errS3KeyIDRequired
|
|
}
|
|
|
|
if c.S3.SecretAccessKey == "" {
|
|
return errS3SecretRequired
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateStorageURL validates URL-based storage configuration. File and
|
|
// rclone URLs need no credentials; S3 URLs require the legacy s3.*
|
|
// credential fields.
|
|
func (c *Config) validateStorageURL() error {
|
|
switch {
|
|
case strings.HasPrefix(c.StorageURL, "file://"):
|
|
// File storage doesn't need S3 credentials
|
|
return nil
|
|
case strings.HasPrefix(c.StorageURL, "rclone://"):
|
|
// Rclone storage uses rclone's own config
|
|
return nil
|
|
case strings.HasPrefix(c.StorageURL, "s3://"):
|
|
// S3 storage needs credentials
|
|
if c.S3.AccessKeyID == "" {
|
|
return fmt.Errorf("%w for s3:// URLs", errS3KeyIDRequired)
|
|
}
|
|
|
|
if c.S3.SecretAccessKey == "" {
|
|
return fmt.Errorf("%w for s3:// URLs", errS3SecretRequired)
|
|
}
|
|
|
|
return nil
|
|
default:
|
|
return errBadStorageScheme
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
//nolint:gochecknoglobals // fx module definitions are package globals
|
|
var Module = fx.Module("config",
|
|
fx.Provide(New),
|
|
)
|