diff --git a/internal/pvetrust/pvetrust.go b/internal/pvetrust/pvetrust.go index 4d0412a..3d927a6 100644 --- a/internal/pvetrust/pvetrust.go +++ b/internal/pvetrust/pvetrust.go @@ -247,11 +247,15 @@ func (m *Manager) Ensure(ctx context.Context, why string) bool { c, err := m.EnsureCopy(cctx) ccancel() if err != nil { - if rootStepTooOld(err) && cur.Fingerprint != "" { + m.mu.Lock() + recorded := m.status.Problem + m.mu.Unlock() + if rootStepTooOld(err) && cur.Fingerprint != "" && !isTrustCause(why) && !isTrustCause(recorded) { // The agent is newer than the box's root files: between the agent update and the root-files job the // wrapper does not know the step yet. The pinned certificate works; this is not a trust problem, and // naming one mailed the operator at every box's update (seen on Tester 1, 2026-11-15). The bundle - // install calls Ensure again. + // install calls Ensure again. NEVER on a run that a failed call started, and never over a recorded + // trust failure: then the pin does NOT work, and the box must say so (the commit's security review). lg.Info("pvetrust: the root files are older than this agent (no CA-copy step yet) — the pinned certificate stays; the next root-files update moves the box", "err", err) set(modeOf(cur), "", "", nil) return false @@ -419,5 +423,15 @@ func (m *Manager) writeConfig(caPath string) error { // R11 „unknown mode”): the box's root files are older than the agent. Pinned by // TestRootStepOlderThanTheAgent_IsNotATrustProblem. func rootStepTooOld(err error) bool { - return err != nil && strings.Contains(err.Error(), "unknown mode") + return err != nil && strings.Contains(err.Error(), `"R11"`) && strings.Contains(err.Error(), "unknown mode") +} + +// isTrustCause says whether s is one of the causes proxmox.TrustProblem names for a FAILED call. +func isTrustCause(s string) bool { + switch s { + case proxmox.TrustPinnedCertChanged, proxmox.TrustCAMismatch, proxmox.TrustCertExpired, + proxmox.TrustCAFileUnreadable, proxmox.TrustNameMismatch: + return true + } + return false } diff --git a/internal/pvetrust/pvetrust_test.go b/internal/pvetrust/pvetrust_test.go index 9bd3595..bc9c706 100644 --- a/internal/pvetrust/pvetrust_test.go +++ b/internal/pvetrust/pvetrust_test.go @@ -525,4 +525,42 @@ func TestRootStepOlderThanTheAgent_IsNotATrustProblem(t *testing.T) { if st := b3.m.Status(); st.Problem != ProblemCopyUnavailable { t.Fatalf("a real refusal lost its name: %+v", st) } + + // a refusal with ANOTHER code whose text happens to hold the words is still named: the code decides + b4 := newBox(t, srv, proxmox.TLSConfig{Fingerprint: fp(leaf)}, nil) + b4.m.EnsureCopy = func(context.Context) (Copy, error) { + return Copy{}, errors.New(`pve-ca-copy refused: {"code": "T3", "reason": "the source has an unknown mode"}`) + } + b4.m.Ensure(context.Background(), "start") + if st := b4.m.Status(); st.Problem != ProblemCopyUnavailable { + t.Fatalf("a refusal with another code was read as older root files: %+v", st) + } +} + +// The quiet branch must never hide a box whose pin does NOT work: a run started by a failed call, or a run after a +// failed call was recorded, names the problem even when the root files are older than the agent. +func TestRootStepOlderThanTheAgent_NeverHidesAFailedPin(t *testing.T) { + p := newPKI(t, "PVE CA") + leaf := p.leaf() + srv := newPVE(t, leaf) + old := errors.New(`pve-ca-copy refused: {"code": "R11", "reason": "unknown mode 'pve-ca-copy'"}`) + + // (1) the run was started by a failed call + b := newBox(t, srv, proxmox.TLSConfig{Fingerprint: fp(leaf)}, nil) + b.m.EnsureCopy = func(context.Context) (Copy, error) { return Copy{}, old } + b.m.Ensure(context.Background(), proxmox.TrustPinnedCertChanged) + if st := b.m.Status(); st.Problem == "" { + t.Fatalf("a run started by a failed pin reports no problem: %+v", st) + } + + // (2) a failed call was recorded, then the timer runs + b2 := newBox(t, srv, proxmox.TLSConfig{Fingerprint: fp(leaf)}, nil) + b2.m.EnsureCopy = func(context.Context) (Copy, error) { return Copy{}, old } + b2.m.mu.Lock() + b2.m.status.Problem = proxmox.TrustPinnedCertChanged + b2.m.mu.Unlock() + b2.m.Ensure(context.Background(), "timer") + if st := b2.m.Status(); st.Problem == "" { + t.Fatalf("the timer cleared a recorded pin failure: %+v", st) + } }