Skip to content

feat(netguard,wireguard): design-13 G1+G2 netguard foundation & compiler, W1 topology, W2 apply safety - #6

Merged
lr00rl merged 3 commits into
mainfrom
feat/netguard-foundation
Jul 9, 2026
Merged

feat(netguard,wireguard): design-13 G1+G2 netguard foundation & compiler, W1 topology, W2 apply safety#6
lr00rl merged 3 commits into
mainfrom
feat/netguard-foundation

Conversation

@lr00rl

@lr00rl lr00rl commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Implements design-13 (LatticeNet/lattice#1) slices G1, G2, W1, W2 — iter-068/069/070.

G1 — foundation (read-only, zero apply-path change)

  • netguard:read/netguard:admin in the RBAC catalog + plugin capability risk table
  • store SecurityGroups/GuardZones/GuardBindings with nil-guarded upgrade and ErrGuardVersionConflict optimistic concurrency (closes the silent-clobber gap legacy NFTInputs upserts have)
  • internal/netguard.LegacyBaseline converts every existing baseline into the design-13 shape; conversion happens on read and persists nothing

G2 — compiler, byte-parity gate, lint, write path

The central call: lower, don't rewrite. The compiler lowers zones/groups/bindings into the existing network.NFTPlan; GenerateNFTPlan stays the single renderer of table inet lattice_guard. Byte-parity with the legacy path is therefore structural, and the gate is mutation-checked — disabling the fast path turns TestLegacyBaselineRendersByteIdentically red (8 fixtures incl. the real dmit-eb-wee baseline).

  • NFTInputRule.Interface → renders iifname, making a trusted overlay zone expressible. It renders before the broad allows, so a node's tailscale0 path survives a policy-drop guard — the exact lockout class found on dmit-eb-wee.
  • lockout_risk_ssh is a blocking lint: a default-drop plan with no path to tcp/22 is refused before it reaches a node. Cleared by a management-port allow, an any-protocol accept, or a trusted zone. Overriding is explicit and audited. unverified_apply warns (loudly) when public_url is unset.
  • netguard:admin write path: rules compile at write time; referential integrity on delete; reserved sg-legacy-* id space; 409 on stale versions.
  • Plans carry Approval.Plugin="nft" — same ruleset, same rollback-protected apply script — so G2 is end-to-end usable with zero apply-path change.

W1 — WireGuard topology

BuildTopology generalizes BuildMesh to named networks: mesh, hub-and-spoke, and a fail-closed custom (never silently degrades to mesh). Security invariants preserved verbatim: host-route pinning survives a member reporting 10.66.0.5/16; a spoke's self-declared 0.0.0.0/0 is ignored; only a hub's reviewed ExtraAllowedIPs widen a peer. Interface gains MTU/DNS (rendered only when set, validated); AllowedIPs accepts multi-value.

Migration gate: the existing implicit mesh renders identically through BuildTopology(mesh) — same interface, same peers, same rendered config, for every node.

W2 — the headline safety gap, closed

wireguard was the only host-mutating apply path with no dead-man protection (server.go:4810-4821). A bad wg0.conf stranded a node. It now does what nft always did:

wg-quick strip validate → snapshot → arm detached watchdog → commit → --selfcheck-controlplaneassert_watchdog_clean → disarm.

Plus a wg syncconf fast path (no tunnel flap on peer-only changes, gated on an unchanged [Interface] block), a shared applyWatchdogWindowSec across nft and wireguard, and removal of the key-bearing stripped config on the failure path. The nft watchdog itself is untouched — a tested crown-jewel path got a structurally identical sibling, not a risky refactor.

Test plan

  • go build, gofmt -l, go vet clean (GOWORK=off, pinned SDK)
  • go test -race -count=1 ./...exit 0, 25 packages
  • Parity gate mutation-checked (red-if-removed)
  • Every generated apply script passes sh -n — catches quoting errors in the watchdog's nested sh -c bodies that string assertions cannot
  • Live-node E2E: a deliberately broken wg0.conf auto-restores within the watchdog window (needs a scratch node; manual step)

SDK pin → v0.2.17-0.20260709055807-30d4d08e6fa8 (pairs with LatticeNet/lattice-sdk#5). No stable tags minted; the alpha image train stays at alpha-0.2.1a24 until this merges.

Next: G3 reality/drift, G4 dashboard, W1b store/API/discovery.

https://claude.ai/code/session_01D6PbasV2UT8nytJXGpn47Q

…nly legacy views

- rbac + plugin capability registration: netguard:read (read),
  netguard:admin (host), mirroring the iter-020 netpolicy pattern.
- store: SecurityGroups/GuardZones/GuardBindings collections with
  nil-guarded state upgrade and ErrGuardVersionConflict optimistic
  concurrency on group/binding upserts.
- new internal/netguard package: PortRanges compression and
  LegacyBaseline (NFTInputs -> node-private sg-legacy-<node> group +
  observe-only binding + resolved builtin zones); semantics preserved,
  wireguard stays a rule remote, never a trusted zone.
- read-only GET /api/netguard/{groups,zones,nodes} behind netguard:read
  with per-node allowlist filtering; stored records supersede legacy
  views; conversion persists nothing.
- SDK pin bumped to v0.2.17-0.20260709050800-d0f6124704ec (netguard
  model types).

Zero apply-path changes. Verified: go build, gofmt, go vet, and
go test -race across netguard/store/rbac/plugin/server (server suite
355s green) with GOWORK=off against the pinned SDK.

Claude-Session: https://claude.ai/code/session_01D6PbasV2UT8nytJXGpn47Q
…ogy + W2 apply safety

G2 (iter-069) — netguard compiler LOWERS zones/groups/bindings into the
existing network.NFTPlan, so GenerateNFTPlan stays the single renderer of
lattice_guard and byte-parity with the legacy baseline is structural.
The parity gate is mutation-checked: disabling the fast path turns it red.
- network.NFTInputRule gains Interface -> renders iifname, which is what
  makes a trusted overlay zone (tailscale0) expressible; it renders before
  the broad allows, so a node's overlay path survives a policy-drop guard.
- lockout_risk_ssh is a BLOCKING lint: a default-drop plan with no path to
  tcp/22 is refused before it reaches a node. The dmit-eb-wee failure class
  moves from post-apply watchdog rollback to pre-plan refusal. Overriding it
  is explicit and audited.
- netguard:admin write path: rules compile at write time (no unrenderable
  rule reaches the store), referential integrity on delete, reserved legacy
  id space, 409 on stale versions.
- Plans ride Approval{Plugin:nft} - same ruleset, same rollback-protected
  apply script - so G2 is end-to-end usable with zero apply-path change.

W1 (iter-070) — wireguard.BuildTopology generalizes BuildMesh to named
networks with mesh/hub-and-spoke and a fail-closed custom mode. Mesh renders
identically to BuildMesh (interface, peers, and rendered config) for every
node of the fixture fleet. Host-route pinning survives: a spoke's
self-declared 0.0.0.0/0 is ignored; only a hub's reviewed ExtraAllowedIPs
widen AllowedIPs. Interface gains MTU/DNS; AllowedIPs accepts multi-value.

W2 (iter-070) — wireguard was the ONLY host-mutating apply path with no
dead-man protection. It now validates (wg-quick strip), snapshots, arms a
detached watchdog, commits, runs the control-plane selfcheck, and refuses to
report success if the watchdog fired. wg syncconf fast path avoids flapping
established tunnels when only peers changed, gated on an unchanged
[Interface] block. applyWatchdogWindowSec is now shared by nft and wireguard.
The key-bearing stripped config is removed on the failure path too.
The nft watchdog itself is untouched.

Verified: go build, gofmt, go vet clean; go test -race ./... exit 0 across
25 packages. Every generated apply script passes sh -n, which catches quoting
errors in the watchdog's nested sh -c bodies that string assertions cannot.
SDK pin -> v0.2.17-0.20260709055807-30d4d08e6fa8.

Claude-Session: https://claude.ai/code/session_01D6PbasV2UT8nytJXGpn47Q
@lr00rl lr00rl changed the title feat(netguard): design-13 G1 foundation — store, scopes, read-only legacy views feat(netguard,wireguard): design-13 G1+G2 netguard foundation & compiler, W1 topology, W2 apply safety Jul 9, 2026
@lr00rl
lr00rl marked this pull request as ready for review July 9, 2026 06:26
The server now treats NetGuard groups and zones as fleet-global catalog objects: node-allowlisted PATs can still read/adopt/plan their allowed nodes, but cannot list or mutate global groups and zones. Node remotes are also normalized to host routes before nft lowering, so a node-reported wide WireGuard prefix cannot widen source matches. The server pin moves to the SDK commit that keeps zone_id and the guard models coherent across protobuf and Go/JSON contracts.

Constraint: Firewall authoring is a host-risk path; node allowlists must remain hard boundaries

Rejected: Filter group catalog partially for restricted PATs | shared groups/zones are fleet-global and need an explicit ownership model before scoped editing

Rejected: Trust node-reported CIDR prefixes | a compromised node could expand source matches beyond its identity

Confidence: high

Scope-risk: moderate

Directive: Do not expose global NetGuard catalog routes to server-allowlisted tokens without a node-owned resource model and regression tests

Tested: go test ./...

Not-tested: Live nft/wireguard rollback E2E on a scratch VM
@lr00rl
lr00rl merged commit 985be06 into main Jul 9, 2026
1 check passed
@lr00rl
lr00rl deleted the feat/netguard-foundation branch July 9, 2026 10:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant