From 46ea15028e70ab6adee6001e6722e92a283161b7 Mon Sep 17 00:00:00 2001 From: lr00rl Date: Thu, 9 Jul 2026 01:15:32 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(netguard):=20design-13=20G1=20foundati?= =?UTF-8?q?on=20=E2=80=94=20model=20store,=20scopes,=20read-only=20legacy?= =?UTF-8?q?=20views?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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- 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 --- go.mod | 2 +- go.sum | 2 + internal/netguard/convert.go | 141 ++++++++++++++ internal/netguard/convert_test.go | 141 ++++++++++++++ internal/plugin/plugin.go | 2 + internal/rbac/rbac.go | 2 + internal/server/server.go | 3 + internal/server/server_netguard.go | 154 +++++++++++++++ internal/server/server_netguard_test.go | 244 ++++++++++++++++++++++++ internal/store/store.go | 183 ++++++++++++++++++ 10 files changed, 873 insertions(+), 1 deletion(-) create mode 100644 internal/netguard/convert.go create mode 100644 internal/netguard/convert_test.go create mode 100644 internal/server/server_netguard.go create mode 100644 internal/server/server_netguard_test.go diff --git a/go.mod b/go.mod index 9cebcbc..0e63126 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/LatticeNet/lattice-server go 1.26 require ( - github.com/LatticeNet/lattice-sdk v0.2.17-0.20260708093236-9de870576def + github.com/LatticeNet/lattice-sdk v0.2.17-0.20260709050800-d0f6124704ec github.com/coreos/go-oidc/v3 v3.18.0 github.com/descope/virtualwebauthn v1.0.5 github.com/go-webauthn/webauthn v0.17.4 diff --git a/go.sum b/go.sum index f4126c8..b59454d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/LatticeNet/lattice-sdk v0.2.17-0.20260708093236-9de870576def h1:nfrzGLB3xMRL9j0Zub8BMkfTP5bn87hiIjVQWlcPfHo= github.com/LatticeNet/lattice-sdk v0.2.17-0.20260708093236-9de870576def/go.mod h1:7ENUQ4EoS/TSW/eNomCGfZGliUPJZ46uAvp7dVcEXoE= +github.com/LatticeNet/lattice-sdk v0.2.17-0.20260709050800-d0f6124704ec h1:SnafEo1Z+rZUpKzxMSO6L984uuTXQptbQ1wiSANiXTE= +github.com/LatticeNet/lattice-sdk v0.2.17-0.20260709050800-d0f6124704ec/go.mod h1:7ENUQ4EoS/TSW/eNomCGfZGliUPJZ46uAvp7dVcEXoE= github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/internal/netguard/convert.go b/internal/netguard/convert.go new file mode 100644 index 0000000..11bf76a --- /dev/null +++ b/internal/netguard/convert.go @@ -0,0 +1,141 @@ +// Package netguard implements the design-13 security-group firewall plane. +// This G1 slice is read-only: it materializes the design-13 view (security +// group + node binding + resolved builtin zones) of a node's legacy NFTInputs +// baseline without mutating the store or any apply path. The G2 slice adds the +// compiler with a byte-parity gate against network.GenerateNFTPlan before the +// legacy path retires. +package netguard + +import ( + "sort" + + "github.com/LatticeNet/lattice-sdk/model" +) + +const ( + // LegacyGroupPrefix namespaces the node-private security groups derived + // from legacy NFTInputs baselines (design-13 §7.1). + LegacyGroupPrefix = "sg-legacy-" + + defaultInterface = "eth0" + defaultWireGuardCIDR = "10.66.0.0/24" +) + +// PortRanges compresses a port list into sorted, deduplicated inclusive +// ranges: [9009,9010,9011,9013] becomes 9009-9011 and 9013. Out-of-range +// values are dropped rather than widened (fail-closed). +func PortRanges(ports []int) []model.GuardPortRange { + valid := make([]int, 0, len(ports)) + for _, p := range ports { + if p >= 1 && p <= 65535 { + valid = append(valid, p) + } + } + if len(valid) == 0 { + return nil + } + sort.Ints(valid) + out := []model.GuardPortRange{{From: valid[0], To: valid[0]}} + for _, p := range valid[1:] { + last := &out[len(out)-1] + switch { + case p == last.To: // duplicate + case p == last.To+1: + last.To = p + default: + out = append(out, model.GuardPortRange{From: p, To: p}) + } + } + return out +} + +// LegacyView is the read-only design-13 rendering of one node's legacy +// NFTInputs baseline. +type LegacyView struct { + Group model.SecurityGroup + Binding model.NodeGuardBinding + Zones []model.GuardZone +} + +// LegacyBaseline converts a legacy NFTInputs record into the design-13 shape: +// one node-private security group whose rules reference the builtin public and +// wireguard zones, a binding attaching that group, and the node-resolved zone +// definitions. Semantics are preserved exactly: legacy "wireguard ports" were +// port-scoped source-CIDR allows, so the wireguard zone appears as a rule +// remote, never as a trusted zone in Binding.ZoneIDs. Managed is false: the +// node stays observe-only until an operator explicitly adopts it (G2). +func LegacyBaseline(inputs model.NFTInputs) LegacyView { + iface := inputs.InterfaceName + if iface == "" { + iface = defaultInterface + } + wgCIDR := inputs.WireGuardCIDR + if wgCIDR == "" { + wgCIDR = defaultWireGuardCIDR + } + + publicRemote := model.NetEndpoint{Kind: model.NetRefZone, ZoneID: model.GuardZonePublic} + wgRemote := model.NetEndpoint{Kind: model.NetRefZone, ZoneID: model.GuardZoneWireGuard} + + rules := make([]model.GuardRule, 0, 4) + appendRule := func(id, proto, comment string, ports []int, remote model.NetEndpoint) { + ranges := PortRanges(ports) + if len(ranges) == 0 { + return + } + rules = append(rules, model.GuardRule{ + ID: id, + Comment: comment, + Action: model.NetRuleAllow, + Direction: model.NetDirIngress, + Protocol: proto, + Ports: ranges, + Remote: remote, + }) + } + appendRule("legacy-public-tcp", model.NetProtoTCP, "public lattice tcp ports", inputs.PublicTCP, publicRemote) + appendRule("legacy-public-udp", model.NetProtoUDP, "public lattice udp ports", inputs.PublicUDP, publicRemote) + appendRule("legacy-wg-tcp", model.NetProtoTCP, "wg tcp services", inputs.WireGuardTCP, wgRemote) + appendRule("legacy-wg-udp", model.NetProtoUDP, "wg udp services", inputs.WireGuardUDP, wgRemote) + + groupID := LegacyGroupPrefix + inputs.NodeID + group := model.SecurityGroup{ + ID: groupID, + Name: "legacy-baseline-" + inputs.NodeID, + Description: "Converted from the legacy Network Guard baseline (NFTInputs). Read-only until adopted.", + Rules: rules, + CreatedAt: inputs.CreatedAt, + UpdatedAt: inputs.UpdatedAt, + } + + binding := model.NodeGuardBinding{ + NodeID: inputs.NodeID, + GroupIDs: []string{groupID}, + Managed: false, + CreatedAt: inputs.CreatedAt, + UpdatedAt: inputs.UpdatedAt, + } + + zones := []model.GuardZone{ + { + ID: model.GuardZonePublic, + Name: "public", + Builtin: true, + Interfaces: []string{iface}, + }, + { + ID: model.GuardZoneWireGuard, + Name: "wireguard", + Builtin: true, + CIDRs: []string{wgCIDR}, + }, + { + ID: model.GuardZoneLoopback, + Name: "loopback", + Builtin: true, + Interfaces: []string{"lo"}, + }, + } + + return LegacyView{Group: group, Binding: binding, Zones: zones} +} diff --git a/internal/netguard/convert_test.go b/internal/netguard/convert_test.go new file mode 100644 index 0000000..d5d3de8 --- /dev/null +++ b/internal/netguard/convert_test.go @@ -0,0 +1,141 @@ +package netguard + +import ( + "reflect" + "testing" + + "github.com/LatticeNet/lattice-sdk/model" +) + +func TestPortRanges(t *testing.T) { + cases := []struct { + name string + in []int + want []model.GuardPortRange + }{ + {"empty", nil, nil}, + {"single", []int{443}, []model.GuardPortRange{{From: 443, To: 443}}}, + {"unsorted with duplicates", []int{443, 80, 443}, + []model.GuardPortRange{{From: 80, To: 80}, {From: 443, To: 443}}}, + {"adjacent run collapses", []int{9010, 9009, 9011, 9012, 9013}, + []model.GuardPortRange{{From: 9009, To: 9013}}}, + {"run plus gap", []int{22, 9009, 9010, 9011, 9013}, + []model.GuardPortRange{{From: 22, To: 22}, {From: 9009, To: 9011}, {From: 9013, To: 9013}}}, + {"out of range dropped", []int{0, -1, 70000, 80}, + []model.GuardPortRange{{From: 80, To: 80}}}, + {"all invalid", []int{0, 70000}, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := PortRanges(tc.in) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("PortRanges(%v) = %+v, want %+v", tc.in, got, tc.want) + } + }) + } +} + +// The dmit-eb-wee incident fixture: 15 mirrored TCP+UDP baseline ports on +// eth0 / 10.66.0.0/24. The converted view must compress the croc run +// 9009-9013 into one range, reference the builtin zones, and stay +// observe-only (Managed=false, wireguard never a trusted zone). +func TestLegacyBaselineConvertsRealBaseline(t *testing.T) { + ports := []int{115, 3433, 7443, 7500, 7780, 9009, 9010, 9011, 9012, 9013, 17891, 17893, 42622, 48358, 57289} + view := LegacyBaseline(model.NFTInputs{ + ID: "dmit-eb-wee", + NodeID: "dmit-eb-wee", + InterfaceName: "eth0", + WireGuardCIDR: "10.66.0.0/24", + PublicTCP: ports, + PublicUDP: ports, + }) + + if view.Group.ID != "sg-legacy-dmit-eb-wee" { + t.Fatalf("group id = %q", view.Group.ID) + } + if len(view.Group.Rules) != 2 { + t.Fatalf("want 2 rules (tcp+udp), got %d: %+v", len(view.Group.Rules), view.Group.Rules) + } + wantRanges := []model.GuardPortRange{ + {From: 115, To: 115}, {From: 3433, To: 3433}, {From: 7443, To: 7443}, + {From: 7500, To: 7500}, {From: 7780, To: 7780}, {From: 9009, To: 9013}, + {From: 17891, To: 17891}, {From: 17893, To: 17893}, {From: 42622, To: 42622}, + {From: 48358, To: 48358}, {From: 57289, To: 57289}, + } + for i, proto := range []string{model.NetProtoTCP, model.NetProtoUDP} { + rule := view.Group.Rules[i] + if rule.Protocol != proto || rule.Action != model.NetRuleAllow || rule.Direction != model.NetDirIngress { + t.Fatalf("rule %d shape wrong: %+v", i, rule) + } + if rule.Remote.Kind != model.NetRefZone || rule.Remote.ZoneID != model.GuardZonePublic { + t.Fatalf("rule %d remote wrong: %+v", i, rule.Remote) + } + if !reflect.DeepEqual(rule.Ports, wantRanges) { + t.Fatalf("rule %d ranges = %+v, want %+v", i, rule.Ports, wantRanges) + } + } + + if view.Binding.Managed { + t.Fatal("legacy binding must be observe-only (Managed=false)") + } + if len(view.Binding.ZoneIDs) != 0 { + t.Fatalf("legacy binding must not trust any zone, got %v", view.Binding.ZoneIDs) + } + if !reflect.DeepEqual(view.Binding.GroupIDs, []string{"sg-legacy-dmit-eb-wee"}) { + t.Fatalf("binding groups = %v", view.Binding.GroupIDs) + } + + zoneByID := map[string]model.GuardZone{} + for _, z := range view.Zones { + zoneByID[z.ID] = z + } + if got := zoneByID[model.GuardZonePublic].Interfaces; !reflect.DeepEqual(got, []string{"eth0"}) { + t.Fatalf("public zone interfaces = %v", got) + } + if got := zoneByID[model.GuardZoneWireGuard].CIDRs; !reflect.DeepEqual(got, []string{"10.66.0.0/24"}) { + t.Fatalf("wireguard zone cidrs = %v", got) + } + if _, ok := zoneByID[model.GuardZoneLoopback]; !ok { + t.Fatal("loopback zone missing") + } +} + +func TestLegacyBaselineWireGuardPortsAndDefaults(t *testing.T) { + view := LegacyBaseline(model.NFTInputs{ + ID: "node-a", + NodeID: "node-a", + WireGuardTCP: []int{9100, 22}, + WireGuardUDP: []int{51820}, + }) + if len(view.Group.Rules) != 2 { + t.Fatalf("want 2 wg rules, got %d", len(view.Group.Rules)) + } + for _, rule := range view.Group.Rules { + if rule.Remote.Kind != model.NetRefZone || rule.Remote.ZoneID != model.GuardZoneWireGuard { + t.Fatalf("wg rule remote wrong: %+v", rule.Remote) + } + } + if got := view.Group.Rules[0].Ports; !reflect.DeepEqual(got, []model.GuardPortRange{{From: 22, To: 22}, {From: 9100, To: 9100}}) { + t.Fatalf("wg tcp ranges = %+v", got) + } + zoneByID := map[string]model.GuardZone{} + for _, z := range view.Zones { + zoneByID[z.ID] = z + } + if got := zoneByID[model.GuardZonePublic].Interfaces; !reflect.DeepEqual(got, []string{"eth0"}) { + t.Fatalf("default public interface = %v", got) + } + if got := zoneByID[model.GuardZoneWireGuard].CIDRs; !reflect.DeepEqual(got, []string{"10.66.0.0/24"}) { + t.Fatalf("default wg cidr = %v", got) + } +} + +func TestLegacyBaselineEmptyInputsYieldsNoRules(t *testing.T) { + view := LegacyBaseline(model.NFTInputs{ID: "node-b", NodeID: "node-b"}) + if len(view.Group.Rules) != 0 { + t.Fatalf("empty baseline must convert to zero rules, got %+v", view.Group.Rules) + } + if view.Binding.Managed { + t.Fatal("empty baseline must stay observe-only") + } +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 23c2223..e32f76b 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -65,6 +65,7 @@ var capabilityRisk = map[string]string{ "http:egress": RiskHost, "kv:read": RiskRead, "monitor:read": RiskRead, + "netguard:read": RiskRead, "netpolicy:read": RiskRead, "node:read": RiskRead, "static:read": RiskRead, @@ -75,6 +76,7 @@ var capabilityRisk = map[string]string{ "worker:route": RiskWrite, "ddns:admin": RiskHost, "monitor:admin": RiskHost, + "netguard:admin": RiskHost, "network:apply": RiskHost, "network:plan": RiskHost, "netpolicy:admin": RiskHost, diff --git a/internal/rbac/rbac.go b/internal/rbac/rbac.go index ac983fd..64687c3 100644 --- a/internal/rbac/rbac.go +++ b/internal/rbac/rbac.go @@ -62,6 +62,8 @@ var KnownScopes = map[string]struct{}{ "log:write": {}, "monitor:admin": {}, "monitor:read": {}, + "netguard:admin": {}, + "netguard:read": {}, "netpolicy:admin": {}, "netpolicy:read": {}, "network:apply": {}, diff --git a/internal/server/server.go b/internal/server/server.go index 9c5fcd7..b11ca5e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -877,6 +877,9 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/api/group-policies/delete", s.withAuth("netpolicy:admin", s.handleDeleteGroupPolicy)) mux.HandleFunc("/api/group-policies/plan", s.withAuth("netpolicy:admin", s.handleGroupPolicyPlan)) mux.HandleFunc("/api/netpolicy/matrix", s.withAuth("netpolicy:read", s.handleNetPolicyMatrix)) + mux.HandleFunc("/api/netguard/groups", s.withAuth("netguard:read", s.handleNetGuardGroups)) + mux.HandleFunc("/api/netguard/zones", s.withAuth("netguard:read", s.handleNetGuardZones)) + mux.HandleFunc("/api/netguard/nodes", s.withAuth("netguard:read", s.handleNetGuardNodes)) mux.HandleFunc("/api/network/wireguard/plan", s.withAuth("network:plan", s.handleWireGuardPlan)) mux.HandleFunc("/api/tunnels", s.withAuth("tunnel:admin", s.handleTunnels)) mux.HandleFunc("/api/tunnels/delete", s.withAuth("tunnel:admin", s.handleDeleteTunnel)) diff --git a/internal/server/server_netguard.go b/internal/server/server_netguard.go new file mode 100644 index 0000000..5556695 --- /dev/null +++ b/internal/server/server_netguard.go @@ -0,0 +1,154 @@ +package server + +import ( + "errors" + "net/http" + + "github.com/LatticeNet/lattice-sdk/model" + "github.com/LatticeNet/lattice-server/internal/netguard" + "github.com/LatticeNet/lattice-server/internal/rbac" +) + +// design-13 G1: read-only netguard views. Stored security groups, zones, and +// bindings are served as-is; nodes that only have a legacy NFTInputs baseline +// are served as an on-the-fly converted view marked source:"legacy". Nothing +// here mutates the store or touches any apply path. + +const ( + netGuardSourceStored = "stored" + netGuardSourceLegacy = "legacy" +) + +type securityGroupView struct { + model.SecurityGroup + Source string `json:"source"` + NodeID string `json:"node_id,omitempty"` // set for node-private legacy groups +} + +type nodeGuardView struct { + NodeID string `json:"node_id"` + NodeName string `json:"node_name,omitempty"` + Source string `json:"source"` + Binding model.NodeGuardBinding `json:"binding"` + Groups []securityGroupView `json:"groups"` + Zones []model.GuardZone `json:"zones"` +} + +func (s *Server) handleNetGuardGroups(w http.ResponseWriter, r *http.Request, p principal) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed")) + return + } + views := make([]securityGroupView, 0) + for _, group := range s.store.SecurityGroups() { + views = append(views, securityGroupView{SecurityGroup: group, Source: netGuardSourceStored}) + } + for _, inputs := range s.store.AllNFTInputs() { + if !rbac.Allows(p.Principal, "netguard:read", inputs.NodeID) { + continue + } + if _, ok := s.store.SecurityGroup(netguard.LegacyGroupPrefix + inputs.NodeID); ok { + continue // an adopted stored group supersedes the legacy view + } + converted := netguard.LegacyBaseline(inputs) + views = append(views, securityGroupView{ + SecurityGroup: converted.Group, + Source: netGuardSourceLegacy, + NodeID: inputs.NodeID, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"groups": views}) +} + +func (s *Server) handleNetGuardZones(w http.ResponseWriter, r *http.Request, p principal) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed")) + return + } + builtin := []model.GuardZone{ + {ID: model.GuardZonePublic, Name: "public", Builtin: true}, + {ID: model.GuardZoneLoopback, Name: "loopback", Builtin: true, Interfaces: []string{"lo"}}, + {ID: model.GuardZoneWireGuard, Name: "wireguard", Builtin: true}, + {ID: model.GuardZoneTailscale, Name: "tailscale", Builtin: true}, + } + zones := make([]model.GuardZone, 0, len(builtin)) + seen := map[string]bool{} + for _, zone := range s.store.GuardZones() { + zones = append(zones, zone) + seen[zone.ID] = true + } + for _, zone := range builtin { + if !seen[zone.ID] { + zones = append(zones, zone) + } + } + writeJSON(w, http.StatusOK, map[string]any{"zones": zones}) +} + +func (s *Server) handleNetGuardNodes(w http.ResponseWriter, r *http.Request, p principal) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed")) + return + } + views := make([]nodeGuardView, 0) + covered := map[string]bool{} + for _, binding := range s.store.NodeGuardBindings() { + if !rbac.Allows(p.Principal, "netguard:read", binding.NodeID) { + continue + } + covered[binding.NodeID] = true + views = append(views, s.storedNodeGuardView(binding)) + } + for _, inputs := range s.store.AllNFTInputs() { + if covered[inputs.NodeID] { + continue + } + if !rbac.Allows(p.Principal, "netguard:read", inputs.NodeID) { + continue + } + converted := netguard.LegacyBaseline(inputs) + views = append(views, nodeGuardView{ + NodeID: inputs.NodeID, + NodeName: s.nodeName(inputs.NodeID), + Source: netGuardSourceLegacy, + Binding: converted.Binding, + Groups: []securityGroupView{{ + SecurityGroup: converted.Group, + Source: netGuardSourceLegacy, + NodeID: inputs.NodeID, + }}, + Zones: converted.Zones, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"nodes": views}) +} + +func (s *Server) storedNodeGuardView(binding model.NodeGuardBinding) nodeGuardView { + groups := make([]securityGroupView, 0, len(binding.GroupIDs)) + for _, groupID := range binding.GroupIDs { + if group, ok := s.store.SecurityGroup(groupID); ok { + groups = append(groups, securityGroupView{SecurityGroup: group, Source: netGuardSourceStored}) + } + } + zones := make([]model.GuardZone, 0, len(binding.ZoneIDs)) + for _, zoneID := range binding.ZoneIDs { + if zone, ok := s.store.GuardZone(zoneID); ok { + zones = append(zones, zone) + } + } + return nodeGuardView{ + NodeID: binding.NodeID, + NodeName: s.nodeName(binding.NodeID), + Source: netGuardSourceStored, + Binding: binding, + Groups: groups, + Zones: zones, + } +} + +func (s *Server) nodeName(nodeID string) string { + if node, ok := s.store.Node(nodeID); ok { + return node.Name + } + return "" +} diff --git a/internal/server/server_netguard_test.go b/internal/server/server_netguard_test.go new file mode 100644 index 0000000..4c49c55 --- /dev/null +++ b/internal/server/server_netguard_test.go @@ -0,0 +1,244 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "testing" + + "github.com/LatticeNet/lattice-sdk/model" + "github.com/LatticeNet/lattice-server/internal/store" +) + +type netGuardGroupsResponse struct { + Groups []struct { + ID string `json:"id"` + Name string `json:"name"` + Source string `json:"source"` + NodeID string `json:"node_id"` + Rules []struct { + ID string `json:"id"` + Action string `json:"action"` + Protocol string `json:"protocol"` + Ports []struct { + From int `json:"from"` + To int `json:"to"` + } `json:"ports"` + Remote struct { + Kind string `json:"kind"` + ZoneID string `json:"zone_id"` + } `json:"remote"` + } `json:"rules"` + } `json:"groups"` +} + +type netGuardNodesResponse struct { + Nodes []struct { + NodeID string `json:"node_id"` + NodeName string `json:"node_name"` + Source string `json:"source"` + Binding struct { + Managed bool `json:"managed"` + GroupIDs []string `json:"group_ids"` + ZoneIDs []string `json:"zone_ids"` + } `json:"binding"` + Zones []struct { + ID string `json:"id"` + Builtin bool `json:"builtin"` + Interfaces []string `json:"interfaces"` + CIDRs []string `json:"cidrs"` + } `json:"zones"` + } `json:"nodes"` +} + +func TestNetGuardLegacyReadOnlyViews(t *testing.T) { + handler, st := newTestServer(t) + cookies, csrf := loginSession(t, handler) + enrollNamedNode(t, handler, cookies, csrf, "node-a", "Node A") + + save := doJSON(t, handler, http.MethodPost, "/api/network/nft/inputs", `{ + "node_id":"node-a", + "interface_name":"ens3", + "wireguard_cidr":"10.66.0.0/24", + "public_tcp":[22,9009,9010,9011,9012,9013], + "public_udp":[51820] + }`, cookies, csrf) + defer save.Body.Close() + if save.StatusCode != http.StatusOK { + t.Fatalf("save nft inputs failed: %d", save.StatusCode) + } + + nodesRes := doJSON(t, handler, http.MethodGet, "/api/netguard/nodes", "", cookies, csrf) + defer nodesRes.Body.Close() + if nodesRes.StatusCode != http.StatusOK { + t.Fatalf("netguard nodes failed: %d", nodesRes.StatusCode) + } + var nodes netGuardNodesResponse + if err := json.NewDecoder(nodesRes.Body).Decode(&nodes); err != nil { + t.Fatal(err) + } + if len(nodes.Nodes) != 1 { + t.Fatalf("want 1 node view, got %d", len(nodes.Nodes)) + } + node := nodes.Nodes[0] + if node.NodeID != "node-a" || node.NodeName != "Node A" || node.Source != "legacy" { + t.Fatalf("bad node view: %+v", node) + } + if node.Binding.Managed { + t.Fatal("legacy view must be observe-only") + } + if len(node.Binding.GroupIDs) != 1 || node.Binding.GroupIDs[0] != "sg-legacy-node-a" { + t.Fatalf("bad binding groups: %v", node.Binding.GroupIDs) + } + if len(node.Binding.ZoneIDs) != 0 { + t.Fatalf("legacy binding must not trust zones: %v", node.Binding.ZoneIDs) + } + zoneIfaces := map[string][]string{} + zoneCIDRs := map[string][]string{} + for _, z := range node.Zones { + zoneIfaces[z.ID] = z.Interfaces + zoneCIDRs[z.ID] = z.CIDRs + } + if got := zoneIfaces["public"]; len(got) != 1 || got[0] != "ens3" { + t.Fatalf("public zone interfaces = %v", got) + } + if got := zoneCIDRs["wireguard"]; len(got) != 1 || got[0] != "10.66.0.0/24" { + t.Fatalf("wireguard zone cidrs = %v", got) + } + + groupsRes := doJSON(t, handler, http.MethodGet, "/api/netguard/groups", "", cookies, csrf) + defer groupsRes.Body.Close() + if groupsRes.StatusCode != http.StatusOK { + t.Fatalf("netguard groups failed: %d", groupsRes.StatusCode) + } + var groups netGuardGroupsResponse + if err := json.NewDecoder(groupsRes.Body).Decode(&groups); err != nil { + t.Fatal(err) + } + if len(groups.Groups) != 1 { + t.Fatalf("want 1 legacy group, got %d", len(groups.Groups)) + } + group := groups.Groups[0] + if group.ID != "sg-legacy-node-a" || group.Source != "legacy" || group.NodeID != "node-a" { + t.Fatalf("bad group view: %+v", group) + } + if len(group.Rules) != 2 { + t.Fatalf("want tcp+udp rules, got %d", len(group.Rules)) + } + tcp := group.Rules[0] + if tcp.Protocol != "tcp" || tcp.Remote.Kind != "zone" || tcp.Remote.ZoneID != "public" { + t.Fatalf("bad tcp rule: %+v", tcp) + } + // 22 stays single, 9009-9013 collapses into one reviewable range. + if len(tcp.Ports) != 2 || tcp.Ports[0].From != 22 || tcp.Ports[0].To != 22 || + tcp.Ports[1].From != 9009 || tcp.Ports[1].To != 9013 { + t.Fatalf("bad tcp ranges: %+v", tcp.Ports) + } + + // The legacy view must not have persisted anything. + if _, ok := st.SecurityGroup("sg-legacy-node-a"); ok { + t.Fatal("legacy conversion must not write to the store") + } + if _, ok := st.NodeGuardBinding("node-a"); ok { + t.Fatal("legacy conversion must not persist bindings") + } + + zonesRes := doJSON(t, handler, http.MethodGet, "/api/netguard/zones", "", cookies, csrf) + defer zonesRes.Body.Close() + if zonesRes.StatusCode != http.StatusOK { + t.Fatalf("netguard zones failed: %d", zonesRes.StatusCode) + } + var zones struct { + Zones []struct { + ID string `json:"id"` + Builtin bool `json:"builtin"` + } `json:"zones"` + } + if err := json.NewDecoder(zonesRes.Body).Decode(&zones); err != nil { + t.Fatal(err) + } + builtins := map[string]bool{} + for _, z := range zones.Zones { + if z.Builtin { + builtins[z.ID] = true + } + } + for _, want := range []string{"public", "loopback", "wireguard", "tailscale"} { + if !builtins[want] { + t.Fatalf("builtin zone %q missing from %+v", want, zones.Zones) + } + } +} + +func TestNetGuardStoredBindingSupersedesLegacyView(t *testing.T) { + handler, st := newTestServer(t) + cookies, csrf := loginSession(t, handler) + enrollNamedNode(t, handler, cookies, csrf, "node-a", "Node A") + + save := doJSON(t, handler, http.MethodPost, "/api/network/nft/inputs", + `{"node_id":"node-a","public_tcp":[22]}`, cookies, csrf) + defer save.Body.Close() + if save.StatusCode != http.StatusOK { + t.Fatalf("save nft inputs failed: %d", save.StatusCode) + } + + group, err := st.UpsertSecurityGroup(model.SecurityGroup{ID: "sg-web", Name: "web"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.UpsertNodeGuardBinding(model.NodeGuardBinding{ + NodeID: "node-a", + GroupIDs: []string{group.ID}, + Managed: true, + }); err != nil { + t.Fatal(err) + } + + nodesRes := doJSON(t, handler, http.MethodGet, "/api/netguard/nodes", "", cookies, csrf) + defer nodesRes.Body.Close() + var nodes netGuardNodesResponse + if err := json.NewDecoder(nodesRes.Body).Decode(&nodes); err != nil { + t.Fatal(err) + } + if len(nodes.Nodes) != 1 { + t.Fatalf("stored binding must supersede the legacy view, got %d views", len(nodes.Nodes)) + } + if nodes.Nodes[0].Source != "stored" || !nodes.Nodes[0].Binding.Managed { + t.Fatalf("bad stored view: %+v", nodes.Nodes[0]) + } +} + +func TestNetGuardStoreVersionConflicts(t *testing.T) { + _, st := newTestServer(t) + + created, err := st.UpsertSecurityGroup(model.SecurityGroup{ID: "sg-a", Name: "a"}) + if err != nil { + t.Fatal(err) + } + if created.Version != 1 { + t.Fatalf("first upsert version = %d, want 1", created.Version) + } + + // Stale write (echoes version 0 after the record moved to 1) must fail. + if _, err := st.UpsertSecurityGroup(model.SecurityGroup{ID: "sg-a", Name: "clobber"}); !errors.Is(err, store.ErrGuardVersionConflict) { + t.Fatalf("stale group upsert error = %v, want ErrGuardVersionConflict", err) + } + updated, err := st.UpsertSecurityGroup(model.SecurityGroup{ID: "sg-a", Name: "a2", Version: created.Version}) + if err != nil { + t.Fatal(err) + } + if updated.Version != 2 || updated.Name != "a2" { + t.Fatalf("bad updated group: %+v", updated) + } + + binding, err := st.UpsertNodeGuardBinding(model.NodeGuardBinding{NodeID: "node-a", Managed: true}) + if err != nil { + t.Fatal(err) + } + if binding.Version != 1 { + t.Fatalf("first binding version = %d, want 1", binding.Version) + } + if _, err := st.UpsertNodeGuardBinding(model.NodeGuardBinding{NodeID: "node-a"}); !errors.Is(err, store.ErrGuardVersionConflict) { + t.Fatalf("stale binding upsert error = %v, want ErrGuardVersionConflict", err) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 8a3b990..1eb03c2 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -65,6 +65,9 @@ type State struct { MachineProfiles map[string]model.MachineProfile `json:"machine_profiles"` MachineVendors map[string]model.MachineVendor `json:"machine_vendors"` NFTInputs map[string]model.NFTInputs `json:"nft_inputs"` + SecurityGroups map[string]model.SecurityGroup `json:"security_groups"` + GuardZones map[string]model.GuardZone `json:"guard_zones"` + GuardBindings map[string]model.NodeGuardBinding `json:"guard_bindings"` DNSDeployments map[string]model.DNSDeployment `json:"dns_deployments"` NetPolicies map[string]model.NetPolicy `json:"net_policies"` Groups map[string]model.Group `json:"groups"` @@ -352,6 +355,9 @@ func emptyState() State { MachineProfiles: map[string]model.MachineProfile{}, MachineVendors: map[string]model.MachineVendor{}, NFTInputs: map[string]model.NFTInputs{}, + SecurityGroups: map[string]model.SecurityGroup{}, + GuardZones: map[string]model.GuardZone{}, + GuardBindings: map[string]model.NodeGuardBinding{}, DNSDeployments: map[string]model.DNSDeployment{}, NetPolicies: map[string]model.NetPolicy{}, Groups: map[string]model.Group{}, @@ -442,6 +448,17 @@ func (st *State) ensureMaps() { if st.NFTInputs == nil { st.NFTInputs = map[string]model.NFTInputs{} } + // Nil-checked so a pre-design-13 on-disk state file upgrades cleanly to + // empty netguard collections on load. + if st.SecurityGroups == nil { + st.SecurityGroups = map[string]model.SecurityGroup{} + } + if st.GuardZones == nil { + st.GuardZones = map[string]model.GuardZone{} + } + if st.GuardBindings == nil { + st.GuardBindings = map[string]model.NodeGuardBinding{} + } if st.DNSDeployments == nil { st.DNSDeployments = map[string]model.DNSDeployment{} } @@ -2247,6 +2264,172 @@ func (s *Store) DeleteNetPolicy(nodeID string) error { return s.Save() } +// ErrGuardVersionConflict is returned when an optimistic-concurrency upsert +// carries a stale Version. Security groups and node guard bindings require the +// caller to echo the current version so two operators cannot silently clobber +// each other's firewall edits (design-13, closing the NFTInputs upsert gap). +var ErrGuardVersionConflict = errors.New("guard record version conflict") + +// UpsertSecurityGroup creates or updates a reusable security group. New +// records must carry Version 0; updates must echo the stored Version. The +// store bumps the version and returns the persisted record. +func (s *Store) UpsertSecurityGroup(group model.SecurityGroup) (model.SecurityGroup, error) { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now().UTC() + if existing, ok := s.state.SecurityGroups[group.ID]; ok { + if group.Version != existing.Version { + return model.SecurityGroup{}, ErrGuardVersionConflict + } + group.CreatedAt = existing.CreatedAt + } else { + if group.Version != 0 { + return model.SecurityGroup{}, ErrGuardVersionConflict + } + group.CreatedAt = now + } + group.Version++ + group.UpdatedAt = now + s.state.SecurityGroups[group.ID] = group + if err := s.Save(); err != nil { + return model.SecurityGroup{}, err + } + return group, nil +} + +// SecurityGroup returns one stored security group by id. +func (s *Store) SecurityGroup(id string) (model.SecurityGroup, bool) { + s.mu.Lock() + defer s.mu.Unlock() + group, ok := s.state.SecurityGroups[id] + return group, ok +} + +// SecurityGroups returns all stored security groups sorted by id. +func (s *Store) SecurityGroups() []model.SecurityGroup { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]model.SecurityGroup, 0, len(s.state.SecurityGroups)) + for _, group := range s.state.SecurityGroups { + out = append(out, group) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +// DeleteSecurityGroup removes a stored security group. +func (s *Store) DeleteSecurityGroup(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.state.SecurityGroups[id]; !ok { + return nil + } + delete(s.state.SecurityGroups, id) + return s.Save() +} + +// UpsertGuardZone creates or updates a named guard zone. +func (s *Store) UpsertGuardZone(zone model.GuardZone) error { + s.mu.Lock() + defer s.mu.Unlock() + zone.UpdatedAt = time.Now().UTC() + if existing, ok := s.state.GuardZones[zone.ID]; ok { + zone.CreatedAt = existing.CreatedAt + } else if zone.CreatedAt.IsZero() { + zone.CreatedAt = zone.UpdatedAt + } + s.state.GuardZones[zone.ID] = zone + return s.Save() +} + +// GuardZone returns one stored guard zone by id. +func (s *Store) GuardZone(id string) (model.GuardZone, bool) { + s.mu.Lock() + defer s.mu.Unlock() + zone, ok := s.state.GuardZones[id] + return zone, ok +} + +// GuardZones returns all stored guard zones sorted by id. +func (s *Store) GuardZones() []model.GuardZone { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]model.GuardZone, 0, len(s.state.GuardZones)) + for _, zone := range s.state.GuardZones { + out = append(out, zone) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +// DeleteGuardZone removes a stored guard zone. +func (s *Store) DeleteGuardZone(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.state.GuardZones[id]; !ok { + return nil + } + delete(s.state.GuardZones, id) + return s.Save() +} + +// UpsertNodeGuardBinding creates or updates a node's guard binding with the +// same optimistic-concurrency contract as UpsertSecurityGroup. +func (s *Store) UpsertNodeGuardBinding(binding model.NodeGuardBinding) (model.NodeGuardBinding, error) { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now().UTC() + if existing, ok := s.state.GuardBindings[binding.NodeID]; ok { + if binding.Version != existing.Version { + return model.NodeGuardBinding{}, ErrGuardVersionConflict + } + binding.CreatedAt = existing.CreatedAt + } else { + if binding.Version != 0 { + return model.NodeGuardBinding{}, ErrGuardVersionConflict + } + binding.CreatedAt = now + } + binding.Version++ + binding.UpdatedAt = now + s.state.GuardBindings[binding.NodeID] = binding + if err := s.Save(); err != nil { + return model.NodeGuardBinding{}, err + } + return binding, nil +} + +// NodeGuardBinding returns the guard binding for a node. +func (s *Store) NodeGuardBinding(nodeID string) (model.NodeGuardBinding, bool) { + s.mu.Lock() + defer s.mu.Unlock() + binding, ok := s.state.GuardBindings[nodeID] + return binding, ok +} + +// NodeGuardBindings returns all guard bindings sorted by node id. +func (s *Store) NodeGuardBindings() []model.NodeGuardBinding { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]model.NodeGuardBinding, 0, len(s.state.GuardBindings)) + for _, binding := range s.state.GuardBindings { + out = append(out, binding) + } + sort.Slice(out, func(i, j int) bool { return out[i].NodeID < out[j].NodeID }) + return out +} + +// DeleteNodeGuardBinding removes a node's guard binding. +func (s *Store) DeleteNodeGuardBinding(nodeID string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.state.GuardBindings[nodeID]; !ok { + return nil + } + delete(s.state.GuardBindings, nodeID) + return s.Save() +} + // UpsertGroup creates or updates a fleet group. The group's own ID is the key; // callers mint it as "grp_" (see internal/id). Slices are deep-copied on // store so the caller cannot mutate persisted state through a retained header. From 7b5da116ea33782cb94078bc6e4799f24fff7cbd Mon Sep 17 00:00:00 2001 From: lr00rl Date: Thu, 9 Jul 2026 02:23:24 -0400 Subject: [PATCH 2/3] feat(netguard,wireguard): design-13 G2 compiler/write path + W1 topology + W2 apply safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- go.mod | 2 +- go.sum | 2 + internal/netguard/compile.go | 347 +++++++++++++ internal/netguard/compile_test.go | 407 ++++++++++++++++ internal/netguard/lint.go | 113 +++++ internal/network/nft.go | 11 + internal/server/server.go | 143 +++++- internal/server/server_netguard.go | 454 +++++++++++++++++- internal/server/server_netguard_test.go | 233 +++++++++ .../server/server_wireguard_apply_test.go | 167 +++++++ internal/wireguard/topology.go | 193 ++++++++ internal/wireguard/topology_test.go | 218 +++++++++ internal/wireguard/wireguard.go | 38 +- 13 files changed, 2310 insertions(+), 18 deletions(-) create mode 100644 internal/netguard/compile.go create mode 100644 internal/netguard/compile_test.go create mode 100644 internal/netguard/lint.go create mode 100644 internal/server/server_wireguard_apply_test.go create mode 100644 internal/wireguard/topology.go create mode 100644 internal/wireguard/topology_test.go diff --git a/go.mod b/go.mod index 0e63126..c4e6649 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/LatticeNet/lattice-server go 1.26 require ( - github.com/LatticeNet/lattice-sdk v0.2.17-0.20260709050800-d0f6124704ec + github.com/LatticeNet/lattice-sdk v0.2.17-0.20260709055807-30d4d08e6fa8 github.com/coreos/go-oidc/v3 v3.18.0 github.com/descope/virtualwebauthn v1.0.5 github.com/go-webauthn/webauthn v0.17.4 diff --git a/go.sum b/go.sum index b59454d..dc82e62 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/LatticeNet/lattice-sdk v0.2.17-0.20260708093236-9de870576def h1:nfrzG github.com/LatticeNet/lattice-sdk v0.2.17-0.20260708093236-9de870576def/go.mod h1:7ENUQ4EoS/TSW/eNomCGfZGliUPJZ46uAvp7dVcEXoE= github.com/LatticeNet/lattice-sdk v0.2.17-0.20260709050800-d0f6124704ec h1:SnafEo1Z+rZUpKzxMSO6L984uuTXQptbQ1wiSANiXTE= github.com/LatticeNet/lattice-sdk v0.2.17-0.20260709050800-d0f6124704ec/go.mod h1:7ENUQ4EoS/TSW/eNomCGfZGliUPJZ46uAvp7dVcEXoE= +github.com/LatticeNet/lattice-sdk v0.2.17-0.20260709055807-30d4d08e6fa8 h1:8dWVveGh2eYvJMOcsgFjCoLVfEeh0M85xMjD9gzigU4= +github.com/LatticeNet/lattice-sdk v0.2.17-0.20260709055807-30d4d08e6fa8/go.mod h1:7ENUQ4EoS/TSW/eNomCGfZGliUPJZ46uAvp7dVcEXoE= github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/internal/netguard/compile.go b/internal/netguard/compile.go new file mode 100644 index 0000000..3d09d80 --- /dev/null +++ b/internal/netguard/compile.go @@ -0,0 +1,347 @@ +package netguard + +import ( + "errors" + "fmt" + "sort" + + "github.com/LatticeNet/lattice-sdk/model" + "github.com/LatticeNet/lattice-server/internal/network" +) + +// The compiler LOWERS the guard model into the existing network.NFTPlan rather +// than emitting nft syntax itself. network.GenerateNFTPlan stays the single +// renderer of `table inet lattice_guard`, so byte-for-byte parity with the +// legacy Network Guard path is structural, not a lucky test result, and no +// competing default-drop input hook can appear. +// +// Rule shapes that exactly match the legacy broad-port allows take a fast path +// into the plan's Public*/WireGuard* port lists. Everything else compiles to +// typed network.NFTInputRule values, which the renderer emits BEFORE those +// broad allows — that ordering is what lets a targeted deny override an +// otherwise-open service port. (design-13 §4.4) + +// MaxExpandedPortsPerRule bounds range expansion. The current renderer emits +// explicit port lists, so a very wide range would produce an unreadable, +// unreviewable ruleset. Native `from-to` nft range emission is a later +// renderer upgrade (design-13 L2); until then wide ranges fail closed with a +// named error rather than silently exploding the plan. +const MaxExpandedPortsPerRule = 1024 + +// NodeResolver mirrors netpolicy.NodeResolver so node remotes resolve against +// current fleet state at compile time. +type NodeResolver func(nodeID string) (model.Node, bool) + +// CompileInput is the fully-resolved authoring state for one node. +type CompileInput struct { + Binding model.NodeGuardBinding + // Groups in binding order. The caller resolves Binding.GroupIDs. + Groups []model.SecurityGroup + // Zones by id, including the builtin zones resolved for this node. + Zones map[string]model.GuardZone + Resolve NodeResolver +} + +// ErrNodeUnmanaged is returned when a plan is requested for an observe-only +// binding. Converted legacy baselines start unmanaged: an operator must adopt +// a node before its firewall can be planned from the new model. +var ErrNodeUnmanaged = errors.New("node guard binding is observe-only; adopt the node before planning") + +// Compile lowers zones, trusted-zone accepts, per-node overrides, and attached +// security groups into a single network.NFTPlan. +func Compile(in CompileInput) (network.NFTPlan, error) { + if !in.Binding.Managed { + return network.NFTPlan{}, ErrNodeUnmanaged + } + if in.Resolve == nil { + return network.NFTPlan{}, errors.New("node resolver is required") + } + + plan := network.NFTPlan{ + InterfaceName: zoneInterface(in.Zones, model.GuardZonePublic, defaultInterface), + WireGuardCIDR: zoneCIDR(in.Zones, model.GuardZoneWireGuard, defaultWireGuardCIDR), + } + + // 1. Trusted zones accept first: an overlay the node depends on (tailscale0, + // wg0) must never be dropped by the guard it is being protected with. + for _, zoneID := range in.Binding.ZoneIDs { + zone, ok := in.Zones[zoneID] + if !ok { + return network.NFTPlan{}, fmt.Errorf("trusted zone %q not found", zoneID) + } + if zoneID == model.GuardZonePublic { + return network.NFTPlan{}, errors.New("the public zone cannot be trusted wholesale") + } + rules, err := trustedZoneRules(zone) + if err != nil { + return network.NFTPlan{}, err + } + plan.InputRules = append(plan.InputRules, rules...) + } + + // 2. Per-node overrides, then 3. attached groups in binding order. + ordered := make([]model.GuardRule, 0, len(in.Binding.Overrides)) + ordered = append(ordered, in.Binding.Overrides...) + for _, group := range in.Groups { + ordered = append(ordered, group.Rules...) + } + + for _, rule := range ordered { + if rule.Disabled { + continue + } + if err := lowerRule(&plan, rule, in); err != nil { + return network.NFTPlan{}, fmt.Errorf("rule %q: %w", rule.ID, err) + } + } + + // NormalizeNFTPlan validates/canonicalizes everything and sorts+dedups the + // fast-path port lists, so two groups contributing the same port union + // cleanly. + return network.NormalizeNFTPlan(plan) +} + +// CompileRuleset renders the final lattice_guard ruleset for a node. +func CompileRuleset(in CompileInput) (string, error) { + plan, err := Compile(in) + if err != nil { + return "", err + } + return network.GenerateNFTPlan(plan) +} + +func lowerRule(plan *network.NFTPlan, rule model.GuardRule, in CompileInput) error { + if rule.Direction != model.NetDirIngress { + return fmt.Errorf("direction %q is not compiled into the guard table (egress stays with netpolicy)", rule.Direction) + } + switch rule.Action { + case model.NetRuleAllow, model.NetRuleDeny: + default: + return fmt.Errorf("invalid action %q", rule.Action) + } + switch rule.Protocol { + case model.NetProtoTCP, model.NetProtoUDP, model.NetProtoAny: + case model.GuardProtoICMP, model.GuardProtoICMPv6: + return fmt.Errorf("protocol %q is not supported by the current guard renderer", rule.Protocol) + default: + return fmt.Errorf("invalid protocol %q", rule.Protocol) + } + if rule.RateLimit != "" { + return errors.New("rate_limit is not supported by the current guard renderer") + } + if rule.Log { + return errors.New("log is not supported by the current guard renderer") + } + + ports, err := ExpandPortRanges(rule.Ports) + if err != nil { + return err + } + if rule.Protocol == model.NetProtoAny && len(ports) > 0 { + return errors.New("protocol any cannot carry ports") + } + + // Fast path: exactly the legacy broad-allow shape. Preserving it is what + // makes converted legacy baselines render byte-identically. + if fast := fastPathBucket(plan, rule, ports); fast != nil { + *fast = append(*fast, ports...) + return nil + } + + sources, iface, err := ruleSource(rule, in) + if err != nil { + return err + } + plan.InputRules = append(plan.InputRules, network.NFTInputRule{ + Interface: iface, + SourceCIDRs: sources, + Protocol: rule.Protocol, + Ports: ports, + Action: nftAction(rule.Action), + Comment: ruleComment(rule), + }) + return nil +} + +// fastPathBucket returns the plan port list a rule belongs in, or nil when the +// rule needs the general InputRule path. Only the exact legacy shape qualifies: +// an ingress allow, tcp or udp, with at least one port, whose remote is the +// public or wireguard builtin zone. Callers have already rejected the L2 +// render features (rate limit, log) and disabled rules. +func fastPathBucket(plan *network.NFTPlan, rule model.GuardRule, ports []int) *[]int { + if rule.Action != model.NetRuleAllow || len(ports) == 0 { + return nil + } + if rule.Remote.Kind != model.NetRefZone { + return nil + } + switch rule.Remote.ZoneID { + case model.GuardZonePublic: + switch rule.Protocol { + case model.NetProtoTCP: + return &plan.PublicTCP + case model.NetProtoUDP: + return &plan.PublicUDP + } + case model.GuardZoneWireGuard: + switch rule.Protocol { + case model.NetProtoTCP: + return &plan.WireGuardTCP + case model.NetProtoUDP: + return &plan.WireGuardUDP + } + } + return nil +} + +func nftAction(action string) string { + if action == model.NetRuleDeny { + return network.NFTActionDrop + } + return network.NFTActionAccept +} + +func ruleComment(rule model.GuardRule) string { + if rule.Comment != "" { + return rule.Comment + } + return rule.ID +} + +// ruleSource resolves a rule's remote into source CIDRs and/or an inbound +// interface constraint. +func ruleSource(rule model.GuardRule, in CompileInput) ([]string, string, error) { + switch rule.Remote.Kind { + case model.NetRefAny, "": + return nil, "", nil + case model.NetRefCIDR: + if rule.Remote.CIDR == "" { + return nil, "", errors.New("cidr remote requires a cidr") + } + return []string{rule.Remote.CIDR}, "", nil + case model.NetRefNode: + node, ok := in.Resolve(rule.Remote.NodeID) + if !ok { + return nil, "", fmt.Errorf("remote node %q not found", rule.Remote.NodeID) + } + sources := nodeSources(node) + if len(sources) == 0 { + return nil, "", fmt.Errorf("remote node %q has no resolvable address", rule.Remote.NodeID) + } + return sources, "", nil + case model.NetRefZone: + zone, ok := in.Zones[rule.Remote.ZoneID] + if !ok { + return nil, "", fmt.Errorf("remote zone %q not found", rule.Remote.ZoneID) + } + if len(zone.CIDRs) > 0 { + return append([]string(nil), zone.CIDRs...), "", nil + } + if len(zone.Interfaces) == 1 { + return nil, zone.Interfaces[0], nil + } + if len(zone.Interfaces) > 1 { + return nil, "", fmt.Errorf("zone %q has multiple interfaces; split the rule per interface", zone.ID) + } + return nil, "", fmt.Errorf("zone %q resolves to no interface or cidr on this node", zone.ID) + case model.NetRefDomain: + return nil, "", errors.New("domain remotes are egress-only") + case model.NetRefGroup: + return nil, "", errors.New("group remotes must be expanded to node refs before compile") + default: + return nil, "", fmt.Errorf("invalid remote kind %q", rule.Remote.Kind) + } +} + +// nodeSources pins a node remote to its own addresses, mirroring the /32 +// AllowedIPs discipline: a node ref can only ever mean that node's addresses. +func nodeSources(node model.Node) []string { + out := make([]string, 0, 2) + for _, addr := range []string{node.WireGuardIP, node.PublicIP} { + if addr != "" { + out = append(out, addr) + } + } + return out +} + +func trustedZoneRules(zone model.GuardZone) ([]network.NFTInputRule, error) { + if len(zone.Interfaces) == 0 && len(zone.CIDRs) == 0 { + return nil, fmt.Errorf("trusted zone %q resolves to no interface or cidr on this node", zone.ID) + } + rules := make([]network.NFTInputRule, 0, len(zone.Interfaces)+1) + for _, iface := range zone.Interfaces { + rules = append(rules, network.NFTInputRule{ + Interface: iface, + Protocol: network.NFTProtoAny, + Action: network.NFTActionAccept, + Comment: "trusted zone " + zone.ID, + }) + } + if len(zone.CIDRs) > 0 { + rules = append(rules, network.NFTInputRule{ + SourceCIDRs: append([]string(nil), zone.CIDRs...), + Protocol: network.NFTProtoAny, + Action: network.NFTActionAccept, + Comment: "trusted zone " + zone.ID, + }) + } + return rules, nil +} + +// ExpandPortRanges flattens inclusive ranges into the explicit port list the +// current renderer emits, fail-closed on invalid or excessively wide ranges. +func ExpandPortRanges(ranges []model.GuardPortRange) ([]int, error) { + if len(ranges) == 0 { + return nil, nil + } + total := 0 + for _, r := range ranges { + if r.From < 1 || r.From > 65535 || r.To < 1 || r.To > 65535 { + return nil, fmt.Errorf("invalid port range %d-%d", r.From, r.To) + } + if r.From > r.To { + return nil, fmt.Errorf("inverted port range %d-%d", r.From, r.To) + } + total += r.To - r.From + 1 + if total > MaxExpandedPortsPerRule { + return nil, fmt.Errorf("port ranges expand to more than %d ports; split the rule", MaxExpandedPortsPerRule) + } + } + seen := make(map[int]struct{}, total) + out := make([]int, 0, total) + for _, r := range ranges { + for p := r.From; p <= r.To; p++ { + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + out = append(out, p) + } + } + sort.Ints(out) + return out, nil +} + +func zoneInterface(zones map[string]model.GuardZone, id, fallback string) string { + if zone, ok := zones[id]; ok && len(zone.Interfaces) > 0 { + return zone.Interfaces[0] + } + return fallback +} + +func zoneCIDR(zones map[string]model.GuardZone, id, fallback string) string { + if zone, ok := zones[id]; ok && len(zone.CIDRs) > 0 { + return zone.CIDRs[0] + } + return fallback +} + +// ZoneMap indexes zones by id for CompileInput. +func ZoneMap(zones []model.GuardZone) map[string]model.GuardZone { + out := make(map[string]model.GuardZone, len(zones)) + for _, zone := range zones { + out[zone.ID] = zone + } + return out +} diff --git a/internal/netguard/compile_test.go b/internal/netguard/compile_test.go new file mode 100644 index 0000000..c15fac0 --- /dev/null +++ b/internal/netguard/compile_test.go @@ -0,0 +1,407 @@ +package netguard + +import ( + "errors" + "strings" + "testing" + + "github.com/LatticeNet/lattice-sdk/model" + "github.com/LatticeNet/lattice-server/internal/network" +) + +func noNodes(string) (model.Node, bool) { return model.Node{}, false } + +// legacyPlan renders a baseline through the untouched legacy path. +func legacyPlan(t *testing.T, inputs model.NFTInputs) string { + t.Helper() + ruleset, err := network.GenerateNFTPlan(network.NFTPlan{ + InterfaceName: inputs.InterfaceName, + WireGuardCIDR: inputs.WireGuardCIDR, + PublicTCP: inputs.PublicTCP, + PublicUDP: inputs.PublicUDP, + WireGuardTCP: inputs.WireGuardTCP, + WireGuardUDP: inputs.WireGuardUDP, + }) + if err != nil { + t.Fatalf("legacy render: %v", err) + } + return ruleset +} + +// convertedPlan renders the same baseline through the design-13 model. +func convertedPlan(t *testing.T, inputs model.NFTInputs, resolve NodeResolver) string { + t.Helper() + view := LegacyBaseline(inputs) + binding := view.Binding + binding.Managed = true // adoption; conversion itself stays observe-only + ruleset, err := CompileRuleset(CompileInput{ + Binding: binding, + Groups: []model.SecurityGroup{view.Group}, + Zones: ZoneMap(view.Zones), + Resolve: resolve, + }) + if err != nil { + t.Fatalf("netguard render: %v", err) + } + return ruleset +} + +// THE PARITY GATE (design-13 §7.1): the converted model must reproduce the +// legacy renderer byte-for-byte before the legacy path may retire. If this +// test ever needs "normalizing" to pass, the migration is unsafe — a firewall +// that silently changes shape is exactly what this design exists to prevent. +func TestLegacyBaselineRendersByteIdentically(t *testing.T) { + fixtures := []struct { + name string + inputs model.NFTInputs + }{ + {"empty baseline", model.NFTInputs{NodeID: "n1"}}, + {"public only", model.NFTInputs{ + NodeID: "n2", InterfaceName: "ens3", PublicTCP: []int{80, 443}, + }}, + {"public tcp and udp", model.NFTInputs{ + NodeID: "n3", InterfaceName: "ens3", WireGuardCIDR: "10.66.0.0/24", + PublicTCP: []int{443, 80, 443}, PublicUDP: []int{53}, + }}, + {"wireguard services", model.NFTInputs{ + NodeID: "n4", WireGuardCIDR: "10.66.0.0/24", + WireGuardTCP: []int{9100, 22}, WireGuardUDP: []int{51820}, + }}, + {"all four lists", model.NFTInputs{ + NodeID: "n5", InterfaceName: "eth1", WireGuardCIDR: "10.99.0.0/16", + PublicTCP: []int{22, 443}, PublicUDP: []int{51820}, + WireGuardTCP: []int{9100}, WireGuardUDP: []int{53}, + }}, + {"dmit-eb-wee real baseline", model.NFTInputs{ + NodeID: "dmit-eb-wee", InterfaceName: "eth0", WireGuardCIDR: "10.66.0.0/24", + PublicTCP: []int{115, 3433, 7443, 7500, 7780, 9009, 9010, 9011, 9012, 9013, 17891, 17893, 42622, 48358, 57289}, + PublicUDP: []int{115, 3433, 7443, 7500, 7780, 9009, 9010, 9011, 9012, 9013, 17891, 17893, 42622, 48358, 57289}, + }}, + {"adjacent run that the converter collapses", model.NFTInputs{ + NodeID: "n6", PublicTCP: []int{9009, 9010, 9011, 9012, 9013}, + }}, + {"unsorted with duplicates", model.NFTInputs{ + NodeID: "n7", PublicTCP: []int{443, 80, 443, 8080}, + }}, + } + for _, tc := range fixtures { + t.Run(tc.name, func(t *testing.T) { + want := legacyPlan(t, tc.inputs) + got := convertedPlan(t, tc.inputs, noNodes) + if got != want { + t.Fatalf("parity gate broken.\n--- legacy ---\n%s\n--- netguard ---\n%s", want, got) + } + }) + } +} + +func TestCompileRefusesUnmanagedBinding(t *testing.T) { + view := LegacyBaseline(model.NFTInputs{NodeID: "n1", PublicTCP: []int{22}}) + _, err := Compile(CompileInput{ + Binding: view.Binding, // Managed=false + Groups: []model.SecurityGroup{view.Group}, + Zones: ZoneMap(view.Zones), + Resolve: noNodes, + }) + if !errors.Is(err, ErrNodeUnmanaged) { + t.Fatalf("err = %v, want ErrNodeUnmanaged", err) + } +} + +// The headline fix: a node that depends on an overlay (tailscale0) can be +// guarded without severing it, and the trusted-zone accept renders before the +// broad public allows. +func TestTrustedZoneRendersIifnameAcceptBeforeBroadAllows(t *testing.T) { + zones := ZoneMap([]model.GuardZone{ + {ID: model.GuardZonePublic, Interfaces: []string{"eth0"}}, + {ID: model.GuardZoneWireGuard, CIDRs: []string{"10.66.0.0/24"}}, + {ID: model.GuardZoneTailscale, Interfaces: []string{"tailscale0"}}, + }) + ruleset, err := CompileRuleset(CompileInput{ + Binding: model.NodeGuardBinding{ + NodeID: "n1", + Managed: true, + ZoneIDs: []string{model.GuardZoneTailscale}, + }, + Groups: []model.SecurityGroup{{ID: "sg", Rules: []model.GuardRule{{ + ID: "ssh", Action: model.NetRuleAllow, Direction: model.NetDirIngress, + Protocol: model.NetProtoTCP, Ports: []model.GuardPortRange{{From: 22, To: 22}}, + Remote: model.NetEndpoint{Kind: model.NetRefZone, ZoneID: model.GuardZonePublic}, + }}}}, + Zones: zones, + Resolve: noNodes, + }) + if err != nil { + t.Fatal(err) + } + trusted := `iifname "tailscale0" accept comment "trusted zone tailscale"` + broad := `iifname "eth0" tcp dport { 22 } accept` + ti, bi := strings.Index(ruleset, trusted), strings.Index(ruleset, broad) + if ti < 0 { + t.Fatalf("trusted zone accept missing:\n%s", ruleset) + } + if bi < 0 { + t.Fatalf("public allow missing:\n%s", ruleset) + } + if ti > bi { + t.Fatalf("trusted zone accept must render before broad allows:\n%s", ruleset) + } +} + +func TestCompileRefusesTrustingPublicZone(t *testing.T) { + _, err := Compile(CompileInput{ + Binding: model.NodeGuardBinding{NodeID: "n1", Managed: true, ZoneIDs: []string{model.GuardZonePublic}}, + Zones: ZoneMap([]model.GuardZone{{ID: model.GuardZonePublic, Interfaces: []string{"eth0"}}}), + Resolve: noNodes, + }) + if err == nil || !strings.Contains(err.Error(), "public zone cannot be trusted") { + t.Fatalf("err = %v, want refusal to trust the public zone", err) + } +} + +// A targeted deny must beat an otherwise-open broad service port, which is +// only true because InputRules render before the fast-path allows. +func TestDenyRuleRendersBeforeBroadAllow(t *testing.T) { + ruleset, err := CompileRuleset(CompileInput{ + Binding: model.NodeGuardBinding{NodeID: "n1", Managed: true}, + Groups: []model.SecurityGroup{{ID: "sg", Rules: []model.GuardRule{ + { + ID: "open-1234", Action: model.NetRuleAllow, Direction: model.NetDirIngress, + Protocol: model.NetProtoTCP, Ports: []model.GuardPortRange{{From: 1234, To: 1234}}, + Remote: model.NetEndpoint{Kind: model.NetRefZone, ZoneID: model.GuardZonePublic}, + }, + { + ID: "deny-bad-peer", Action: model.NetRuleDeny, Direction: model.NetDirIngress, + Protocol: model.NetProtoTCP, Ports: []model.GuardPortRange{{From: 1234, To: 1234}}, + Remote: model.NetEndpoint{Kind: model.NetRefCIDR, CIDR: "198.51.100.7/32"}, + }, + }}}, + Zones: ZoneMap([]model.GuardZone{{ID: model.GuardZonePublic, Interfaces: []string{"eth0"}}}), + Resolve: noNodes, + }) + if err != nil { + t.Fatal(err) + } + deny := `ip saddr 198.51.100.7 tcp dport { 1234 } drop` + allow := `iifname "eth0" tcp dport { 1234 } accept` + di, ai := strings.Index(ruleset, deny), strings.Index(ruleset, allow) + if di < 0 || ai < 0 || di > ai { + t.Fatalf("deny must render before the broad allow:\n%s", ruleset) + } +} + +func TestNodeRemoteResolvesToNodeAddresses(t *testing.T) { + resolve := func(id string) (model.Node, bool) { + if id != "peer" { + return model.Node{}, false + } + return model.Node{ID: "peer", WireGuardIP: "10.66.0.2/32", PublicIP: "198.51.100.2"}, true + } + ruleset, err := CompileRuleset(CompileInput{ + Binding: model.NodeGuardBinding{NodeID: "n1", Managed: true}, + Groups: []model.SecurityGroup{{ID: "sg", Rules: []model.GuardRule{{ + ID: "peer-9100", Action: model.NetRuleAllow, Direction: model.NetDirIngress, + Protocol: model.NetProtoTCP, Ports: []model.GuardPortRange{{From: 9100, To: 9100}}, + Remote: model.NetEndpoint{Kind: model.NetRefNode, NodeID: "peer"}, + }}}}, + Zones: ZoneMap(nil), + Resolve: resolve, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(ruleset, `ip saddr { 10.66.0.2, 198.51.100.2 } tcp dport { 9100 } accept`) { + t.Fatalf("node remote did not resolve to both addresses:\n%s", ruleset) + } + + if _, err := CompileRuleset(CompileInput{ + Binding: model.NodeGuardBinding{NodeID: "n1", Managed: true}, + Groups: []model.SecurityGroup{{ID: "sg", Rules: []model.GuardRule{{ + ID: "ghost", Action: model.NetRuleAllow, Direction: model.NetDirIngress, + Protocol: model.NetProtoTCP, Ports: []model.GuardPortRange{{From: 1, To: 1}}, + Remote: model.NetEndpoint{Kind: model.NetRefNode, NodeID: "missing"}, + }}}}, + Zones: ZoneMap(nil), + Resolve: resolve, + }); err == nil { + t.Fatal("unknown node remote must be rejected, never silently widened") + } +} + +func TestCompileFailsClosedOnUnsupportedShapes(t *testing.T) { + base := func(rule model.GuardRule) error { + _, err := Compile(CompileInput{ + Binding: model.NodeGuardBinding{NodeID: "n1", Managed: true}, + Groups: []model.SecurityGroup{{ID: "sg", Rules: []model.GuardRule{rule}}}, + Zones: ZoneMap([]model.GuardZone{{ID: model.GuardZonePublic, Interfaces: []string{"eth0"}}}), + Resolve: noNodes, + }) + return err + } + pub := model.NetEndpoint{Kind: model.NetRefZone, ZoneID: model.GuardZonePublic} + p80 := []model.GuardPortRange{{From: 80, To: 80}} + + cases := []struct { + name string + rule model.GuardRule + want string + }{ + {"egress direction", model.GuardRule{ID: "r", Action: model.NetRuleAllow, Direction: model.NetDirEgress, Protocol: model.NetProtoTCP, Ports: p80, Remote: pub}, "not compiled into the guard table"}, + {"icmp", model.GuardRule{ID: "r", Action: model.NetRuleAllow, Direction: model.NetDirIngress, Protocol: model.GuardProtoICMP, Remote: pub}, "not supported by the current guard renderer"}, + {"rate limit", model.GuardRule{ID: "r", Action: model.NetRuleAllow, Direction: model.NetDirIngress, Protocol: model.NetProtoTCP, Ports: p80, Remote: pub, RateLimit: "10/second"}, "rate_limit is not supported"}, + {"log", model.GuardRule{ID: "r", Action: model.NetRuleAllow, Direction: model.NetDirIngress, Protocol: model.NetProtoTCP, Ports: p80, Remote: pub, Log: true}, "log is not supported"}, + {"domain remote", model.GuardRule{ID: "r", Action: model.NetRuleAllow, Direction: model.NetDirIngress, Protocol: model.NetProtoTCP, Ports: p80, Remote: model.NetEndpoint{Kind: model.NetRefDomain, Domain: "x.example"}}, "egress-only"}, + {"group remote", model.GuardRule{ID: "r", Action: model.NetRuleAllow, Direction: model.NetDirIngress, Protocol: model.NetProtoTCP, Ports: p80, Remote: model.NetEndpoint{Kind: model.NetRefGroup, GroupID: "g"}}, "expanded to node refs"}, + {"bad action", model.GuardRule{ID: "r", Action: "maybe", Direction: model.NetDirIngress, Protocol: model.NetProtoTCP, Ports: p80, Remote: pub}, "invalid action"}, + {"any protocol with ports", model.GuardRule{ID: "r", Action: model.NetRuleAllow, Direction: model.NetDirIngress, Protocol: model.NetProtoAny, Ports: p80, Remote: pub}, "cannot carry ports"}, + {"unknown zone", model.GuardRule{ID: "r", Action: model.NetRuleAllow, Direction: model.NetDirIngress, Protocol: model.NetProtoTCP, Ports: p80, Remote: model.NetEndpoint{Kind: model.NetRefZone, ZoneID: "ghost"}}, "not found"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := base(tc.rule) + if err == nil { + t.Fatal("want fail-closed error, got nil") + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want it to mention %q", err, tc.want) + } + }) + } +} + +func TestDisabledRulesAreSkipped(t *testing.T) { + plan, err := Compile(CompileInput{ + Binding: model.NodeGuardBinding{NodeID: "n1", Managed: true}, + Groups: []model.SecurityGroup{{ID: "sg", Rules: []model.GuardRule{{ + ID: "off", Action: model.NetRuleAllow, Direction: model.NetDirIngress, + Protocol: model.NetProtoTCP, Ports: []model.GuardPortRange{{From: 80, To: 80}}, + Remote: model.NetEndpoint{Kind: model.NetRefZone, ZoneID: model.GuardZonePublic}, + Disabled: true, + }}}}, + Zones: ZoneMap([]model.GuardZone{{ID: model.GuardZonePublic, Interfaces: []string{"eth0"}}}), + Resolve: noNodes, + }) + if err != nil { + t.Fatal(err) + } + if len(plan.PublicTCP) != 0 { + t.Fatalf("disabled rule leaked into the plan: %v", plan.PublicTCP) + } +} + +func TestOverridesRenderBeforeGroupRules(t *testing.T) { + ruleset, err := CompileRuleset(CompileInput{ + Binding: model.NodeGuardBinding{ + NodeID: "n1", Managed: true, + Overrides: []model.GuardRule{{ + ID: "override-deny", Action: model.NetRuleDeny, Direction: model.NetDirIngress, + Protocol: model.NetProtoTCP, Ports: []model.GuardPortRange{{From: 80, To: 80}}, + Remote: model.NetEndpoint{Kind: model.NetRefCIDR, CIDR: "203.0.113.0/24"}, + }}, + }, + Groups: []model.SecurityGroup{{ID: "sg", Rules: []model.GuardRule{{ + ID: "group-deny", Action: model.NetRuleDeny, Direction: model.NetDirIngress, + Protocol: model.NetProtoTCP, Ports: []model.GuardPortRange{{From: 80, To: 80}}, + Remote: model.NetEndpoint{Kind: model.NetRefCIDR, CIDR: "198.51.100.0/24"}, + }}}}, + Zones: ZoneMap(nil), + Resolve: noNodes, + }) + if err != nil { + t.Fatal(err) + } + oi := strings.Index(ruleset, "203.0.113.0/24") + gi := strings.Index(ruleset, "198.51.100.0/24") + if oi < 0 || gi < 0 || oi > gi { + t.Fatalf("node overrides must render before group rules:\n%s", ruleset) + } +} + +func TestExpandPortRanges(t *testing.T) { + got, err := ExpandPortRanges([]model.GuardPortRange{{From: 9009, To: 9013}, {From: 22, To: 22}}) + if err != nil { + t.Fatal(err) + } + want := []int{22, 9009, 9010, 9011, 9012, 9013} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } + + if _, err := ExpandPortRanges([]model.GuardPortRange{{From: 5, To: 1}}); err == nil { + t.Fatal("inverted range must fail closed") + } + if _, err := ExpandPortRanges([]model.GuardPortRange{{From: 0, To: 10}}); err == nil { + t.Fatal("out-of-range port must fail closed") + } + if _, err := ExpandPortRanges([]model.GuardPortRange{{From: 1, To: 65535}}); err == nil { + t.Fatal("excessively wide range must fail closed rather than explode the ruleset") + } +} + +func TestLintLockoutRisk(t *testing.T) { + pub := model.NetEndpoint{Kind: model.NetRefZone, ZoneID: model.GuardZonePublic} + zones := ZoneMap([]model.GuardZone{ + {ID: model.GuardZonePublic, Interfaces: []string{"eth0"}}, + {ID: model.GuardZoneTailscale, Interfaces: []string{"tailscale0"}}, + }) + compile := func(binding model.NodeGuardBinding, rules []model.GuardRule) network.NFTPlan { + t.Helper() + binding.Managed = true + plan, err := Compile(CompileInput{ + Binding: binding, + Groups: []model.SecurityGroup{{ID: "sg", Rules: rules}}, + Zones: zones, + Resolve: noNodes, + }) + if err != nil { + t.Fatal(err) + } + return plan + } + + // The dmit-eb-wee shape: 15 public ports, none of them 22. + risky := compile(model.NodeGuardBinding{NodeID: "n1"}, []model.GuardRule{{ + ID: "svc", Action: model.NetRuleAllow, Direction: model.NetDirIngress, + Protocol: model.NetProtoTCP, Ports: []model.GuardPortRange{{From: 7443, To: 7443}}, + Remote: pub, + }}) + findings := Lint(risky, LintOptions{PublicURLConfigured: true}) + if !Blocking(findings) { + t.Fatalf("a plan with no tcp/22 accept must block: %+v", findings) + } + if findings[0].Code != FindingLockoutRiskSSH { + t.Fatalf("finding = %+v", findings[0]) + } + + // Allowing tcp/22 clears it. + safe := compile(model.NodeGuardBinding{NodeID: "n1"}, []model.GuardRule{{ + ID: "ssh", Action: model.NetRuleAllow, Direction: model.NetDirIngress, + Protocol: model.NetProtoTCP, Ports: []model.GuardPortRange{{From: 22, To: 22}}, + Remote: pub, + }}) + if Blocking(Lint(safe, LintOptions{PublicURLConfigured: true})) { + t.Fatal("a plan that accepts tcp/22 must not block") + } + + // So does trusting an overlay zone that still reaches the node. + viaOverlay := compile(model.NodeGuardBinding{NodeID: "n1", ZoneIDs: []string{model.GuardZoneTailscale}}, nil) + if Blocking(Lint(viaOverlay, LintOptions{PublicURLConfigured: true})) { + t.Fatal("a trusted overlay zone must satisfy the management-path lint") + } +} + +func TestLintUnverifiedApplyWarnsButDoesNotBlock(t *testing.T) { + plan := network.NFTPlan{PublicTCP: []int{22}} + findings := Lint(plan, LintOptions{PublicURLConfigured: false}) + if Blocking(findings) { + t.Fatal("missing public url must warn, not block") + } + if len(findings) != 1 || findings[0].Code != FindingUnverifiedApply { + t.Fatalf("findings = %+v", findings) + } +} diff --git a/internal/netguard/lint.go b/internal/netguard/lint.go new file mode 100644 index 0000000..eaabbbc --- /dev/null +++ b/internal/netguard/lint.go @@ -0,0 +1,113 @@ +package netguard + +import ( + "fmt" + + "github.com/LatticeNet/lattice-server/internal/network" +) + +// Plan linting turns the dmit-eb-wee failure class from a post-apply rollback +// into a pre-plan refusal. The guard chain is policy drop, so a plan that +// accepts nothing on the node's management port severs the operator's own SSH +// path the moment it commits; only the 60s dead-man watchdog would save it. +// (design-13 §4.4) + +const ( + // FindingLockoutRiskSSH fires when no compiled rule can accept traffic on + // the management port from anywhere. + FindingLockoutRiskSSH = "lockout_risk_ssh" + // FindingUnverifiedApply fires when the server has no public URL, so the + // node-side apply cannot run a control-plane selfcheck after committing. + FindingUnverifiedApply = "unverified_apply" + + SeverityBlock = "block" + SeverityWarn = "warn" + + // ManagementPort is the port the lockout lint protects. Until reality + // reporting lands (design-13 G3 surfaces the node's real sshd listener), + // tcp/22 is the safe universal assumption. + ManagementPort = 22 +) + +// Finding is one lint result. Blocking findings refuse the plan unless the +// operator explicitly accepts the risk, which is audited. +type Finding struct { + Code string `json:"code"` + Severity string `json:"severity"` + Message string `json:"message"` +} + +// LintOptions carries the plan-time context the compiled ruleset cannot know. +type LintOptions struct { + // PublicURLConfigured reports whether the node-side apply will be able to + // run `lattice-agent --selfcheck-controlplane` after committing. + PublicURLConfigured bool +} + +// Lint inspects a compiled plan for the failure modes that make a guard apply +// unsafe. It never mutates the plan. +func Lint(plan network.NFTPlan, opts LintOptions) []Finding { + var findings []Finding + if !acceptsManagementPort(plan) { + findings = append(findings, Finding{ + Code: FindingLockoutRiskSSH, + Severity: SeverityBlock, + Message: fmt.Sprintf( + "no rule accepts inbound tcp/%d: committing this default-drop ruleset would cut the operator's SSH path. Add a management-port allow, trust an overlay zone, or explicitly accept the lockout risk.", + ManagementPort), + }) + } + if !opts.PublicURLConfigured { + findings = append(findings, Finding{ + Code: FindingUnverifiedApply, + Severity: SeverityWarn, + Message: "the server has no public URL configured, so the node cannot run a control-plane selfcheck after committing. The apply will be protected only by the dead-man watchdog.", + }) + } + return findings +} + +// Blocking reports whether any finding blocks the plan. +func Blocking(findings []Finding) bool { + for _, f := range findings { + if f.Severity == SeverityBlock { + return true + } + } + return false +} + +// acceptsManagementPort reports whether some compiled rule could accept a new +// inbound connection on the management port. It is deliberately generous: a +// trusted-zone accept, an any-protocol accept, or a tcp accept whose port list +// is empty (all ports) or contains the management port all count. Being +// generous means the lint only fires when the plan really has no path, so it +// stays a signal rather than noise. +func acceptsManagementPort(plan network.NFTPlan) bool { + if containsPort(plan.PublicTCP, ManagementPort) || containsPort(plan.WireGuardTCP, ManagementPort) { + return true + } + for _, rule := range plan.InputRules { + if rule.Action != network.NFTActionAccept { + continue + } + switch rule.Protocol { + case network.NFTProtoAny: + return true + case network.NFTProtoTCP: + if len(rule.Ports) == 0 || containsPort(rule.Ports, ManagementPort) { + return true + } + } + } + return false +} + +func containsPort(ports []int, want int) bool { + for _, p := range ports { + if p == want { + return true + } + } + return false +} diff --git a/internal/network/nft.go b/internal/network/nft.go index ed4b67b..8c195dd 100644 --- a/internal/network/nft.go +++ b/internal/network/nft.go @@ -36,6 +36,11 @@ const ( ) type NFTInputRule struct { + // Interface, when set, scopes the rule to traffic arriving on that inbound + // interface (rendered as `iifname ""`). It is how a trusted overlay + // zone (wireguard, tailscale) is accepted without widening the public + // surface. Empty means "any inbound interface". + Interface string SourceCIDRs []string Protocol string Ports []int @@ -147,6 +152,9 @@ func normalizeInputRule(rule NFTInputRule) (NFTInputRule, error) { default: return NFTInputRule{}, fmt.Errorf("invalid protocol %q", rule.Protocol) } + if rule.Interface != "" && !ifaceNameRe.MatchString(rule.Interface) { + return NFTInputRule{}, fmt.Errorf("invalid interface name %q", rule.Interface) + } var err error if rule.Ports, err = normalizePorts(rule.Ports); err != nil { return NFTInputRule{}, fmt.Errorf("ports: %w", err) @@ -241,6 +249,9 @@ func renderInputRule(rule NFTInputRule) []string { lines := make([]string, 0, len(sourceExprs)) for _, sourceExpr := range sourceExprs { parts := []string{} + if rule.Interface != "" { + parts = append(parts, fmt.Sprintf("iifname %q", rule.Interface)) + } if sourceExpr != "" { parts = append(parts, sourceExpr) } diff --git a/internal/server/server.go b/internal/server/server.go index b11ca5e..3b7919c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -878,8 +878,13 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/api/group-policies/plan", s.withAuth("netpolicy:admin", s.handleGroupPolicyPlan)) mux.HandleFunc("/api/netpolicy/matrix", s.withAuth("netpolicy:read", s.handleNetPolicyMatrix)) mux.HandleFunc("/api/netguard/groups", s.withAuth("netguard:read", s.handleNetGuardGroups)) + mux.HandleFunc("/api/netguard/groups/delete", s.withAuth("netguard:admin", s.handleDeleteSecurityGroup)) mux.HandleFunc("/api/netguard/zones", s.withAuth("netguard:read", s.handleNetGuardZones)) + mux.HandleFunc("/api/netguard/zones/delete", s.withAuth("netguard:admin", s.handleDeleteGuardZone)) mux.HandleFunc("/api/netguard/nodes", s.withAuth("netguard:read", s.handleNetGuardNodes)) + mux.HandleFunc("/api/netguard/bindings", s.withAuth("", s.handleNetGuardBindings)) + mux.HandleFunc("/api/netguard/nodes/adopt", s.withAuth("", s.handleNetGuardAdopt)) + mux.HandleFunc("/api/netguard/plan", s.withAuth("", s.handleNetGuardPlan)) mux.HandleFunc("/api/network/wireguard/plan", s.withAuth("network:plan", s.handleWireGuardPlan)) mux.HandleFunc("/api/tunnels", s.withAuth("tunnel:admin", s.handleTunnels)) mux.HandleFunc("/api/tunnels/delete", s.withAuth("tunnel:admin", s.handleDeleteTunnel)) @@ -4811,17 +4816,7 @@ func applyScriptForWithServer(approval model.Approval, serverURL string) string "cloudflared --config /etc/cloudflared/config.yml ingress validate\n" + "systemctl reload cloudflared 2>/dev/null || systemctl restart cloudflared 2>/dev/null || echo 'config written; start cloudflared manually'\n" case "wireguard": - return "set -e\n" + - "umask 077\n" + - "mkdir -p /etc/wireguard\n" + - "KEY_FILE=${LATTICE_WG_KEY:-/etc/wireguard/lattice.key}\n" + - "if [ ! -f \"$KEY_FILE\" ]; then echo \"missing wireguard private key at $KEY_FILE\" >&2; exit 1; fi\n" + - "PRIV=$(cat \"$KEY_FILE\")\n" + - heredocWrite("/etc/wireguard/wg0.conf.new", "LATTICE_WG_EOF", approval.Plan) + - "sed -i \"s|" + wireguard.PrivateKeyPlaceholder + "|$PRIV|\" /etc/wireguard/wg0.conf.new\n" + - "mv /etc/wireguard/wg0.conf.new /etc/wireguard/wg0.conf\n" + - "wg-quick down wg0 2>/dev/null || true\n" + - "wg-quick up wg0\n" + return wireguardApplyScript(approval.Plan, serverURL) case "nftpolicy": payload, err := nftPolicyApprovalPayload(approval, serverURL) if err != nil { @@ -5054,6 +5049,128 @@ func nftPolicyApplyScript(plan, serverURL string, domainSets []nftPolicyDomainSe "echo 'lattice nftpolicy: applied and verified'\n" } +// applyWatchdogWindowSec is the dead-man window every host-mutating apply arms +// before committing. If the operator's own control path is severed by the +// change, the detached watchdog restores the snapshot after this many seconds. +// It is a single named constant so the nft, nftpolicy, selfdns, and wireguard +// paths cannot drift apart. +const applyWatchdogWindowSec = 60 + +// wireguardApplyScript gives WireGuard the same dead-man protection the nft +// paths have had: validate the candidate, snapshot the live config, arm a +// detached watchdog, commit, verify the control plane is still reachable, then +// disarm. A bad wg0.conf used to strand a node with no way back (the interface +// carrying the agent's own route could go down and nothing would restore it). +// +// Peer-only changes take a `wg syncconf` fast path so established tunnels do +// not flap; interface-level changes (address, listen port, MTU) still require +// a full down/up, which is why the candidate is compared block-by-block first. +func wireguardApplyScript(plan, serverURL string) string { + serverURL = strings.TrimRight(serverURL, "/") + selfcheck := "echo 'lattice wireguard: control-plane selfcheck skipped because public_url is unset' >&2\n" + done := "echo \"lattice wireguard: applied via $MODE; control-plane selfcheck skipped\"\n" + if serverURL != "" { + selfcheck = "AGENT_BIN=${LATTICE_AGENT_BIN:-lattice-agent}\n" + + "\"$AGENT_BIN\" --selfcheck-controlplane -server " + shellQuote(serverURL) + "\n" + done = "echo \"lattice wireguard: applied via $MODE and verified\"\n" + } + return "set -e\n" + + "umask 077\n" + + "mkdir -p /etc/wireguard\n" + + "KEY_FILE=${LATTICE_WG_KEY:-/etc/wireguard/lattice.key}\n" + + "if [ ! -f \"$KEY_FILE\" ]; then echo \"missing wireguard private key at $KEY_FILE\" >&2; exit 1; fi\n" + + "PRIV=$(cat \"$KEY_FILE\")\n" + + "CANDIDATE=/etc/wireguard/wg0.conf.new\n" + + "ACTIVE=/etc/wireguard/wg0.conf\n" + + "ROLLBACK=/etc/wireguard/wg0.rollback.conf\n" + + "STRIPPED=/etc/wireguard/wg0.stripped.conf\n" + + heredocWrite("$CANDIDATE", "LATTICE_WG_EOF", plan) + + "sed -i \"s|" + wireguard.PrivateKeyPlaceholder + "|$PRIV|\" \"$CANDIDATE\"\n" + + // Parse the candidate before it can touch the kernel. This is the + // wg-quick analogue of `nft -c`. + "wg-quick strip \"$CANDIDATE\" > /dev/null\n" + + "HAD_ACTIVE=0\n" + + "if [ -f \"$ACTIVE\" ]; then cp \"$ACTIVE\" \"$ROLLBACK\"; HAD_ACTIVE=1; else rm -f \"$ROLLBACK\"; fi\n" + + "iface_block() { awk 'BEGIN{p=1} /^[[:space:]]*\\[Peer\\]/{p=0} p{print}' \"$1\"; }\n" + + wireguardRollbackWatchdogScript() + + // $STRIPPED carries the substituted private key. Clear it on every exit + // path, not just the happy one, so a failed syncconf cannot leave key + // material behind. + "trap 'rollback; cleanup_watchdog; rm -f \"$STRIPPED\"' ERR\n" + + "start_watchdog\n" + + "MODE=restart\n" + + "if [ \"$HAD_ACTIVE\" = 1 ] && wg show wg0 >/dev/null 2>&1 && " + + "[ \"$(iface_block \"$ACTIVE\")\" = \"$(iface_block \"$CANDIDATE\")\" ]; then\n" + + " MODE=syncconf\n" + + "fi\n" + + "mv \"$CANDIDATE\" \"$ACTIVE\"\n" + + "if [ \"$MODE\" = syncconf ]; then\n" + + " wg-quick strip \"$ACTIVE\" > \"$STRIPPED\"\n" + + " wg syncconf wg0 \"$STRIPPED\"\n" + + " rm -f \"$STRIPPED\"\n" + + "else\n" + + " wg-quick down wg0 2>/dev/null || true\n" + + " wg-quick up wg0\n" + + "fi\n" + + selfcheck + + "assert_watchdog_clean\n" + + "trap - ERR\n" + + "cleanup_watchdog\n" + + "rm -f \"$ROLLBACK\"\n" + + done +} + +// wireguardRollbackWatchdogScript mirrors nftRollbackWatchdogScript, but its +// rollback restores the previous wg0.conf and re-establishes the interface (or +// tears it down when there was no prior config), because `nft -f` has no +// meaning for WireGuard state. +func wireguardRollbackWatchdogScript() string { + window := strconv.Itoa(applyWatchdogWindowSec) + const fired = "lattice wireguard: watchdog rollback fired" + const rolling = "lattice wireguard: rolling back wg0 configuration" + // restoreBody runs both in-process (rollback) and inside the detached + // watchdog child, which receives the paths as positional parameters. + restore := "if [ -f \"$1\" ]; then cp \"$1\" \"$2\" 2>/dev/null || true; " + + "wg-quick down wg0 2>/dev/null || true; wg-quick up wg0 2>/dev/null || true; " + + "else wg-quick down wg0 2>/dev/null || true; rm -f \"$2\"; fi" + return "WATCHDOG=\n" + + "WATCHDOG_FIRED=/tmp/lattice-wireguard-watchdog.$$\n" + + "cleanup_watchdog() {\n" + + " if [ -n \"$WATCHDOG\" ]; then\n" + + " kill \"$WATCHDOG\" 2>/dev/null || true\n" + + " wait \"$WATCHDOG\" 2>/dev/null || true\n" + + " fi\n" + + " rm -f \"$WATCHDOG_FIRED\"\n" + + "}\n" + + "rollback() {\n" + + " echo '" + rolling + "' >&2\n" + + " sh -c '" + restore + "' sh \"$ROLLBACK\" \"$ACTIVE\"\n" + + "}\n" + + "start_watchdog() {\n" + + " if command -v setsid >/dev/null 2>&1; then\n" + + " setsid sh -c 'sleep " + window + "; echo \"" + fired + "\" >&2; touch \"$1\" 2>/dev/null || true; " + + "echo \"" + rolling + "\" >&2; " + restoreShiftedBody(restore) + "' sh \"$WATCHDOG_FIRED\" \"$ROLLBACK\" \"$ACTIVE\" &\n" + + " else\n" + + " ( sleep " + window + "; echo '" + fired + "' >&2; touch \"$WATCHDOG_FIRED\" 2>/dev/null || true; rollback ) &\n" + + " fi\n" + + " WATCHDOG=$!\n" + + "}\n" + + "assert_watchdog_clean() {\n" + + " if [ -f \"$WATCHDOG_FIRED\" ]; then\n" + + " echo '" + fired + " before commit; refusing to mark apply verified' >&2\n" + + " false\n" + + " fi\n" + + "}\n" +} + +// restoreShiftedBody rewrites the restore body's positional parameters for the +// detached watchdog child, whose $1 is the fired-marker path, so $ROLLBACK and +// $ACTIVE land on $2 and $3. +func restoreShiftedBody(restore string) string { + shifted := strings.ReplaceAll(restore, "\"$2\"", "\"$3\"") + return strings.ReplaceAll(shifted, "\"$1\"", "\"$2\"") +} + func nftRollbackWatchdogScript(name, firedMessage, rollbackMessage string) string { return "WATCHDOG=\n" + "WATCHDOG_FIRED=/tmp/lattice-" + name + "-watchdog.$$\n" + @@ -5070,9 +5187,9 @@ func nftRollbackWatchdogScript(name, firedMessage, rollbackMessage string) strin "}\n" + "start_watchdog() {\n" + " if command -v setsid >/dev/null 2>&1; then\n" + - " setsid sh -c 'sleep 60; echo \"" + firedMessage + "\" >&2; touch \"$1\" 2>/dev/null || true; echo \"" + rollbackMessage + "\" >&2; nft -f \"$2\" 2>/dev/null || true' sh \"$WATCHDOG_FIRED\" \"$ROLLBACK\" &\n" + + " setsid sh -c 'sleep " + strconv.Itoa(applyWatchdogWindowSec) + "; echo \"" + firedMessage + "\" >&2; touch \"$1\" 2>/dev/null || true; echo \"" + rollbackMessage + "\" >&2; nft -f \"$2\" 2>/dev/null || true' sh \"$WATCHDOG_FIRED\" \"$ROLLBACK\" &\n" + " else\n" + - " ( sleep 60; echo '" + firedMessage + "' >&2; touch \"$WATCHDOG_FIRED\" 2>/dev/null || true; rollback ) &\n" + + " ( sleep " + strconv.Itoa(applyWatchdogWindowSec) + "; echo '" + firedMessage + "' >&2; touch \"$WATCHDOG_FIRED\" 2>/dev/null || true; rollback ) &\n" + " fi\n" + " WATCHDOG=$!\n" + "}\n" + diff --git a/internal/server/server_netguard.go b/internal/server/server_netguard.go index 5556695..ea02072 100644 --- a/internal/server/server_netguard.go +++ b/internal/server/server_netguard.go @@ -2,11 +2,18 @@ package server import ( "errors" + "fmt" "net/http" + "regexp" + "strings" + "time" "github.com/LatticeNet/lattice-sdk/model" + "github.com/LatticeNet/lattice-server/internal/id" "github.com/LatticeNet/lattice-server/internal/netguard" + "github.com/LatticeNet/lattice-server/internal/network" "github.com/LatticeNet/lattice-server/internal/rbac" + "github.com/LatticeNet/lattice-server/internal/store" ) // design-13 G1: read-only netguard views. Stored security groups, zones, and @@ -34,8 +41,17 @@ type nodeGuardView struct { Zones []model.GuardZone `json:"zones"` } +// guardIDRe bounds operator-chosen group and zone ids to a charset that is +// safe wherever they surface (nft comments, routes, audit metadata). +var guardIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) + func (s *Server) handleNetGuardGroups(w http.ResponseWriter, r *http.Request, p principal) { - if r.Method != http.MethodGet { + switch r.Method { + case http.MethodGet: + case http.MethodPost: + s.handleUpsertSecurityGroup(w, r, p) + return + default: writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed")) return } @@ -61,7 +77,12 @@ func (s *Server) handleNetGuardGroups(w http.ResponseWriter, r *http.Request, p } func (s *Server) handleNetGuardZones(w http.ResponseWriter, r *http.Request, p principal) { - if r.Method != http.MethodGet { + switch r.Method { + case http.MethodGet: + case http.MethodPost: + s.handleUpsertGuardZone(w, r, p) + return + default: writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed")) return } @@ -152,3 +173,432 @@ func (s *Server) nodeName(nodeID string) string { } return "" } + +// resolveNodeZones builds the zone map used to compile one node. Zones are +// fleet-scoped by name but resolve per-node facts: the "public" zone means +// *this* node's public interface, the "wireguard" zone means *this* node's +// mesh CIDR. Operator-authored zones (e.g. a tailscale zone pinning +// tailscale0) are used verbatim. +func (s *Server) resolveNodeZones(nodeID string) map[string]model.GuardZone { + zones := netguard.ZoneMap(s.store.GuardZones()) + if zones == nil { + zones = map[string]model.GuardZone{} + } + inputs, hasInputs := s.store.NFTInputs(nodeID) + + public := zones[model.GuardZonePublic] + public.ID, public.Name, public.Builtin = model.GuardZonePublic, "public", true + if len(public.Interfaces) == 0 { + iface := "eth0" + if hasInputs && inputs.InterfaceName != "" { + iface = inputs.InterfaceName + } + public.Interfaces = []string{iface} + } + zones[model.GuardZonePublic] = public + + wg := zones[model.GuardZoneWireGuard] + wg.ID, wg.Name, wg.Builtin = model.GuardZoneWireGuard, "wireguard", true + if len(wg.CIDRs) == 0 { + cidr := "10.66.0.0/24" + if hasInputs && inputs.WireGuardCIDR != "" { + cidr = inputs.WireGuardCIDR + } + wg.CIDRs = []string{cidr} + } + zones[model.GuardZoneWireGuard] = wg + + if _, ok := zones[model.GuardZoneLoopback]; !ok { + zones[model.GuardZoneLoopback] = model.GuardZone{ + ID: model.GuardZoneLoopback, Name: "loopback", Builtin: true, Interfaces: []string{"lo"}, + } + } + return zones +} + +func (s *Server) compileInputFor(nodeID string) (netguard.CompileInput, error) { + binding, ok := s.store.NodeGuardBinding(nodeID) + if !ok { + return netguard.CompileInput{}, fmt.Errorf("node %q has no guard binding; adopt it first", nodeID) + } + groups := make([]model.SecurityGroup, 0, len(binding.GroupIDs)) + for _, groupID := range binding.GroupIDs { + group, ok := s.store.SecurityGroup(groupID) + if !ok { + return netguard.CompileInput{}, fmt.Errorf("security group %q not found", groupID) + } + groups = append(groups, group) + } + return netguard.CompileInput{ + Binding: binding, + Groups: groups, + Zones: s.resolveNodeZones(nodeID), + Resolve: s.resolveNode, + }, nil +} + +func (s *Server) handleUpsertSecurityGroup(w http.ResponseWriter, r *http.Request, p principal) { + if !rbac.Allows(p.Principal, "netguard:admin", "") { + writeError(w, http.StatusForbidden, errors.New("netguard:admin is required")) + return + } + var req model.SecurityGroup + if !decodeClientJSON(w, r, &req) { + return + } + if req.ID == "" { + req.ID = id.New("sg") + } + if !guardIDRe.MatchString(req.ID) { + writeError(w, http.StatusBadRequest, fmt.Errorf("invalid security group id %q", req.ID)) + return + } + if strings.HasPrefix(req.ID, netguard.LegacyGroupPrefix) { + if _, ok := s.store.SecurityGroup(req.ID); !ok { + writeError(w, http.StatusBadRequest, errors.New("legacy group ids are reserved; adopt the node instead")) + return + } + } + if strings.TrimSpace(req.Name) == "" { + writeError(w, http.StatusBadRequest, errors.New("name is required")) + return + } + // Validate rules by compiling them in isolation: an unrenderable rule must + // never reach the store, so a later plan cannot fail on stored garbage. + if err := s.validateGuardRules(req.Rules); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + saved, err := s.store.UpsertSecurityGroup(req) + if err != nil { + if errors.Is(err, store.ErrGuardVersionConflict) { + writeError(w, http.StatusConflict, err) + return + } + writeError(w, http.StatusInternalServerError, err) + return + } + s.recordPrincipalAudit(p, model.AuditEvent{ + ID: id.New("audit"), Action: "netguard.group.upsert", Scope: "netguard:admin", + Metadata: map[string]string{"group_id": saved.ID}, + }) + writeJSON(w, http.StatusOK, securityGroupView{SecurityGroup: saved, Source: netGuardSourceStored}) +} + +// validateGuardRules compiles a candidate rule set against a permissive +// synthetic node so unsupported or malformed shapes are rejected at write +// time rather than at plan time. +func (s *Server) validateGuardRules(rules []model.GuardRule) error { + if len(rules) == 0 { + return nil + } + zones := map[string]model.GuardZone{ + model.GuardZonePublic: {ID: model.GuardZonePublic, Interfaces: []string{"eth0"}}, + model.GuardZoneWireGuard: {ID: model.GuardZoneWireGuard, CIDRs: []string{"10.66.0.0/24"}}, + } + for _, zone := range s.store.GuardZones() { + zones[zone.ID] = zone + } + _, err := netguard.Compile(netguard.CompileInput{ + Binding: model.NodeGuardBinding{NodeID: "validate", Managed: true}, + Groups: []model.SecurityGroup{{ID: "validate", Rules: rules}}, + Zones: zones, + Resolve: s.resolveNode, + }) + return err +} + +func (s *Server) handleDeleteSecurityGroup(w http.ResponseWriter, r *http.Request, p principal) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed")) + return + } + if !rbac.Allows(p.Principal, "netguard:admin", "") { + writeError(w, http.StatusForbidden, errors.New("netguard:admin is required")) + return + } + var req struct { + ID string `json:"id"` + } + if !decodeClientJSON(w, r, &req) { + return + } + if req.ID == "" { + writeError(w, http.StatusBadRequest, errors.New("id is required")) + return + } + // A group still attached to a node would leave that binding uncompilable. + for _, binding := range s.store.NodeGuardBindings() { + for _, groupID := range binding.GroupIDs { + if groupID == req.ID { + writeError(w, http.StatusConflict, fmt.Errorf("security group %q is still attached to node %q", req.ID, binding.NodeID)) + return + } + } + } + if err := s.store.DeleteSecurityGroup(req.ID); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + s.recordPrincipalAudit(p, model.AuditEvent{ + ID: id.New("audit"), Action: "netguard.group.delete", Scope: "netguard:admin", + Metadata: map[string]string{"group_id": req.ID}, + }) + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func (s *Server) handleUpsertGuardZone(w http.ResponseWriter, r *http.Request, p principal) { + if !rbac.Allows(p.Principal, "netguard:admin", "") { + writeError(w, http.StatusForbidden, errors.New("netguard:admin is required")) + return + } + var req model.GuardZone + if !decodeClientJSON(w, r, &req) { + return + } + if !guardIDRe.MatchString(req.ID) { + writeError(w, http.StatusBadRequest, fmt.Errorf("invalid zone id %q", req.ID)) + return + } + if req.ID == model.GuardZoneLoopback { + writeError(w, http.StatusBadRequest, errors.New("the loopback zone is not editable")) + return + } + if len(req.Interfaces) == 0 && len(req.CIDRs) == 0 { + writeError(w, http.StatusBadRequest, errors.New("a zone needs at least one interface or cidr")) + return + } + // Canonicalize by rendering a throwaway trusted-zone accept: the same + // interface-name and CIDR validation the compiler enforces. + if _, err := netguard.Compile(netguard.CompileInput{ + Binding: model.NodeGuardBinding{NodeID: "validate", Managed: true, ZoneIDs: []string{req.ID}}, + Zones: map[string]model.GuardZone{req.ID: req}, + Resolve: s.resolveNode, + }); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + req.Builtin = false + if err := s.store.UpsertGuardZone(req); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + s.recordPrincipalAudit(p, model.AuditEvent{ + ID: id.New("audit"), Action: "netguard.zone.upsert", Scope: "netguard:admin", + Metadata: map[string]string{"zone_id": req.ID}, + }) + stored, _ := s.store.GuardZone(req.ID) + writeJSON(w, http.StatusOK, stored) +} + +func (s *Server) handleDeleteGuardZone(w http.ResponseWriter, r *http.Request, p principal) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed")) + return + } + if !rbac.Allows(p.Principal, "netguard:admin", "") { + writeError(w, http.StatusForbidden, errors.New("netguard:admin is required")) + return + } + var req struct { + ID string `json:"id"` + } + if !decodeClientJSON(w, r, &req) { + return + } + for _, binding := range s.store.NodeGuardBindings() { + for _, zoneID := range binding.ZoneIDs { + if zoneID == req.ID { + writeError(w, http.StatusConflict, fmt.Errorf("zone %q is still trusted by node %q", req.ID, binding.NodeID)) + return + } + } + } + if err := s.store.DeleteGuardZone(req.ID); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + s.recordPrincipalAudit(p, model.AuditEvent{ + ID: id.New("audit"), Action: "netguard.zone.delete", Scope: "netguard:admin", + Metadata: map[string]string{"zone_id": req.ID}, + }) + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func (s *Server) handleNetGuardBindings(w http.ResponseWriter, r *http.Request, p principal) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed")) + return + } + var req model.NodeGuardBinding + if !decodeClientJSON(w, r, &req) { + return + } + if req.NodeID == "" { + writeError(w, http.StatusBadRequest, errors.New("node_id is required")) + return + } + if _, ok := s.store.Node(req.NodeID); !ok { + writeError(w, http.StatusNotFound, errors.New("node not found")) + return + } + if !s.requireNodeScope(w, p, "netguard:admin", req.NodeID) { + return + } + for _, groupID := range req.GroupIDs { + if _, ok := s.store.SecurityGroup(groupID); !ok { + writeError(w, http.StatusBadRequest, fmt.Errorf("security group %q not found", groupID)) + return + } + } + if err := s.validateGuardRules(req.Overrides); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + saved, err := s.store.UpsertNodeGuardBinding(req) + if err != nil { + if errors.Is(err, store.ErrGuardVersionConflict) { + writeError(w, http.StatusConflict, err) + return + } + writeError(w, http.StatusInternalServerError, err) + return + } + s.recordPrincipalAudit(p, model.AuditEvent{ + ID: id.New("audit"), NodeID: req.NodeID, Action: "netguard.binding.upsert", Scope: "netguard:admin", + Metadata: map[string]string{"node_id": req.NodeID}, + }) + writeJSON(w, http.StatusOK, s.storedNodeGuardView(saved)) +} + +// handleNetGuardAdopt materializes a node's converted legacy baseline into +// stored records and marks it managed. Until a node is adopted its converted +// view stays observe-only and cannot be planned. +func (s *Server) handleNetGuardAdopt(w http.ResponseWriter, r *http.Request, p principal) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed")) + return + } + var req struct { + NodeID string `json:"node_id"` + } + if !decodeClientJSON(w, r, &req) { + return + } + if req.NodeID == "" { + writeError(w, http.StatusBadRequest, errors.New("node_id is required")) + return + } + if !s.requireNodeScope(w, p, "netguard:admin", req.NodeID) { + return + } + if _, ok := s.store.NodeGuardBinding(req.NodeID); ok { + writeError(w, http.StatusConflict, errors.New("node is already adopted")) + return + } + inputs, ok := s.store.NFTInputs(req.NodeID) + if !ok { + writeError(w, http.StatusNotFound, errors.New("node has no legacy baseline to adopt")) + return + } + view := netguard.LegacyBaseline(inputs) + group := view.Group + group.Version = 0 + saved, err := s.store.UpsertSecurityGroup(group) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + binding := view.Binding + binding.Version = 0 + binding.Managed = true + binding.GroupIDs = []string{saved.ID} + storedBinding, err := s.store.UpsertNodeGuardBinding(binding) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + s.recordPrincipalAudit(p, model.AuditEvent{ + ID: id.New("audit"), NodeID: req.NodeID, Action: "netguard.node.adopt", Scope: "netguard:admin", + Metadata: map[string]string{"node_id": req.NodeID, "group_id": saved.ID}, + }) + writeJSON(w, http.StatusOK, s.storedNodeGuardView(storedBinding)) +} + +// handleNetGuardPlan compiles a node's guard model, lints it, and records a +// pending approval. The plan text is the same `table inet lattice_guard` +// ruleset the legacy Network Guard path produces, so it rides the existing +// `nft` apply script — validate, snapshot, dead-man watchdog, commit, +// control-plane selfcheck — with no new apply branch. +func (s *Server) handleNetGuardPlan(w http.ResponseWriter, r *http.Request, p principal) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed")) + return + } + var req struct { + NodeID string `json:"node_id"` + AcceptLockoutRisk bool `json:"accept_lockout_risk"` + } + if !decodeClientJSON(w, r, &req) { + return + } + if req.NodeID == "" { + writeError(w, http.StatusBadRequest, errors.New("node_id is required")) + return + } + if !s.requireNodeScope(w, p, "netguard:admin", req.NodeID) { + return + } + if !s.requireNodeScope(w, p, "network:plan", req.NodeID) { + return + } + input, err := s.compileInputFor(req.NodeID) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + compiled, err := netguard.Compile(input) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + findings := netguard.Lint(compiled, netguard.LintOptions{PublicURLConfigured: s.publicURL != ""}) + if netguard.Blocking(findings) && !req.AcceptLockoutRisk { + writeJSON(w, http.StatusConflict, map[string]any{ + "error": "plan blocked by lint findings", + "findings": findings, + }) + return + } + ruleset, err := network.GenerateNFTPlan(compiled) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + approval := model.Approval{ + ID: id.New("approval"), + NodeID: req.NodeID, + Plugin: "nft", + Action: "apply-ruleset", + Plan: ruleset, + Status: model.ApprovalPending, + ActorID: p.ActorID, + CreatedAt: time.Now().UTC(), + } + if err := s.store.UpsertApproval(approval); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + metadata := map[string]string{"approval_id": approval.ID, "source": "netguard"} + if netguard.Blocking(findings) { + metadata["lockout_risk_accepted"] = "true" + s.recordPrincipalAudit(p, model.AuditEvent{ + ID: id.New("audit"), NodeID: req.NodeID, Action: "netguard.lockout_risk.accepted", Scope: "netguard:admin", + Metadata: map[string]string{"node_id": req.NodeID, "approval_id": approval.ID}, + }) + } + s.recordPrincipalAudit(p, model.AuditEvent{ + ID: id.New("audit"), NodeID: req.NodeID, Action: "netguard.plan", Scope: "network:plan", Metadata: metadata, + }) + writeJSON(w, http.StatusOK, map[string]any{"approval": approval, "findings": findings}) +} diff --git a/internal/server/server_netguard_test.go b/internal/server/server_netguard_test.go index 4c49c55..5e7eb09 100644 --- a/internal/server/server_netguard_test.go +++ b/internal/server/server_netguard_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "errors" "net/http" + "strconv" + "strings" "testing" "github.com/LatticeNet/lattice-sdk/model" @@ -208,6 +210,237 @@ func TestNetGuardStoredBindingSupersedesLegacyView(t *testing.T) { } } +// End-to-end G2: adopt a legacy node, then plan from the new model. The plan +// must be a lattice_guard ruleset carried by an `nft` approval so it rides the +// existing rollback-protected apply script unchanged. +func TestNetGuardAdoptThenPlan(t *testing.T) { + handler, st := newTestServerWithPublicURL(t, "https://203.0.113.99") + cookies, csrf := loginSession(t, handler) + enrollNamedNode(t, handler, cookies, csrf, "node-a", "Node A") + + save := doJSON(t, handler, http.MethodPost, "/api/network/nft/inputs", `{ + "node_id":"node-a","interface_name":"ens3","public_tcp":[22,443] + }`, cookies, csrf) + defer save.Body.Close() + if save.StatusCode != http.StatusOK { + t.Fatalf("save inputs: %d", save.StatusCode) + } + + // Planning before adoption is refused: converted views are observe-only. + early := doJSON(t, handler, http.MethodPost, "/api/netguard/plan", `{"node_id":"node-a"}`, cookies, csrf) + defer early.Body.Close() + if early.StatusCode != http.StatusBadRequest { + t.Fatalf("plan before adopt = %d, want 400", early.StatusCode) + } + + adopt := doJSON(t, handler, http.MethodPost, "/api/netguard/nodes/adopt", `{"node_id":"node-a"}`, cookies, csrf) + defer adopt.Body.Close() + if adopt.StatusCode != http.StatusOK { + t.Fatalf("adopt failed: %d", adopt.StatusCode) + } + binding, ok := st.NodeGuardBinding("node-a") + if !ok || !binding.Managed { + t.Fatalf("adopt must persist a managed binding, got %+v", binding) + } + if _, ok := st.SecurityGroup("sg-legacy-node-a"); !ok { + t.Fatal("adopt must persist the converted group") + } + + // Adopting twice is a conflict, not a silent re-materialization. + again := doJSON(t, handler, http.MethodPost, "/api/netguard/nodes/adopt", `{"node_id":"node-a"}`, cookies, csrf) + defer again.Body.Close() + if again.StatusCode != http.StatusConflict { + t.Fatalf("re-adopt = %d, want 409", again.StatusCode) + } + + plan := doJSON(t, handler, http.MethodPost, "/api/netguard/plan", `{"node_id":"node-a"}`, cookies, csrf) + defer plan.Body.Close() + if plan.StatusCode != http.StatusOK { + t.Fatalf("netguard plan failed: %d", plan.StatusCode) + } + var planRes struct { + Approval model.Approval `json:"approval"` + Findings []struct { + Code string `json:"code"` + } `json:"findings"` + } + if err := json.NewDecoder(plan.Body).Decode(&planRes); err != nil { + t.Fatal(err) + } + if planRes.Approval.Plugin != "nft" || planRes.Approval.Action != "apply-ruleset" { + t.Fatalf("plan must ride the existing nft apply path: %+v", planRes.Approval) + } + for _, want := range []string{ + `destroy table inet lattice_guard`, + `iifname "ens3" tcp dport { 22, 443 }`, + `counter drop`, + } { + if !strings.Contains(planRes.Approval.Plan, want) { + t.Fatalf("plan missing %q:\n%s", want, planRes.Approval.Plan) + } + } + if len(planRes.Findings) != 0 { + t.Fatalf("a plan allowing tcp/22 with a public url must be clean: %+v", planRes.Findings) + } +} + +// The dmit-eb-wee guard: a plan with no management-port accept is refused +// before it can ever reach a node, and only an explicit, audited override +// lets it through. +func TestNetGuardPlanBlocksLockoutRisk(t *testing.T) { + handler, _ := newTestServerWithPublicURL(t, "https://203.0.113.99") + cookies, csrf := loginSession(t, handler) + enrollNamedNode(t, handler, cookies, csrf, "node-a", "Node A") + + // A baseline with the real incident's shape: services, but no SSH. + save := doJSON(t, handler, http.MethodPost, "/api/network/nft/inputs", + `{"node_id":"node-a","public_tcp":[7443,7500]}`, cookies, csrf) + defer save.Body.Close() + adopt := doJSON(t, handler, http.MethodPost, "/api/netguard/nodes/adopt", `{"node_id":"node-a"}`, cookies, csrf) + defer adopt.Body.Close() + if adopt.StatusCode != http.StatusOK { + t.Fatalf("adopt: %d", adopt.StatusCode) + } + + blocked := doJSON(t, handler, http.MethodPost, "/api/netguard/plan", `{"node_id":"node-a"}`, cookies, csrf) + defer blocked.Body.Close() + if blocked.StatusCode != http.StatusConflict { + t.Fatalf("lockout plan = %d, want 409", blocked.StatusCode) + } + var blockedRes struct { + Findings []struct { + Code string `json:"code"` + Severity string `json:"severity"` + } `json:"findings"` + } + if err := json.NewDecoder(blocked.Body).Decode(&blockedRes); err != nil { + t.Fatal(err) + } + if len(blockedRes.Findings) == 0 || blockedRes.Findings[0].Code != "lockout_risk_ssh" || + blockedRes.Findings[0].Severity != "block" { + t.Fatalf("findings = %+v", blockedRes.Findings) + } + + forced := doJSON(t, handler, http.MethodPost, "/api/netguard/plan", + `{"node_id":"node-a","accept_lockout_risk":true}`, cookies, csrf) + defer forced.Body.Close() + if forced.StatusCode != http.StatusOK { + t.Fatalf("explicit override = %d, want 200", forced.StatusCode) + } +} + +// A trusted overlay zone is the safe remedy for the lockout case: the node +// keeps its tailscale path and the plan stops blocking. +func TestNetGuardTrustedZoneClearsLockoutAndRendersIifname(t *testing.T) { + handler, st := newTestServerWithPublicURL(t, "https://203.0.113.99") + cookies, csrf := loginSession(t, handler) + enrollNamedNode(t, handler, cookies, csrf, "node-a", "Node A") + save := doJSON(t, handler, http.MethodPost, "/api/network/nft/inputs", + `{"node_id":"node-a","public_tcp":[7443]}`, cookies, csrf) + defer save.Body.Close() + adopt := doJSON(t, handler, http.MethodPost, "/api/netguard/nodes/adopt", `{"node_id":"node-a"}`, cookies, csrf) + defer adopt.Body.Close() + + zone := doJSON(t, handler, http.MethodPost, "/api/netguard/zones", + `{"id":"tailscale","name":"tailscale","interfaces":["tailscale0"]}`, cookies, csrf) + defer zone.Body.Close() + if zone.StatusCode != http.StatusOK { + t.Fatalf("create zone: %d", zone.StatusCode) + } + + binding, _ := st.NodeGuardBinding("node-a") + body := `{"node_id":"node-a","managed":true,"version":` + + strconv.FormatInt(binding.Version, 10) + + `,"group_ids":["sg-legacy-node-a"],"zone_ids":["tailscale"]}` + bind := doJSON(t, handler, http.MethodPost, "/api/netguard/bindings", body, cookies, csrf) + defer bind.Body.Close() + if bind.StatusCode != http.StatusOK { + t.Fatalf("bind zone: %d", bind.StatusCode) + } + + plan := doJSON(t, handler, http.MethodPost, "/api/netguard/plan", `{"node_id":"node-a"}`, cookies, csrf) + defer plan.Body.Close() + if plan.StatusCode != http.StatusOK { + t.Fatalf("plan with trusted zone = %d, want 200 (lockout lint satisfied)", plan.StatusCode) + } + var planRes struct { + Approval model.Approval `json:"approval"` + } + if err := json.NewDecoder(plan.Body).Decode(&planRes); err != nil { + t.Fatal(err) + } + if !strings.Contains(planRes.Approval.Plan, `iifname "tailscale0" accept comment "trusted zone tailscale"`) { + t.Fatalf("trusted zone accept missing:\n%s", planRes.Approval.Plan) + } + + // A zone still trusted by a node cannot be deleted out from under it. + del := doJSON(t, handler, http.MethodPost, "/api/netguard/zones/delete", `{"id":"tailscale"}`, cookies, csrf) + defer del.Body.Close() + if del.StatusCode != http.StatusConflict { + t.Fatalf("delete in-use zone = %d, want 409", del.StatusCode) + } +} + +func TestNetGuardWriteValidationAndConflicts(t *testing.T) { + handler, _ := newTestServer(t) + cookies, csrf := loginSession(t, handler) + enrollNamedNode(t, handler, cookies, csrf, "node-a", "Node A") + + // An unrenderable rule must be rejected at write time, never stored. + bad := doJSON(t, handler, http.MethodPost, "/api/netguard/groups", `{ + "id":"sg-bad","name":"bad","rules":[{"id":"r","action":"allow","direction":"ingress", + "protocol":"icmp","remote":{"kind":"zone","zone_id":"public"}}]}`, cookies, csrf) + defer bad.Body.Close() + if bad.StatusCode != http.StatusBadRequest { + t.Fatalf("icmp rule = %d, want 400", bad.StatusCode) + } + + good := doJSON(t, handler, http.MethodPost, "/api/netguard/groups", `{ + "id":"sg-web","name":"web","rules":[{"id":"https","action":"allow","direction":"ingress", + "protocol":"tcp","ports":[{"from":443,"to":443}],"remote":{"kind":"zone","zone_id":"public"}}]}`, cookies, csrf) + defer good.Body.Close() + if good.StatusCode != http.StatusOK { + t.Fatalf("valid group = %d, want 200", good.StatusCode) + } + + // Stale version write is a 409, not a silent clobber. + stale := doJSON(t, handler, http.MethodPost, "/api/netguard/groups", + `{"id":"sg-web","name":"clobber","version":0}`, cookies, csrf) + defer stale.Body.Close() + if stale.StatusCode != http.StatusConflict { + t.Fatalf("stale group write = %d, want 409", stale.StatusCode) + } + + // Reserved legacy id space cannot be squatted. + squat := doJSON(t, handler, http.MethodPost, "/api/netguard/groups", + `{"id":"sg-legacy-node-a","name":"squat"}`, cookies, csrf) + defer squat.Body.Close() + if squat.StatusCode != http.StatusBadRequest { + t.Fatalf("legacy id squat = %d, want 400", squat.StatusCode) + } + + // A group attached to a node cannot be deleted. + bind := doJSON(t, handler, http.MethodPost, "/api/netguard/bindings", + `{"node_id":"node-a","managed":true,"group_ids":["sg-web"]}`, cookies, csrf) + defer bind.Body.Close() + if bind.StatusCode != http.StatusOK { + t.Fatalf("bind: %d", bind.StatusCode) + } + del := doJSON(t, handler, http.MethodPost, "/api/netguard/groups/delete", `{"id":"sg-web"}`, cookies, csrf) + defer del.Body.Close() + if del.StatusCode != http.StatusConflict { + t.Fatalf("delete attached group = %d, want 409", del.StatusCode) + } + + // The loopback zone is not editable. + lo := doJSON(t, handler, http.MethodPost, "/api/netguard/zones", + `{"id":"loopback","name":"lo","interfaces":["lo"]}`, cookies, csrf) + defer lo.Body.Close() + if lo.StatusCode != http.StatusBadRequest { + t.Fatalf("edit loopback zone = %d, want 400", lo.StatusCode) + } +} + func TestNetGuardStoreVersionConflicts(t *testing.T) { _, st := newTestServer(t) diff --git a/internal/server/server_wireguard_apply_test.go b/internal/server/server_wireguard_apply_test.go new file mode 100644 index 0000000..fddb459 --- /dev/null +++ b/internal/server/server_wireguard_apply_test.go @@ -0,0 +1,167 @@ +package server + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/LatticeNet/lattice-sdk/model" + "github.com/LatticeNet/lattice-server/internal/wireguard" +) + +const testWGPlan = "[Interface]\nAddress = 10.66.0.1/32\nPrivateKey = " + + wireguard.PrivateKeyPlaceholder + "\nListenPort = 51820\n\n[Peer]\nPublicKey = " + + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP=\nAllowedIPs = 10.66.0.2/32\n" + +// design-13 W2 / D9: WireGuard apply must carry the same dead-man protection +// the nft paths have. Before this, a bad wg0.conf could strand a node with no +// way back — the interface carrying the agent's own route went down and +// nothing restored it. +func TestWireGuardApplyHasRollbackWatchdogAndSelfcheck(t *testing.T) { + script := applyScriptForWithServer( + model.Approval{Plugin: "wireguard", Plan: testWGPlan}, + "https://203.0.113.99", + ) + for _, want := range []string{ + // validate before the kernel sees it + `wg-quick strip "$CANDIDATE" > /dev/null`, + // snapshot the live config + `cp "$ACTIVE" "$ROLLBACK"`, + // dead-man switch, armed before the commit + "start_watchdog", + "WATCHDOG_FIRED=/tmp/lattice-wireguard-watchdog.$$", + "trap 'rollback; cleanup_watchdog; rm -f \"$STRIPPED\"' ERR", + "sleep 60", + // commit + "wg-quick up wg0", + // verify the control plane survived, then disarm + "--selfcheck-controlplane -server 'https://203.0.113.99'", + "assert_watchdog_clean", + "cleanup_watchdog", + } { + if !strings.Contains(script, want) { + t.Fatalf("wireguard apply script missing %q:\n%s", want, script) + } + } + + // Ordering is the whole point: arm before commit, verify before disarm. + // Measure only the executable tail — the rollback()/start_watchdog() + // function bodies defined above it also mention these commands. + arm := strings.Index(script, "\nstart_watchdog\n") + if arm < 0 { + t.Fatalf("watchdog is never armed:\n%s", script) + } + tail := script[arm:] + commit := strings.Index(tail, "wg-quick up wg0") + verify := strings.Index(tail, "assert_watchdog_clean\n") + disarm := strings.Index(tail, "\ncleanup_watchdog\n") + if commit < 0 || verify < 0 || disarm < 0 || !(commit < verify && verify < disarm) { + t.Fatalf("unsafe ordering after arming: commit=%d verify=%d disarm=%d:\n%s", commit, verify, disarm, tail) + } + + // The private key placeholder is substituted on-node; the plan the server + // stores and the operator reviews never carries a secret. + if !strings.Contains(script, wireguard.PrivateKeyPlaceholder) { + t.Fatal("script must substitute the private-key placeholder on-node") + } + if strings.Contains(script, "PrivateKey = abc") { + t.Fatal("a real private key must never appear in the apply script") + } +} + +// Peer-only changes reload without dropping established tunnels; interface +// changes still take the full restart path. +func TestWireGuardApplyUsesSyncconfFastPath(t *testing.T) { + script := applyScriptForWithServer(model.Approval{Plugin: "wireguard", Plan: testWGPlan}, "") + for _, want := range []string{ + "iface_block()", + `MODE=syncconf`, + `wg syncconf wg0 "$STRIPPED"`, + `MODE=restart`, + } { + if !strings.Contains(script, want) { + t.Fatalf("missing syncconf fast path %q:\n%s", want, script) + } + } + // syncconf must only be chosen when the [Interface] block is unchanged. + if !strings.Contains(script, `[ "$(iface_block "$ACTIVE")" = "$(iface_block "$CANDIDATE")" ]`) { + t.Fatalf("syncconf must be gated on an unchanged interface block:\n%s", script) + } +} + +// $STRIPPED holds the substituted private key while syncconf runs. It must be +// cleared on the failure path too, not only on success. +func TestWireGuardApplyClearsStrippedKeyOnEveryExitPath(t *testing.T) { + script := applyScriptForWithServer(model.Approval{Plugin: "wireguard", Plan: testWGPlan}, "") + if !strings.Contains(script, `trap 'rollback; cleanup_watchdog; rm -f "$STRIPPED"' ERR`) { + t.Fatalf("the ERR trap must remove the stripped key file:\n%s", script) + } + if !strings.Contains(script, " rm -f \"$STRIPPED\"\n") { + t.Fatalf("the success path must remove the stripped key file:\n%s", script) + } + // umask 077 means anything written under /etc/wireguard is 0600. + if !strings.Contains(script, "umask 077\n") { + t.Fatal("key-bearing files must be written with umask 077") + } +} + +func TestWireGuardApplyWithoutPublicURLSkipsSelfcheckLoudly(t *testing.T) { + script := applyScriptForWithServer(model.Approval{Plugin: "wireguard", Plan: testWGPlan}, "") + if strings.Contains(script, "--selfcheck-controlplane") { + t.Fatal("no public url means no selfcheck") + } + if !strings.Contains(script, "control-plane selfcheck skipped because public_url is unset") { + t.Fatalf("the skip must be loud, not silent:\n%s", script) + } + // The watchdog is still armed: it is the only remaining net. + if !strings.Contains(script, "start_watchdog") { + t.Fatal("watchdog must be armed even when the selfcheck is skipped") + } +} + +// The generated shell must actually parse. `sh -n` catches quoting mistakes in +// the watchdog's nested `sh -c` bodies that a string-contains test cannot. +func TestApplyScriptsAreValidShell(t *testing.T) { + sh, err := exec.LookPath("sh") + if err != nil { + t.Skip("no sh available") + } + cases := []struct { + name string + app model.Approval + }{ + {"wireguard", model.Approval{Plugin: "wireguard", Plan: testWGPlan}}, + {"nft", model.Approval{Plugin: "nft", Plan: "table inet lattice_guard {\n}\n"}}, + } + for _, tc := range cases { + for _, serverURL := range []string{"", "https://203.0.113.99"} { + t.Run(tc.name+"/url="+serverURL, func(t *testing.T) { + script := applyScriptForWithServer(tc.app, serverURL) + path := filepath.Join(t.TempDir(), "apply.sh") + if err := os.WriteFile(path, []byte(script), 0o600); err != nil { + t.Fatal(err) + } + out, err := exec.Command(sh, "-n", path).CombinedOutput() + if err != nil { + t.Fatalf("generated script is not valid shell: %v\n%s\n--- script ---\n%s", err, out, script) + } + }) + } + } +} + +// The watchdog window is one shared constant so the nft and wireguard paths +// cannot drift apart. +func TestWatchdogWindowIsSharedAcrossApplyPaths(t *testing.T) { + wg := applyScriptForWithServer(model.Approval{Plugin: "wireguard", Plan: testWGPlan}, "") + nft := applyScriptForWithServer(model.Approval{Plugin: "nft", Plan: "table inet lattice_guard {\n}\n"}, "") + window := "sleep 60" + if !strings.Contains(wg, window) || !strings.Contains(nft, window) { + t.Fatalf("both apply paths must arm the same %q window", window) + } + if applyWatchdogWindowSec != 60 { + t.Fatalf("applyWatchdogWindowSec = %d; update this test deliberately", applyWatchdogWindowSec) + } +} diff --git a/internal/wireguard/topology.go b/internal/wireguard/topology.go new file mode 100644 index 0000000..153b480 --- /dev/null +++ b/internal/wireguard/topology.go @@ -0,0 +1,193 @@ +package wireguard + +import ( + "errors" + "fmt" + "sort" + + "github.com/LatticeNet/lattice-sdk/model" +) + +// BuildTopology generalizes BuildMesh to named networks with explicit +// topologies (design-13 §5.3). The security invariants of BuildMesh are +// preserved verbatim: +// +// - a peer's own address is always pinned to a host route (/32 or /128), so +// a member reporting a wide prefix can never intercept another member's +// traffic; +// - additive routes come only from a hub's reviewed ExtraAllowedIPs, never +// from a member's self-reported address; +// - the target's private key never enters this package. +// +// Mesh mode reproduces BuildMesh byte-for-byte for the same fleet, which is +// the migration gate for the existing implicit mesh. + +// ErrCustomTopology marks the not-yet-implemented explicit-edge mode. It fails +// closed rather than silently degrading to mesh, which would quietly widen a +// deliberately restricted topology. +var ErrCustomTopology = errors.New("custom topology is not implemented; use mesh or hub-and-spoke") + +// BuildTopology computes the interface and peers for one member of a network. +// nodes supplies the public keys and fallback endpoints; memberships define +// the topology roles and addresses. +func BuildTopology( + network model.WGNetwork, + memberships []model.WGMembership, + nodes []model.Node, + targetNodeID string, +) (Interface, []Peer, error) { + target, ok := membershipFor(memberships, targetNodeID) + if !ok { + return Interface{}, nil, fmt.Errorf("node %q is not a member of network %q", targetNodeID, network.ID) + } + if target.Address == "" { + return Interface{}, nil, fmt.Errorf("member %q has no address", targetNodeID) + } + + byID := make(map[string]model.Node, len(nodes)) + for _, n := range nodes { + byID[n.ID] = n + } + + iface := Interface{ + Name: firstNonEmpty(target.InterfaceName, "wg0"), + Address: ensureCIDR(target.Address, 24), + ListenPort: firstNonZero(target.ListenPort, network.ListenPort, byID[targetNodeID].WireGuardPort, model.WGDefaultListenPort), + MTU: firstNonZero(target.MTU, network.MTU), + DNS: append([]string(nil), network.DNS...), + } + + var peers []Peer + for _, member := range memberships { + if member.NodeID == targetNodeID { + continue + } + node, ok := byID[member.NodeID] + if !ok || node.WireGuardPublicKey == "" || member.Address == "" { + continue + } + linked, err := peersWith(network.Topology, target, member) + if err != nil { + return Interface{}, nil, err + } + if !linked { + continue + } + // The peer's own address is always a host route. Extra routes are only + // honored from a hub, and only when the target is not itself that hub. + allowed := hostCIDR(member.Address) + if allowed == "" { + continue + } + allowedIPs := []string{allowed} + if member.Role == model.WGRoleHub { + allowedIPs = append(allowedIPs, member.ExtraAllowedIPs...) + } + peers = append(peers, Peer{ + Name: node.Name, + PublicKey: node.WireGuardPublicKey, + AllowedIPs: joinAllowedIPs(allowedIPs), + Endpoint: firstNonEmpty(member.Endpoint, node.WireGuardEndpoint), + Keepalive: firstNonZero(member.Keepalive, network.Keepalive, model.WGDefaultKeepalive), + }) + } + sort.Slice(peers, func(i, j int) bool { return peers[i].AllowedIPs < peers[j].AllowedIPs }) + return iface, peers, nil +} + +// peersWith reports whether target should carry a [Peer] section for member. +func peersWith(topology string, target, member model.WGMembership) (bool, error) { + switch topology { + case model.WGTopologyMesh, "": + return true, nil + case model.WGTopologyHubSpoke: + // Spokes peer only with hubs; hubs peer with everyone. + if target.Role == model.WGRoleHub || member.Role == model.WGRoleHub { + return true, nil + } + return false, nil + case model.WGTopologyCustom: + return false, ErrCustomTopology + default: + return false, fmt.Errorf("invalid topology %q", topology) + } +} + +// MeshFromNodes converts the existing implicit fleet mesh — the one encoded in +// Node.WireGuard* fields — into a named network plus memberships. It is the +// migration bridge: BuildTopology over its output must render identically to +// BuildMesh over the same nodes. +func MeshFromNodes(nodes []model.Node, listenPort int) (model.WGNetwork, []model.WGMembership) { + network := model.WGNetwork{ + ID: "default", + Name: "default", + Topology: model.WGTopologyMesh, + ListenPort: listenPort, + Keepalive: defaultKeepalive, + } + members := make([]model.WGMembership, 0, len(nodes)) + for _, n := range nodes { + if n.WireGuardIP == "" { + continue + } + members = append(members, model.WGMembership{ + NetworkID: network.ID, + NodeID: n.ID, + Address: n.WireGuardIP, + Role: model.WGRolePeer, + ListenPort: n.WireGuardPort, + Endpoint: n.WireGuardEndpoint, + }) + } + return network, members +} + +func membershipFor(memberships []model.WGMembership, nodeID string) (model.WGMembership, bool) { + for _, m := range memberships { + if m.NodeID == nodeID { + return m, true + } + } + return model.WGMembership{}, false +} + +func joinAllowedIPs(values []string) string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, v := range values { + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + result := "" + for i, v := range out { + if i > 0 { + result += ", " + } + result += v + } + return result +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +func firstNonZero(values ...int) int { + for _, v := range values { + if v != 0 { + return v + } + } + return 0 +} diff --git a/internal/wireguard/topology_test.go b/internal/wireguard/topology_test.go new file mode 100644 index 0000000..8e06766 --- /dev/null +++ b/internal/wireguard/topology_test.go @@ -0,0 +1,218 @@ +package wireguard + +import ( + "errors" + "reflect" + "strings" + "testing" + + "github.com/LatticeNet/lattice-sdk/model" +) + +func fleet() []model.Node { + return []model.Node{ + {ID: "a", Name: "node-a", WireGuardIP: "10.66.0.1", WireGuardPublicKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", WireGuardEndpoint: "203.0.113.1:51820", WireGuardPort: 51820}, + {ID: "b", Name: "node-b", WireGuardIP: "10.66.0.2", WireGuardPublicKey: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="}, + {ID: "c", Name: "node-c", WireGuardIP: "10.66.0.3", WireGuardPublicKey: "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=", WireGuardEndpoint: "198.51.100.3:51820"}, + {ID: "keyless", Name: "no-key", WireGuardIP: "10.66.0.9"}, + } +} + +// THE MIGRATION GATE (design-13 W1): the existing implicit fleet mesh, once +// expressed as a named network + memberships, must render exactly what +// BuildMesh renders today. A silent topology change is a silent loss of +// connectivity. +func TestMeshTopologyMatchesBuildMesh(t *testing.T) { + nodes := fleet() + for _, target := range nodes { + if target.WireGuardIP == "" { + continue + } + t.Run(target.ID, func(t *testing.T) { + wantIface, wantPeers, wantErr := BuildMesh(nodes, target, 0) + network, members := MeshFromNodes(nodes, 0) + gotIface, gotPeers, gotErr := BuildTopology(network, members, nodes, target.ID) + + if (wantErr == nil) != (gotErr == nil) { + t.Fatalf("err mismatch: BuildMesh=%v BuildTopology=%v", wantErr, gotErr) + } + if wantErr != nil { + return + } + // BuildTopology carries new optional fields; compare the fields + // BuildMesh actually produces. + if gotIface.Name != wantIface.Name || gotIface.Address != wantIface.Address || + gotIface.ListenPort != wantIface.ListenPort { + t.Fatalf("interface mismatch:\n got %+v\nwant %+v", gotIface, wantIface) + } + if gotIface.MTU != 0 || len(gotIface.DNS) != 0 { + t.Fatalf("mesh conversion must not invent MTU/DNS: %+v", gotIface) + } + if !reflect.DeepEqual(gotPeers, wantPeers) { + t.Fatalf("peer mismatch:\n got %+v\nwant %+v", gotPeers, wantPeers) + } + + // And the rendered configs must be identical, not merely equivalent. + wantConf, err := GenerateConfig(wantIface, wantPeers) + if err != nil { + t.Fatal(err) + } + gotConf, err := GenerateConfig(gotIface, gotPeers) + if err != nil { + t.Fatal(err) + } + if gotConf != wantConf { + t.Fatalf("rendered config diverged.\n--- BuildMesh ---\n%s\n--- BuildTopology ---\n%s", wantConf, gotConf) + } + }) + } +} + +func TestHubAndSpokeEdges(t *testing.T) { + nodes := fleet() + network := model.WGNetwork{ID: "n", Topology: model.WGTopologyHubSpoke, ListenPort: 51820} + members := []model.WGMembership{ + {NetworkID: "n", NodeID: "a", Address: "10.66.0.1", Role: model.WGRoleHub, Endpoint: "203.0.113.1:51820", ExtraAllowedIPs: []string{"192.168.50.0/24"}}, + {NetworkID: "n", NodeID: "b", Address: "10.66.0.2", Role: model.WGRoleSpoke}, + {NetworkID: "n", NodeID: "c", Address: "10.66.0.3", Role: model.WGRoleSpoke}, + } + + // A spoke peers only with the hub, and inherits the hub's advertised route. + _, spokePeers, err := BuildTopology(network, members, nodes, "b") + if err != nil { + t.Fatal(err) + } + if len(spokePeers) != 1 { + t.Fatalf("spoke must peer only with the hub, got %d peers: %+v", len(spokePeers), spokePeers) + } + if spokePeers[0].AllowedIPs != "10.66.0.1/32, 192.168.50.0/24" { + t.Fatalf("spoke must inherit the hub's reviewed routes, got %q", spokePeers[0].AllowedIPs) + } + if spokePeers[0].Endpoint != "203.0.113.1:51820" { + t.Fatalf("spoke must dial the hub endpoint, got %q", spokePeers[0].Endpoint) + } + + // The hub peers with every spoke, each pinned to its own host route. + _, hubPeers, err := BuildTopology(network, members, nodes, "a") + if err != nil { + t.Fatal(err) + } + if len(hubPeers) != 2 { + t.Fatalf("hub must peer with both spokes, got %d", len(hubPeers)) + } + for _, p := range hubPeers { + if !strings.HasSuffix(p.AllowedIPs, "/32") { + t.Fatalf("spoke route must stay pinned to a host route, got %q", p.AllowedIPs) + } + } + + // Spoke-to-spoke edges must not exist. + for _, p := range spokePeers { + if p.Name == "node-c" { + t.Fatal("spokes must not peer with each other in hub-and-spoke") + } + } +} + +// A spoke advertising extra routes must never have them honored: only a hub's +// reviewed ExtraAllowedIPs widen a peer's AllowedIPs. +func TestSpokeCannotAdvertiseExtraRoutes(t *testing.T) { + nodes := fleet() + network := model.WGNetwork{ID: "n", Topology: model.WGTopologyHubSpoke, ListenPort: 51820} + members := []model.WGMembership{ + {NetworkID: "n", NodeID: "a", Address: "10.66.0.1", Role: model.WGRoleHub}, + {NetworkID: "n", NodeID: "b", Address: "10.66.0.2", Role: model.WGRoleSpoke, ExtraAllowedIPs: []string{"0.0.0.0/0"}}, + } + _, hubPeers, err := BuildTopology(network, members, nodes, "a") + if err != nil { + t.Fatal(err) + } + if len(hubPeers) != 1 || hubPeers[0].AllowedIPs != "10.66.0.2/32" { + t.Fatalf("a spoke's self-declared routes must be ignored, got %+v", hubPeers) + } +} + +// A member reporting a wide prefix still gets pinned to a host route — the +// BuildMesh invariant, preserved. +func TestTopologyPinsWidePrefixToHostRoute(t *testing.T) { + nodes := []model.Node{ + {ID: "a", WireGuardIP: "10.66.0.1", WireGuardPublicKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}, + {ID: "evil", Name: "evil", WireGuardIP: "10.66.0.5/16", WireGuardPublicKey: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="}, + } + network, members := MeshFromNodes(nodes, 0) + _, peers, err := BuildTopology(network, members, nodes, "a") + if err != nil { + t.Fatal(err) + } + if len(peers) != 1 || peers[0].AllowedIPs != "10.66.0.5/32" { + t.Fatalf("a wide self-reported prefix must be pinned to /32, got %+v", peers) + } +} + +func TestCustomTopologyFailsClosed(t *testing.T) { + nodes := fleet() + network := model.WGNetwork{ID: "n", Topology: model.WGTopologyCustom, ListenPort: 51820} + members := []model.WGMembership{ + {NetworkID: "n", NodeID: "a", Address: "10.66.0.1"}, + {NetworkID: "n", NodeID: "b", Address: "10.66.0.2"}, + } + if _, _, err := BuildTopology(network, members, nodes, "a"); !errors.Is(err, ErrCustomTopology) { + t.Fatalf("err = %v, want ErrCustomTopology (never silently degrade to mesh)", err) + } +} + +func TestTopologyRejectsUnknownModeAndNonMember(t *testing.T) { + nodes := fleet() + members := []model.WGMembership{{NetworkID: "n", NodeID: "a", Address: "10.66.0.1"}} + + if _, _, err := BuildTopology(model.WGNetwork{ID: "n", Topology: "star", ListenPort: 51820}, append(members, + model.WGMembership{NetworkID: "n", NodeID: "b", Address: "10.66.0.2"}), nodes, "a"); err == nil { + t.Fatal("unknown topology must fail closed") + } + if _, _, err := BuildTopology(model.WGNetwork{ID: "n", Topology: model.WGTopologyMesh}, members, nodes, "zzz"); err == nil { + t.Fatal("a non-member target must be rejected") + } +} + +func TestGenerateConfigRendersMTUAndDNSOnlyWhenSet(t *testing.T) { + iface := Interface{Name: "wg0", Address: "10.66.0.1/24", ListenPort: 51820} + plain, err := GenerateConfig(iface, nil) + if err != nil { + t.Fatal(err) + } + if strings.Contains(plain, "MTU") || strings.Contains(plain, "DNS") { + t.Fatalf("unset MTU/DNS must not render:\n%s", plain) + } + + iface.MTU = 1420 + iface.DNS = []string{"10.66.0.1", "1.1.1.1"} + rich, err := GenerateConfig(iface, nil) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(rich, "MTU = 1420\n") || !strings.Contains(rich, "DNS = 10.66.0.1, 1.1.1.1\n") { + t.Fatalf("MTU/DNS not rendered:\n%s", rich) + } + + iface.MTU = 70000 + if _, err := GenerateConfig(iface, nil); err == nil { + t.Fatal("absurd MTU must be rejected") + } + iface.MTU = 1420 + iface.DNS = []string{"not-an-ip"} + if _, err := GenerateConfig(iface, nil); err == nil { + t.Fatal("non-IP DNS must be rejected, never interpolated") + } +} + +func TestGenerateConfigValidatesMultiValueAllowedIPs(t *testing.T) { + iface := Interface{Name: "wg0", Address: "10.66.0.1/24", ListenPort: 51820} + good := []Peer{{PublicKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", AllowedIPs: "10.66.0.2/32, 192.168.1.0/24"}} + if _, err := GenerateConfig(iface, good); err != nil { + t.Fatalf("multi-value AllowedIPs must be accepted: %v", err) + } + bad := []Peer{{PublicKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", AllowedIPs: "10.66.0.2/32, not-a-cidr"}} + if _, err := GenerateConfig(iface, bad); err == nil { + t.Fatal("a malformed element must reject the whole value") + } +} diff --git a/internal/wireguard/wireguard.go b/internal/wireguard/wireguard.go index acf5ce6..23107f7 100644 --- a/internal/wireguard/wireguard.go +++ b/internal/wireguard/wireguard.go @@ -9,6 +9,7 @@ package wireguard import ( + "errors" "fmt" "net" "regexp" @@ -44,6 +45,8 @@ type Interface struct { Name string Address string // mesh address, e.g. 10.66.0.1/24 ListenPort int + MTU int // rendered only when > 0 + DNS []string // resolver IPs; rendered only when non-empty } // Peer is one [Peer] section. @@ -115,17 +118,31 @@ func GenerateConfig(iface Interface, peers []Peer) (string, error) { if _, _, err := net.ParseCIDR(iface.Address); err != nil { return "", fmt.Errorf("invalid interface address %q: %w", iface.Address, err) } + if iface.MTU != 0 && (iface.MTU < 576 || iface.MTU > 9000) { + return "", fmt.Errorf("invalid mtu %d", iface.MTU) + } + for _, dns := range iface.DNS { + if net.ParseIP(dns) == nil { + return "", fmt.Errorf("invalid dns address %q", dns) + } + } var b strings.Builder fmt.Fprintf(&b, "[Interface]\n") fmt.Fprintf(&b, "Address = %s\n", iface.Address) fmt.Fprintf(&b, "ListenPort = %d\n", iface.ListenPort) fmt.Fprintf(&b, "PrivateKey = %s\n", PrivateKeyPlaceholder) + if iface.MTU > 0 { + fmt.Fprintf(&b, "MTU = %d\n", iface.MTU) + } + if len(iface.DNS) > 0 { + fmt.Fprintf(&b, "DNS = %s\n", strings.Join(iface.DNS, ", ")) + } for _, p := range peers { if !ValidatePublicKey(p.PublicKey) { return "", fmt.Errorf("invalid public key for peer %q", p.Name) } - if _, _, err := net.ParseCIDR(p.AllowedIPs); err != nil { - return "", fmt.Errorf("invalid allowed ips %q: %w", p.AllowedIPs, err) + if err := validateAllowedIPs(p.AllowedIPs); err != nil { + return "", fmt.Errorf("peer %q: %w", p.Name, err) } if err := validateEndpoint(p.Endpoint); err != nil { return "", fmt.Errorf("peer %q: %w", p.Name, err) @@ -146,6 +163,23 @@ func GenerateConfig(iface Interface, peers []Peer) (string, error) { return b.String(), nil } +// validateAllowedIPs accepts one or more comma-separated CIDRs. A hub may +// advertise additive routes beyond its own pinned host route, so this is no +// longer a single-value field; every element must still parse as a CIDR so +// nothing operator-influenced reaches the config uncanonicalized. +func validateAllowedIPs(value string) error { + if strings.TrimSpace(value) == "" { + return errors.New("empty allowed ips") + } + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if _, _, err := net.ParseCIDR(part); err != nil { + return fmt.Errorf("invalid allowed ips %q: %w", part, err) + } + } + return nil +} + func validateEndpoint(ep string) error { if ep == "" { return nil From 7405223623b14f04729bd2d56079a57fab442a01 Mon Sep 17 00:00:00 2001 From: lr00rl Date: Thu, 9 Jul 2026 05:54:18 -0400 Subject: [PATCH 3/3] Close NetGuard authorization and node-remote gaps 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 --- go.mod | 2 +- go.sum | 2 + internal/netguard/compile.go | 31 +++++++++++-- internal/netguard/compile_test.go | 6 ++- internal/server/server_netguard.go | 39 ++++++++++++---- internal/server/server_netguard_test.go | 62 +++++++++++++++++++++++++ 6 files changed, 126 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index c4e6649..7bd9a28 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/LatticeNet/lattice-server go 1.26 require ( - github.com/LatticeNet/lattice-sdk v0.2.17-0.20260709055807-30d4d08e6fa8 + github.com/LatticeNet/lattice-sdk v0.2.17-0.20260709095251-1d3b85bca9be github.com/coreos/go-oidc/v3 v3.18.0 github.com/descope/virtualwebauthn v1.0.5 github.com/go-webauthn/webauthn v0.17.4 diff --git a/go.sum b/go.sum index dc82e62..78d01a1 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/LatticeNet/lattice-sdk v0.2.17-0.20260709050800-d0f6124704ec h1:SnafE github.com/LatticeNet/lattice-sdk v0.2.17-0.20260709050800-d0f6124704ec/go.mod h1:7ENUQ4EoS/TSW/eNomCGfZGliUPJZ46uAvp7dVcEXoE= github.com/LatticeNet/lattice-sdk v0.2.17-0.20260709055807-30d4d08e6fa8 h1:8dWVveGh2eYvJMOcsgFjCoLVfEeh0M85xMjD9gzigU4= github.com/LatticeNet/lattice-sdk v0.2.17-0.20260709055807-30d4d08e6fa8/go.mod h1:7ENUQ4EoS/TSW/eNomCGfZGliUPJZ46uAvp7dVcEXoE= +github.com/LatticeNet/lattice-sdk v0.2.17-0.20260709095251-1d3b85bca9be h1:jiD7wqUEwibJCV4jB9auUI6zWlV9ognGzWnkDekmHB4= +github.com/LatticeNet/lattice-sdk v0.2.17-0.20260709095251-1d3b85bca9be/go.mod h1:7ENUQ4EoS/TSW/eNomCGfZGliUPJZ46uAvp7dVcEXoE= github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/internal/netguard/compile.go b/internal/netguard/compile.go index 3d09d80..8aeef1a 100644 --- a/internal/netguard/compile.go +++ b/internal/netguard/compile.go @@ -3,7 +3,9 @@ package netguard import ( "errors" "fmt" + "net" "sort" + "strings" "github.com/LatticeNet/lattice-sdk/model" "github.com/LatticeNet/lattice-server/internal/network" @@ -126,9 +128,6 @@ func lowerRule(plan *network.NFTPlan, rule model.GuardRule, in CompileInput) err default: return fmt.Errorf("invalid protocol %q", rule.Protocol) } - if rule.RateLimit != "" { - return errors.New("rate_limit is not supported by the current guard renderer") - } if rule.Log { return errors.New("log is not supported by the current guard renderer") } @@ -258,13 +257,35 @@ func ruleSource(rule model.GuardRule, in CompileInput) ([]string, string, error) func nodeSources(node model.Node) []string { out := make([]string, 0, 2) for _, addr := range []string{node.WireGuardIP, node.PublicIP} { - if addr != "" { - out = append(out, addr) + if host := hostCIDR(addr); host != "" { + out = append(out, host) } } return out } +func hostCIDR(addr string) string { + host := strings.TrimSpace(addr) + if host == "" { + return "" + } + if strings.Contains(host, "/") { + ip, _, err := net.ParseCIDR(host) + if err != nil { + return "" + } + host = ip.String() + } + parsed := net.ParseIP(host) + if parsed == nil { + return "" + } + if parsed.To4() == nil { + return parsed.String() + "/128" + } + return parsed.String() + "/32" +} + func trustedZoneRules(zone model.GuardZone) ([]network.NFTInputRule, error) { if len(zone.Interfaces) == 0 && len(zone.CIDRs) == 0 { return nil, fmt.Errorf("trusted zone %q resolves to no interface or cidr on this node", zone.ID) diff --git a/internal/netguard/compile_test.go b/internal/netguard/compile_test.go index c15fac0..3c6b192 100644 --- a/internal/netguard/compile_test.go +++ b/internal/netguard/compile_test.go @@ -195,7 +195,7 @@ func TestNodeRemoteResolvesToNodeAddresses(t *testing.T) { if id != "peer" { return model.Node{}, false } - return model.Node{ID: "peer", WireGuardIP: "10.66.0.2/32", PublicIP: "198.51.100.2"}, true + return model.Node{ID: "peer", WireGuardIP: "10.66.0.2/16", PublicIP: "198.51.100.2"}, true } ruleset, err := CompileRuleset(CompileInput{ Binding: model.NodeGuardBinding{NodeID: "n1", Managed: true}, @@ -210,6 +210,9 @@ func TestNodeRemoteResolvesToNodeAddresses(t *testing.T) { if err != nil { t.Fatal(err) } + if strings.Contains(ruleset, `10.66.0.0/16`) || strings.Contains(ruleset, `10.66.0.2/16`) { + t.Fatalf("node remote must not preserve a peer-advertised wide prefix:\n%s", ruleset) + } if !strings.Contains(ruleset, `ip saddr { 10.66.0.2, 198.51.100.2 } tcp dport { 9100 } accept`) { t.Fatalf("node remote did not resolve to both addresses:\n%s", ruleset) } @@ -248,7 +251,6 @@ func TestCompileFailsClosedOnUnsupportedShapes(t *testing.T) { }{ {"egress direction", model.GuardRule{ID: "r", Action: model.NetRuleAllow, Direction: model.NetDirEgress, Protocol: model.NetProtoTCP, Ports: p80, Remote: pub}, "not compiled into the guard table"}, {"icmp", model.GuardRule{ID: "r", Action: model.NetRuleAllow, Direction: model.NetDirIngress, Protocol: model.GuardProtoICMP, Remote: pub}, "not supported by the current guard renderer"}, - {"rate limit", model.GuardRule{ID: "r", Action: model.NetRuleAllow, Direction: model.NetDirIngress, Protocol: model.NetProtoTCP, Ports: p80, Remote: pub, RateLimit: "10/second"}, "rate_limit is not supported"}, {"log", model.GuardRule{ID: "r", Action: model.NetRuleAllow, Direction: model.NetDirIngress, Protocol: model.NetProtoTCP, Ports: p80, Remote: pub, Log: true}, "log is not supported"}, {"domain remote", model.GuardRule{ID: "r", Action: model.NetRuleAllow, Direction: model.NetDirIngress, Protocol: model.NetProtoTCP, Ports: p80, Remote: model.NetEndpoint{Kind: model.NetRefDomain, Domain: "x.example"}}, "egress-only"}, {"group remote", model.GuardRule{ID: "r", Action: model.NetRuleAllow, Direction: model.NetDirIngress, Protocol: model.NetProtoTCP, Ports: p80, Remote: model.NetEndpoint{Kind: model.NetRefGroup, GroupID: "g"}}, "expanded to node refs"}, diff --git a/internal/server/server_netguard.go b/internal/server/server_netguard.go index ea02072..ecf8915 100644 --- a/internal/server/server_netguard.go +++ b/internal/server/server_netguard.go @@ -48,6 +48,9 @@ var guardIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) func (s *Server) handleNetGuardGroups(w http.ResponseWriter, r *http.Request, p principal) { switch r.Method { case http.MethodGet: + if !s.requireGlobalNetGuardScope(w, p, "netguard:read") { + return + } case http.MethodPost: s.handleUpsertSecurityGroup(w, r, p) return @@ -79,6 +82,9 @@ func (s *Server) handleNetGuardGroups(w http.ResponseWriter, r *http.Request, p func (s *Server) handleNetGuardZones(w http.ResponseWriter, r *http.Request, p principal) { switch r.Method { case http.MethodGet: + if !s.requireGlobalNetGuardScope(w, p, "netguard:read") { + return + } case http.MethodPost: s.handleUpsertGuardZone(w, r, p) return @@ -174,6 +180,27 @@ func (s *Server) nodeName(nodeID string) string { return "" } +func (s *Server) requireGlobalNetGuardScope(w http.ResponseWriter, p principal, scope string) bool { + if !s.requireScope(w, p, scope) { + return false + } + if !principalHasNodeRestriction(p) { + return true + } + s.recordAudit(model.AuditEvent{ + ID: id.New("audit"), + ActorID: p.ActorID, + TokenID: p.TokenID, + Action: "authorize.scope", + Scope: scope, + Decision: "deny", + Reason: "global netguard objects require an unrestricted server allowlist", + CorrelationID: p.CorrelationID, + }) + writeError(w, http.StatusForbidden, apiError(model.APIErrorCapabilityDenied, "forbidden")) + return false +} + // resolveNodeZones builds the zone map used to compile one node. Zones are // fleet-scoped by name but resolve per-node facts: the "public" zone means // *this* node's public interface, the "wireguard" zone means *this* node's @@ -238,8 +265,7 @@ func (s *Server) compileInputFor(nodeID string) (netguard.CompileInput, error) { } func (s *Server) handleUpsertSecurityGroup(w http.ResponseWriter, r *http.Request, p principal) { - if !rbac.Allows(p.Principal, "netguard:admin", "") { - writeError(w, http.StatusForbidden, errors.New("netguard:admin is required")) + if !s.requireGlobalNetGuardScope(w, p, "netguard:admin") { return } var req model.SecurityGroup @@ -313,8 +339,7 @@ func (s *Server) handleDeleteSecurityGroup(w http.ResponseWriter, r *http.Reques writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed")) return } - if !rbac.Allows(p.Principal, "netguard:admin", "") { - writeError(w, http.StatusForbidden, errors.New("netguard:admin is required")) + if !s.requireGlobalNetGuardScope(w, p, "netguard:admin") { return } var req struct { @@ -348,8 +373,7 @@ func (s *Server) handleDeleteSecurityGroup(w http.ResponseWriter, r *http.Reques } func (s *Server) handleUpsertGuardZone(w http.ResponseWriter, r *http.Request, p principal) { - if !rbac.Allows(p.Principal, "netguard:admin", "") { - writeError(w, http.StatusForbidden, errors.New("netguard:admin is required")) + if !s.requireGlobalNetGuardScope(w, p, "netguard:admin") { return } var req model.GuardZone @@ -396,8 +420,7 @@ func (s *Server) handleDeleteGuardZone(w http.ResponseWriter, r *http.Request, p writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed")) return } - if !rbac.Allows(p.Principal, "netguard:admin", "") { - writeError(w, http.StatusForbidden, errors.New("netguard:admin is required")) + if !s.requireGlobalNetGuardScope(w, p, "netguard:admin") { return } var req struct { diff --git a/internal/server/server_netguard_test.go b/internal/server/server_netguard_test.go index 5e7eb09..7275e16 100644 --- a/internal/server/server_netguard_test.go +++ b/internal/server/server_netguard_test.go @@ -210,6 +210,68 @@ func TestNetGuardStoredBindingSupersedesLegacyView(t *testing.T) { } } +func TestNetGuardGlobalCatalogRejectsRestrictedToken(t *testing.T) { + handler, _ := newTestServer(t) + cookies, csrf := loginSession(t, handler) + enrollNamedNode(t, handler, cookies, csrf, "node-a", "Node A") + enrollNamedNode(t, handler, cookies, csrf, "node-b", "Node B") + + for _, nodeID := range []string{"node-a", "node-b"} { + save := doJSON(t, handler, http.MethodPost, "/api/network/nft/inputs", + `{"node_id":"`+nodeID+`","public_tcp":[22]}`, cookies, csrf) + save.Body.Close() + if save.StatusCode != http.StatusOK { + t.Fatalf("save nft inputs for %s failed: %d", nodeID, save.StatusCode) + } + } + + token := createPAT(t, handler, cookies, csrf, + []string{"netguard:read", "netguard:admin", "network:plan"}, + []string{"node-a"}) + + nodesRes := doBearerJSON(t, handler, http.MethodGet, "/api/netguard/nodes", "", token) + defer nodesRes.Body.Close() + if nodesRes.StatusCode != http.StatusOK { + t.Fatalf("restricted node view = %d, want 200", nodesRes.StatusCode) + } + var nodes netGuardNodesResponse + if err := json.NewDecoder(nodesRes.Body).Decode(&nodes); err != nil { + t.Fatal(err) + } + if len(nodes.Nodes) != 1 || nodes.Nodes[0].NodeID != "node-a" { + t.Fatalf("restricted node view leaked nodes: %+v", nodes.Nodes) + } + + for _, tc := range []struct { + name string + method string + path string + body string + }{ + {name: "list groups", method: http.MethodGet, path: "/api/netguard/groups"}, + {name: "create group", method: http.MethodPost, path: "/api/netguard/groups", body: `{"id":"sg-a","name":"a"}`}, + {name: "list zones", method: http.MethodGet, path: "/api/netguard/zones"}, + {name: "create zone", method: http.MethodPost, path: "/api/netguard/zones", body: `{"id":"tail","name":"tail","interfaces":["tailscale0"]}`}, + } { + res := doBearerJSON(t, handler, tc.method, tc.path, tc.body, token) + res.Body.Close() + if res.StatusCode != http.StatusForbidden { + t.Fatalf("%s with restricted token = %d, want 403", tc.name, res.StatusCode) + } + } + + adoptA := doBearerJSON(t, handler, http.MethodPost, "/api/netguard/nodes/adopt", `{"node_id":"node-a"}`, token) + defer adoptA.Body.Close() + if adoptA.StatusCode != http.StatusOK { + t.Fatalf("restricted token should adopt node-a, got %d", adoptA.StatusCode) + } + adoptB := doBearerJSON(t, handler, http.MethodPost, "/api/netguard/nodes/adopt", `{"node_id":"node-b"}`, token) + defer adoptB.Body.Close() + if adoptB.StatusCode != http.StatusForbidden { + t.Fatalf("restricted token must not adopt node-b, got %d", adoptB.StatusCode) + } +} + // End-to-end G2: adopt a legacy node, then plan from the new model. The plan // must be a lattice_guard ruleset carried by an `nft` approval so it rides the // existing rollback-protected apply script unchanged.