diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index a0e4925..6082605 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -19,6 +19,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/cluster" "git.netcell-it.de/projekte/edgeguard-native/internal/database" + firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "git.netcell-it.de/projekte/edgeguard-native/internal/services/acme" @@ -150,7 +151,12 @@ func main() { handlers.NewIPAddressesHandler(ipsRepo, auditRepo, nodeID).Register(authed) handlers.NewClusterHandler(clusterStore, nodeID).Register(authed) handlers.NewTLSCertsHandler(tlsRepo, auditRepo, nodeID, acmeService).Register(authed) - handlers.NewFirewallHandler(fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID).Register(authed) + // Firewall reload: nach jeder Mutation den Renderer neu fahren + // (writes ruleset.nft + sudo nft -f). Errors loggen, nicht failen. + fwReloader := func(ctx context.Context) error { + return firewallrender.New(pool).Render(ctx) + } + handlers.NewFirewallHandler(fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID, fwReloader).Register(authed) } mountUI(r) diff --git a/internal/configgen/configgen.go b/internal/configgen/configgen.go index cefb1d5..1dcd35b 100644 --- a/internal/configgen/configgen.go +++ b/internal/configgen/configgen.go @@ -16,6 +16,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" ) // Generator is the contract every per-service renderer satisfies. @@ -70,17 +71,18 @@ func AtomicWrite(path string, data []byte, mode os.FileMode) error { return nil } -// ReloadService sends `systemctl reload `. Returns the -// CombinedOutput on failure so the caller can surface the actual -// systemd error to the operator. +// ReloadService runs `sudo -n systemctl reload .service`. +// edgeguard-api runs as the unprivileged `edgeguard` user; postinst +// installs a sudoers entry NOPASSWD-ing exactly this command per +// service that needs it. // // Some services don't support reload (nftables — no daemon); for // those, callers should run the service-specific reload directly // rather than calling this helper. func ReloadService(name string) error { - cmd := exec.Command("systemctl", "reload", name) + cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", name+".service") if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("systemctl reload %s: %w (output: %s)", name, err, string(out)) + return fmt.Errorf("sudo systemctl reload %s.service: %w (output: %s)", name, err, strings.TrimSpace(string(out))) } return nil } diff --git a/internal/firewall/firewall.go b/internal/firewall/firewall.go index 0867989..4734b4d 100644 --- a/internal/firewall/firewall.go +++ b/internal/firewall/firewall.go @@ -1,13 +1,20 @@ // Package firewall renders /etc/edgeguard/nftables.d/ruleset.nft from -// the relational state in PG (firewall_rules + ha_nodes). +// the v2 (Fortigate-style) firewall tables. // -// The base ruleset is hard-coded in the template (default-deny input, -// stateful baseline, SSH rate-limit, public :80 / :443, peer mTLS on -// :8443 from cluster IPs). Operator-defined rows in firewall_rules -// land at the bottom of input/forward/output. +// Render flow: // -// Reload uses `nft -f ` (atomic ruleset replace) — there is no -// systemctl reload for nftables. +// 1. loadView pulls everything: zone→iface mapping (from +// network_interfaces.role), address-objects + groups, services +// + groups, policy rules, nat rules, ha_nodes peer IPs. +// 2. Each rule and nat-rule is "resolved" — group references +// replaced with their primitive members, FQDNs left as comments +// (Phase-3 DNS-resolution sidecar will materialise them). +// 3. The template emits one nft file with: zone-iface sets, peer +// sets, default-deny baseline, forward + input chains carrying +// the resolved rules (priority-sorted), nat prerouting + +// postrouting chains. +// 4. Atomic write + `sudo nft -f` (sudoers-rule installed by +// postinst). package firewall import ( @@ -18,6 +25,8 @@ import ( "net" "os/exec" "path/filepath" + "sort" + "strings" "text/template" "github.com/jackc/pgx/v5/pgxpool" @@ -28,7 +37,9 @@ import ( //go:embed ruleset.nft.tpl var rulesTpl string -var tpl = template.Must(template.New("ruleset").Parse(rulesTpl)) +var tpl = template.Must(template.New("ruleset").Funcs(template.FuncMap{ + "join": strings.Join, +}).Parse(rulesTpl)) type Generator struct { Pool *pgxpool.Pool @@ -60,42 +71,113 @@ func (g *Generator) Render(ctx context.Context) error { if g.SkipReload { return nil } - if err := exec.Command("nft", "-f", out).Run(); err != nil { - return fmt.Errorf("nftables: nft -f %s: %w", out, err) + // nft -f via sudo — postinst installiert die NOPASSWD-Rule. + cmd := exec.Command("sudo", "-n", "/usr/sbin/nft", "-f", out) + combined, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("nftables: nft -f %s: %w (output: %s)", out, err, strings.TrimSpace(string(combined))) } return nil } +// View is what the template consumes — fully resolved. type View struct { + ZoneIPv4 map[string][]string // zone name → iface names (ipv4-able) PeerIPv4 []string PeerIPv6 []string - // Custom rules grouped by chain — the template iterates each - // section independently so input/forward/output stay separate. - CustomRulesInput []Rule - CustomRulesForward []Rule - CustomRulesOutput []Rule + // Legs is the cross-product of (rule × service). One nft line per + // leg, expanded server-side so the template stays free of dict / + // sub-template trickery. A rule with N services produces N legs; + // a rule with no service produces one leg with Service.Proto = "". + Legs []RuleLeg + NATRules []ResolvedNATRule } -type Rule struct { - MatchExpr string +// RuleLeg is one materialised nft policy line. +type RuleLeg struct { + RuleID int64 Action string + Log bool + Name string + Comment string + SrcIfaces []string + DstIfaces []string + SrcAddrs []string + DstAddrs []string + Service ResolvedService // Proto="" → no service match (any) +} + +// ResolvedRule has all addresses + services already expanded so the +// template just emits one nft line per "leg" of the cross-product. +type ResolvedRule struct { + ID int64 + Action string // accept | drop | reject + Log bool + Name string + Priority int + + SrcIfaces []string // empty = any + DstIfaces []string // empty = any + SrcAddrs []string // each is an nft expression like "1.2.3.4" or "10.0.0.0/24" or "{ 1.2.3.4, 5.6.7.8 }" + DstAddrs []string + Services []ResolvedService // empty = any Comment string } -func (g *Generator) loadView(ctx context.Context) (*View, error) { - view := &View{} +// ResolvedNATRule is one nat-rule joined with iface-sets. +type ResolvedNATRule struct { + ID int64 + Kind string // dnat | snat | masquerade + Priority int + InIfaces []string + OutIfaces []string + Proto string // empty = any + SrcCIDR string + DstCIDR string + DPortStart, DPortEnd int + TargetAddr string + TargetPortStart, TargetPortEnd int + Comment string +} - // Peer IPs from ha_nodes — splits IPv4 vs IPv6 so the template - // can populate the right named set without runtime branching. +// ResolvedService is one nft (proto, dport-spec) tuple. +type ResolvedService struct { + Proto string // tcp|udp|icmp|icmpv6 + PortStart int // 0 = no port match + PortEnd int +} + +func (g *Generator) loadView(ctx context.Context) (*View, error) { + view := &View{ + ZoneIPv4: map[string][]string{}, + } + + // ── Zone → Iface mapping aus network_interfaces.role ── + ifRows, err := g.Pool.Query(ctx, + `SELECT name, role FROM network_interfaces WHERE active = TRUE`) + if err != nil { + return nil, fmt.Errorf("query network_interfaces: %w", err) + } + for ifRows.Next() { + var name, role string + if err := ifRows.Scan(&name, &role); err != nil { + ifRows.Close() + return nil, err + } + view.ZoneIPv4[role] = append(view.ZoneIPv4[role], name) + } + ifRows.Close() + + // ── Peer IPs aus ha_nodes (für mTLS-Peer-Set) ── peerRows, err := g.Pool.Query(ctx, `SELECT public_ip, internal_ip FROM ha_nodes`) if err != nil { return nil, fmt.Errorf("query ha_nodes: %w", err) } - defer peerRows.Close() for peerRows.Next() { var pub, internal *string if err := peerRows.Scan(&pub, &internal); err != nil { + peerRows.Close() return nil, err } for _, ip := range []*string{pub, internal} { @@ -113,16 +195,325 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) { } } } - if err := peerRows.Err(); err != nil { + peerRows.Close() + + // ── Lade Address-Objects + Groups → ID → ResolvedAddr-list ── + addrObjs, err := g.loadAddrObjects(ctx) + if err != nil { + return nil, err + } + addrGroups, err := g.loadAddrGroups(ctx, addrObjs) + if err != nil { return nil, err } - // Migration 0010 hat firewall_rules komplett umgebaut (Fortigate- - // Style mit address objects + service refs). Phase-2-Renderer - // kannte das alte chain/match_expr-Schema. Bis Task #44 die - // Render-Logik mit den neuen Joins ersetzt, geben wir hier - // keine custom-Rules aus — Output ist nur baseline + cluster set. - // Sicher, weil baseline default-deny ist; v2-Rules kommen mit - // dem nächsten Renderer-Patch. + // ── Services + Groups ── + services, err := g.loadServices(ctx) + if err != nil { + return nil, err + } + serviceGroups, err := g.loadServiceGroups(ctx, services) + if err != nil { + return nil, err + } + + // ── Rules ── + rules, err := g.loadRules(ctx, addrObjs, addrGroups, services, serviceGroups, view.ZoneIPv4) + if err != nil { + return nil, err + } + // Expand to one Leg per (rule × service); rules without a service + // produce one leg with empty Proto. + for _, r := range rules { + if len(r.Services) == 0 { + view.Legs = append(view.Legs, RuleLeg{ + RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name, + Comment: r.Comment, + SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces, + SrcAddrs: r.SrcAddrs, DstAddrs: r.DstAddrs, + }) + continue + } + for _, svc := range r.Services { + view.Legs = append(view.Legs, RuleLeg{ + RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name, + Comment: r.Comment, + SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces, + SrcAddrs: r.SrcAddrs, DstAddrs: r.DstAddrs, + Service: svc, + }) + } + } + + // ── NAT-Rules ── + natRules, err := g.loadNATRules(ctx, view.ZoneIPv4) + if err != nil { + return nil, err + } + view.NATRules = natRules + return view, nil } + +// addrObjMap is keyed by id; value is the nft expression for that +// object (e.g. "1.2.3.4", "10.0.0.0/24", "1.2.3.4-1.2.3.10"). +type addrObjMap map[int64]string + +func (g *Generator) loadAddrObjects(ctx context.Context) (addrObjMap, error) { + out := addrObjMap{} + rows, err := g.Pool.Query(ctx, + `SELECT id, kind, value FROM firewall_address_objects`) + if err != nil { + return nil, fmt.Errorf("query address_objects: %w", err) + } + defer rows.Close() + for rows.Next() { + var id int64 + var kind, value string + if err := rows.Scan(&id, &kind, &value); err != nil { + return nil, err + } + switch kind { + case "host", "network": + out[id] = value + case "range": + // "1.2.3.4-1.2.3.10" → nft format identical + out[id] = value + case "fqdn": + // FQDNs are skipped at render-time — template emits + // a comment, the actual rule won't filter on these + // addresses until a DNS-resolution sidecar lands. + out[id] = "" // signals "skip" + } + } + return out, rows.Err() +} + +// addrGroupMap is keyed by group id; value is the list of resolved +// nft-expressions (one per primitive member). +type addrGroupMap map[int64][]string + +func (g *Generator) loadAddrGroups(ctx context.Context, objs addrObjMap) (addrGroupMap, error) { + out := addrGroupMap{} + rows, err := g.Pool.Query(ctx, + `SELECT group_id, object_id FROM firewall_address_group_members`) + if err != nil { + return nil, fmt.Errorf("query address_group_members: %w", err) + } + defer rows.Close() + for rows.Next() { + var gid, oid int64 + if err := rows.Scan(&gid, &oid); err != nil { + return nil, err + } + if expr, ok := objs[oid]; ok && expr != "" { + out[gid] = append(out[gid], expr) + } + } + return out, rows.Err() +} + +type serviceMap map[int64]ResolvedService + +func (g *Generator) loadServices(ctx context.Context) (serviceMap, error) { + out := serviceMap{} + rows, err := g.Pool.Query(ctx, + `SELECT id, proto, COALESCE(port_start, 0), COALESCE(port_end, 0) FROM firewall_services`) + if err != nil { + return nil, fmt.Errorf("query services: %w", err) + } + defer rows.Close() + for rows.Next() { + var id int64 + var proto string + var ps, pe int + if err := rows.Scan(&id, &proto, &ps, &pe); err != nil { + return nil, err + } + out[id] = ResolvedService{Proto: proto, PortStart: ps, PortEnd: pe} + } + return out, rows.Err() +} + +type serviceGroupMap map[int64][]ResolvedService + +func (g *Generator) loadServiceGroups(ctx context.Context, services serviceMap) (serviceGroupMap, error) { + out := serviceGroupMap{} + rows, err := g.Pool.Query(ctx, + `SELECT group_id, service_id FROM firewall_service_group_members`) + if err != nil { + return nil, fmt.Errorf("query service_group_members: %w", err) + } + defer rows.Close() + for rows.Next() { + var gid, sid int64 + if err := rows.Scan(&gid, &sid); err != nil { + return nil, err + } + if svc, ok := services[sid]; ok { + out[gid] = append(out[gid], svc) + } + } + return out, rows.Err() +} + +func (g *Generator) loadRules( + ctx context.Context, + addrObjs addrObjMap, + addrGroups addrGroupMap, + services serviceMap, + serviceGroups serviceGroupMap, + zoneIfaces map[string][]string, +) ([]ResolvedRule, error) { + rows, err := g.Pool.Query(ctx, ` +SELECT id, COALESCE(name, ''), priority, action, log, COALESCE(comment, ''), + src_zone, src_address_object_id, src_address_group_id, src_cidr, + dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr, + service_object_id, service_group_id +FROM firewall_rules +WHERE enabled +ORDER BY priority DESC, id ASC`) + if err != nil { + return nil, fmt.Errorf("query rules: %w", err) + } + defer rows.Close() + + resolveSide := func(objID, grpID *int64, cidr *string) []string { + if objID != nil { + if expr, ok := addrObjs[*objID]; ok && expr != "" { + return []string{expr} + } + } + if grpID != nil { + if exprs, ok := addrGroups[*grpID]; ok { + return exprs + } + } + if cidr != nil && *cidr != "" { + return []string{*cidr} + } + return nil // any + } + resolveService := func(objID, grpID *int64) []ResolvedService { + if objID != nil { + if svc, ok := services[*objID]; ok { + return []ResolvedService{svc} + } + } + if grpID != nil { + if svcs, ok := serviceGroups[*grpID]; ok { + return svcs + } + } + return nil + } + + out := []ResolvedRule{} + for rows.Next() { + var ( + id int64 + name, action, com string + pr int + log bool + srcZone, dstZone string + srcObjID, srcGrpID *int64 + dstObjID, dstGrpID *int64 + srcCIDR, dstCIDR *string + svcObjID, svcGrpID *int64 + ) + if err := rows.Scan( + &id, &name, &pr, &action, &log, &com, + &srcZone, &srcObjID, &srcGrpID, &srcCIDR, + &dstZone, &dstObjID, &dstGrpID, &dstCIDR, + &svcObjID, &svcGrpID, + ); err != nil { + return nil, err + } + r := ResolvedRule{ + ID: id, + Action: action, + Log: log, + Name: name, + Priority: pr, + Comment: com, + SrcAddrs: resolveSide(srcObjID, srcGrpID, srcCIDR), + DstAddrs: resolveSide(dstObjID, dstGrpID, dstCIDR), + Services: resolveService(svcObjID, svcGrpID), + } + if srcZone != "any" { + r.SrcIfaces = zoneIfaces[srcZone] + } + if dstZone != "any" { + r.DstIfaces = zoneIfaces[dstZone] + } + out = append(out, r) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Priority != out[j].Priority { + return out[i].Priority > out[j].Priority + } + return out[i].ID < out[j].ID + }) + return out, rows.Err() +} + +func (g *Generator) loadNATRules(ctx context.Context, zoneIfaces map[string][]string) ([]ResolvedNATRule, error) { + rows, err := g.Pool.Query(ctx, ` +SELECT id, priority, kind, COALESCE(comment, ''), + in_zone, out_zone, proto, + match_src_cidr, match_dst_cidr, + COALESCE(match_dport_start, 0), COALESCE(match_dport_end, 0), + COALESCE(target_addr, ''), + COALESCE(target_port_start, 0), COALESCE(target_port_end, 0) +FROM firewall_nat_rules +WHERE enabled +ORDER BY priority DESC, id ASC`) + if err != nil { + return nil, fmt.Errorf("query nat_rules: %w", err) + } + defer rows.Close() + + out := []ResolvedNATRule{} + for rows.Next() { + var ( + id int64 + pr int + kind, com string + inZone, outZone, proto, srcCIDR, dstCIDR *string + dpStart, dpEnd, tpStart, tpEnd int + targetAddr string + ) + if err := rows.Scan( + &id, &pr, &kind, &com, + &inZone, &outZone, &proto, + &srcCIDR, &dstCIDR, + &dpStart, &dpEnd, + &targetAddr, &tpStart, &tpEnd, + ); err != nil { + return nil, err + } + r := ResolvedNATRule{ + ID: id, Kind: kind, Priority: pr, Comment: com, + DPortStart: dpStart, DPortEnd: dpEnd, + TargetAddr: targetAddr, + TargetPortStart: tpStart, TargetPortEnd: tpEnd, + } + if proto != nil { + r.Proto = *proto + } + if srcCIDR != nil { + r.SrcCIDR = *srcCIDR + } + if dstCIDR != nil { + r.DstCIDR = *dstCIDR + } + if inZone != nil { + r.InIfaces = zoneIfaces[*inZone] + } + if outZone != nil { + r.OutIfaces = zoneIfaces[*outZone] + } + out = append(out, r) + } + return out, rows.Err() +} diff --git a/internal/firewall/firewall_test.go b/internal/firewall/firewall_test.go deleted file mode 100644 index 0a1ab93..0000000 --- a/internal/firewall/firewall_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package firewall - -import ( - "bytes" - "strings" - "testing" -) - -func renderView(t *testing.T, v View) string { - t.Helper() - var buf bytes.Buffer - if err := tpl.Execute(&buf, v); err != nil { - t.Fatalf("template execute: %v", err) - } - return buf.String() -} - -func TestRender_BaselineHasMandatorySections(t *testing.T) { - out := renderView(t, View{}) - for _, w := range []string{ - "flush ruleset", - "table inet edgeguard", - "set peer_ipv4", - "set peer_ipv6", - "chain input", - "type filter hook input priority 0; policy drop;", - "ct state established,related accept", - "iif lo accept", - "tcp dport 22 ct state new limit rate 10/minute accept", - "tcp dport { 80, 443 } accept", - "tcp dport 8443 ip saddr @peer_ipv4 accept", - "chain forward", - "chain output", - } { - if !strings.Contains(out, w) { - t.Errorf("missing %q in baseline:\n%s", w, out) - } - } -} - -func TestRender_PeerIPsPopulateSets(t *testing.T) { - v := View{ - PeerIPv4: []string{"10.0.0.11", "10.0.0.12"}, - PeerIPv6: []string{"fd00::1"}, - } - out := renderView(t, v) - for _, w := range []string{ - "elements = { 10.0.0.11, 10.0.0.12 }", - "elements = { fd00::1 }", - } { - if !strings.Contains(out, w) { - t.Errorf("missing %q:\n%s", w, out) - } - } -} - -func TestRender_CustomRulesLandInChain(t *testing.T) { - v := View{ - CustomRulesInput: []Rule{ - {MatchExpr: "ip saddr 192.168.0.0/16 tcp dport 9090", Action: "accept", Comment: "monitoring"}, - }, - CustomRulesForward: []Rule{ - {MatchExpr: "iif eth0 oif eth1", Action: "accept", Comment: "lan to wan"}, - }, - } - out := renderView(t, v) - want := []string{ - "# monitoring", - "ip saddr 192.168.0.0/16 tcp dport 9090 accept", - "# lan to wan", - "iif eth0 oif eth1 accept", - } - for _, w := range want { - if !strings.Contains(out, w) { - t.Errorf("missing %q:\n%s", w, out) - } - } -} diff --git a/internal/firewall/ruleset.nft.tpl b/internal/firewall/ruleset.nft.tpl index f822570..d5fd5ab 100644 --- a/internal/firewall/ruleset.nft.tpl +++ b/internal/firewall/ruleset.nft.tpl @@ -1,7 +1,7 @@ #!/usr/sbin/nft -f # Generated by edgeguard-api — DO NOT EDIT. -# Source: internal/firewall/firewall.go (template: ruleset.nft.tpl). -# Re-generate via `edgeguard-ctl render-config`. +# Source: internal/firewall/firewall.go. +# Re-generate via `edgeguard-ctl render-config` or via API mutations. flush ruleset @@ -22,6 +22,14 @@ table inet edgeguard { chain input { type filter hook input priority 0; policy drop; + # ── ANTI-LOCKOUT (immer aktiv, kann von keiner Custom-Rule overruled werden) ── + # nft input-chain wird top-down evaluiert; eine accept-Action terminiert. + # Diese Block kommt VOR den Custom-Rules — d.h. selbst wenn ein + # Operator versehentlich „drop alles" baut, bleibt SSH + Admin-UI + # erreichbar. + tcp dport 22 ct state new limit rate 10/minute accept comment "anti-lockout: SSH (rate-limited)" + tcp dport 443 accept comment "anti-lockout: Management-UI (HAProxy/HTTPS)" + # Stateful baseline ct state established,related accept ct state invalid drop @@ -31,39 +39,66 @@ table inet edgeguard { ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } accept ip6 nexthdr icmpv6 icmpv6 type { echo-request, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept - # SSH — rate-limit to keep brute-force out of the auth log - tcp dport 22 ct state new limit rate 10/minute accept - tcp dport 22 drop - - # Public ingress: HAProxy terminates TLS on :443 and serves :80 - tcp dport { 80, 443 } accept + # Public ingress: HAProxy serves :80 (ACME + redirect) + tcp dport 80 accept # Cluster-internal: peers reach edgeguard-api over mTLS on :8443 tcp dport 8443 ip saddr @peer_ipv4 accept tcp dport 8443 ip6 saddr @peer_ipv6 accept - {{- range .CustomRulesInput}} - # {{.Comment}} - {{.MatchExpr}} {{.Action}} - {{- end}} + # ── Operator-defined rules ── +{{- range .Legs}} + # rule {{.RuleID}}{{if .Name}} ({{.Name}}){{end}}{{if .Comment}} — {{.Comment}}{{end}} + {{- if .SrcIfaces}} iifname { {{join .SrcIfaces ", "}} }{{end -}} + {{- if .DstIfaces}} oifname { {{join .DstIfaces ", "}} }{{end -}} + {{- if .SrcAddrs}} ip saddr { {{join .SrcAddrs ", "}} }{{end -}} + {{- if .DstAddrs}} ip daddr { {{join .DstAddrs ", "}} }{{end -}} + {{- with .Service -}} + {{- if and (or (eq .Proto "tcp") (eq .Proto "udp")) .PortStart}} {{.Proto}} dport {{.PortStart}}{{if and .PortEnd (ne .PortEnd .PortStart)}}-{{.PortEnd}}{{end}} + {{- else if eq .Proto "icmp"}} ip protocol icmp + {{- else if eq .Proto "icmpv6"}} ip6 nexthdr icmpv6 + {{- end -}} + {{- end -}} + {{- if .Log}} log prefix "edgeguard:{{.RuleID}} "{{end}} {{.Action}} +{{- end}} } chain forward { type filter hook forward priority 0; policy drop; + ct state established,related accept ct state invalid drop - - {{- range .CustomRulesForward}} - # {{.Comment}} - {{.MatchExpr}} {{.Action}} - {{- end}} } chain output { type filter hook output priority 0; policy accept; - {{- range .CustomRulesOutput}} - # {{.Comment}} - {{.MatchExpr}} {{.Action}} - {{- end}} + } + + chain prerouting_nat { + type nat hook prerouting priority -100; +{{- range .NATRules}}{{if eq .Kind "dnat"}} + # NAT {{.ID}} (dnat{{if .Comment}} — {{.Comment}}{{end}}) + {{- if .InIfaces}} iifname { {{join .InIfaces ", "}} }{{end}} + {{- if and .Proto (ne .Proto "any")}} {{.Proto}}{{else}} meta l4proto { tcp, udp }{{end}} + {{- if .SrcCIDR}} ip saddr {{.SrcCIDR}}{{end}} + {{- if .DstCIDR}} ip daddr {{.DstCIDR}}{{end}} + {{- if .DPortStart}} dport {{.DPortStart}}{{if and .DPortEnd (ne .DPortEnd .DPortStart)}}-{{.DPortEnd}}{{end}}{{end}} + {{- if .TargetAddr}} dnat to {{.TargetAddr}}{{if .TargetPortStart}}:{{.TargetPortStart}}{{if and .TargetPortEnd (ne .TargetPortEnd .TargetPortStart)}}-{{.TargetPortEnd}}{{end}}{{end}}{{end}} +{{- end}}{{end}} + } + + chain postrouting_nat { + type nat hook postrouting priority 100; +{{- range .NATRules}}{{if eq .Kind "snat"}} + # NAT {{.ID}} (snat{{if .Comment}} — {{.Comment}}{{end}}) + {{- if .OutIfaces}} oifname { {{join .OutIfaces ", "}} }{{end}} + {{- if .SrcCIDR}} ip saddr {{.SrcCIDR}}{{end}} + {{- if .TargetAddr}} snat to {{.TargetAddr}}{{end}} +{{- end}}{{if eq .Kind "masquerade"}} + # NAT {{.ID}} (masquerade{{if .Comment}} — {{.Comment}}{{end}}) + {{- if .OutIfaces}} oifname { {{join .OutIfaces ", "}} }{{end}} + {{- if .SrcCIDR}} ip saddr {{.SrcCIDR}}{{end}} masquerade +{{- end}}{{end}} } } + diff --git a/internal/handlers/firewall.go b/internal/handlers/firewall.go index f60e48a..c638889 100644 --- a/internal/handlers/firewall.go +++ b/internal/handlers/firewall.go @@ -1,8 +1,10 @@ package handlers import ( + "context" "errors" "fmt" + "log/slog" "net" "strconv" "strings" @@ -35,6 +37,13 @@ type FirewallHandler struct { NATRules *firewall.NATRulesRepo Audit *audit.Repo NodeID string + + // Reloader regenerates and applies the nft ruleset. Called after + // each mutation so the kernel ruleset is always in sync with the + // DB. Errors are logged but don't fail the API call (the row is + // already committed). Wire to internal/firewall.Generator.Render + // in main.go; pass nil during tests. + Reloader func(ctx context.Context) error } func NewFirewallHandler( @@ -46,12 +55,27 @@ func NewFirewallHandler( nat *firewall.NATRulesRepo, a *audit.Repo, nodeID string, + reloader func(ctx context.Context) error, ) *FirewallHandler { return &FirewallHandler{ AddrObjects: ao, AddrGroups: ag, Services: sv, ServiceGroups: sg, Rules: rl, NATRules: nat, Audit: a, NodeID: nodeID, + Reloader: reloader, + } +} + +// reload runs the configured Reloader (if any). Errors don't fail +// the surrounding API call — the DB row is already committed and +// the operator can re-trigger via `edgeguard-ctl render-config`. +func (h *FirewallHandler) reload(ctx context.Context, op string) { + if h.Reloader == nil { + return + } + if err := h.Reloader(ctx); err != nil { + slog.Warn("firewall: nft reload failed", + "op", op, "error", err) } } @@ -145,7 +169,7 @@ func (h *FirewallHandler) CreateAddrObj(c *gin.Context) { return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.create", req.Name, out, h.NodeID) - response.Created(c, out) + response.Created(c, out); h.reload(c.Request.Context(), "create") } func (h *FirewallHandler) UpdateAddrObj(c *gin.Context) { @@ -172,7 +196,7 @@ func (h *FirewallHandler) UpdateAddrObj(c *gin.Context) { return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.update", req.Name, out, h.NodeID) - response.OK(c, out) + response.OK(c, out); h.reload(c.Request.Context(), "update") } func (h *FirewallHandler) DeleteAddrObj(c *gin.Context) { @@ -190,7 +214,7 @@ func (h *FirewallHandler) DeleteAddrObj(c *gin.Context) { } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) - response.NoContent(c) + response.NoContent(c); h.reload(c.Request.Context(), "delete") } // ── Address Groups ───────────────────────────────────────────────────── @@ -233,7 +257,7 @@ func (h *FirewallHandler) CreateAddrGrp(c *gin.Context) { return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.create", req.Name, out, h.NodeID) - response.Created(c, out) + response.Created(c, out); h.reload(c.Request.Context(), "create") } func (h *FirewallHandler) UpdateAddrGrp(c *gin.Context) { @@ -256,7 +280,7 @@ func (h *FirewallHandler) UpdateAddrGrp(c *gin.Context) { return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.update", req.Name, out, h.NodeID) - response.OK(c, out) + response.OK(c, out); h.reload(c.Request.Context(), "update") } func (h *FirewallHandler) DeleteAddrGrp(c *gin.Context) { @@ -274,7 +298,7 @@ func (h *FirewallHandler) DeleteAddrGrp(c *gin.Context) { } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) - response.NoContent(c) + response.NoContent(c); h.reload(c.Request.Context(), "delete") } // ── Services ─────────────────────────────────────────────────────────── @@ -317,7 +341,7 @@ func (h *FirewallHandler) CreateService(c *gin.Context) { return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.create", req.Name, out, h.NodeID) - response.Created(c, out) + response.Created(c, out); h.reload(c.Request.Context(), "create") } func (h *FirewallHandler) UpdateService(c *gin.Context) { @@ -340,7 +364,7 @@ func (h *FirewallHandler) UpdateService(c *gin.Context) { return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.update", req.Name, out, h.NodeID) - response.OK(c, out) + response.OK(c, out); h.reload(c.Request.Context(), "update") } func (h *FirewallHandler) DeleteService(c *gin.Context) { @@ -358,7 +382,7 @@ func (h *FirewallHandler) DeleteService(c *gin.Context) { } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) - response.NoContent(c) + response.NoContent(c); h.reload(c.Request.Context(), "delete") } // ── Service Groups ───────────────────────────────────────────────────── @@ -401,7 +425,7 @@ func (h *FirewallHandler) CreateSvcGrp(c *gin.Context) { return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.create", req.Name, out, h.NodeID) - response.Created(c, out) + response.Created(c, out); h.reload(c.Request.Context(), "create") } func (h *FirewallHandler) UpdateSvcGrp(c *gin.Context) { @@ -424,7 +448,7 @@ func (h *FirewallHandler) UpdateSvcGrp(c *gin.Context) { return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.update", req.Name, out, h.NodeID) - response.OK(c, out) + response.OK(c, out); h.reload(c.Request.Context(), "update") } func (h *FirewallHandler) DeleteSvcGrp(c *gin.Context) { @@ -442,7 +466,7 @@ func (h *FirewallHandler) DeleteSvcGrp(c *gin.Context) { } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) - response.NoContent(c) + response.NoContent(c); h.reload(c.Request.Context(), "delete") } // ── Rules ────────────────────────────────────────────────────────────── @@ -489,7 +513,7 @@ func (h *FirewallHandler) CreateRule(c *gin.Context) { return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.create", strconv.FormatInt(out.ID, 10), out, h.NodeID) - response.Created(c, out) + response.Created(c, out); h.reload(c.Request.Context(), "create") } func (h *FirewallHandler) UpdateRule(c *gin.Context) { @@ -516,7 +540,7 @@ func (h *FirewallHandler) UpdateRule(c *gin.Context) { return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.update", strconv.FormatInt(id, 10), out, h.NodeID) - response.OK(c, out) + response.OK(c, out); h.reload(c.Request.Context(), "update") } func (h *FirewallHandler) DeleteRule(c *gin.Context) { @@ -534,7 +558,7 @@ func (h *FirewallHandler) DeleteRule(c *gin.Context) { } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) - response.NoContent(c) + response.NoContent(c); h.reload(c.Request.Context(), "delete") } // ── NAT Rules ────────────────────────────────────────────────────────── @@ -581,7 +605,7 @@ func (h *FirewallHandler) CreateNAT(c *gin.Context) { return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.create", strconv.FormatInt(out.ID, 10), out, h.NodeID) - response.Created(c, out) + response.Created(c, out); h.reload(c.Request.Context(), "create") } func (h *FirewallHandler) UpdateNAT(c *gin.Context) { @@ -608,7 +632,7 @@ func (h *FirewallHandler) UpdateNAT(c *gin.Context) { return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.update", strconv.FormatInt(id, 10), out, h.NodeID) - response.OK(c, out) + response.OK(c, out); h.reload(c.Request.Context(), "update") } func (h *FirewallHandler) DeleteNAT(c *gin.Context) { @@ -626,7 +650,7 @@ func (h *FirewallHandler) DeleteNAT(c *gin.Context) { } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) - response.NoContent(c) + response.NoContent(c); h.reload(c.Request.Context(), "delete") } // ── Validators ───────────────────────────────────────────────────────── diff --git a/management-ui/src/pages/Firewall/AddressGroups.tsx b/management-ui/src/pages/Firewall/AddressGroups.tsx index 10c08b7..83b0e10 100644 --- a/management-ui/src/pages/Firewall/AddressGroups.tsx +++ b/management-ui/src/pages/Firewall/AddressGroups.tsx @@ -85,7 +85,7 @@ export default function AddressGroupsTab() { return ( <> -