Files
ruflo/docs/federation/phase7-mesh-bringup.md
rUvandReuven 70e2339469 feat(federation): ADR-111 Phases 4-6 — firewall projection + witness chain + MCP tools (#1895)
* feat(federation): ADR-111 Phases 4-6 — firewall projection + witness chain + MCP tools

Phases 4-6 of ADR-111. Phase 7 (cross-OS WG mesh bringup) is operator-mediated
and lives outside the package — staged but not auto-executed per CLAUDE.md
destructive-actions guidance.

## Phase 4 — Trust-graded firewall projection
`domain/services/wg-firewall-service.ts` (new)
- Two backends: linux-nftables (atomic `nft -f`, table `inet ruflo_fed`,
  default DROP, interface-scoped) and darwin-pf (anchor-scoped pfctl).
- Projects WG_NETWORK_GATES → concrete rule lines per peer.
- UNTRUSTED excluded (no mesh IP), peers without wgMeshIP skipped.
- Defense-in-depth: regex-validated rule args (no shell metachars in
  pubkey/IP/iface).
- Returns rule file content + the load command the operator runs —
  service never shells out.
- 16 unit tests.

## Phase 5 — Witness attestation chain
`domain/services/wg-witness-service.ts` (new)
- Append-only chain of Ed25519-signed entries. Each entry hash-links to
  the previous (`prevHash` = sha256 of previous canonical encoding).
- Canonical encoding: sorted keys, no whitespace, omitted-on-undefined.
- `verifyWitnessChain` walks the chain and reports `failedAt` + reason
  (`broken-chain-link` | `invalid-signature-or-hash`).
- `attestWgCommand` adapter maps WgCommand.verb → event type.
- 11 unit tests (canonicalization stability, deterministic hash,
  chain link integrity, signature-tamper detection).

## Phase 6 — Operator MCP tools
`mcp-tools.ts` (extended)
- `federation_wg_status` — per-peer mesh state (trust, meshIP, AllowedIPs,
  suspended/evicted flags) via `WgMeshService.summarize()`.
- `federation_wg_attest` — operator-signed witness chain entry. v1 stub
  returns wire-up instructions until plugin.ts integrates WgWitnessService.
- `federation_wg_keyrotate` — emits a new X25519 keypair (public key only
  via MCP; private key write is operator-mediated) + the upgrade checklist.
  Requires `confirm: true` (destructive — drops existing tunnels until peers
  fetch updated manifest).
- `createMcpTools` gets optional `getWgMesh` getter (additive, no break).
- `FederationCoordinator.listPeers()` added as public discovery accessor.

## Tests
- 543/543 pre-existing + new tests pass (513 + 27 phase 4/5 + 3 plugin
  test count assertions for the new MCP tools).

## Not in this PR (Phase 7 — operator-mediated)
- Real WG mesh bringup on mac↔ruvultra over tailscale: stage configs,
  operator-approved `wg-quick up`, peer-add via federation manifests,
  SUSPEND → confirm L3 isolation → reactivate restores, witness-verify
  the change log. Tracked alongside #1879.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(federation): security — validate peer wg fields before splice (ADR-111)

Manual security audit of ADR-111 Phases 1-6 found a real vulnerability in
Phase 2's WgMeshService that the existing tests did not catch.

## The bug

`WgMeshService.buildInterfaceConfig()` and `summarize()` spliced
`peer.metadata.wgPublicKey` / `wgMeshIP` / `wgEndpoint` into the wg-quick
config string *without validation*. The ADR's threat model explicitly
includes "compromised federation peer with valid WG key" — that peer
signs their own manifest, so the Ed25519 signature only proves origin,
not content safety.

Concrete attack: a compromised peer publishes

  wgEndpoint = "host:51820\n[Peer]\nPublicKey = attacker\nEndpoint = ..."

and the operator's wg-quick config ends up with an extra [Peer] block.
Cross-peer lateral movement, bypassing the per-peer trust gates.

Phase 4 (WgFirewallService) already validated meshIP via
`assertSafeRuleArg`. Phase 2 didn't — fixed here.

## The fix

New `readSafePeerWgFields(peer)` returns the validated triple or null.
Three regexes:
  - wgPublicKey: ^[A-Za-z0-9+/]{43}=$            (base64 X25519, 32 raw bytes)
  - wgMeshIP:    ^d.d.d.d/32$ + octet range check
  - wgEndpoint:  ^(hostname|ipv4|[ipv6]):port$  + port range check

Mismatched fields → peer is skipped entirely (no [Peer] block, no
AllowedIPs, summarize() returns empty strings). Silent skip beats partial
write because partial writes can leak attacker content into operator
review while looking syntactically valid.

## Tests

7 new adversarial cases under `WgMeshService — readSafePeerWgFields
(compromised-peer defense)`:
  - newline injection in wgEndpoint
  - wrong-length / non-base64 wgPublicKey
  - out-of-range octet in wgMeshIP
  - missing /32 CIDR
  - port out of range
  - IPv6 bracket positive case
  - summarize() also sanitizes (regression: it used the same unsafe path)

Full suite: 550/550 pass.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(federation): ADR-111 Phase 7 staging helper

Generates WG keypair + wg-quick config + per-OS firewall rules on the
current host, naming a single peer. Outputs go to /tmp/adr-111-stage.
Nothing loaded into the kernel — wg-quick up / nft -f / pfctl -f are
operator-mediated.

Usage:
  node scripts/phase7-stage.mjs <localId> <peerId> <peerPk> <peerIP> <peerEndpoint>

Workflow:
  1. Run on host A with placeholder peer values → emit host-A pubkey
  2. Run on host B with placeholder peer values → emit host-B pubkey
  3. Re-run on both with the OTHER host's pubkey → final config files
  4. Operator reviews + activates per the printed checklist

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(federation): ADR-111 phase7-stage idempotent — reuse key file

Without persistence, every run generates a fresh keypair, which breaks
the cross-feed workflow (run on host A → capture pubkey → run on host B
with A's pubkey → A regenerates a different key on its next run).

Now reads /tmp/adr-111-stage/wg-key-<nodeId>.json if present, falls back
to fresh generation if not. Operator can delete the file to force
rotation.

Co-Authored-By: RuFlo <ruv@ruv.net>

* docs(federation): user guide + Phase 7 bringup procedure + README link

Adds:
  - docs/federation/README.md — full user guide (when to use, MCP tools,
    trust levels, breaker, ADR-111 mesh layer, claude -p headless usage)
  - docs/federation/phase7-mesh-bringup.md — step-by-step cross-OS WG
    mesh activation procedure with operator review checklist
  - README.md — link to docs/federation/ from the existing Federation
    section

Co-Authored-By: RuFlo <ruv@ruv.net>

---------

Co-authored-by: Reuven <cohen@ruv-mac-mini.local>
2026-05-10 23:03:19 -04:00

6.4 KiB

ADR-111 Phase 7 — Cross-OS WG mesh bringup

Step-by-step procedure for activating the opt-in WireGuard mesh between two federation peers. This is the operator-mediated step that follows Phases 1-6 (which ship as code in @claude-flow/plugin-agent-federation).

⚠️ Phase 7 is destructive. It modifies host networking and requires root. A typo in the firewall projection can drop ssh. Operator review of the staged configs is mandatory before activation.

Prerequisites

  • WireGuard installed on both hosts (brew install wireguard-tools on mac, apt install wireguard on linux).
  • Both hosts reachable on a common UDP port (default 51820). Tailscale, a LAN, or a VPN concentrator works. ADR-111 does NOT do NAT traversal.
  • Federation plugin built on both hosts (pnpm install && pnpm run build in v3/@claude-flow/plugin-agent-federation).
  • Federation Ed25519 identity already initialized — Phase 7 reuses it for manifest signing.

Step 1 — Stage configs on each host

On host A (mac mini in this example):

cd v3/@claude-flow/plugin-agent-federation
node scripts/phase7-stage.mjs \
  ruv-mac-mini \
  ruvultra \
  '<placeholder-pubkey>' \
  '10.50.0.0/32' \
  'ruvultra-tailnet-name:51820'

The script will print host A's freshly-generated pubkey, e.g.:

publicKey:   61005ZbEMJq0tTIYwSHINdmJEsBM39sM5TIV6p4UfHA=
meshIP:      10.50.119.95/32

On host B (ruvultra):

node scripts/phase7-stage.mjs \
  ruvultra \
  ruv-mac-mini \
  '<placeholder-pubkey>' \
  '10.50.0.0/32' \
  'mac-mini-tailnet-name:51820'

Note host B's pubkey, e.g.:

publicKey:   v+cwXZ3BoYZZAodDI38RYf9UO5c9xz+TkjaAZg8mzhs=
meshIP:      10.50.242.138/32

Step 2 — Re-stage with REAL peer values

Now that both hosts have generated keys, re-run staging with the actual peer pubkey + meshIP. The script reuses each host's own key from /tmp/adr-111-stage/wg-key-<nodeId>.json (idempotent).

On host A:

node scripts/phase7-stage.mjs \
  ruv-mac-mini \
  ruvultra \
  'v+cwXZ3BoYZZAodDI38RYf9UO5c9xz+TkjaAZg8mzhs=' \
  '10.50.242.138/32' \
  'ruvultra-tailnet:51820'

On host B:

node scripts/phase7-stage.mjs \
  ruvultra \
  ruv-mac-mini \
  '61005ZbEMJq0tTIYwSHINdmJEsBM39sM5TIV6p4UfHA=' \
  '10.50.119.95/32' \
  'mac-mini-tailnet:51820'

Each host now has cross-coherent staged configs in /tmp/adr-111-stage/.

Step 3 — Operator review checklist

Before activating, inspect each staged file:

cat /tmp/adr-111-stage/ruflo-fed.conf
cat /tmp/adr-111-stage/ruflo-fed.nft     # linux only
cat /tmp/adr-111-stage/ruflo-fed.pf      # macos only

Checklist:

  • ruflo-fed.conf has exactly one [Peer] block (per peer expected)
  • The [Peer] block's PublicKey matches the OTHER host's emitted pubkey
  • AllowedIPs lists ONLY the peer's mesh IP — no broader CIDR
  • ListenPort is what you expect (default 51820)
  • Firewall projection contains only ATTESTED+ peers
  • Default policy is drop (nftables) / not affecting main pf ruleset (pf anchor-scoped)
  • No mention of UNTRUSTED peers anywhere

If anything looks off, stop and re-stage with corrected inputs — ruflo-fed.conf is what wg-quick up parses, and a misconfigured rule can drop ssh.

Step 4 — Activate (per host)

After the checklist passes:

# Install the wg-quick config
sudo install -m 0600 /tmp/adr-111-stage/ruflo-fed.conf /etc/wireguard/ruflo-fed.conf

# Load the firewall rules (atomic, scoped to the WG interface or pf anchor)
# Linux:
sudo nft -f /tmp/adr-111-stage/ruflo-fed.nft
# macOS:
sudo pfctl -a ruflo-fed -f /tmp/adr-111-stage/ruflo-fed.pf

# Bring up the WG interface
sudo wg-quick up ruflo-fed

Step 5 — Verify reachability

On host A:

sudo wg show ruflo-fed
# Expected: [Peer] section shows ruvultra's pubkey, last handshake timestamp,
# transfer counters update after activity.

ping 10.50.242.138       # ruvultra's mesh IP — should respond

On host B (mirror):

sudo wg show ruflo-fed
ping 10.50.119.95

Step 6 — Validate breaker → L3 propagation

Trigger an operator-initiated evict on host A and confirm L3 isolation propagates:

# On host A, evict ruvultra at the federation layer
ruflo federation evict --node-id ruvultra
# Or via MCP: federation_evict

# Confirm WG layer responded
sudo wg show ruflo-fed     # ruvultra peer's [Peer] line should be gone

# From host A:
ping 10.50.242.138         # NOW unreachable — L3 followed L7 trust eviction

To restore:

ruflo federation reactivate --node-id ruvultra
# A wg set ruflo-fed peer ... allowed-ips ... command is emitted via the
# wgCommandSink the operator wired during plugin init.
sudo wg show ruflo-fed     # ruvultra back in [Peer] list
ping 10.50.242.138         # responsive again

Step 7 — Witness verification (optional)

If WgWitnessService is wired into your federation plugin lifecycle (Phase 5 integration — operator-supplied):

cat .claude-flow/federation/wg-changes.log   # append-only chain
node plugins/ruflo-core/scripts/witness/verify.mjs \
  --manifest .claude-flow/federation/wg-witness.md.json
# Expected: Ed25519 signature valid, chain link verified end-to-end

Rollback

sudo wg-quick down ruflo-fed

# Linux:
sudo nft delete table inet ruflo_fed

# macOS:
sudo pfctl -a ruflo-fed -F all

The configs in /tmp/adr-111-stage/ and /etc/wireguard/ruflo-fed.conf stay on disk — re-run wg-quick up ruflo-fed to reactivate. To fully tear down also delete /etc/wireguard/ruflo-fed.conf (private key inside).

Known limitations (v1)

  • No NAT traversal — peers must be direct-UDP reachable. Use Tailscale, a VPN concentrator, or a public IP.
  • No DERP-equivalent relay — if direct UDP fails, fall back to Tailscale instead.
  • No MagicDNS — mesh IPs are derived from nodeId, not resolved.
  • Trust-graded firewall rules use ports — but WG itself routes at L3. The firewall projection ships now and enforces these tighter rules once loaded.
  • Mobile / Windows clients — out of scope for v1. Windows requires WireGuard for Windows + a different config-gen path.

When NOT to use ADR-111

If you... Use this instead
Need NAT traversal Tailscale or Headscale
Have >50 peers Tailscale (their infra handles your scale)
Don't need trust↔L3 coupling Plain tailnet + federation breaker is simpler
Don't need cryptographic provenance of mesh changes Tailscale's audit log suffices