agent/secrets: separate admin slot with backwards-compatible decode

Split the on-disk bundle into repo + admin slots. Legacy flat Repo blobs
are detected at load time by the presence of "repo_url" at the top level
and transparently promoted into the new shape on the next Save/SaveAdmin.
Adds ErrNoAdmin sentinel, LoadAdmin, SaveAdmin, and three new tests.
This commit is contained in:
2026-05-03 22:34:33 +01:00
parent c9be9040d9
commit 212fd3e400
2 changed files with 224 additions and 15 deletions
+86 -15
View File
@@ -9,6 +9,7 @@
package secrets
import (
"bytes"
"encoding/json"
"errors"
"fmt"
@@ -24,6 +25,11 @@ import (
// depth — the key is per-host today, but cheap to be careful.)
const additionalData = "rm-agent-repo-creds-v1"
// ErrNoAdmin is returned by LoadAdmin when no admin slot has been
// written yet. Callers must distinguish this from a hard error: the
// agent simply hasn't received an admin config.update push yet.
var ErrNoAdmin = errors.New("secrets: admin slot not configured")
// Repo is the plaintext shape persisted inside the AEAD blob.
type Repo struct {
URL string `json:"repo_url,omitempty"`
@@ -35,6 +41,15 @@ type Repo struct {
// minimum (URL + password) needed to run a backup.
func (r Repo) Empty() bool { return r.URL == "" || r.Password == "" }
// bundle is the on-disk JSON shape as of secrets v2. It holds the
// everyday repo slot and an optional admin slot (prune / unlock).
// Legacy files (pre-v2) contain a flat Repo object; loadBundle
// transparently upgrades those on the next Save.
type bundle struct {
Repo Repo `json:"repo,omitempty"`
Admin *Repo `json:"admin,omitempty"`
}
// Store reads and writes the encrypted secrets file at Path, sealed
// under the 32-byte key Key.
type Store struct {
@@ -55,32 +70,47 @@ func New(path string, key []byte) (*Store, error) {
return &Store{path: path, a: a}, nil
}
// Load returns the persisted Repo, or a zero-value Repo (with no
// error) if the file does not exist yet — first-run agents have
// nothing on disk until the server pushes a config.update.
func (s *Store) Load() (Repo, error) {
// loadBundle reads and decrypts the on-disk blob, returning a bundle.
// It handles back-compat decode: legacy flat Repo blobs are detected
// by the presence of a top-level "repo_url" key and re-wrapped into
// the bundle shape transparently. Returns an empty bundle when the
// file does not exist yet.
func (s *Store) loadBundle() (bundle, error) {
body, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return Repo{}, nil
return bundle{}, nil
}
return Repo{}, fmt.Errorf("secrets: read %q: %w", s.path, err)
return bundle{}, fmt.Errorf("secrets: read %q: %w", s.path, err)
}
plain, err := s.a.Decrypt(string(body), []byte(additionalData))
if err != nil {
return Repo{}, fmt.Errorf("secrets: decrypt %q: %w", s.path, err)
return bundle{}, fmt.Errorf("secrets: decrypt %q: %w", s.path, err)
}
var r Repo
if err := json.Unmarshal(plain, &r); err != nil {
return Repo{}, fmt.Errorf("secrets: parse %q: %w", s.path, err)
// Try the new bundle shape first.
var b bundle
if err := json.Unmarshal(plain, &b); err != nil {
return bundle{}, fmt.Errorf("secrets: parse %q: %w", s.path, err)
}
return r, nil
// If the bundle has an empty Repo slot but the raw JSON contains
// a top-level "repo_url" key, this is a legacy flat blob —
// re-unmarshal it as a Repo and slot it in.
if b.Repo == (Repo{}) && bytes.Contains(plain, []byte(`"repo_url"`)) {
var legacy Repo
if err := json.Unmarshal(plain, &legacy); err == nil {
b.Repo = legacy
}
}
return b, nil
}
// Save replaces the on-disk blob atomically. Mode is 0600. Parent
// directory must already exist (the install script lays it down).
func (s *Store) Save(r Repo) error {
body, err := json.Marshal(r)
// saveBundle marshals b, encrypts it and writes it atomically at
// mode 0600. Parent directory must already exist.
func (s *Store) saveBundle(b bundle) error {
body, err := json.Marshal(b)
if err != nil {
return fmt.Errorf("secrets: marshal: %w", err)
}
@@ -115,3 +145,44 @@ func (s *Store) Save(r Repo) error {
}
return nil
}
// Load returns the persisted Repo (the everyday repo slot), or a
// zero-value Repo (with no error) if the file does not exist yet —
// first-run agents have nothing on disk until the server pushes a
// config.update.
func (s *Store) Load() (Repo, error) {
b, err := s.loadBundle()
if err != nil {
return Repo{}, err
}
return b.Repo, nil
}
// Save replaces the repo slot on disk atomically, preserving the
// admin slot. Mode is 0600. Parent directory must already exist.
func (s *Store) Save(r Repo) error {
b, _ := s.loadBundle() // ignore read errors; we overwrite repo slot
b.Repo = r
return s.saveBundle(b)
}
// LoadAdmin returns the admin slot, or (Repo{}, ErrNoAdmin) when no
// admin slot has been set. All other errors are hard failures.
func (s *Store) LoadAdmin() (Repo, error) {
b, err := s.loadBundle()
if err != nil {
return Repo{}, err
}
if b.Admin == nil {
return Repo{}, ErrNoAdmin
}
return *b.Admin, nil
}
// SaveAdmin replaces the admin slot on disk atomically, preserving
// the repo slot. Mode is 0600.
func (s *Store) SaveAdmin(r Repo) error {
b, _ := s.loadBundle() // ignore read errors; we overwrite admin slot
b.Admin = &r
return s.saveBundle(b)
}