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:
+27
-1
@@ -2,8 +2,12 @@ package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/capability"
|
||||
@@ -191,7 +195,8 @@ func (c *Collector) Collect(ctx context.Context) (*HostReport, error) {
|
||||
}
|
||||
|
||||
host := hostMetrics(c.px.Node(), ns)
|
||||
host.CPUTempC = c.cpuTempC(ctx) // slice 9: operator freebie — temp now rides the hub report too
|
||||
host.CPUTempC = c.cpuTempC(ctx) // slice 9: operator freebie — temp now rides the hub report too
|
||||
host.WrapperSHA256 = pbsWrapperSHA256() // R-50b(a): make privileged-artifact drift answerable
|
||||
report := &HostReport{
|
||||
HostID: c.hostID,
|
||||
ReportedAt: c.now().Format(time.RFC3339),
|
||||
@@ -272,6 +277,27 @@ func (c *Collector) cpuTempC(ctx context.Context) *int {
|
||||
return c.temp.CPUTempC(ctx)
|
||||
}
|
||||
|
||||
// pbsWrapperPath is the installed PBS-DR apply wrapper. Duplicated from internal/pbsdr.WrapperPath
|
||||
// rather than imported, to keep the report collector free of a dependency on the DR bridge.
|
||||
const pbsWrapperPath = "/usr/local/sbin/felhom-pbs-apply"
|
||||
|
||||
// pbsWrapperSHA256 hashes the installed wrapper for the report (R-50b(a)). Best-effort: a missing or
|
||||
// unreadable file yields "", which the hub reads as UNKNOWN rather than as drift — a host that
|
||||
// legitimately has no DR wrapper must not light up amber. The file is 0755, so no privilege is
|
||||
// needed to read it.
|
||||
func pbsWrapperSHA256() string {
|
||||
f, err := os.Open(pbsWrapperPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func hostMetrics(node string, ns proxmox.NodeStatus) HostMetrics {
|
||||
h := HostMetrics{
|
||||
Node: node,
|
||||
|
||||
@@ -116,6 +116,12 @@ type PBSDRStatus struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
ConsumedFailed bool `json:"consumed_failed,omitempty"`
|
||||
AppliedAt string `json:"applied_at,omitempty"` // RFC3339; set on adopted/applied
|
||||
// AuthFailed (R-39, v0.91.0) — the credential this box holds is REJECTED by PBS (401). Set by the
|
||||
// verify loop's ProbeAuth, which before v0.91.0 could not run at all: the loop read the secret
|
||||
// file directly as non-root and always failed with "permission denied", so an applied-and-dead
|
||||
// tier was invisible to both tiers. The hub's pbsdrheal escalates state="auth_failed" to a fresh
|
||||
// mint.
|
||||
AuthFailed bool `json:"auth_failed,omitempty"`
|
||||
}
|
||||
|
||||
// OOBStatus is the per-heartbeat operator-access health (TASK H1). Carries no secret.
|
||||
@@ -170,6 +176,17 @@ type HostMetrics struct {
|
||||
// the per-disk SmartSummary.TemperatureC. Sourced from sysfs (hwmon / thermal zones).
|
||||
// Cross-repo wire field (slice 9) — the hub's HostMetrics copy + golden carry it too.
|
||||
CPUTempC *int `json:"cpu_temp_c"`
|
||||
// WrapperSHA256 is the sha256 of the installed PBS-DR apply wrapper
|
||||
// (/usr/local/sbin/felhom-pbs-apply), R-50b(a), v0.91.0.
|
||||
//
|
||||
// That wrapper is root-owned 0755 and the pinned sudoers vector for the PBS storage verbs, yet it
|
||||
// is installed from `raw/branch/main` — unversioned, unpinned and absent from the Day-0 artifact
|
||||
// manifest. So "which wrapper is on this host?" had no answer: two hosts installed a week apart
|
||||
// could carry different privileged code while reporting the same agent version. Reporting the hash
|
||||
// does not fix the delivery channel (R-50b(b)/(c)); it makes drift VISIBLE.
|
||||
//
|
||||
// Empty = unreadable/absent, which the hub treats as UNKNOWN, never as drift.
|
||||
WrapperSHA256 string `json:"wrapper_sha256,omitempty"`
|
||||
}
|
||||
|
||||
// Guest is one LXC. The agent reports vmid; the hub derives the guest PK
|
||||
@@ -429,6 +446,20 @@ type WirePBSDR struct {
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
TokenID string `json:"token_id,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
// SecretGeneration (R-39, agent v0.91.0 / hub v0.68.0) is the hub's monotonic per-host counter,
|
||||
// advanced by every fresh secret MINT. It carries no secret material — only the fact that one
|
||||
// rotated.
|
||||
//
|
||||
// THIS FIELD IS THE RE-ARM SIGNAL, and it works only because descriptorHash marshals THIS STRUCT:
|
||||
// an ep0 re-issue re-keys the secret of an existing token, so token_id, fingerprint, datastore and
|
||||
// namespace all come back byte-identical. Without this field the descriptor never moves, the
|
||||
// converged agent short-circuits, the fresh secret is never consumed, and the box serves a revoked
|
||||
// credential while reporting `applied` (the N100, 2026-07-18).
|
||||
//
|
||||
// Corollary worth stating: an agent that does NOT carry this field drops the unknown JSON key and
|
||||
// keeps today's behaviour exactly — inert, not broken. That is why hub v0.68.0 is safe to deploy
|
||||
// ahead of the fleet, and why the re-arm guarantee needs agent >= 0.91.0.
|
||||
SecretGeneration int64 `json:"secret_generation,omitempty"`
|
||||
}
|
||||
|
||||
// WireWireguard is the hub-owned offsite-tunnel assignment (S3) — field-exact with the S2 golden
|
||||
|
||||
Reference in New Issue
Block a user