feat(wgtunnel): render operator OOB /32 into wg-felhom AllowedIPs (H1 Part 1, [OF-1])
renderConf appends a validated, deterministically-SORTED oob_peer_ip /32 so the operator peer survives self-heal/restart (a runtime wg set was wiped by self-heal). WireWireguard gains oob_peer_ip (omitempty — absent = byte-identical pre-H1 conf). Non-hollow tests: sorted+byte-stable, lower-IP-sorts-first, absent-identical, invalid/v6 rejected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user