Verwalteter DHCPv4-Server analog Unbound/Squid/Chrony. - Migration 0041: dhcp_settings (singleton, node-lokal), dhcp_subnets, dhcp_reservations. - internal/kea: Renderer baut Kea-JSON via Go-Struct→Marshal (garantiert valide), managed /etc/edgeguard/kea/kea-dhcp4.conf (Symlink von /etc/kea), Service-Lifecycle an enabled gekoppelt (default AUS, kein rogue DHCP). Interface per NAME (cluster-sicher, kein node-lokaler FK). - internal/services/dhcp + internal/handlers/dhcp.go: Settings + Subnet/Reservation-CRUD, Validierung (CIDR/IP/MAC/interface exists). - configgen: Stop/Enable/DisableService. Firewall: AutoFWRule.Iface → udp/67 pro LAN-Interface gescopt (kein WAN). Cluster: subnets/reservations repliziert (hashSpec), dhcp_settings node-lokal (localOnlyTables). - main.go + render.go + WithAllReloaders Wiring. Packaging: kea-dhcp4-server Dependency, /etc/edgeguard/kea Dir, Symlink, disable-on-install, sudoers (restart/stop/enable/disable). - UI: DHCP-Seite (Settings + Subnets + Reservierungen pro Subnet), Route/Nav/i18n de/en, HA-Warnung 'nur auf einer Node aktivieren'. - Tests (guarded EG_FWTEST_DSN): Kea-Renderer gegen DB (valides JSON + Felder), FW-Auto-Rule-Iface inkl. nft -c. Scope v1: DHCPv4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
861 lines
25 KiB
Go
861 lines
25 KiB
Go
// 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})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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()
|
||
}
|