v0.91.0 — the DR tier can no longer be applied and dead at the same time (R-39 + R-50b(a))

Closes the agent half of R-39's fleet fix. Requires hub >=0.68.0 for the re-arm signal;
that hub is safe for 0.90.0 agents (unknown key dropped), so it deploys first.

Three compounding defects let a box report `applied` while every PBS request 401'd:

1. The re-key was INVISIBLE. An ep0 re-issue rotates the secret of an existing token, so
   token_id/fingerprint/datastore/namespace come back byte-identical and the descriptor
   content hash never moved — the converged agent short-circuited and never consumed the
   fresh secret. WirePBSDR.SecretGeneration (field-exact with the hub) is what moves the
   hash now, because descriptorHash marshals this struct.

2. The agent could not READ its own credential. It writes /etc/pve/priv/storage/<id>.pw
   through the root wrapper, but that dir is 0700 root:www-data and the wrapper had no
   read verb — so the target resolver got "permission denied" every cycle, warned, and
   skipped. The one loop that could have caught the 401 was blind BY CONSTRUCTION. Adds a
   narrow `read` verb (+ exactly one sudoers line, + a pbsdr-read capability row): one
   secret to stdout, no network, no mutation, never in argv (sudo logs argv), traversal
   refused by the id grammar, the dir allowlist AND a resolved-path prefix assertion.

3. Nothing probed AUTHENTICATION. pbs.ProbeAuth (GET /version + an ErrUnauthorized
   sentinel) runs on the 15-minute collect path and its verdict becomes a loud
   `auth_failed` the hub escalates to a fresh mint. /version needs no datastore, namespace
   or privilege, so a 401 means the CREDENTIAL is bad; 403 is deliberately NOT treated as
   unauthorized, since re-keying a too-narrow token would mint forever without fixing
   anything. A transport error is UNKNOWN, never a rejection — otherwise every network
   blip burns a credential. Recovery self-clears.

R-50b(a): the report now carries the installed wrapper's sha256 so drift against the
vouched manifest value is answerable. Empty = unknown, never drift.

Three red-proofs, all at the assertion level. Removing SecretGeneration fails the re-arm
test with "consume calls=1, want 2". Swallowing the probe result leaves State:applied
AuthFailed:false — the July-18 shape exactly. Notably, deleting the wrapper's id charset
guard alone does NOT open a traversal hole (readlink + the prefix assertion still catch
it), so the isolating red-proof removes BOTH and shows the out-of-tree secret printed —
the layering is real, and a single-guard red-proof would have passed vacuously.
This commit is contained in:
2026-07-21 10:12:31 +02:00
parent 8c55ac7fda
commit b2ca63ee9f
14 changed files with 784 additions and 8 deletions
+46 -2
View File
@@ -19,6 +19,7 @@ import (
"net/http"
"net/netip"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
@@ -1054,6 +1055,49 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
return 0
}
// pbsSecretReader is the seam that reads a PBS storage's token secret (R-39 leg b). Overridable in
// tests; production is readPBSSecretViaWrapper.
var pbsSecretReader = readPBSSecretViaWrapper
// readPBSSecret reads a storage's token secret, preferring a directly-readable file and falling back
// to the root wrapper.
//
// WHY THE FALLBACK EXISTS. The agent runs NON-ROOT and writes /etc/pve/priv/storage/<id>.pw through
// the root wrapper — but /etc/pve/priv is 0700 root:www-data, so it could never read that file back.
// The old code called readTrimmed on it directly, got "permission denied" every cycle, logged a Warn
// and SKIPPED the datastore. That is why the 401 in R-39 went unnoticed for weeks: the one loop that
// could have caught it was blind by construction, not by accident.
//
// The direct read is kept first because a box configured with its own agent-owned secret dir
// (place_copies puts a 0600 felhom-agent copy there) needs no sudo at all; the wrapper is the path
// for the default PRIVDIR case.
func readPBSSecret(ctx context.Context, cfg config.Config, storageID string) (string, error) {
path := cfg.Backup.PBSSecretPath(storageID)
if secret, err := readTrimmed(path); err == nil && secret != "" {
return secret, nil
}
return pbsSecretReader(ctx, cfg, storageID)
}
// readPBSSecretViaWrapper shells the root wrapper's `read` verb. The secret arrives on STDOUT and is
// never passed through argv (sudo logs argv) and never logged.
func readPBSSecretViaWrapper(ctx context.Context, cfg config.Config, storageID string) (string, error) {
dir := filepath.Dir(cfg.Backup.PBSSecretPath(storageID))
cmd := exec.CommandContext(ctx, "sudo", "-n", pbsdr.WrapperPath, "read", storageID, dir)
var out, errb bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
// The wrapper's refusal text is safe to surface (it never echoes the value).
return "", fmt.Errorf("wrapper read %s: %w: %s", storageID, err, strings.TrimSpace(errb.String()))
}
secret := strings.TrimSpace(out.String())
if secret == "" {
return "", fmt.Errorf("wrapper read %s: empty secret", storageID)
}
return secret, nil
}
// pbsTargetsFromPVE returns a pbs.Targets closure that, each cycle, discovers the pbs
// storages from the PVE config and builds a fingerprint-pinned, token-authed client for each
// (token id from the storage `username`, secret read from <PBSSecretDir>/<id>.pw). A storage
@@ -1070,7 +1114,7 @@ func pbsTargetsFromPVE(cfg config.Config, px *proxmox.Client, logger *slog.Logge
if s.Type != "pbs" {
continue
}
secret, err := readTrimmed(cfg.Backup.PBSSecretPath(s.Storage))
secret, err := readPBSSecret(ctx, cfg, s.Storage)
if err != nil {
logger.Warn("pbs: cannot read token secret; skipping datastore", "storage", s.Storage, "err", err)
continue
@@ -1080,7 +1124,7 @@ func pbsTargetsFromPVE(cfg config.Config, px *proxmox.Client, logger *slog.Logge
logger.Warn("pbs: cannot build client; skipping datastore", "storage", s.Storage, "err", err)
continue
}
targets = append(targets, pbs.Target{Datastore: s.Datastore, Client: c})
targets = append(targets, pbs.Target{Datastore: s.Datastore, Client: c, StorageID: s.Storage})
}
return targets, nil
}