Files
restic-manager/internal/agent/config/config.go
T
steve 27086783da P1-33: agent-side encrypted secrets store + push-on-update
New internal/agent/secrets package: AEAD blob at
/var/lib/restic-manager/secrets.enc, atomic write (os.CreateTemp +
Sync + Rename), 0600. Key lives in agent.yaml as base64
(SecretsKey) — same trust boundary as the bearer token, minted on
first start via EnsureSecretsKey.

cmd/agent: dispatcher reads creds fresh from secrets.Load() on
each job rather than from in-memory config. config.update merges
the push with what's on disk and persists, so a daemon restart
keeps the latest values. Legacy plaintext repo_url/repo_password
in agent.yaml are silently migrated into secrets.enc on next start
and stripped from the YAML on the following save.

Tests: round-trip + wrong-key rejection + atomic-write
post-condition for secrets; key idempotence + legacy-field
parse/clear for config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:41:28 +01:00

184 lines
5.9 KiB
Go

// 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 <path>.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 != ""
}