Mehrere zusammenhängende Fehler beim Import der NAT-Rules von der
alten EdgeGuard-Box gefunden + behoben:
1. nft-Template: NAT-Rules landeten als Comment (gleicher
Whitespace-Trimmer-Bug wie bei den Operator-Rules vor zwei
commits). Fix: Body auf eigener Zeile via {{""}}-Padding.
2. nft-Syntax-Reihenfolge: emittierte 'tcp ip daddr X dport Y' →
parser-Fehler. Korrekt ist L3-match (ip saddr/daddr) zuerst,
dann L4 (tcp/udp dport). Reihenfolge in der dnat-Zeile
getauscht.
3. eth0 als Iface-Row hinzugefügt (Type ethernet, role wan) damit
der zone→iface-Lookup für 'wan' tatsächlich auf das Linux-Iface
trifft. Vorher war nur 'WAN'-bridge in der DB, das im Kernel
nicht existiert → iifname-match griff nicht.
4. forward-chain: ct status dnat accept (DNAT-Pakete dürfen
forwarden) + Auto-Forward pro SNAT/masquerade-Rule für die
Origin-Pakete (return geht via established,related).
5. postrouting_nat: ct status dnat masquerade als Hairpin-Catch-All
— sonst antwortet das DNAT-Ziel via seinem default-GW (oft
nicht zur EdgeGuard-Box) → SYN_SENT + UNREPLIED. Trade-off:
Backend sieht Box-IP statt client-IP.
6. Sysctl-Profil /etc/sysctl.d/99-edgeguard.conf bei jedem Install:
- Forwarding (ip_forward + ipv6 forwarding) — Voraussetzung für
ALLES NAT/DNAT/Masquerade.
- Conntrack-Buckets + max=524288 (Edge-Box trackt viele
parallele Sessions).
- HAProxy-Tuning (somaxconn 64k, rmem/wmem 16M, keepalive,
tcp_tw_reuse, ip_local_port_range).
- BBR + fq als modernes Congestion-Control + Queueing.
- Anti-DoS: tcp_syncookies, log_martians, kptr_restrict.
Verified end-to-end:
$ nc -v 89.163.205.100 2030
SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.16
Version 1.0.25.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
123 lines
6.2 KiB
Smarty
123 lines
6.2 KiB
Smarty
#!/usr/sbin/nft -f
|
|
# Generated by edgeguard-api — DO NOT EDIT.
|
|
# Source: internal/firewall/firewall.go.
|
|
# Re-generate via `edgeguard-ctl render-config` or via API mutations.
|
|
|
|
flush ruleset
|
|
|
|
table inet edgeguard {
|
|
set peer_ipv4 {
|
|
type ipv4_addr; flags interval
|
|
{{- if .PeerIPv4}}
|
|
elements = { {{range $i, $ip := .PeerIPv4}}{{if $i}}, {{end}}{{$ip}}{{end}} }
|
|
{{- end}}
|
|
}
|
|
set peer_ipv6 {
|
|
type ipv6_addr; flags interval
|
|
{{- if .PeerIPv6}}
|
|
elements = { {{range $i, $ip := .PeerIPv6}}{{if $i}}, {{end}}{{$ip}}{{end}} }
|
|
{{- end}}
|
|
}
|
|
|
|
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: HAProxy public HTTPS"
|
|
tcp dport 3443 accept comment "anti-lockout: Management-UI (HAProxy admin HTTPS)"
|
|
|
|
# Stateful baseline
|
|
ct state established,related accept
|
|
ct state invalid drop
|
|
iif lo accept
|
|
|
|
# ICMP — keep PMTUD and basic diagnostics
|
|
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
|
|
|
|
# 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
|
|
|
|
# ── Operator-defined rules ──
|
|
{{range .Legs}}
|
|
# rule {{.RuleID}}{{if .Name}} ({{.Name}}){{end}}{{if .Comment}} — {{.Comment}}{{end}}
|
|
{{- /* Body MUSS auf EIGENER Zeile starten (nicht via {{- }} an
|
|
die Comment-Zeile angehängt — sonst frisst nft die rule
|
|
als Teil des # Kommentars). */ -}}
|
|
{{""}}
|
|
{{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
|
|
|
|
# DNAT-rewrites aus prerouting_nat haben den ct.status DNAT-Bit
|
|
# gesetzt — die müssen forward-passieren dürfen, sonst kommen
|
|
# Port-Forwards (z.B. :2030 → 10.10.20.12:22) zwar durch das
|
|
# NAT-Rewrite, scheitern aber an policy=drop. Equivalent zu
|
|
# iptables -m conntrack --ctstate DNAT.
|
|
ct status dnat accept
|
|
|
|
# Auto-Forward für SNAT/Masquerade-Origin-Pakete. Forward-chain
|
|
# sieht das Paket VOR der postrouting-Translation; ct.status ist
|
|
# also noch nicht "snat". Wir ziehen pro NAT-Rule das SrcCIDR
|
|
# nach und erlauben new-state-Pakete von dort. Return-Pakete
|
|
# gehen via ct state established schon durch.
|
|
{{range .NATRules}}{{if or (eq .Kind "snat") (eq .Kind "masquerade")}}{{if .SrcCIDR}}
|
|
ip saddr {{.SrcCIDR}} ct state new accept comment "auto-forward for NAT rule {{.ID}}"
|
|
{{end}}{{end}}{{end}}
|
|
}
|
|
|
|
chain output {
|
|
type filter hook output priority 0; policy accept;
|
|
}
|
|
|
|
chain prerouting_nat {
|
|
type nat hook prerouting priority -100;
|
|
{{range .NATRules}}{{if eq .Kind "dnat"}}
|
|
# NAT {{.ID}} (dnat{{if .Comment}} — {{.Comment}}{{end}})
|
|
{{""}}
|
|
{{/* nft-Syntax: erst L3-match (ip saddr/daddr), DANN L4 (tcp/udp dport).
|
|
Sonst quittiert der parser '... unexpected ip' an dieser Stelle. */}}
|
|
{{if .InIfaces}}iifname { {{join .InIfaces ", "}} } {{end}}{{if .SrcCIDR}}ip saddr {{.SrcCIDR}} {{end}}{{if .DstCIDR}}ip daddr {{.DstCIDR}} {{end}}{{if and .Proto (ne .Proto "any")}}{{.Proto}} {{else}}meta l4proto { tcp, udp } {{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;
|
|
|
|
# Auto-Hairpin für DNAT-Pakete: alle in prerouting_nat
|
|
# umgeschriebenen Pakete bekommen zusätzlich SNAT auf die
|
|
# Box-IP des ausgehenden Iface (masquerade). Sonst antwortet
|
|
# das DNAT-Ziel via seinem eigenen default-Gateway, das oft
|
|
# nicht zur EdgeGuard-Box zeigt → SYN_SENT + UNREPLIED.
|
|
# Trade-off: Backend sieht die Box-IP statt der echten
|
|
# client-IP (für Logging / Geo-Block: später optional via
|
|
# NAT-Rule-Flag preserve_client_ip).
|
|
ct status dnat masquerade
|
|
{{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}}
|
|
}
|
|
}
|
|
|