2b (unreleased): the agent moves from a pinned Proxmox certificate to the box's root CA copy — copy, proof, then the config; the cause of a trust failure is named (R-1380)
gates / gates (push) Successful in 38s

This commit is contained in:
admin committed 2026-11-14 16:35:22 +01:00
1 parent a9ab47ec71
commit 386a0c4cf4
13 files changed
+1191 -6

No files matched your search

+37
View File
@@ -54,6 +54,7 @@ import (
"gitea.dooplex.hu/admin/felhom-agent/internal/poweroff"
"gitea.dooplex.hu/admin/felhom-agent/internal/provision"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
"gitea.dooplex.hu/admin/felhom-agent/internal/pvetrust"
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
"gitea.dooplex.hu/admin/felhom-agent/internal/restorespace"
"gitea.dooplex.hu/admin/felhom-agent/internal/selfheal"
@@ -988,6 +989,13 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
osLeg := newOSLeg(cfg, client, px, logger)
collector.SetSystemReporter(&factsReporter{leg: osLeg, guest: firstGuest(px)}) // R-852: the versions
desiredSyncer.AddConsumer(osLeg)
// R-1380 (D80, felhom.eu `03` §12a): trust in Proxmox by a copy of the box's own root CA. At this start the root
// step makes the copy, a REAL call proves it while the pinned fingerprint is still the trust, and only then ONE
// write of the config sets ca_file and clears the fingerprint. A call that fails on trust later names the cause and
// runs the same order again (a pinned box whose certificate changed moves right then, with no restart).
pveTrust := newPVETrust(cfg, px, osLeg, logger)
collector.SetProxmoxTrustSource(func() any { return pveTrust.Status() })
loop.SetCollectErrorHook(pveTrust.OnCallError)
// S5: consume a host_loss restore_directive into an inspectable restore PLAN (derive + surface,
// execute nothing). The recipe is fetched on-demand (rare directive) via a fresh Collect.
desiredSyncer.AddConsumer(dr.NewConsumer(func(ctx context.Context) *hub.DRRecipeHostHalf {
@@ -1018,6 +1026,10 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
// network and stays under the hub's 45-minute host_stale (its
// alerting.stale_threshold; host_down at 90). On a box without the kernel lane (an older wrapper, a BYO host) the check is refused and logged.
go osLeg.KernelAfterBoot(ctx, 0, osupdate.KernelJudge{Wait: osupdate.DefaultKernelJudgeWait})
// R-1380: the trust order at this start (the copy, the proof, then the config) — before the first report cycle, so
// a pinned box whose certificate changed while the agent was down is not blind for a cycle. Then on a timer.
pveTrust.Ensure(ctx, "start")
go pveTrust.Run(ctx, time.Hour, 24*time.Hour)
// R-1192: every guest may swap as much as its RAM — raised once at start (a live change, no restart). A minute
// after start so the guest has settled; a failure is logged and the next start retries.
go func() {
@@ -1301,6 +1313,8 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
// R-1388: a new wrapper may know the profile refresh the old one refused — ask again (its own goroutine:
// it may wait for the heavy-op gate, and the signed job must not wait with it).
go osLeg.ProfileRefresh(daemonCtx, "config bundle installed", profileGate, 12, 5*time.Minute)
// R-1380: a new wrapper may know the CA-copy step the old one refused — run the trust order again.
go pveTrust.Ensure(daemonCtx, "config bundle installed")
}}
// R-1388 / R-1301 (`11` §5.12): after a start, load the security profiles from a fresh compile when the box runs
// another kernel than at the last refresh (the wrapper keeps that record and answers "not needed" otherwise).
@@ -3948,6 +3962,29 @@ func (f *selftestFlag) Set(v string) error {
return nil
}
// newPVETrust wires internal/pvetrust (R-1380): the root step through the OS wrapper, the proof as a throwaway client
// with EXACTLY the trust under test, and the running client's SetTLS.
func newPVETrust(cfg config.Config, px *proxmox.Client, leg *osupdate.Leg, logger *slog.Logger) *pvetrust.Manager {
m := &pvetrust.Manager{ConfigPath: cfg.SourcePath, StateDir: agentStateDir(cfg), Logger: logger,
EnsureCopy: func(ctx context.Context) (pvetrust.Copy, error) {
r, err := leg.PVECACopy(ctx)
return pvetrust.Copy{Path: r.Path, Outcome: r.Outcome, NotAfter: r.NotAfter, DaysLeft: r.DaysLeft}, err
},
Probe: func(ctx context.Context, t proxmox.TLSConfig) error {
p, err := proxmox.NewClient(proxmox.Config{Endpoint: cfg.Proxmox.Endpoint, Node: cfg.Proxmox.Node, Token: cfg.Proxmox.Token, TLS: t})
if err != nil {
return err
}
_, err = p.Version(ctx)
return err
},
Apply: px.SetTLS,
}
m.Start(proxmox.TLSConfig{CAFile: cfg.Proxmox.TLS.CAFile, Fingerprint: cfg.Proxmox.TLS.Fingerprint,
InsecureSkipVerify: cfg.Proxmox.TLS.InsecureSkipVerify})
return m
}
// newOSLeg builds the OS-update leg (agent v0.140.0). The wrapper runs through sudo (FELHOM_OSAPPLY); the plan and
// the once-per-night marker live in the agent's own os/ dir.
func newOSLeg(cfg config.Config, client *hub.Client, px *proxmox.Client, logger *slog.Logger) *osupdate.Leg {
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"testing"
)
// R-1380: the trust move is a component with seams (the root step, the proof, the running client, the report stanza,
// the failed-call hook). A unit-green pvetrust that main.go never starts is the inert-seam defect, so the wiring is
// asserted from the AST: built once, run at start and after a bundle install, on a timer, fed by a failed collect, and
// reported. The same for the profile refresh (R-1388), which had no such test.
func TestMainWiresPVETrust(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main.go", nil, 0)
if err != nil {
t.Fatalf("parse main.go: %v", err)
}
seen := map[string]int{}
ast.Inspect(f, func(n ast.Node) bool {
switch node := n.(type) {
case *ast.CallExpr:
sel, ok := node.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
x, _ := sel.X.(*ast.Ident)
switch {
case x != nil && x.Name == "pveTrust" && sel.Sel.Name == "Ensure":
seen["ensure"]++
case sel.Sel.Name == "SetProxmoxTrustSource":
seen["stanza"]++
case sel.Sel.Name == "SetCollectErrorHook" && len(node.Args) == 1:
if a, ok := node.Args[0].(*ast.SelectorExpr); ok && a.Sel.Name == "OnCallError" {
seen["hook"]++
}
case x != nil && x.Name == "osLeg" && sel.Sel.Name == "ProfileRefresh":
seen["profile-refresh"]++
}
case *ast.GoStmt:
if sel, ok := node.Call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Run" {
if id, ok := sel.X.(*ast.Ident); ok && id.Name == "pveTrust" {
seen["timer"]++
}
}
case *ast.KeyValueExpr:
if k, ok := node.Key.(*ast.Ident); ok && k.Name == "Apply" {
if v, ok := node.Value.(*ast.SelectorExpr); ok && v.Sel.Name == "SetTLS" {
seen["apply=SetTLS"]++
}
}
}
return true
})
want := map[string]int{"ensure": 2, "stanza": 1, "hook": 1, "timer": 1, "apply=SetTLS": 1, "profile-refresh": 2}
for k, n := range want {
if seen[k] < n {
t.Errorf("main.go wires %q %d time(s), want at least %d (the two-call ones: at start and after a bundle install)", k, seen[k], n)
}
}
}