backupy-agent/apps/agent/internal/config/config.go
TronoSfera 8b0c978337 feat(initial): Backupy agent + backupy-decrypt CLI
Source ports from the TronoSfera/backupy-cloud monorepo:
- apps/agent/        — Go agent (WSS client, persistent queue, Docker
                       discovery, 5 DB drivers: PG/MySQL/Mongo/Redis/SQLite,
                       pre/post hooks, Prometheus metrics)
- apps/backupy-decrypt/ — standalone CLI for client-side decryption
- packages/proto/    — protobuf wire format (generated .pb.go committed
                       so the repo builds without protoc)
- docs/              — agent spec + wire-protocol contract

Apache-2.0 license. Image published to ghcr.io/tronosfera/backupy-agent
on every v* tag via .github/workflows/release.yml (multi-arch amd64+arm64).
2026-05-17 20:22:35 +03:00

125 lines
4.4 KiB
Go

// Package config loads agent configuration from environment variables.
//
// The bootstrap surface is intentionally tiny — per docs/03-agent-spec.md
// the only required inputs are the server URL and the agent key. Everything
// else (targets, schedules, S3 creds, etc.) arrives from the server via
// ConfigUpdate after the WSS handshake.
//
// Secrets are tagged `json:"-"` so they never leak through structured
// logging or `agent dump-state` output.
package config
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"github.com/caarlos0/env/v11"
)
// agentKeyPattern enforces the documented BACKUP_AGENT_KEY format
// `bkpy_(live|test)_<32 base62 chars>`. The server issues keys in
// this exact shape — see docs/03-agent-spec.md and server task A-09.
var agentKeyPattern = regexp.MustCompile(`^bkpy_(live|test)_[A-Za-z0-9]{32}$`)
// Config holds all agent bootstrap configuration.
type Config struct {
ServerURL string `env:"BACKUP_SERVER_URL,required" envDefault:"https://api.backupy.ru"`
AgentKey string `env:"BACKUP_AGENT_KEY,required" json:"-"`
StateDir string `env:"BACKUP_STATE_DIR" envDefault:"/var/lib/backup-agent"`
LogLevel string `env:"BACKUP_LOG_LEVEL" envDefault:"info"`
DockerSocket string `env:"BACKUP_DOCKER_SOCKET" envDefault:"/var/run/docker.sock"`
// DevAllowInsecure relaxes the https:// requirement on ServerURL.
// Intended for local development against a plaintext server only.
DevAllowInsecure bool `env:"BACKUP_DEV_ALLOW_INSECURE" envDefault:"false"`
// MetricsListenAddr is the bind address for the Prometheus
// `/metrics` endpoint (D-19). Default is loopback only —
// 127.0.0.1:9090. Set to empty to disable the metrics server.
// SECURITY: never bind to 0.0.0.0 in production; the endpoint
// reveals job IDs and run cadence usable for host fingerprinting.
MetricsListenAddr string `env:"BACKUP_METRICS_LISTEN_ADDR" envDefault:"127.0.0.1:9090"`
}
// Load parses environment variables into a Config and validates them.
// Missing required vars or malformed values cause Load to return an error
// describing the problem; nothing about secret values is included.
func Load() (*Config, error) {
cfg := &Config{}
if err := env.Parse(cfg); err != nil {
return nil, fmt.Errorf("config: parse env: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
// Validate enforces the documented constraints on each field.
//
// - ServerURL must parse as an https:// URL (http:// only with
// BACKUP_DEV_ALLOW_INSECURE=true).
// - AgentKey must match the canonical `bkpy_(live|test)_…` pattern.
// - StateDir must be writable; we test by creating and removing a temp
// file so a misconfigured volume mount fails fast at startup.
func (c *Config) Validate() error {
if err := validateServerURL(c.ServerURL, c.DevAllowInsecure); err != nil {
return err
}
if !agentKeyPattern.MatchString(c.AgentKey) {
return errors.New("config: BACKUP_AGENT_KEY has invalid format; expected bkpy_(live|test)_<32 alnum>")
}
if err := validateStateDirWritable(c.StateDir); err != nil {
return err
}
return nil
}
func validateServerURL(raw string, allowInsecure bool) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("config: BACKUP_SERVER_URL is not a valid URL: %w", err)
}
if u.Host == "" {
return errors.New("config: BACKUP_SERVER_URL is missing host")
}
switch u.Scheme {
case "https":
return nil
case "http":
if !allowInsecure {
return errors.New("config: BACKUP_SERVER_URL must use https:// (set BACKUP_DEV_ALLOW_INSECURE=true for local dev)")
}
return nil
default:
return fmt.Errorf("config: BACKUP_SERVER_URL has unsupported scheme %q (expected https)", u.Scheme)
}
}
func validateStateDirWritable(dir string) error {
if dir == "" {
return errors.New("config: BACKUP_STATE_DIR must not be empty")
}
// Ensure the directory exists; create it (and parents) if missing.
// 0o700 — only the agent UID should ever touch state.
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("config: cannot create state dir %q: %w", dir, err)
}
probe, err := os.CreateTemp(dir, ".writable-probe-*")
if err != nil {
return fmt.Errorf("config: state dir %q is not writable: %w", dir, err)
}
name := probe.Name()
_ = probe.Close()
_ = os.Remove(name)
return nil
}
// StateDBPath returns the absolute path to the BoltDB state file.
func (c *Config) StateDBPath() string {
return filepath.Join(c.StateDir, "state.db")
}