P1-33: agent-side encrypted secrets store + push-on-update
New internal/agent/secrets package: AEAD blob at /var/lib/restic-manager/secrets.enc, atomic write (os.CreateTemp + Sync + Rename), 0600. Key lives in agent.yaml as base64 (SecretsKey) — same trust boundary as the bearer token, minted on first start via EnsureSecretsKey. cmd/agent: dispatcher reads creds fresh from secrets.Load() on each job rather than from in-memory config. config.update merges the push with what's on disk and persists, so a daemon restart keeps the latest values. Legacy plaintext repo_url/repo_password in agent.yaml are silently migrated into secrets.enc on next start and stripped from the YAML on the following save. Tests: round-trip + wrong-key rejection + atomic-write post-condition for secrets; key idempotence + legacy-field parse/clear for config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+98
-21
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/agent/config"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/agent/runner"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/agent/secrets"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/agent/sysinfo"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/agent/wsclient"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
||||
@@ -76,6 +77,14 @@ func run() error {
|
||||
|
||||
resticBin, _ := restic.Locate(cfg.ResticPath) // empty is fine; commands fail with a clear error later
|
||||
|
||||
// Open the secrets store. If the agent is enrolled but has no
|
||||
// secrets key yet (legacy YAML), mint one and migrate any
|
||||
// plaintext repo fields into the encrypted blob.
|
||||
sec, err := openSecretsStore(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("secrets: %w", err)
|
||||
}
|
||||
|
||||
wsCfg := wsclient.Config{
|
||||
ServerURL: cfg.ServerURL,
|
||||
AgentToken: cfg.AgentToken,
|
||||
@@ -92,9 +101,8 @@ func run() error {
|
||||
}
|
||||
|
||||
d := &dispatcher{
|
||||
resticBin: resticBin,
|
||||
repoURL: cfg.RepoURL,
|
||||
repoPassword: cfg.RepoPassword,
|
||||
resticBin: resticBin,
|
||||
secrets: sec,
|
||||
}
|
||||
if err := wsclient.Run(ctx, wsCfg, d.handle); err != nil {
|
||||
return fmt.Errorf("ws run: %w", err)
|
||||
@@ -103,13 +111,61 @@ func run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// dispatcher closes over the long-lived agent settings (restic path,
|
||||
// repo creds) so handle() can spawn the runner without re-loading
|
||||
// config every time.
|
||||
// openSecretsStore opens (or one-time migrates) the agent's encrypted
|
||||
// secrets file. Side effects:
|
||||
// - mints SecretsKey if absent and persists agent.yaml.
|
||||
// - if legacy plaintext repo_url/repo_password sit in agent.yaml,
|
||||
// copies them into secrets.enc and clears the YAML fields on
|
||||
// the next save.
|
||||
func openSecretsStore(cfg *config.Config) (*secrets.Store, error) {
|
||||
if err := cfg.EnsureSecretsKey(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyBytes, err := cfg.SecretsKeyBytes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := secrets.New(cfg.ResolvedSecretsPath(), keyBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
migrated := false
|
||||
if cfg.LegacyRepoURL != "" || cfg.LegacyRepoPassword != "" {
|
||||
cur, _ := st.Load() // empty Repo on first run is fine
|
||||
if cur.URL == "" {
|
||||
cur.URL = cfg.LegacyRepoURL
|
||||
}
|
||||
if cur.Password == "" {
|
||||
cur.Password = cfg.LegacyRepoPassword
|
||||
}
|
||||
if err := st.Save(cur); err != nil {
|
||||
return nil, fmt.Errorf("migrate legacy creds into secrets.enc: %w", err)
|
||||
}
|
||||
cfg.LegacyRepoURL = ""
|
||||
cfg.LegacyRepoPassword = ""
|
||||
migrated = true
|
||||
slog.Info("agent: migrated legacy plaintext repo creds into secrets.enc")
|
||||
}
|
||||
|
||||
// Persist key (and the cleared legacy fields) regardless of
|
||||
// whether we migrated, in case we just minted SecretsKey.
|
||||
if migrated || cfg.SecretsKey != "" {
|
||||
if err := cfg.Save(); err != nil {
|
||||
return nil, fmt.Errorf("persist agent config: %w", err)
|
||||
}
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// dispatcher closes over the long-lived agent settings (restic path
|
||||
// + secrets handle) so handle() can spawn the runner without
|
||||
// re-loading config every time. Repo creds are read fresh from the
|
||||
// secrets store on each job — config.update writes through to disk,
|
||||
// so a job dispatched in the same session sees the latest values.
|
||||
type dispatcher struct {
|
||||
resticBin string
|
||||
repoURL string
|
||||
repoPassword string
|
||||
resticBin string
|
||||
secrets *secrets.Store
|
||||
}
|
||||
|
||||
func (d *dispatcher) handle(ctx context.Context, env api.Envelope, tx wsclient.Sender) error {
|
||||
@@ -132,15 +188,32 @@ func (d *dispatcher) handle(ctx context.Context, env api.Envelope, tx wsclient.S
|
||||
case api.MsgConfigUpdate:
|
||||
var p api.ConfigUpdatePayload
|
||||
_ = env.UnmarshalPayload(&p)
|
||||
// In-memory only for now — restart loses these. Persistent
|
||||
// secret storage lands with P2's keyring work.
|
||||
if p.RepoURL != "" {
|
||||
d.repoURL = p.RepoURL
|
||||
slog.Info("ws agent: repo URL updated via config.update")
|
||||
// Merge with whatever's already in secrets.enc — empty fields
|
||||
// in the push mean "leave alone." Atomic write underneath.
|
||||
cur, err := d.secrets.Load()
|
||||
if err != nil {
|
||||
slog.Error("ws agent: load secrets for merge", "err", err)
|
||||
return nil
|
||||
}
|
||||
if p.RepoPassword != "" {
|
||||
d.repoPassword = p.RepoPassword
|
||||
slog.Info("ws agent: repo password updated via config.update")
|
||||
changed := false
|
||||
if p.RepoURL != "" && p.RepoURL != cur.URL {
|
||||
cur.URL = p.RepoURL
|
||||
changed = true
|
||||
}
|
||||
if p.RepoUsername != "" && p.RepoUsername != cur.Username {
|
||||
cur.Username = p.RepoUsername
|
||||
changed = true
|
||||
}
|
||||
if p.RepoPassword != "" && p.RepoPassword != cur.Password {
|
||||
cur.Password = p.RepoPassword
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
if err := d.secrets.Save(cur); err != nil {
|
||||
slog.Error("ws agent: persist secrets", "err", err)
|
||||
return nil
|
||||
}
|
||||
slog.Info("ws agent: repo credentials updated via config.update")
|
||||
}
|
||||
|
||||
case api.MsgAgentUpdateAvail:
|
||||
@@ -160,13 +233,17 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc
|
||||
if d.resticBin == "" {
|
||||
return fmt.Errorf("restic binary not located on this agent")
|
||||
}
|
||||
if d.repoURL == "" || d.repoPassword == "" {
|
||||
return fmt.Errorf("repo credentials not configured (set repo_url + repo_password in agent.yaml or push via config.update)")
|
||||
creds, err := d.secrets.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load repo credentials: %w", err)
|
||||
}
|
||||
if creds.Empty() {
|
||||
return fmt.Errorf("repo credentials not configured (waiting for server config.update push)")
|
||||
}
|
||||
r := runner.New(runner.Config{
|
||||
ResticBin: d.resticBin,
|
||||
RepoURL: d.repoURL,
|
||||
RepoPassword: d.repoPassword,
|
||||
RepoURL: creds.URL,
|
||||
RepoPassword: creds.Password,
|
||||
}, tx, time.Second)
|
||||
|
||||
switch p.Kind {
|
||||
|
||||
Reference in New Issue
Block a user