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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user