f0dfa689fe
Three small follow-ups from review:
1. Restore target is now operator-editable. Default value is the
literal '\$HOME/rm-restore/<job-id>/' (agent expands \$HOME at
run time using os.UserHomeDir(); also handles \${HOME} and ~/
prefixes). Operator can replace with any absolute path.
- ui_restore.go validates the input is either absolute or starts
with one of the recognised prefixes; other env-var refs (\$PATH
etc.) are deliberately rejected so operator paths can't pick up
arbitrary agent env values.
- host_restore.html replaces the read-only mono-text display with
a real <input>; help text spells out that \$HOME resolves
agent-side and <job-id> is substituted on dispatch.
- install.sh + the systemd unit prep /root/rm-restore so the
default works under the sandbox: ReadWritePaths gains a soft
'-/root/rm-restore' entry (the '-' makes the bind-mount soft-fail
if missing, but install.sh pre-creates it root-owned 0700).
2. --no-ownership flag now gated on restic version. The flag was
added in restic 0.17 and 0.16 rejects it. Previously dropped it
wholesale — that meant new-dir restores silently preserved
ownership against design intent on 0.17+. Now the agent threads
its detected restic version (sysinfo already collects it) through
runner.Config -> restic.Env, and RunRestore appends --no-ownership
only when AtLeastVersion(0, 17) returns true. 0.16 hosts still
restore with original uid/gid; help text in the wizard explicitly
notes this. The previous 'Original ownership is preserved' copy
was wrong for new-dir mode and is corrected.
3. golangci-lint misspell locale switched US -> UK and the codebase
swept (73 corrections, mostly behaviour/serialise/recognise/honour).
Wire-format ErrorCode 'unauthorized' -> 'unauthorised' is a tiny
contract change but the agent doesn't parse those codes today and
no external API consumers exist yet. Tests passed before + after.
Tests:
- internal/restic/version_test.go covers Env.AtLeastVersion across
edge cases (empty, exact match, patch above, minor below, non-
numeric) and expandHome on \$HOME / \${HOME} / ~/, plus
pass-through for absolute paths and refusal of other env vars.
- ui_restore_test updated: TargetDir now starts '\$HOME/rm-restore/'
with the job_id substituted into the placeholder.
Live verified on the smoke env: default target restored to
/root/rm-restore/<job-id>/ as the agent's expanded \$HOME (2 files,
14 bytes); custom override '/tmp/custom-restore/<job-id>/' restored
into the agent's PrivateTmp namespace (1 file, 6 bytes); both jobs
'succeeded', exit 0.
195 lines
6.0 KiB
Go
195 lines
6.0 KiB
Go
// Package secrets persists the agent's restic repo credentials in an
|
|
// AEAD-encrypted file. Phase 1 stores them at rest under a 32-byte
|
|
// key kept in agent.yaml (same 0600 root-only trust boundary as the
|
|
// bearer token); Phase 2 will swap that for the OS keyring on
|
|
// platforms that have one (DPAPI / Secret Service / kwallet).
|
|
//
|
|
// The wire push path (server → agent over WS as `config.update`) is
|
|
// unchanged; this package owns only on-disk persistence.
|
|
package secrets
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
|
|
)
|
|
|
|
// additionalData binds ciphertexts to the agent-secrets context, so a
|
|
// blob lifted from one role's file can't be replayed into another's
|
|
// row in some unrelated table that uses the same key. (Defence in
|
|
// 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"`
|
|
Username string `json:"repo_username,omitempty"`
|
|
Password string `json:"repo_password,omitempty"`
|
|
}
|
|
|
|
// Empty reports whether the credential set is missing the bare
|
|
// 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 {
|
|
path string
|
|
a *crypto.AEAD
|
|
}
|
|
|
|
// New opens a Store. The key must be exactly crypto.KeyLen bytes
|
|
// (32). The file at path is not read here — call Load.
|
|
func New(path string, key []byte) (*Store, error) {
|
|
if path == "" {
|
|
return nil, errors.New("secrets: empty path")
|
|
}
|
|
a, err := crypto.NewAEAD(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("secrets: %w", err)
|
|
}
|
|
return &Store{path: path, a: a}, nil
|
|
}
|
|
|
|
// 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 bundle{}, nil
|
|
}
|
|
return bundle{}, fmt.Errorf("secrets: read %q: %w", s.path, err)
|
|
}
|
|
plain, err := s.a.Decrypt(string(body), []byte(additionalData))
|
|
if err != nil {
|
|
return bundle{}, fmt.Errorf("secrets: decrypt %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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
ct, err := s.a.Encrypt(body, []byte(additionalData))
|
|
if err != nil {
|
|
return fmt.Errorf("secrets: encrypt: %w", err)
|
|
}
|
|
dir := filepath.Dir(s.path)
|
|
tmp, err := os.CreateTemp(dir, ".secrets-*.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("secrets: create tmp: %w", err)
|
|
}
|
|
tmpPath := tmp.Name()
|
|
defer func() {
|
|
_ = tmp.Close()
|
|
_ = os.Remove(tmpPath) // no-op once Rename succeeds
|
|
}()
|
|
if err := tmp.Chmod(0o600); err != nil {
|
|
return fmt.Errorf("secrets: chmod tmp: %w", err)
|
|
}
|
|
if _, err := tmp.WriteString(ct); err != nil {
|
|
return fmt.Errorf("secrets: write tmp: %w", err)
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
return fmt.Errorf("secrets: fsync tmp: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("secrets: close tmp: %w", err)
|
|
}
|
|
if err := os.Rename(tmpPath, s.path); err != nil {
|
|
return fmt.Errorf("secrets: rename: %w", err)
|
|
}
|
|
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, err := s.loadBundle()
|
|
if err != nil {
|
|
return fmt.Errorf("secrets: load before save: %w", err)
|
|
}
|
|
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, err := s.loadBundle()
|
|
if err != nil {
|
|
return fmt.Errorf("secrets: load before save: %w", err)
|
|
}
|
|
b.Admin = &r
|
|
return s.saveBundle(b)
|
|
}
|