wgtunnel: S3 Part 3 — FELHOM_WG sudoers + capabilities + config (DEFAULT OFF) + main wiring + escrow join
Sudoers: fixed-path conf install, enable/restart/disable, latest-handshakes-only wg read (dump FORBIDDEN — the S1 incident). 6 capability-manifest entries (Critical=false until S4 makes the tunnel load-bearing). WGTunnelConfig with enabled=false DEFAULT (the safety gate: a v0.64.0 rollout without explicit config is a no-op). Daemon wiring mirrors lanresolver + AddConsumer + SetWireguardReporter; --selftest=wgtunnel single-shot. IdentityBundle +wg_private_key (omitempty; pre-S3 blobs cannot be retrofitted — documented) with escrow-create auto-inject (field name only in logs). Red-proof (e) run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -41,6 +41,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/wgtunnel"
|
||||
)
|
||||
|
||||
// version is the agent version. Overridable at build time with
|
||||
@@ -179,6 +180,8 @@ func main() {
|
||||
os.Exit(runSelftestPBSVerify(context.Background(), cfg, logger))
|
||||
case "lanresolver":
|
||||
os.Exit(runSelftestLANResolver(context.Background(), cfg, logger, vmid))
|
||||
case "wgtunnel":
|
||||
os.Exit(runSelftestWGTunnel(context.Background(), cfg, logger))
|
||||
case "bring-up":
|
||||
os.Exit(runSelftestBringUp(context.Background(), cfg, logger, mode, archive, vmid, hostname, keep,
|
||||
bringUpSizing{RootfsGrowGB: rootfsGrow, DataVolGrowGB: dataVolGrow, DataVolMount: dataVolMount,
|
||||
@@ -201,6 +204,54 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// runSelftestWGTunnel is the supervised single-shot bring-up (S3): keygen/register (marker-
|
||||
// gated), one desired-state fetch, one apply, then the status stanza. Runs regardless of
|
||||
// wg_tunnel.enabled (the operator invoked it deliberately) but uses the config's state
|
||||
// dir/runner exactly as the daemon would.
|
||||
func runSelftestWGTunnel(ctx context.Context, cfg config.Config, logger *slog.Logger) int {
|
||||
fmt.Printf("=== felhom-agent %s selftest=wgtunnel ===\n", version)
|
||||
wt := cfg.WGTunnel.WithDefaults()
|
||||
client, err := hub.NewClient(cfg.Hub, logger)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, " [FAIL] hub client:", err)
|
||||
return 1
|
||||
}
|
||||
mode := proxmox.RunnerMode(cfg.Privileged.Mode)
|
||||
if mode == "" {
|
||||
mode = proxmox.RunnerSudo
|
||||
}
|
||||
runner := &proxmox.ExecRunner{Mode: mode, SudoPath: cfg.Privileged.SudoPath}
|
||||
mgr := wgtunnel.NewManager(runner, client, wt.StateDir, logger)
|
||||
|
||||
// One registration/adopt pass with no desired data, then one fetch + apply.
|
||||
mgr.Apply(ctx, false, nil)
|
||||
var fetched bool
|
||||
var block *hub.WireWireguard
|
||||
if resp, err := client.FetchDesiredState(ctx); err != nil {
|
||||
fmt.Fprintln(os.Stderr, " [WARN] desired-state fetch failed (apply skipped):", err)
|
||||
} else {
|
||||
fetched = true
|
||||
block = resp.DesiredState.Wireguard
|
||||
fmt.Printf(" desired-state generation=%d wireguard-block=%v\n", resp.Generation, block != nil)
|
||||
}
|
||||
mgr.Apply(ctx, fetched, block)
|
||||
|
||||
st := mgr.Status(ctx)
|
||||
fmt.Printf(" status: pubkey=%s registered=%v active=%v assigned_ip=%s", st.Pubkey, st.Registered, st.Active, st.AssignedIP)
|
||||
if st.LastHandshakeAgeS != nil {
|
||||
fmt.Printf(" handshake_age_s=%d", *st.LastHandshakeAgeS)
|
||||
}
|
||||
fmt.Println()
|
||||
if st.Registered && (block == nil || st.Active) {
|
||||
fmt.Println(" [OK] wgtunnel selftest complete")
|
||||
return 0
|
||||
}
|
||||
if !st.Registered {
|
||||
fmt.Println(" [WARN] not registered (hub unreachable or endpoint unset) — see log above")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// newProxmoxClient builds the read-path proxmox client from config.
|
||||
func newProxmoxClient(cfg config.Config) (*proxmox.Client, error) {
|
||||
return proxmox.NewClient(proxmox.Config{
|
||||
@@ -583,6 +634,28 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
|
||||
}
|
||||
}
|
||||
|
||||
// Offsite WG tunnel (S3, doc 06 §3.3): keygen + one-shot hub registration + wg-quick@wg-felhom
|
||||
// managed from the hub-served desired-state block. DEFAULT OFF (the safety gate): a v0.64.0
|
||||
// rollout to a box without explicit wg_tunnel.enabled=true is a no-op — no keygen, no
|
||||
// registration, no report stanza.
|
||||
wgServers := 0
|
||||
var wgLoop *wgtunnel.Loop
|
||||
{
|
||||
wt := cfg.WGTunnel.WithDefaults()
|
||||
if wt.Enabled {
|
||||
wtMode := proxmox.RunnerMode(cfg.Privileged.Mode)
|
||||
if wtMode == "" {
|
||||
wtMode = proxmox.RunnerSudo
|
||||
}
|
||||
wtRunner := &proxmox.ExecRunner{Mode: wtMode, SudoPath: cfg.Privileged.SudoPath}
|
||||
wgMgr := wgtunnel.NewManager(wtRunner, client, wt.StateDir, logger)
|
||||
wgLoop = wgtunnel.NewLoop(wgMgr, time.Duration(wt.IntervalSeconds)*time.Second, logger)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -641,10 +714,14 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
|
||||
lanServers = 1
|
||||
go func() { errc <- lanLoop.Run(ctx) }()
|
||||
}
|
||||
if wgLoop != nil {
|
||||
wgServers = 1
|
||||
go func() { errc <- wgLoop.Run(ctx) }()
|
||||
}
|
||||
|
||||
err = <-errc
|
||||
stop() // tear down the siblings on the first exit
|
||||
for i := 0; i < 4+localServers+lanServers; i++ { // wait for the other goroutines
|
||||
stop() // tear down the siblings on the first exit
|
||||
for i := 0; i < 4+localServers+lanServers+wgServers; i++ { // wait for the other goroutines
|
||||
<-errc
|
||||
}
|
||||
if err != nil && err != context.Canceled {
|
||||
@@ -1497,6 +1574,25 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo
|
||||
}
|
||||
}
|
||||
}
|
||||
// S3: auto-inject the offsite WG private key into the escrowed identity when the key file
|
||||
// exists — a NEW escrow run should always capture the live tunnel identity. Field NAME only
|
||||
// in logs, never the value. No key file + no bundle flag → pre-S3 behavior, byte-compatible.
|
||||
{
|
||||
wgKeyPath := wgtunnel.KeyFilePath(cfg.WGTunnel.WithDefaults().StateDir)
|
||||
probe := identity
|
||||
if probe == nil {
|
||||
probe = &escrow.IdentityBundle{}
|
||||
}
|
||||
attached, err := escrow.AttachWGKey(probe, wgKeyPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "selftest=escrow-create: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if attached {
|
||||
identity = probe
|
||||
logger.Info("escrow: identity bundle: +wg_private_key")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("=== felhom-agent %s selftest=escrow-create (storage=%s posture=%s identity=%v) ===\n", version, storage, escrow.DefaultPosture, identity != nil)
|
||||
// NB: nothing about R is logged. The logger never sees R; only stdout does, once.
|
||||
|
||||
Reference in New Issue
Block a user