v0.89.0: pbsdr self-grant (R-22) + escrow config live-reload + agent-plane poke listener (Direction-2a)

- pbsdr: on a 403 pre-check (non-default storage id, no ACL yet) self-grant via the root wrapper then re-read, instead of aborting before the grant — closes F4/R-22. Red-proof TestSelfGrant_PreCheck403DoesNotAbortBeforeGrant.
- escrow preflight: late-bound CurrentPBSStorageID re-reads agent.json so a pbsdr-seeded pbs_storage_id flips the row green in-process (no restart). Red-proof TestEscrowPreflight_PBSStorageIDLiveReload.
- internal/poke: contentless UDP poke listener bound exclusively to the box WG /32 (port 51822), leading-edge debounced, fires the hub-loop out-of-band trigger for an immediate desired-state cycle. First slice of R-13. Red-proofs TestBindConfinement + TestDebounceCoalescesBurst.
This commit is contained in:
2026-07-16 22:47:22 +02:00
parent c040c180e9
commit a659e5dc09
11 changed files with 643 additions and 15 deletions
+40 -4
View File
@@ -17,6 +17,7 @@ import (
"log/slog"
"net"
"net/http"
"net/netip"
"os"
"os/signal"
"path/filepath"
@@ -41,6 +42,7 @@ import (
"gitea.dooplex.hu/admin/felhom-agent/internal/mgmtplane"
"gitea.dooplex.hu/admin/felhom-agent/internal/pbs"
"gitea.dooplex.hu/admin/felhom-agent/internal/pbsdr"
"gitea.dooplex.hu/admin/felhom-agent/internal/poke"
"gitea.dooplex.hu/admin/felhom-agent/internal/provision"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
@@ -53,7 +55,7 @@ import (
// version is the agent version. Overridable at build time with
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
var version = "0.86.0"
var version = "0.89.0"
// runGuestHook is the PVE hook body (`felhom-agent guest-hook <vmid> <phase>`). On pre-start it
// creates placeholder dirs for any absent bind-mount source so the guest always boots (the C1 net);
@@ -728,8 +730,22 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
// the pbsdr manager is constructed further down; the closure reads drConfigured at call time
// (nil until then → preflight reports the tier not applied, which is the honest pre-wire answer).
escrowCeremonyCfg := &localapi.EscrowCeremonyConfig{
SudoPath: cfg.Privileged.SudoPath,
PBSStorageID: cfg.Escrow.PBSStorageID,
SudoPath: cfg.Privileged.SudoPath,
PBSStorageID: cfg.Escrow.PBSStorageID,
// Live-reload (v0.89.0): the pbsdr bridge seeds escrow.pbs_storage_id into agent.json on DR
// convergence; re-read it from disk at preflight time so the row flips green without a
// restart. Reading the same file the ceremony subprocess loads keeps the preflight honest.
// On a read error, fall back to the daemon-start snapshot. cfg.SourcePath == "" (all-env
// config) → nothing to re-read, the snapshot stands.
CurrentPBSStorageID: func() string {
if cfg.SourcePath == "" {
return cfg.Escrow.PBSStorageID
}
if c, err := config.Load(cfg.SourcePath); err == nil {
return c.Escrow.PBSStorageID
}
return cfg.Escrow.PBSStorageID
},
HubConfigured: cfg.Hub.URL != "" && cfg.Hub.HostID != "" && cfg.Hub.APIKey != "",
DRConfigured: func() bool {
if drConfigured != nil {
@@ -771,6 +787,7 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
// registration, no report stanza.
wgServers := 0
var wgLoop *wgtunnel.Loop
var pokeListener *poke.Listener
{
wt := cfg.WGTunnel.WithDefaults()
if wt.Enabled {
@@ -785,6 +802,22 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
desiredSyncer.AddConsumer(wgLoop) // raw desired-state → the wireguard block
collector.SetWireguardReporter(wgLoop) // heartbeat status stanza
logger.Info("wgtunnel: enabled", "interval_s", wt.IntervalSeconds, "state_dir", wt.StateDir)
// Agent-plane immediate-sync LISTENER (Direction-2a, v0.89.0). Binds a contentless UDP
// poke socket EXCLUSIVELY to the box's WG /32 (from registered.json) and nudges the hub
// control loop's out-of-band report trigger — the SAME channel the storage watchdog
// uses (fan-in; the loop coalesces). Enabled whenever the tunnel is (WG is the only path
// a poke can arrive on); a lost poke is harmless — the 15-min cycle still reconciles.
pokeListener = poke.NewListener(
func() (netip.Addr, bool) { return wgtunnel.LoadAssignedAddr(wt.StateDir) },
func() {
select {
case storageTrigger <- struct{}{}:
default:
}
},
poke.Port, logger)
logger.Info("poke: agent-plane sync listener enabled", "port", poke.Port)
}
}
@@ -840,13 +873,16 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
// Run reconcile, the hub loop, the storage watchdog, the restore-test scheduler, the PBS
// verify loop, (optionally) the local-API server, and (optionally) the LAN resolver loop
// concurrently; any one returning ends the daemon (ctx cancel tears down the rest).
errc := make(chan error, 7)
errc := make(chan error, 8)
go func() { errc <- engine.Run(ctx, interval) }()
go func() { errc <- loop.Run(ctx) }()
go func() { errc <- watchdog.Run(ctx) }()
go func() { errc <- scheduler.Run(ctx) }()
go func() { errc <- pbsLoop.Run(ctx) }()
go func() { errc <- pbsdrLoop.Run(ctx) }()
if pokeListener != nil {
go func() { errc <- pokeListener.Run(ctx) }() // agent-plane immediate-sync listener (v0.89.0)
}
if localSrv != nil {
localServers = 1
// Host-reboot remount fix: BEFORE binding into the guest, re-assert enrolled drive MOUNTS on the