// 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). package config import ( "fmt" "os" "path/filepath" "gopkg.in/yaml.v3" ) // 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"` // RepoURL + RepoPassword are the credentials this host uses to // reach its restic repository. Phase 1 keeps these in plaintext // in agent.yaml (mode 0600 owned by the agent service user); the // server-pushed config.update message can override them in // memory. Phase 2 moves them into the OS keyring (DPAPI on // Windows, Secret Service on Linux). RepoURL string `yaml:"repo_url,omitempty"` RepoPassword string `yaml:"repo_password,omitempty"` // path is the file we loaded from. Used by Save. path string `yaml:"-"` } // 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 != "" }