Files
edgeguard-native/internal/firewall/firewall.go
Debian 5f92851a96 feat(radius): RADIUS-Server via FreeRADIUS (PAP/CHAP) — v1.2.93
Files-basierter RADIUS-Server (Clients + Users), managed analog DHCP/WireGuard.
- Migration 0042: radius_settings (singleton, node-lokal), radius_clients (secret_enc), radius_users (password_enc) — Secrets via secrets.Box verschlüsselt.
- internal/freeradius: Multi-File-Renderer (clients.conf + authorize) via Box.Open, Secret-Escaping (" \), Service default-off/an enabled gekoppelt. internal/services/radius + internal/handlers/radius.go: Settings + Client/User-CRUD, write-only Secret-Semantik, Validierung (IP/CIDR, name-charset), GET liefert secret_configured statt Secret.
- Firewall: udp 1812/1813 Auto-Rule bei enabled. Cluster: clients/users repliziert (hashSpec), radius_settings node-lokal.
- main.go + render.go + WithAllReloaders. Packaging: freeradius Dependency, setgid-Dir /etc/edgeguard/freeradius (Gruppe freeradius), Symlinks clients.conf+authorize, disable-on-install, sudoers.
- UI: RADIUS-Seite (Einstellungen + Clients + Benutzer) unter Sicherheit, Route/Nav/i18n de/en.
- Tests (guarded): Renderer-Inhalt + Secret-Escaping/Roundtrip + Masking. Scope v1: PAP/CHAP files-based (kein EAP/802.1X).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:46:42 +02:00

884 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
"log/slog"
"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) RenderToString(ctx context.Context) (string, 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)
}
return buf.String(), nil
}
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 <listen_port> 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
// WGServerIfaces holds the interface names of all active
// WireGuard server-mode interfaces. The forward chain emits
// iifname "<iface>" accept for each — required so that peer-to-
// peer traffic (Peer A → Peer B both connected to the same wg
// interface) can be forwarded by the box. Without this, the
// forward policy=drop silently kills all inter-peer packets.
WGServerIfaces []string
// WGSiteMasq drives masquerade rules in postrouting_nat: one entry
// per active WireGuard server interface. Without masquerade, traffic
// from VPN roadwarrior clients (e.g. 192.168.99.3) forwarded to a
// site-to-site LAN (10.0.10.0/24) comes back with the client's tunnel
// IP as destination. The remote gateway (Unify Home) doesn't know
// that IP and drops the reply. Masquerade rewrites the source to the
// server's own tunnel IP so return traffic follows the same path back.
WGSiteMasq []WGSiteMasqEntry
}
// WGSiteMasqEntry is one WireGuard server interface's masquerade config.
type WGSiteMasqEntry struct {
Iface string // wg interface name, e.g. "wg7"
VPNNet string // network CIDR of the VPN subnet, e.g. "192.168.99.0/24"
L3 string // "ip" oder "ip6" — Familie von VPNNet
}
// 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
Iface string // optional: scope auf ein iifname (z.B. DHCP udp/67 nur auf LAN)
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
// L3 ist "ip" (IPv4) oder "ip6" (IPv6) für das Adress-Matching —
// gesetzt, sobald SrcAddrs/DstAddrs nicht leer sind. Bei adresslosen
// Regeln bleibt es "" (familienagnostisch, kein ip/ip6-Match).
L3 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
// L3 ist "ip" oder "ip6" — Adressfamilie der Regel (aus SrcCIDR/
// DstCIDR/TargetAddr abgeleitet). TargetHost ist TargetAddr, bei
// IPv6 MIT Port in eckigen Klammern ("[2001:db8::1]") für korrekte
// nft-dnat-Syntax.
L3 string
TargetHost string
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 × address-family). Rules
// without a service produce one leg-set with empty Proto.
for _, r := range rules {
if len(r.Services) == 0 {
view.Legs = append(view.Legs, expandFamilyLegs(r, ResolvedService{}, false)...)
continue
}
for _, svc := range r.Services {
view.Legs = append(view.Legs, expandFamilyLegs(r, svc, true)...)
}
}
// ── 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)
// ── WireGuard server-iface names (für forward-chain + site-to-site masquerade) ──
wgRows, err := g.Pool.Query(ctx,
`SELECT name, address_cidr FROM wireguard_interfaces WHERE active AND mode = 'server'`)
if err == nil {
defer wgRows.Close()
for wgRows.Next() {
var name, cidr string
if wgRows.Scan(&name, &cidr) == nil {
view.WGServerIfaces = append(view.WGServerIfaces, name)
if _, ipNet, err := net.ParseCIDR(cidr); err == nil {
l3 := addrFamily(ipNet.String())
if l3 == "" {
l3 = "ip"
}
view.WGSiteMasq = append(view.WGSiteMasq, WGSiteMasqEntry{
Iface: name,
VPNNet: ipNet.String(),
L3: l3,
})
}
}
}
}
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: lese Port + Listen-Adressen aus
// forward_proxy_settings. Für jede nicht-loopback IP eine
// Auto-Rule; leere Liste = alle Interfaces (generische Regel).
var squidAddrs string
var squidPort int
if err := g.Pool.QueryRow(ctx,
`SELECT listen_addresses, listen_port FROM forward_proxy_settings WHERE id=1`,
).Scan(&squidAddrs, &squidPort); err == nil && squidPort > 0 {
addrs := splitCSV(squidAddrs)
if len(addrs) == 0 {
out = append(out, AutoFWRule{Proto: "tcp", Port: squidPort, Comment: "Forward-Proxy (Squid)"})
} else {
for _, ip := range addrs {
if !isLoopback(ip) {
out = append(out, AutoFWRule{Proto: "tcp", Port: squidPort, DstIP: ip, Comment: "Forward-Proxy (Squid) auf " + ip})
}
}
}
}
// WireGuard server-mode: udp <listen_port> 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})
}
}
// DHCP (Kea): wenn auf DIESER Node aktiviert → udp/67 pro aktivem
// Subnet-Interface (gescopt auf die LAN-iface, NICHT global/WAN).
var dhcpEnabled bool
if err := g.Pool.QueryRow(ctx, `SELECT enabled FROM dhcp_settings WHERE id=1`).Scan(&dhcpEnabled); err == nil && dhcpEnabled {
rows, err := g.Pool.Query(ctx, `SELECT DISTINCT interface_name FROM dhcp_subnets WHERE active AND interface_name <> ''`)
if err == nil {
defer rows.Close()
for rows.Next() {
var iface string
if rows.Scan(&iface) == nil && iface != "" {
out = append(out, AutoFWRule{Proto: "udp", Port: 67, Iface: iface, Comment: "DHCP (Kea) auf " + iface})
}
}
}
}
// RADIUS (FreeRADIUS): wenn aktiviert → udp 1812 (auth) + 1813 (acct).
// Pro listen-IP, sonst global. FreeRADIUS verwirft unbekannte Clients selbst.
var radiusEnabled bool
var radiusListen string
if err := g.Pool.QueryRow(ctx, `SELECT enabled, listen_addresses FROM radius_settings WHERE id=1`).Scan(&radiusEnabled, &radiusListen); err == nil && radiusEnabled {
ips := splitCSV(radiusListen)
emit := func(ip string) {
out = append(out,
AutoFWRule{Proto: "udp", Port: 1812, DstIP: ip, Comment: "RADIUS-Auth (FreeRADIUS)"},
AutoFWRule{Proto: "udp", Port: 1813, DstIP: ip, Comment: "RADIUS-Acct (FreeRADIUS)"},
)
}
if len(ips) == 0 {
emit("")
} else {
for _, ip := range ips {
if !isLoopback(ip) && ip != "0.0.0.0" && ip != "::" {
emit(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.")
}
// addrFamily klassifiziert einen nft-Adressausdruck (host, CIDR oder
// range "a-b") als "ip" (IPv4), "ip6" (IPv6) oder "" (unbestimmt, z.B.
// FQDN-Platzhalter). Adressen enthalten selbst kein '-', daher trennt der
// erste Bindestrich sicher eine Range in ihr erstes Element.
func addrFamily(expr string) string {
expr = strings.TrimSpace(expr)
if expr == "" {
return ""
}
if i := strings.IndexByte(expr, '-'); i > 0 {
expr = strings.TrimSpace(expr[:i])
}
if i := strings.IndexByte(expr, '/'); i > 0 {
expr = expr[:i]
}
ip := net.ParseIP(expr)
if ip == nil {
return ""
}
if ip.To4() != nil {
return "ip"
}
return "ip6"
}
// splitByFamily teilt eine Liste von nft-Adressausdrücken in v4 und v6.
// Unbestimmte (FQDN o.ä.) werden verworfen.
func splitByFamily(exprs []string) (v4, v6 []string) {
for _, e := range exprs {
switch addrFamily(e) {
case "ip":
v4 = append(v4, e)
case "ip6":
v6 = append(v6, e)
}
}
return v4, v6
}
// serviceL3: icmp ist v4-only, icmpv6 v6-only, tcp/udp/leer agnostisch.
func serviceL3(svc ResolvedService) string {
switch svc.Proto {
case "icmp":
return "ip"
case "icmpv6":
return "ip6"
default:
return ""
}
}
// natFamily ermittelt die Adressfamilie einer NAT-Regel aus ihren
// Adressen. ok=false bei gemischten v4/v6-Adressen (ungültig → die Regel
// muss übersprungen werden, sonst bricht `nft -f` das gesamte Ruleset).
func natFamily(r ResolvedNATRule) (fam string, ok bool) {
for _, a := range []string{r.SrcCIDR, r.DstCIDR, r.TargetAddr} {
f := addrFamily(a)
if f == "" {
continue
}
if fam == "" {
fam = f
} else if fam != f {
return "", false
}
}
if fam == "" {
fam = "ip" // keine Adressen (reine iface/proto-Regel) → v4-Default
}
return fam, true
}
// expandFamilyLegs materialisiert die nft-Zeilen für eine Regel + optional
// einen Service, getrennt nach Adressfamilie. Adresslose Regeln ergeben eine
// einzige familienagnostische Zeile (unverändertes v4-Verhalten, greift
// zugleich für v6). Regeln mit Adressen werden pro Familie als separate
// Zeile emittiert — ein nft-Paket ist immer entweder v4 oder v6.
func expandFamilyLegs(r ResolvedRule, svc ResolvedService, hasSvc bool) []RuleLeg {
base := RuleLeg{
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name, Comment: r.Comment,
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
}
if hasSvc {
base.Service = svc
}
if len(r.SrcAddrs) == 0 && len(r.DstAddrs) == 0 {
// Kein Adress-Match → eine Zeile, L3 leer. Die Proto-Render-Logik
// im Template setzt icmp/icmpv6 selbst familienkorrekt.
return []RuleLeg{base}
}
src4, src6 := splitByFamily(r.SrcAddrs)
dst4, dst6 := splitByFamily(r.DstAddrs)
svcFam := ""
if hasSvc {
svcFam = serviceL3(svc)
}
var legs []RuleLeg
for _, fam := range []string{"ip", "ip6"} {
if svcFam != "" && svcFam != fam {
continue // icmp nur auf v4, icmpv6 nur auf v6
}
srcF, dstF := src4, dst4
if fam == "ip6" {
srcF, dstF = src6, dst6
}
// Eine eingeschränkte Seite ohne Mitglied dieser Familie → die
// Zeile würde nichts (oder Falsches) matchen → überspringen.
if len(r.SrcAddrs) > 0 && len(srcF) == 0 {
continue
}
if len(r.DstAddrs) > 0 && len(dstF) == 0 {
continue
}
leg := base
leg.L3 = fam
leg.SrcAddrs = srcF
leg.DstAddrs = dstF
legs = append(legs, leg)
}
return legs
}
// 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]
}
fam, ok := natFamily(r)
if !ok {
// Gemischte v4/v6-Adressen → ungültige NAT-Regel. Überspringen
// statt das gesamte Ruleset mit `nft -f` zu brechen.
slog.Warn("firewall: NAT-Regel mit gemischten v4/v6-Adressen übersprungen", "id", r.ID)
continue
}
r.L3 = fam
r.TargetHost = r.TargetAddr
if fam == "ip6" && r.TargetAddr != "" && r.TargetPortStart > 0 {
// nft braucht [v6]:port für dnat-Targets mit Port.
r.TargetHost = "[" + r.TargetAddr + "]"
}
out = append(out, r)
}
return out, rows.Err()
}