diff --git a/internal/hub/report.go b/internal/hub/report.go index 57393b1..4d957fe 100644 --- a/internal/hub/report.go +++ b/internal/hub/report.go @@ -357,6 +357,13 @@ type WireWireguard struct { Endpoint WireWireguardEndpoint `json:"endpoint"` Pubkey string `json:"pubkey"` // the box's registered pubkey AssignedIP string `json:"assigned_ip"` // e.g. "10.77.0.2/32" + + // OOBPeerIP is the operator OOB peer's tunnel address (bare IPv4, e.g. "10.77.0.250", TASK H1). + // When set, the agent appends it as a second /32 to the box's wg-felhom AllowedIPs so packets the + // endpoint forwards FROM the operator are accepted — RENDERED into the conf (not a runtime `wg + // set`) so it survives self-heal/restart/reboot ([OF-1]: a runtime widening was wiped by the + // agent's own self-heal). `omitempty`: absent = OOB off, byte-identical conf for pre-H1 hosts. + OOBPeerIP string `json:"oob_peer_ip,omitempty"` } // WireWireguardEndpoint is the endpoint half of the wireguard block. diff --git a/internal/wgtunnel/manager.go b/internal/wgtunnel/manager.go index 808f6b4..02a557b 100644 --- a/internal/wgtunnel/manager.go +++ b/internal/wgtunnel/manager.go @@ -14,6 +14,7 @@ import ( "os/exec" "path/filepath" "regexp" + "sort" "strconv" "strings" "sync" @@ -226,6 +227,15 @@ func renderConf(block *hub.WireWireguard, privB64, endpointIPv4 string) (string, if block.Endpoint.WGPort < 1 || block.Endpoint.WGPort > 65535 { return "", fmt.Errorf("wgtunnel: wg_port %d out of range", block.Endpoint.WGPort) } + // AllowedIPs: the PBS tunnel IP always; the operator OOB peer /32 too when present (H1 [OF-1]). + // The tunnel carries box→PBS AND (when OOB is on) operator→box SSH — both terminate on the box, so + // the low interface MTU still bounds both directions. RENDERED here (not a runtime `wg set`) so it + // survives the agent's own self-heal. allowedIPs returns a DETERMINISTICALLY SORTED list so the + // conf-hash is stable across ticks (trap 1: nondeterministic order → a restart every 60s). + allowed, err := allowedIPsLine(pbsIP, block.OOBPeerIP) + if err != nil { + return "", err + } var b strings.Builder b.WriteString("# felhom offsite tunnel — agent-managed (S3); DO NOT EDIT\n") @@ -236,11 +246,35 @@ func renderConf(block *hub.WireWireguard, privB64, endpointIPv4 string) (string, b.WriteString("[Peer]\n") fmt.Fprintf(&b, "PublicKey = %s\n", block.Endpoint.ServerPubkey) fmt.Fprintf(&b, "Endpoint = %s:%d\n", epIP, block.Endpoint.WGPort) - fmt.Fprintf(&b, "AllowedIPs = %s/32\n", pbsIP) + fmt.Fprintf(&b, "AllowedIPs = %s\n", allowed) b.WriteString("PersistentKeepalive = 25\n") return b.String(), nil } +// allowedIPsLine builds the peer AllowedIPs value: pbsIP/32 always, plus the operator OOB /32 when +// oobPeerIP is a non-empty valid IPv4. The result is SORTED by address (netip.Addr.Compare) so two +// renders of the same inputs are byte-identical — the conf-hash stability the reconcile loop relies +// on. oobPeerIP is validated (bare IPv4, like pbs_tunnel_ip); a bad value is a hard error, never a +// silently-dropped widening. +func allowedIPsLine(pbsIP netip.Addr, oobPeerIP string) (string, error) { + addrs := []netip.Addr{pbsIP} + if oobPeerIP != "" { + op, err := netip.ParseAddr(oobPeerIP) + if err != nil || !op.Is4() { + return "", fmt.Errorf("wgtunnel: oob_peer_ip %q is not an IPv4 address", oobPeerIP) + } + if op != pbsIP { // never duplicate if (misconfigured) equal to the PBS IP + addrs = append(addrs, op) + } + } + sort.Slice(addrs, func(i, j int) bool { return addrs[i].Compare(addrs[j]) < 0 }) + parts := make([]string, len(addrs)) + for i, a := range addrs { + parts[i] = a.String() + "/32" + } + return strings.Join(parts, ", "), nil +} + // --- the state machine (doc 06 §3.3/§3.5 + spec §7/§8) --- // Apply reconciles local reality toward (fetched, block). fetched=false means "no desired-state diff --git a/internal/wgtunnel/manager_test.go b/internal/wgtunnel/manager_test.go index 5a483a4..0854166 100644 --- a/internal/wgtunnel/manager_test.go +++ b/internal/wgtunnel/manager_test.go @@ -210,6 +210,56 @@ PersistentKeepalive = 25 } } +// TestRenderConf_OOBPeerAppendedSortedAndStable is the [OF-1] core: when oob_peer_ip is set the +// operator /32 joins AllowedIPs, DETERMINISTICALLY SORTED, and two renders are BYTE-IDENTICAL (the +// conf-hash stability the reconcile loop depends on — trap 1). Absent → byte-identical to pre-H1. +func TestRenderConf_OOBPeerAppendedSortedAndStable(t *testing.T) { + // operator IP that sorts AFTER the pbs IP (10.77.0.1 < 10.77.0.250). + block := testBlock("ignored") + block.OOBPeerIP = "10.77.0.250" + c1, err := renderConf(block, vectorPrivB64, "167.233.158.164") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(c1, "AllowedIPs = 10.77.0.1/32, 10.77.0.250/32\n") { + t.Fatalf("OOB /32 not appended sorted:\n%s", c1) + } + // byte-stable across renders (no per-tick flapping). + c2, _ := renderConf(block, vectorPrivB64, "167.233.158.164") + if c1 != c2 { + t.Fatal("two renders of the same block differ — conf-hash would flap every tick") + } + + // operator IP that sorts BEFORE the pbs IP → it must come FIRST (proves the sort, not append order). + lower := testBlock("ignored") + lower.OOBPeerIP = "10.76.0.9" + cl, err := renderConf(lower, vectorPrivB64, "167.233.158.164") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(cl, "AllowedIPs = 10.76.0.9/32, 10.77.0.1/32\n") { + t.Fatalf("lower OOB IP not sorted first:\n%s", cl) + } + + // absent oob_peer_ip → byte-identical to the pre-H1 single-/32 line. + none := testBlock("ignored") + cn, _ := renderConf(none, vectorPrivB64, "167.233.158.164") + if !strings.Contains(cn, "AllowedIPs = 10.77.0.1/32\n") { + t.Fatalf("absent oob must render the single pbs /32:\n%s", cn) + } + + // invalid oob_peer_ip is a HARD error (never a silently-dropped widening). + bad := testBlock("ignored") + bad.OOBPeerIP = "not-an-ip" + if _, err := renderConf(bad, vectorPrivB64, "167.233.158.164"); err == nil { + t.Error("invalid oob_peer_ip accepted") + } + bad.OOBPeerIP = "2a01:4f8::1" // v6 → refused (arc is v4-pinned) + if _, err := renderConf(bad, vectorPrivB64, "167.233.158.164"); err == nil { + t.Error("IPv6 oob_peer_ip accepted") + } +} + func TestScenarioA_RegisterThenApplyOrdering(t *testing.T) { fh := &fakeHub{} m, rr, active, _ := testManager(t, fh)