// Package firewall renders /etc/edgeguard/nftables.d/ruleset.nft from // the v2 (Fortigate-style) firewall tables. // // Render flow: // // 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 ( "bytes" "context" _ "embed" "fmt" "net" "os/exec" "path/filepath" "sort" "strings" "text/template" "github.com/jackc/pgx/v5/pgxpool" "git.netcell-it.de/projekte/edgeguard-native/internal/configgen" ) //go:embed ruleset.nft.tpl var rulesTpl string var tpl = template.Must(template.New("ruleset").Funcs(template.FuncMap{ "join": strings.Join, }).Parse(rulesTpl)) type Generator struct { Pool *pgxpool.Pool OutputPath string SkipReload bool } func New(pool *pgxpool.Pool) *Generator { return &Generator{Pool: pool} } func (g *Generator) Name() string { return "nftables" } func (g *Generator) Render(ctx context.Context) error { view, err := g.loadView(ctx) if err != nil { return fmt.Errorf("nftables: load state: %w", err) } var buf bytes.Buffer if err := tpl.Execute(&buf, view); err != nil { return fmt.Errorf("nftables: render template: %w", err) } out := g.OutputPath if out == "" { out = filepath.Join(configgen.EtcEdgeguard, "nftables.d", "ruleset.nft") } if err := configgen.AtomicWrite(out, buf.Bytes(), 0o644); err != nil { return fmt.Errorf("nftables: write: %w", err) } if g.SkipReload { return nil } // 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 // 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 // AutoRules are inbound-accept rules the firewall renderer // derives from the running service config: // - DNS (Unbound): if listen_addresses lists a non-loopback IP, // emit udp/tcp 53 to that IP. // - Squid: if forward_proxy_acls has any active entry, emit // tcp 3128 (operator can lock down via address-objects later). // - WireGuard server-mode: emit udp per active // server iface. // Operator never edits these — they belong to the service. If // the service is removed/disabled, the rule is gone next render. AutoRules []AutoFWRule } // AutoFWRule is one auto-emitted inbound rule. Proto is "tcp" or // "udp"; Port is the listen port; DstIP is the local IP the service // binds to (empty = any local IP). Comment is what the SystemRules // card shows in the UI. type AutoFWRule struct { Proto string Port int DstIP string Comment 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 } // 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 } // 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) } 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} { if ip == nil { continue } parsed := net.ParseIP(*ip) if parsed == nil { continue } if parsed.To4() != nil { view.PeerIPv4 = append(view.PeerIPv4, parsed.String()) } else { view.PeerIPv6 = append(view.PeerIPv6, parsed.String()) } } } 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 } // ── 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 // ── Auto-Rules aus laufender Service-Config ── view.AutoRules = g.loadAutoRules(ctx) return view, nil } // LoadAutoRules ist die exportierte Variante — der UI-Handler ruft // sie auf um die Liste an die SystemRulesCard zu liefern. func (g *Generator) LoadAutoRules(ctx context.Context) []AutoFWRule { return g.loadAutoRules(ctx) } // loadAutoRules berechnet inbound-accept-Rules aus dem aktuellen // State der anderen Services. Best-effort — Fehler beim Lesen einer // einzelnen Service-DB-Tabelle führen zu warning + skip, nicht zum // Abort der gesamten Render. Anti-Lockout-Rules (SSH/443/3443/80) // stehen weiterhin als statische Block im Template. func (g *Generator) loadAutoRules(ctx context.Context) []AutoFWRule { out := []AutoFWRule{} // DNS (Unbound): listen_addresses ohne 127.x/::1 → udp+tcp 53. var listenAddrs string if err := g.Pool.QueryRow(ctx, `SELECT listen_addresses FROM dns_settings WHERE id=1`).Scan(&listenAddrs); err == nil { for _, ip := range splitCSV(listenAddrs) { if isLoopback(ip) || ip == "0.0.0.0" || ip == "::" { continue } out = append(out, AutoFWRule{Proto: "udp", Port: 53, DstIP: ip, Comment: "DNS (Unbound) auf " + ip}, AutoFWRule{Proto: "tcp", Port: 53, DstIP: ip, Comment: "DNS-TCP (Unbound) auf " + ip}, ) } } // Squid Forward-Proxy: wenn ≥1 aktive ACL → tcp 3128 inbound // (squid bindet aktuell 0.0.0.0:3128, daher kein DstIP-Filter). var aclCount int if err := g.Pool.QueryRow(ctx, `SELECT count(*) FROM forward_proxy_acls WHERE active`).Scan(&aclCount); err == nil && aclCount > 0 { out = append(out, AutoFWRule{Proto: "tcp", Port: 3128, Comment: "Forward-Proxy (Squid)"}) } // WireGuard server-mode: udp pro aktive iface. rows, err := g.Pool.Query(ctx, `SELECT name, listen_port FROM wireguard_interfaces WHERE active AND mode='server' AND listen_port IS NOT NULL`) if err == nil { defer rows.Close() for rows.Next() { var name string var port int if err := rows.Scan(&name, &port); err == nil { out = append(out, AutoFWRule{Proto: "udp", Port: port, Comment: "WireGuard " + name}) } } } // Chrony NTP: wenn serve_clients=true und listen_addresses // non-loopback enthält → udp 123 pro IP. Wenn die Liste nur // localhost ist, kein FW-Rule (chrony bindet dann nichts // nach außen). var nlist string var serveClients bool if err := g.Pool.QueryRow(ctx, `SELECT listen_addresses, serve_clients FROM ntp_settings WHERE id=1`).Scan(&nlist, &serveClients); err == nil && serveClients { for _, ip := range splitCSV(nlist) { if isLoopback(ip) || ip == "0.0.0.0" || ip == "::" { continue } out = append(out, AutoFWRule{Proto: "udp", Port: 123, DstIP: ip, Comment: "NTP (chrony) auf " + ip}) } } return out } // splitCSV — wie in den Service-renderern. func splitCSV(s string) []string { out := []string{} for _, p := range strings.Split(s, ",") { t := strings.TrimSpace(p) if t != "" { out = append(out, t) } } return out } // isLoopback erkennt 127.x.x.x, ::1, oder localhost-namen — die // brauchen keine FW-Rule (nft erlaubt iif lo per Anti-Lockout). func isLoopback(ip string) bool { if ip == "::1" || ip == "localhost" { return true } return strings.HasPrefix(ip, "127.") } // 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() }