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
gates / gates (push) Successful in 38s
This commit is contained in:
1 parent
a9ab47ec71
commit
386a0c4cf4
13 files changed
+1191
-6
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user