From ede5d0e6c7e700c5ba26b6853157744ef309ce87 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 14 Nov 2026 17:09:12 +0100 Subject: [PATCH] agent (unreleased): the report carries the box's clock at the moment of sending (R-1384, D79) sent_at is stamped by Client.Report at every send, never by the collector. The hub reads it to tell a wrong box clock. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 10 ++++++ internal/hub/client.go | 1 + internal/hub/client_sentat_test.go | 53 ++++++++++++++++++++++++++++++ internal/hub/report.go | 9 +++-- 4 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 internal/hub/client_sentat_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 513beea..9a361e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## Unreleased (2026-11-14 night) — release 3 („the hub tells you”), the agent's parts, built and NOT released + +Commit only. Each part can be released alone; none needs release 2b. No `configs/` change in this section. + +- **The report carries the box's clock at the moment of sending (R-1384, D79, felhom.eu `09` §3 decision 300).** + `HostReport.SentAt` (`sent_at`, RFC3339 with nanoseconds) is stamped by `Client.Report` at every send, never by the + collector. `reported_at` could not serve: it is stamped when the collect starts. The hub (its own unreleased part) + 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) Commit only. No box ran any of this; the proof on a box (demo-hp, a forced new certificate, twice) is the next attended diff --git a/internal/hub/client.go b/internal/hub/client.go index 49428be..a9c4fe7 100644 --- a/internal/hub/client.go +++ b/internal/hub/client.go @@ -86,6 +86,7 @@ func (e *HTTPError) Error() string { // IS the heartbeat (locked decision 1). Errors are typed (transport vs HTTP) and // never include the bearer token. func (c *Client) Report(ctx context.Context, r *HostReport) (*ControlEnvelope, error) { + r.SentAt = time.Now().UTC().Format(time.RFC3339Nano) // the box clock at SENDING (R-1384); see HostReport.SentAt body, err := json.Marshal(r) if err != nil { return nil, fmt.Errorf("hub: marshaling report: %w", err) diff --git a/internal/hub/client_sentat_test.go b/internal/hub/client_sentat_test.go new file mode 100644 index 0000000..3099c63 --- /dev/null +++ b/internal/hub/client_sentat_test.go @@ -0,0 +1,53 @@ +package hub + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + "time" +) + +// R-1384 (D79): the hub tells a wrong box clock by the time stamped at the moment of SENDING. A report whose collect +// started long ago (an old reported_at) must still leave with a fresh sent_at, and a second send of the SAME report +// struct must carry a new one — or a report that waited on the box reads as a clock that is behind. +// COMPANION RED-PROOFS (observed, felhom.eu audits/night-build-2026-11-14/B/redproof-agent-sent-at.txt). +func TestReport_StampsSentAtAtSending(t *testing.T) { + var bodies []map[string]any + c := testClient(func(r *http.Request) (*http.Response, error) { + raw, _ := io.ReadAll(r.Body) + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("body: %v", err) + } + bodies = append(bodies, m) + return httpResp(200, `{"status":"ok"}`), nil + }) + old := time.Now().Add(-40 * time.Minute).UTC() + rep := &HostReport{HostID: "h", ReportedAt: old.Format(time.RFC3339), SentAt: old.Format(time.RFC3339Nano)} + for i := 0; i < 2; i++ { + if _, err := c.Report(context.Background(), rep); err != nil { + t.Fatalf("Report: %v", err) + } + time.Sleep(5 * time.Millisecond) + } + var sent []time.Time + for i, m := range bodies { + s, _ := m["sent_at"].(string) + at, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + t.Fatalf("send %d: sent_at = %q, want an RFC3339 time", i, s) + } + if d := time.Since(at); d < 0 || d > 5*time.Second { + t.Errorf("send %d: sent_at is %s old, want the moment of sending (reported_at was 40 minutes old)", i, d) + } + if m["reported_at"] != old.Format(time.RFC3339) { + t.Errorf("send %d: reported_at changed to %v — the collect time is not the send time's to touch", i, m["reported_at"]) + } + sent = append(sent, at) + } + if len(sent) == 2 && !sent[1].After(sent[0]) { + t.Errorf("the second send carries %s, not after the first %s — the stamp is not taken per send", sent[1], sent[0]) + } +} diff --git a/internal/hub/report.go b/internal/hub/report.go index ecba28c..5a3cfe1 100644 --- a/internal/hub/report.go +++ b/internal/hub/report.go @@ -15,8 +15,13 @@ import ( // server-side received_at is the hub's dead-man's-switch liveness signal. There is // no separate heartbeat endpoint. type HostReport struct { - HostID string `json:"host_id"` // echoes config.Hub.HostID - ReportedAt string `json:"reported_at"` // RFC3339, agent clock + HostID string `json:"host_id"` // echoes config.Hub.HostID + ReportedAt string `json:"reported_at"` // RFC3339, agent clock + // SentAt (R-1384, D79) is this box's clock at the moment the report is SENT — stamped by Client.Report, never by + // the collector. The hub compares it with its own clock to tell a wrong box clock. ReportedAt cannot serve: it is + // stamped when the collect STARTS, and a collect that waits on Proxmox makes it old on a box whose clock is right. + // Pinned by TestReport_StampsSentAtAtSending. + SentAt string `json:"sent_at,omitempty"` AgentVersion string `json:"agent_version"` // AgentSHA256 is the sha256 of the binary this process is RUNNING (read through /proc/self/exe, // once per process), R-349. The version string cannot tell a hand-built proof binary from the