a3a53e3b87
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.
191 lines
6.3 KiB
Go
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 != ""
|
|
}
|