Files
restic-manager/internal/agent/config/config.go
T
steve a3a53e3b87 agent: P2-18c announce-and-approve enrolment path
When -enroll-server is supplied without -enroll-token, the agent
mints (and persists) an Ed25519 keypair, POSTs /api/agents/announce,
prints the SHA256 fingerprint in a copy-friendly banner, opens
/ws/agent/pending, signs the server's nonce, and blocks until the
admin clicks Accept (1h ceiling). On accept, persists the bearer +
host_id from the 'enrolled' message; on reject (close code 4001)
exits with a clear error.

Repo creds are pushed via config.update on the first standard WS
hello (P1-32 path), not in the enrolled message itself.
2026-05-04 11:09:47 +01:00

191 lines
6.3 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"`
// AnnounceKey is the base64-encoded Ed25519 private key used by
// announce-and-approve enrolment (P2-18). Generated on first
// announce, persisted so the agent can re-attach to the same
// pending row across restarts. 64 bytes when decoded.
// Empty for token-flow enrolments.
AnnounceKey string `yaml:"announce_key,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 != ""
}