b2ca63ee9f
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.
203 lines
8.3 KiB
Go
203 lines
8.3 KiB
Go
package capability
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// fakeRunner returns a canned error per (command line) and records calls. deny holds binaries (or
|
|
// the bare "sudo -n -l" preflight) that should fail; everything else exits 0.
|
|
type fakeRunner struct {
|
|
preflightErr error
|
|
denyBinary map[string]bool // binary path → policy-denied
|
|
calls int
|
|
executedReal bool // set if a probed command was ever run WITHOUT -l (must never happen)
|
|
}
|
|
|
|
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
|
f.calls++
|
|
// Preflight is `sudo -n -l` (exactly 2 args, no `--`).
|
|
if name == "sudo" && len(args) == 2 && args[0] == "-n" && args[1] == "-l" {
|
|
return nil, nil, f.preflightErr
|
|
}
|
|
// Every real probe must be a LIST: `sudo -n -l -- <binary> …`.
|
|
if name != "sudo" || len(args) < 4 || args[0] != "-n" || args[1] != "-l" || args[2] != "--" {
|
|
f.executedReal = true
|
|
return nil, nil, nil
|
|
}
|
|
binary := args[3]
|
|
if f.denyBinary[binary] {
|
|
return nil, nil, errors.New("sudo: a password is required")
|
|
}
|
|
return nil, nil, nil
|
|
}
|
|
|
|
func find(statuses []Status, name string) Status {
|
|
for _, s := range statuses {
|
|
if s.Name == name {
|
|
return s
|
|
}
|
|
}
|
|
return Status{}
|
|
}
|
|
|
|
// §7-A: all grants present + binaries exist → every capability ok.
|
|
func TestProbe_AllOK(t *testing.T) {
|
|
r := &fakeRunner{denyBinary: map[string]bool{}}
|
|
p := Prober{Runner: r, Exists: func(string) bool { return true }}
|
|
statuses := p.Probe(context.Background())
|
|
ok, total, degraded := Summarize(statuses)
|
|
if total != len(Manifest()) {
|
|
t.Fatalf("total=%d want %d", total, len(Manifest()))
|
|
}
|
|
if ok != total || len(degraded) != 0 {
|
|
t.Fatalf("expected all ok, got %d/%d (degraded: %+v)", ok, total, degraded)
|
|
}
|
|
if r.executedReal {
|
|
t.Fatal("probe executed a command without -l (must be list-only)")
|
|
}
|
|
}
|
|
|
|
// §7-B: one grant denied → that capability degraded "sudo policy denied", others ok. Serve-degraded.
|
|
func TestProbe_OneDenied(t *testing.T) {
|
|
r := &fakeRunner{denyBinary: map[string]bool{"/usr/bin/lxc-info": true}}
|
|
p := Prober{Runner: r, Exists: func(string) bool { return true }}
|
|
statuses := p.Probe(context.Background())
|
|
|
|
gi := find(statuses, "guest-init-pid")
|
|
if gi.Status != StatusDegraded || gi.Reason != "sudo policy denied" {
|
|
t.Fatalf("guest-init-pid = %+v, want degraded/sudo policy denied", gi)
|
|
}
|
|
if !gi.Critical {
|
|
t.Fatal("guest-init-pid should be Critical")
|
|
}
|
|
// A sibling stays ok.
|
|
if s := find(statuses, "drive-bind"); s.Status != StatusOK {
|
|
t.Fatalf("drive-bind = %+v, want ok", s)
|
|
}
|
|
ok, total, _ := Summarize(statuses)
|
|
if ok != total-1 {
|
|
t.Fatalf("expected exactly one degraded, got ok=%d total=%d", ok, total)
|
|
}
|
|
}
|
|
|
|
// §7-D: binary missing but policy granted → degraded "binary not found".
|
|
func TestProbe_BinaryMissing(t *testing.T) {
|
|
r := &fakeRunner{denyBinary: map[string]bool{}}
|
|
p := Prober{Runner: r, Exists: func(path string) bool { return path != "/usr/bin/lxc-info" }}
|
|
statuses := p.Probe(context.Background())
|
|
gi := find(statuses, "guest-init-pid")
|
|
if gi.Status != StatusDegraded || gi.Reason != "binary not found" {
|
|
t.Fatalf("guest-init-pid = %+v, want degraded/binary not found", gi)
|
|
}
|
|
}
|
|
|
|
// §8 aggregate: sudo itself unavailable for the user → ONE aggregate degraded, not N.
|
|
func TestProbe_SudoUnavailableAggregates(t *testing.T) {
|
|
r := &fakeRunner{preflightErr: errors.New("Sorry, user felhom-agent may not run sudo"), denyBinary: map[string]bool{}}
|
|
p := Prober{Runner: r, Exists: func(string) bool { return true }}
|
|
statuses := p.Probe(context.Background())
|
|
if len(statuses) != 1 {
|
|
t.Fatalf("expected 1 aggregate status, got %d", len(statuses))
|
|
}
|
|
s := statuses[0]
|
|
if s.Name != "sudo" || s.Status != StatusDegraded || !s.Critical || !strings.Contains(s.Reason, "drop-in not installed") {
|
|
t.Fatalf("aggregate = %+v, want critical degraded sudo-unavailable", s)
|
|
}
|
|
}
|
|
|
|
// Probe must never raise — even with a nil runner (e.g. a dev path) it returns statuses.
|
|
func TestProbe_NilRunnerNoPanic(t *testing.T) {
|
|
p := Prober{Runner: nil, Exists: func(string) bool { return true }}
|
|
if got := len(p.Probe(context.Background())); got != len(Manifest()) {
|
|
t.Fatalf("nil-runner probe returned %d statuses, want %d", got, len(Manifest()))
|
|
}
|
|
}
|
|
|
|
// ── DR-tier gate (v0.86.0) ─────────────────────────────────────────────────────────────────────
|
|
|
|
// Gate OFF + healthy plumbing → the gated pbsdr-* capabilities report INACTIVE (the neutral
|
|
// "disabled by configuration" state), NOT ok and NOT degraded — Scenario B of the DR-by-default
|
|
// spec. Ungated siblings are untouched. Red-proof partner: collapse inactive into ok (drop the
|
|
// gate branch in Probe) → this fails while TestProbe_GateOffBinaryMissingStaysDegraded passes.
|
|
func TestProbe_GateOffHealthyIsInactive(t *testing.T) {
|
|
r := &fakeRunner{denyBinary: map[string]bool{}}
|
|
p := Prober{
|
|
Runner: r,
|
|
Exists: func(string) bool { return true },
|
|
GateActive: func(gate string) bool { return gate != GatePBSDR }, // DR tier OFF
|
|
}
|
|
statuses := p.Probe(context.Background())
|
|
// v0.88.0: escrow-ceremony joins the gate EXPLICITLY (non-pbsdr name, GatedBy literal) —
|
|
// the ceremony only exists behind the DR tier (no PBS key, no ceremony).
|
|
// v0.91.0: pbsdr-read (the R-39 credential-read verb) rides the same `pbsdr-` prefix gate — a new
|
|
// pbsdr-* op is gated by construction, which is exactly the property this list is here to hold.
|
|
for _, name := range []string{"pbsdr-create", "pbsdr-reconcile", "pbsdr-grant", "pbsdr-read", "escrow-ceremony"} {
|
|
s := find(statuses, name)
|
|
if s.Status != StatusInactive || s.Reason != ReasonInactive {
|
|
t.Fatalf("%s = %+v, want inactive/%q", name, s, ReasonInactive)
|
|
}
|
|
}
|
|
// An ungated sibling stays plain ok.
|
|
if s := find(statuses, "drive-bind"); s.Status != StatusOK {
|
|
t.Fatalf("drive-bind = %+v, want ok (ungated)", s)
|
|
}
|
|
// Summarize must NOT count inactive as degraded (it is not error-log-worthy).
|
|
ok, total, degraded := Summarize(statuses)
|
|
if len(degraded) != 0 {
|
|
t.Fatalf("inactive leaked into degraded: %+v", degraded)
|
|
}
|
|
if ok != total-5 {
|
|
t.Fatalf("ok=%d total=%d, want exactly the 5 gated ones non-ok", ok, total)
|
|
}
|
|
}
|
|
|
|
// Gate OFF + BROKEN plumbing (binary missing) → DEGRADED stays degraded. An un-migrated
|
|
// pre-v1.15.0 box must never masquerade as deliberately disabled ("never silently pretend").
|
|
func TestProbe_GateOffBinaryMissingStaysDegraded(t *testing.T) {
|
|
r := &fakeRunner{denyBinary: map[string]bool{}}
|
|
p := Prober{
|
|
Runner: r,
|
|
Exists: func(path string) bool { return path != "/usr/local/sbin/felhom-pbs-apply" },
|
|
GateActive: func(gate string) bool { return gate != GatePBSDR }, // DR tier OFF
|
|
}
|
|
statuses := p.Probe(context.Background())
|
|
for _, name := range []string{"pbsdr-create", "pbsdr-reconcile", "pbsdr-grant"} {
|
|
s := find(statuses, name)
|
|
if s.Status != StatusDegraded || s.Reason != "binary not found" {
|
|
t.Fatalf("%s = %+v, want degraded/binary not found even with the gate off", name, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Gate ON (DR configured) + healthy plumbing → plain ok, exactly the pre-v0.86.0 behavior.
|
|
// A nil GateActive must behave the same (fails ACTIVE).
|
|
func TestProbe_GateOnOrNilIsOK(t *testing.T) {
|
|
for _, gate := range []func(string) bool{nil, func(string) bool { return true }} {
|
|
r := &fakeRunner{denyBinary: map[string]bool{}}
|
|
p := Prober{Runner: r, Exists: func(string) bool { return true }, GateActive: gate}
|
|
statuses := p.Probe(context.Background())
|
|
if s := find(statuses, "pbsdr-create"); s.Status != StatusOK {
|
|
t.Fatalf("pbsdr-create = %+v, want ok (gate active/nil)", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The gate covers exactly the pbsdr-* entries (name-prefix mechanism) PLUS escrow-ceremony (an
|
|
// explicit GatedBy literal — v0.88.0: the ceremony only exists behind the DR tier, but its name
|
|
// says what the feature is). Nothing else may be gated (a regression here would silently un-gate
|
|
// the tier or gate an unrelated capability).
|
|
func TestManifest_ExactlyPBSDRGated(t *testing.T) {
|
|
for _, c := range Manifest() {
|
|
wantGated := strings.HasPrefix(c.Name, "pbsdr-") || c.Name == "escrow-ceremony"
|
|
if gated := c.GatedBy == GatePBSDR; gated != wantGated {
|
|
t.Fatalf("%s: GatedBy=%q, want gated=%v", c.Name, c.GatedBy, wantGated)
|
|
}
|
|
if c.GatedBy != "" && c.GatedBy != GatePBSDR {
|
|
t.Fatalf("%s: unknown gate id %q", c.Name, c.GatedBy)
|
|
}
|
|
}
|
|
}
|