Compare commits

..
4 Commits
3 changed files with 128 additions and 3 deletions

No files matched your search

+20 -3
View File
@@ -1,4 +1,21 @@
## Unreleased (2026-11-15) — a cut write of `agent.json` cannot leave a blind box (R-1380; found before the release)
## v0.175.1 (2026-11-15) — root files older than the agent are not a trust problem — RELEASED 2026-11-15 (release-agent.sh from the clean working clone at `88d5d56`, CI run id 2678; agent sha256 38b1e2350de90a7940c16f8cd2a21d83fcd535410d1017a7df0fee14f0904612, bundle sha256 07ebf86362ed9d44776416bd5c2c4b6b629e44d34595a8120fd8fc3f4d9277ff); NOT vouched yet. 0.175.0 was released the same morning, reached Tester 1 only, and is NOT to be vouched
- **`pvetrust.Ensure`: the wrapper's „unknown mode” refusal on a PINNED box names no problem.** Between the signed
agent update and the signed root-files job, the box's wrapper does not know `pve-ca-copy`. 0.175.0 reported
`ca_copy_unavailable` for those minutes and the hub mailed the operator (seen on Tester 1, 2026-11-15 10:04). The
pin works, so nothing is wrong; the bundle install runs the move. An unpinned box, and every other refusal, still
names the problem. Test `TestRootStepOlderThanTheAgent_IsNotATrustProblem`; 3 mutations seen red.
- **The quiet branch never hides a failed pin** (the commit's security review): it is taken only when the run was
not started by a failed call and no trust failure is recorded, and only for the wrapper's code R11.
`TestRootStepOlderThanTheAgent_NeverHidesAFailedPin`; 3 more mutations seen red (felhom.eu
`documentation/audits/release-2b-3-2026-11-15/part2/`).
## v0.175.0 (2026-11-15) — trust in Proxmox by a copy of the box's own root CA (R-1380, release 2b); the agent tells the hub its clock, a refused job, a give-up and what a clean-up did (release 3); a boot key a clock step cannot move (R-1395) — RELEASED 2026-11-15 (release-agent.sh from the clean working clone at `db01ae0`, CI run id 2671; agent sha256 d1a30ed51f6663d96369ad3e88c221194af92773addf4fbb48852ee58be4b19a, bundle sha256 3b4a8b78155fc4385148c98c229da2e61e7c6b02bad9ed9f1fdc4a9ea1370d95); NOT vouched yet
`configs/` changed (the wrapper's `pve-ca-copy` mode), so a box needs BOTH signed jobs: `agent_update`, then
`agent_config_update`. The three sections below were built on 2026-11-14 night and 2026-11-15 morning.
### A cut write of `agent.json` cannot leave a blind box (R-1380; found before the release, 2026-11-15)
- **`config.ReplaceInPlace` + `config.RecoverCutWrite`** (`internal/config/cutwrite.go`). `agent.json` is the agent's
file in a root-owned folder, so the trust move, the PBS seed and the backup-target move write it in place
@@ -9,7 +26,7 @@
four steps, the wiring in `main`, and a source pin that the three writers use the helper and open no file by hand.
Red-proofs: felhom.eu `documentation/audits/release-2b-3-2026-11-15/part0/`. Not proven: a real power cut.
## Unreleased (2026-11-14 night) — release 3 („the hub tells you”) and the clock-step fix, the agent's parts, built and NOT released
### Release 3 („the hub tells you”) and the clock-step fix, the agent's parts (2026-11-14 night)
Commit only. Each part can be released alone; none needs release 2b. No `configs/` change in this section.
@@ -66,7 +83,7 @@ Commit only. Each part can be released alone; none needs release 2b. No `configs
compares it with its clock and alarms above one minute. Test `TestReport_StampsSentAtAtSending`; 3 mutations seen
red (felhom.eu `documentation/audits/night-build-2026-11-14/B/redproof-agent-sent-at.txt`).
## Unreleased (2026-11-14 night) — release 2b, built and NOT released: trust in Proxmox by a copy of the box's own root CA (R-1380, D80, felhom.eu `09` §3 decision 303; design `03-host-agent.md` §12a)
### Release 2b (2026-11-14 night): trust in Proxmox by a copy of the box's own root CA (R-1380, D80, felhom.eu `09` §3 decision 303; design `03-host-agent.md` §12a)
Commit only. No box ran any of this; the proof on a box (demo-hp, a forced new certificate, twice) is the next attended
session's. `configs/` changes, so the release needs BOTH signed jobs.
+31
View File
@@ -29,6 +29,7 @@ import (
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -246,6 +247,19 @@ func (m *Manager) Ensure(ctx context.Context, why string) bool {
c, err := m.EnsureCopy(cctx)
ccancel()
if err != nil {
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. 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
}
lg.Warn("pvetrust: the root step gave no CA copy — the trust stays as it is", "err", err)
set(modeOf(cur), ProblemCopyUnavailable, truncate(err.Error(), 240), nil)
return false
@@ -404,3 +418,20 @@ func (m *Manager) writeConfig(caPath string) error {
}
return nil
}
// rootStepTooOld says whether the root step refused because it does not know the CA-copy mode at all (the wrapper's
// 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(), `"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
}
+77
View File
@@ -487,3 +487,80 @@ func TestProofCannotBeMadeNow_NoCauseNamed(t *testing.T) {
t.Fatalf("status = %+v moved=%v", st, b.m.Moved())
}
}
// Between the agent update and the root-files job the wrapper does not know the CA-copy step. A pinned box then has
// NO trust problem (the pin works): naming one mails the operator at every box's update. A box with NO working pin
// that gets the same refusal still names the problem.
func TestRootStepOlderThanTheAgent_IsNotATrustProblem(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'"}`)
b := newBox(t, srv, proxmox.TLSConfig{Fingerprint: fp(leaf)}, nil)
b.m.EnsureCopy = func(context.Context) (Copy, error) { return Copy{}, old }
before, _ := os.ReadFile(b.cfgPath)
if b.m.Ensure(context.Background(), "start") {
t.Fatal("the trust changed with no copy")
}
if st := b.m.Status(); st.Problem != "" || st.Mode != "fingerprint" {
t.Fatalf("a pinned box with older root files reports a trust problem: %+v", st)
}
if after, _ := os.ReadFile(b.cfgPath); string(after) != string(before) {
t.Fatal("the config was written")
}
// the same refusal on a box that is NOT pinned is still a problem: nothing else holds its trust up
b2 := newBox(t, srv, proxmox.TLSConfig{}, nil)
b2.m.EnsureCopy = func(context.Context) (Copy, error) { return Copy{}, old }
b2.m.Ensure(context.Background(), "start")
if st := b2.m.Status(); st.Problem != ProblemCopyUnavailable {
t.Fatalf("an unpinned box with no copy step must name the problem: %+v", st)
}
// and another refusal on a pinned box is still named
b3 := newBox(t, srv, proxmox.TLSConfig{Fingerprint: fp(leaf)}, nil)
b3.m.EnsureCopy = func(context.Context) (Copy, error) { return Copy{}, errors.New(`pve-ca-copy refused: {"code": "T3"}`) }
b3.m.Ensure(context.Background(), "start")
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)
}
}