From e2b6c63ea2f07de1af5d47ce6c37f4de7c778cb4 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 4 Jul 2026 07:14:33 +0200 Subject: [PATCH] =?UTF-8?q?wgtunnel:=20S3=20Part=203=20=E2=80=94=20FELHOM?= =?UTF-8?q?=5FWG=20sudoers=20+=20capabilities=20+=20config=20(DEFAULT=20OF?= =?UTF-8?q?F)=20+=20main=20wiring=20+=20escrow=20join?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6 --- cmd/felhom-agent/main.go | 100 +++++++++++++++++++++++++++++++- configs/felhom-agent.sudoers | 16 ++++- internal/capability/manifest.go | 10 ++++ internal/config/config.go | 34 +++++++++-- internal/escrow/identity.go | 28 +++++++++ internal/escrow/wgkey_test.go | 51 ++++++++++++++++ 6 files changed, 230 insertions(+), 9 deletions(-) create mode 100644 internal/escrow/wgkey_test.go diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index 94e52b5..001af8c 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -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. diff --git a/configs/felhom-agent.sudoers b/configs/felhom-agent.sudoers index c87836e..21b3fec 100644 --- a/configs/felhom-agent.sudoers +++ b/configs/felhom-agent.sudoers @@ -150,4 +150,18 @@ Cmnd_Alias FELHOM_NETMOUNT = \ /usr/bin/systemctl stop -- *.automount, \ /usr/bin/rm -f /etc/systemd/system/mnt-felhom* -felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK, FELHOM_PROVISION, FELHOM_FORMAT, FELHOM_DNSMASQ, FELHOM_GUESTHOOK, FELHOM_INTERMEDIARY, FELHOM_CONTROLLERSWAP, FELHOM_STALELOCK, FELHOM_NETMOUNT +# Offsite WG tunnel (S3, doc 06 §3.3). The agent manages wg-quick@wg-felhom as an agent-managed +# host service (the dnsmasq/lanresolver shape): conf staged in the agent-owned StateDir (never +# /tmp), installed 0600 to the FIXED destination, unit enable/restart/disable. The ONLY wg read +# is `latest-handshakes` — `wg show dump` is FORBIDDEN everywhere (its interface line +# carries the PRIVATE KEY; the S1 session-log incident). Both install paths are FIXED (no glob): +# the agent has exactly one tunnel conf to manage. +Cmnd_Alias FELHOM_WG = \ + /usr/bin/apt-get install -y -q wireguard-tools, \ + /usr/bin/install -o root -g root -m 0600 -- /var/lib/felhom-agent/wg/wg-felhom.conf /etc/wireguard/wg-felhom.conf, \ + /usr/bin/systemctl enable --now wg-quick@wg-felhom, \ + /usr/bin/systemctl restart wg-quick@wg-felhom, \ + /usr/bin/systemctl disable --now wg-quick@wg-felhom, \ + /usr/bin/wg show wg-felhom latest-handshakes + +felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK, FELHOM_PROVISION, FELHOM_FORMAT, FELHOM_DNSMASQ, FELHOM_GUESTHOOK, FELHOM_INTERMEDIARY, FELHOM_CONTROLLERSWAP, FELHOM_STALELOCK, FELHOM_NETMOUNT, FELHOM_WG diff --git a/internal/capability/manifest.go b/internal/capability/manifest.go index 546ef9f..01e0d16 100644 --- a/internal/capability/manifest.go +++ b/internal/capability/manifest.go @@ -101,4 +101,14 @@ var manifest = []Capability{ // ---- Stale-lock recovery (FELHOM_STALELOCK, v0.49.0; Critical: a guest stuck behind a stale // reboot-during-backup lock can't start → the customer box stays DOWN until this clears it) ---- {"stalelock-unlock", "reboot-during-backup stale-lock recovery", "/usr/sbin/pct", []string{"unlock", "9201"}, true}, + + // ---- Offsite WG tunnel (FELHOM_WG, S3/v0.64.0; Critical: false — the tunnel is not yet + // load-bearing (offsite backup rides it only from S4, which flips the backup-path entries + // to Critical). The handshake read is the ONLY wg invocation (never `dump`). ---- + {"wg-tools-install", "wireguard-tools package install", "/usr/bin/apt-get", []string{"install", "-y", "-q", "wireguard-tools"}, false}, + {"wg-conf-install", "wg-felhom conf install", "/usr/bin/install", []string{"-o", "root", "-g", "root", "-m", "0600", "--", "/var/lib/felhom-agent/wg/wg-felhom.conf", "/etc/wireguard/wg-felhom.conf"}, false}, + {"wg-enable", "wg-quick@wg-felhom enable", "/usr/bin/systemctl", []string{"enable", "--now", "wg-quick@wg-felhom"}, false}, + {"wg-restart", "wg-quick@wg-felhom restart (conf change)", "/usr/bin/systemctl", []string{"restart", "wg-quick@wg-felhom"}, false}, + {"wg-disable", "wg-quick@wg-felhom disable (revocation)", "/usr/bin/systemctl", []string{"disable", "--now", "wg-quick@wg-felhom"}, false}, + {"wg-handshake-read", "tunnel handshake-age read", "/usr/bin/wg", []string{"show", "wg-felhom", "latest-handshakes"}, false}, } diff --git a/internal/config/config.go b/internal/config/config.go index 67590cd..13f7957 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,18 +22,40 @@ import ( // Config is the agent configuration. type Config struct { - Proxmox ProxmoxConfig `json:"proxmox"` - Privileged PrivilegedConfig `json:"privileged"` - Authz AuthzConfig `json:"authz"` - Hub HubConfig `json:"hub"` - Storage StorageConfig `json:"storage"` - Backup BackupConfig `json:"backup"` + Proxmox ProxmoxConfig `json:"proxmox"` + Privileged PrivilegedConfig `json:"privileged"` + Authz AuthzConfig `json:"authz"` + Hub HubConfig `json:"hub"` + Storage StorageConfig `json:"storage"` + Backup BackupConfig `json:"backup"` Escrow EscrowConfig `json:"escrow"` LocalAPI LocalAPIConfig `json:"local_api"` LANResolver LANResolverConfig `json:"lan_resolver"` + WGTunnel WGTunnelConfig `json:"wg_tunnel"` LogLevel string `json:"log_level"` // debug|info|warn|error (default info) } +// WGTunnelConfig configures the offsite WireGuard tunnel (S3, doc 06). **Enabled DEFAULTS TO +// FALSE — the safety gate:** agent releases roll to near-production boxes, and auto-registering +// one into the DEV endpoint on update would be wrong. Enable explicitly per box; the default +// flips only when the production endpoint exists (a later, deliberate decision). +type WGTunnelConfig struct { + Enabled bool `json:"enabled"` + IntervalSeconds int `json:"interval_seconds"` // reconcile cadence; default 60 + StateDir string `json:"state_dir"` // key/marker/staged-conf under /wg/; default /var/lib/felhom-agent (the FELHOM_WG sudoers install entry hard-codes this default) +} + +// WithDefaults fills interval + state dir. +func (w WGTunnelConfig) WithDefaults() WGTunnelConfig { + if w.IntervalSeconds == 0 { + w.IntervalSeconds = 60 + } + if w.StateDir == "" { + w.StateDir = "/var/lib/felhom-agent" + } + return w +} + // LANResolverConfig configures the host-level split-horizon DNS resolver (internal/lanresolver): a // dnsmasq the agent manages so LAN clients reach their guest DIRECTLY at the same hostname + real cert. // Disabled unless Enable is set. HostIP defaults to the local-API bridge IP (the host LAN anchor); diff --git a/internal/escrow/identity.go b/internal/escrow/identity.go index 52cbd93..457a527 100644 --- a/internal/escrow/identity.go +++ b/internal/escrow/identity.go @@ -2,10 +2,12 @@ package escrow import ( "context" + "encoding/base64" "encoding/json" "fmt" "os" "path/filepath" + "strings" ) // Slice 10D.1 — IDENTITY escrow. The K-escrow (above) wraps the PBS *encryption key* via the @@ -24,6 +26,32 @@ var ageBinary = "/usr/bin/age" type IdentityBundle struct { TunnelToken string `json:"tunnel_token"` // the Cloudflare tunnel connector token PBSToken string `json:"pbs_token"` // the PBS access token (steady-state; rotated on re-establish) + // WGPrivateKey is the offsite WG tunnel private key (S3; base64, 32 bytes). OPTIONAL: escrow + // blobs created before S3 lack it and CANNOT be retro-fitted (R is never retained) — S5 DR + // falls back to fresh-key re-registration, which keeps the box's /32 (hub S2 re-key-in-place). + WGPrivateKey string `json:"wg_private_key,omitempty"` +} + +// AttachWGKey injects the offsite WG private key into the bundle when the key file exists (S3 +// escrow-create auto-inject). Returns whether it attached. The VALUE is validated (base64, 32 +// bytes) but never logged by callers — log the field NAME only. A missing key file is a clean +// no-attach (pre-S3 behavior, byte-compatible bundle); a corrupt one is an error (the operator +// should know their escrow would silently lack a live identity). +func AttachWGKey(b *IdentityBundle, keyPath string) (bool, error) { + raw, err := os.ReadFile(keyPath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("escrow: reading wg key file: %w", err) + } + s := strings.TrimSpace(string(raw)) + dec, err := base64.StdEncoding.DecodeString(s) + if err != nil || len(dec) != 32 { + return false, fmt.Errorf("escrow: wg key file %s is corrupt (not 32-byte base64)", keyPath) + } + b.WGPrivateKey = s + return true, nil } // WrapIdentity wraps arbitrary bundle bytes under `R` via `age -p` (scrypt + ChaCha20-Poly1305) and diff --git a/internal/escrow/wgkey_test.go b/internal/escrow/wgkey_test.go new file mode 100644 index 0000000..6bb8151 --- /dev/null +++ b/internal/escrow/wgkey_test.go @@ -0,0 +1,51 @@ +package escrow + +// S3 Group D — the WG-key escrow join. Red-proof (e): remove the auto-inject call and the +// bundle-contains-key assertion fails. + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestAttachWGKey(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "private.key") + + // Missing key file → clean no-attach (pre-S3 bundles stay byte-compatible). + b := &IdentityBundle{TunnelToken: "tt", PBSToken: "pt"} + attached, err := AttachWGKey(b, keyPath) + if err != nil || attached { + t.Fatalf("missing file: attached=%v err=%v", attached, err) + } + raw, _ := json.Marshal(b) + if string(raw) != `{"tunnel_token":"tt","pbs_token":"pt"}` { + t.Fatalf("bundle without key marshals with extra fields: %s", raw) + } + + // Present key file → attached, field carried. + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + os.WriteFile(keyPath, []byte(key+"\n"), 0o600) + attached, err = AttachWGKey(b, keyPath) + if err != nil || !attached { + t.Fatalf("present file: attached=%v err=%v", attached, err) + } + if b.WGPrivateKey != key { + t.Fatalf("bundle key = %q", b.WGPrivateKey) + } + raw, _ = json.Marshal(b) + var back IdentityBundle + json.Unmarshal(raw, &back) + if back.WGPrivateKey != key { + t.Fatal("wg_private_key does not survive the bundle round-trip") + } + + // Corrupt key file → error (the operator must know their escrow would lack the identity). + os.WriteFile(keyPath, []byte("garbage"), 0o600) + if _, err := AttachWGKey(&IdentityBundle{}, keyPath); err == nil { + t.Fatal("corrupt key file attached silently") + } +}