// Package config loads the agent's persistent configuration. After // enrollment, the file holds the bearer token + server URL; it is // only ever written via Save (which replaces atomically). // // Restic repo credentials live in a separate AEAD-encrypted file // (see internal/agent/secrets) and never appear in this config. package config import ( "crypto/rand" "encoding/base64" "fmt" "io" "os" "path/filepath" "gopkg.in/yaml.v3" "gitea.dcglab.co.uk/steve/restic-manager/internal/crypto" ) // Config is the on-disk shape of the agent's config file. type Config struct { // ServerURL is the base URL of the control plane, e.g. // https://restic.lab.example. The agent appends /ws/agent and // /api/agents/enroll. ServerURL string `yaml:"server_url"` // AgentToken is the bearer credential issued at enrollment. // Empty means "not yet enrolled." AgentToken string `yaml:"agent_token"` // HostID is what the server thinks this host is. HostID string `yaml:"host_id"` // CertPinSHA256 (optional) is the SHA-256 of the server's TLS // cert. When set, the agent refuses to connect to a server // whose cert hash doesn't match. CertPinSHA256 string `yaml:"cert_pin_sha256,omitempty"` // ResticPath overrides the auto-detected restic binary path. ResticPath string `yaml:"restic_path,omitempty"` // SecretsPath is the on-disk location of the AEAD-encrypted // repo-creds blob. Defaults to /var/lib/restic-manager/secrets.enc // when empty. Phase 1 trust boundary: this file is 0600 owned by // the agent service user; the SecretsKey below sits in this same // agent.yaml (also 0600), so anyone who can read one can read the // other. Phase 2 swaps SecretsKey for an OS-keyring lookup. SecretsPath string `yaml:"secrets_path,omitempty"` // SecretsKey is the base64-encoded 32-byte AEAD master key for // the secrets file. Minted on first Save if absent. SecretsKey string `yaml:"secrets_key,omitempty"` // LegacyRepoURL / LegacyRepoPassword: remnants of the pre-P1-33 // world where repo creds lived in plaintext here. Load() pulls // them off (so a config written by an older agent still works on // upgrade), the agent migrates them into secrets.enc on next // start, and Save() writes them out as empty (yamls "omitempty") // — i.e. the next file on disk no longer contains them. LegacyRepoURL string `yaml:"repo_url,omitempty"` LegacyRepoPassword string `yaml:"repo_password,omitempty"` // path is the file we loaded from. Used by Save. path string `yaml:"-"` } // DefaultSecretsPath is where the encrypted secrets blob lives on a // stock Linux install. Override via Config.SecretsPath. const DefaultSecretsPath = "/var/lib/restic-manager/secrets.enc" // ResolvedSecretsPath returns SecretsPath if set, otherwise the // default for the current OS. func (c *Config) ResolvedSecretsPath() string { if c.SecretsPath != "" { return c.SecretsPath } return DefaultSecretsPath } // EnsureSecretsKey mints a fresh 32-byte AEAD key into c.SecretsKey // if one isn't already set. Caller must Save() afterwards. Idempotent. func (c *Config) EnsureSecretsKey() error { if c.SecretsKey != "" { return nil } raw := make([]byte, crypto.KeyLen) if _, err := io.ReadFull(rand.Reader, raw); err != nil { return fmt.Errorf("agent config: mint secrets key: %w", err) } c.SecretsKey = base64.StdEncoding.EncodeToString(raw) return nil } // SecretsKeyBytes returns the decoded AEAD key, or an error if the // key is unset/malformed. func (c *Config) SecretsKeyBytes() ([]byte, error) { if c.SecretsKey == "" { return nil, fmt.Errorf("agent config: secrets_key is empty") } b, err := base64.StdEncoding.DecodeString(c.SecretsKey) if err != nil { return nil, fmt.Errorf("agent config: decode secrets_key: %w", err) } if len(b) != crypto.KeyLen { return nil, fmt.Errorf("agent config: secrets_key must decode to %d bytes, got %d", crypto.KeyLen, len(b)) } return b, nil } // DefaultPath returns the canonical config path for the current OS. // Phase 1 ships Linux only; Windows path lives in the spec for P2. func DefaultPath() string { return "/etc/restic-manager/agent.yaml" } // Load reads and parses the config file at path. A missing file is // returned as an empty Config (not an error) — first-run agents // haven't been enrolled yet. func Load(path string) (*Config, error) { c := &Config{path: path} body, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return c, nil } return nil, fmt.Errorf("agent config: read %q: %w", path, err) } if err := yaml.Unmarshal(body, c); err != nil { return nil, fmt.Errorf("agent config: parse %q: %w", path, err) } c.path = path return c, nil } // Save writes the config back atomically: write to .tmp, fsync, // rename. A crash mid-write either leaves the old file or the new one, // never a half-written one. func (c *Config) Save() error { if c.path == "" { return fmt.Errorf("agent config: no path set") } dir := filepath.Dir(c.path) if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("agent config: mkdir %q: %w", dir, err) } body, err := yaml.Marshal(c) if err != nil { return fmt.Errorf("agent config: marshal: %w", err) } tmp := c.path + ".tmp" f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return fmt.Errorf("agent config: create tmp: %w", err) } if _, err := f.Write(body); err != nil { _ = f.Close() _ = os.Remove(tmp) return fmt.Errorf("agent config: write tmp: %w", err) } if err := f.Sync(); err != nil { _ = f.Close() _ = os.Remove(tmp) return fmt.Errorf("agent config: fsync tmp: %w", err) } if err := f.Close(); err != nil { _ = os.Remove(tmp) return fmt.Errorf("agent config: close tmp: %w", err) } if err := os.Rename(tmp, c.path); err != nil { _ = os.Remove(tmp) return fmt.Errorf("agent config: rename: %w", err) } return nil } // Enrolled reports whether the agent has finished enrollment. func (c *Config) Enrolled() bool { return c.AgentToken != "" && c.HostID != "" && c.ServerURL != "" }