Build Housecarl 0.1.0: the nftables firewall manager

Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and
services, ordered rules in the early, input, forward and output chains,
port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects,
and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS
clamping, DHCPv6 client, flowtable). Every change is validated, checked
with nft -c and applied in one transaction to Housecarl's own table,
inet housecarl; changes made over the network roll back unless
confirmed, and the confirmed ruleset is loaded at boot by
housecarl-ruleset.service. An empty configuration applies nothing.

Also: hit counters and the drop log (nflog), 'hc import' of an existing
nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web
UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's
certificate through Jarl, Debian packages, docs, unit tests with real
nft 1.1.3, and an e2e test with real traffic (71 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 07:57:14 +01:00
1 parent 54beb0bba3
commit 34292eeebb
118 files changed
+22165 -114

No files matched your search

+1
View File
@@ -0,0 +1 @@
{"nftables": [{"metainfo": {"version": "1.1.3", "release_name": "Commodore Bullmoose #4", "json_schema_version": 1}}, {"table": {"family": "inet", "name": "main", "handle": 1}}, {"chain": {"family": "inet", "table": "main", "name": "antispoof", "handle": 1, "type": "filter", "hook": "prerouting", "prio": -300, "policy": "accept"}}, {"chain": {"family": "inet", "table": "main", "name": "input", "handle": 2, "type": "filter", "hook": "input", "prio": 0, "policy": "drop"}}, {"chain": {"family": "inet", "table": "main", "name": "wg0_input", "handle": 3}}, {"chain": {"family": "inet", "table": "main", "name": "prerouting", "handle": 4, "type": "nat", "hook": "prerouting", "prio": -100, "policy": "accept"}}, {"chain": {"family": "inet", "table": "main", "name": "postrouting", "handle": 5, "type": "nat", "hook": "postrouting", "prio": 100, "policy": "accept"}}, {"chain": {"family": "inet", "table": "main", "name": "forward", "handle": 6, "type": "filter", "hook": "forward", "prio": 0, "policy": "drop"}}, {"chain": {"family": "inet", "table": "main", "name": "output", "handle": 7, "type": "filter", "hook": "output", "prio": 0, "policy": "accept"}}, {"rule": {"family": "inet", "table": "main", "chain": "antispoof", "handle": 9, "comment": "Private/bogus source addresses can't legitimately arrive from the internet", "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": {"set": [{"prefix": {"addr": "0.0.0.0", "len": 8}}, {"prefix": {"addr": "10.0.0.0", "len": 8}}, {"prefix": {"addr": "127.0.0.0", "len": 8}}, {"prefix": {"addr": "169.254.0.0", "len": 16}}, {"prefix": {"addr": "172.16.0.0", "len": 12}}, {"prefix": {"addr": "192.168.0.0", "len": 16}}]}}}, {"counter": {"packets": 0, "bytes": 0}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "antispoof", "handle": 10, "comment": "Our own delegated prefix can't legitimately arrive from the internet", "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "ip6", "field": "saddr"}}, "right": {"prefix": {"addr": "2a02:8013:7f3d::", "len": 48}}}}, {"counter": {"packets": 0, "bytes": 0}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 11, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "lo"}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 12, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": ["established", "related"]}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 13, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": "invalid"}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 15, "expr": [{"match": {"op": "!=", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "icmp", "field": "type"}}, "right": {"set": ["echo-reply", "echo-request"]}}}, {"counter": {"packets": 0, "bytes": 0}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 17, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "icmpv6", "field": "type"}}, "right": {"set": ["destination-unreachable", "packet-too-big", "time-exceeded", "parameter-problem", "echo-request", "echo-reply"]}}}, {"counter": {"packets": 0, "bytes": 0}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 19, "comment": "IPv6 neighbour discovery", "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "icmpv6", "field": "type"}}, "right": {"set": ["nd-router-solicit", "nd-router-advert", "nd-neighbor-solicit", "nd-neighbor-advert"]}}}, {"match": {"op": "==", "left": {"payload": {"protocol": "ip6", "field": "hoplimit"}}, "right": 255}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 20, "comment": "DHCPv6 replies from Zen", "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "udp", "field": "dport"}}, "right": 546}}, {"match": {"op": "==", "left": {"payload": {"protocol": "ip6", "field": "saddr"}}, "right": {"prefix": {"addr": "fe80::", "len": 10}}}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 21, "comment": "Accept new incoming wireguard connections", "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "udp", "field": "dport"}}, "right": 51820}}, {"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": "new"}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 22, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "Line truncated
+142
View File
@@ -0,0 +1,142 @@
#! /sbin/nft -f
flush ruleset
define wan_int = "wan"
define wan_ip = 217.155.8.194
define allowed_icmp_types = { echo-request, echo-reply }
define allowed_icmpv6_types = { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, echo-request, echo-reply }
define trusted_ips = { 10.0.10.0/24, 10.20.30.0/24, 10.55.55.0/24, 10.1.1.0/24, 10.65.65.0/24, 10.60.60.0/24, 10.50.50.0/24}
define masq_to_internet = { 10.0.10.0/24, 10.20.30.0/24, 10.55.55.0/24, 10.1.1.0/24, 10.65.65.0/24, 10.60.60.0/24 }
define bogons = { 0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16 }
define internal_net = 10.0.10.0/24
define lan6_net = 2a02:8013:7f3d:10::/64
define my_prefix6 = 2a02:8013:7f3d::/48
define wg0_int = wg0
define wg0_ip = 10.50.50.1
define wg0_net = 10.50.50.0/24
define ekko_wg0 = 10.50.50.2
define epcc_wg0 = 10.50.50.3
define oneplus9_wg0 = 10.50.50.4
define dhcp_int = enp2s0
define heimer_incus = 10.20.30.0/24
define garen_incus = 10.55.55.0/24
define jellyfin_ip = 10.0.10.57
define jellyfin_port = 8096
define haproxy_ip = 10.0.10.37
define checkmk_server = 10.20.30.58
define oracle_wg0 = 10.50.50.6/32
define slskd_ip = 10.0.10.35
define slskd_port = 50300
define terraria_port = { 7777 }
define ula_prefix = fdab:abab:abab::/48
define lan6_ula = fdab:abab:abab:10::/64
define dot_doh_ports = { 8443, 853 }
define dot_doh_ip4 = { 10.0.10.5, 217.155.8.194 }
define dot_doh_ip6 = { fdab:abab:abab:10::5, 2a02:8013:7f3d:10::5 }
define jf_ipv6 = 2a02:8013:7f3d:10::57
table inet main {
chain antispoof {
type filter hook prerouting priority raw; policy accept;
iifname $wan_int ip saddr $bogons counter drop comment "Private/bogus source addresses can't legitimately arrive from the internet"
iifname $wan_int ip6 saddr $my_prefix6 counter drop comment "Our own delegated prefix can't legitimately arrive from the internet"
}
chain input {
type filter hook input priority filter; policy drop;
iifname lo accept
ct state established,related accept
ct state invalid drop
iifname != $wan_int icmp type $allowed_icmp_types counter accept
icmpv6 type $allowed_icmpv6_types counter accept
icmpv6 type { nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } ip6 hoplimit 255 accept comment "IPv6 neighbour discovery"
iifname $wan_int udp dport 546 ip6 saddr fe80::/10 accept comment "DHCPv6 replies from Zen"
udp dport 51820 ct state new accept comment "Accept new incoming wireguard connections"
ip saddr $wg0_net jump wg0_input
udp dport 53 accept comment "Accept DNS Traffic UDP"
tcp dport 53 accept comment "Accept DNS Traffic TCP"
tcp dport $dot_doh_ports ip daddr $dot_doh_ip4 accept comment "Accept DoT and DoH Traffic"
tcp dport $dot_doh_ports ip6 daddr $dot_doh_ip6 accept comment "Accept DoT and DoH Traffic"
#ip saddr $trusted_ips udp dport 53 accept comment "Accept DNS Traffic UDP"
#ip saddr $trusted_ips tcp dport 53 accept comment "Accept DNS Traffic TCP"
ip saddr $trusted_ips tcp dport 8053 accept comment "Accept Jarl traffic TCP"
ip saddr $trusted_ips tcp dport 8067 accept comment "Accept Steward traffic TCP"
ip saddr $trusted_ips tcp dport 22 accept
ip6 saddr $lan6_net udp dport 53 accept comment "Accept DNS Traffic UDP (IPv6)"
ip6 saddr $lan6_net tcp dport 53 accept comment "Accept DNS Traffic TCP (IPv6)"
ip6 saddr $lan6_net tcp dport 22 accept
iifname $dhcp_int udp dport 67 accept comment "Accept DHCP"
iifname $dhcp_int udp dport 547 ip6 saddr fe80::/10 accept comment "Accept DHCPv6"
ip saddr $checkmk_server tcp dport 6556 accept comment "From Checkmk To Agent"
}
chain wg0_input {
udp dport 53 accept
tcp dport 53 accept
ip saddr { $epcc_wg0, $oracle_wg0 } tcp dport 22 accept
ip saddr $wg0_net ip daddr $wg0_ip drop comment "Block WireGuard clients from accessing the server itself"
return
}
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
# Matching on the public IP (not the WAN interface) covers both internet traffic and hairpin traffic from inside
ip daddr $wan_ip tcp dport $jellyfin_port dnat ip to $jellyfin_ip:443 comment "Port Forward Jellyfin"
ip daddr $wan_ip tcp dport 443 dnat ip to $haproxy_ip:443 comment "Port Forward Haproxy"
ip daddr $wan_ip tcp dport $slskd_port dnat ip to $slskd_ip:$slskd_port comment "Port Forward soulseek"
ip daddr $wan_ip tcp dport $terraria_port dnat ip to 10.0.10.76 comment "Port Forward terraria"
ip daddr $wan_ip tcp dport 25 dnat ip to 10.0.10.25 comment "Port Forward SMTP"
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname $wan_int ip saddr $masq_to_internet masquerade comment "external masquerade"
oifname != $wg0_int ip saddr $wg0_net masquerade
# Same-subnet clients would otherwise get replies direct from the server, bypassing the router's NAT
ip saddr $internal_net ip daddr { $haproxy_ip, $jellyfin_ip, $slskd_ip, 10.0.10.76, 10.0.10.25 } ct status dnat masquerade comment "Hairpin NAT same-subnet"
}
chain forward {
type filter hook forward priority filter; policy drop;
# PPPoE MTU is smaller than ethernet; must sit before the established rule so SYN-ACKs get clamped too
oifname $wan_int tcp flags syn tcp option maxseg size set rt mtu comment "PPPoE MSS clamping"
ct state established,related accept
iifname != $wan_int ct state invalid ip daddr $trusted_ips accept comment "Needed due to asymmetric routing (internal only)"
ct state invalid drop
iifname $dhcp_int oifname $wan_int ip6 saddr $lan6_net accept comment "LAN IPv6 out"
# From the internet, only allow traffic that was port forwarded
iifname $wan_int ip daddr $jellyfin_ip tcp dport 443 ct status dnat accept comment "Forward jellyfin traffic"
iifname $wan_int ip daddr $haproxy_ip tcp dport 443 ct status dnat accept comment "Forward Haproxy traffic"
iifname $wan_int ip daddr $slskd_ip tcp dport $slskd_port ct status dnat accept comment "Forward slskd traffic"
iifname $wan_int ip daddr 10.0.10.76 tcp dport $terraria_port ct status dnat accept comment "Forward terraria traffic"
iifname $wan_int ip daddr 10.0.10.25 tcp dport 25 ct status dnat accept comment "Forward SMTP"
ip6 daddr $jf_ipv6 tcp dport 443 accept
iifname $wan_int counter drop comment "Nothing else gets in from the internet"
ip daddr $wg0_net drop comment "Drop client-to-client traffic for wg0 network"
icmp type $allowed_icmp_types accept
ip saddr $wg0_net accept comment "Forward from wg0 network"
ip saddr $trusted_ips accept comment "Forward from trusted IPs"
}
chain output {
type filter hook output priority filter; policy accept;
}
}
+81
View File
@@ -0,0 +1,81 @@
table inet main { # handle 1
chain antispoof { # handle 1
type filter hook prerouting priority raw; policy accept;
iifname "wan" ip saddr { 0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16 } counter packets 0 bytes 0 drop comment "Private/bogus source addresses can't legitimately arrive from the internet" # handle 9
iifname "wan" ip6 saddr 2a02:8013:7f3d::/48 counter packets 0 bytes 0 drop comment "Our own delegated prefix can't legitimately arrive from the internet" # handle 10
}
chain input { # handle 2
type filter hook input priority filter; policy drop;
iifname "lo" accept # handle 11
ct state established,related accept # handle 12
ct state invalid drop # handle 13
iifname != "wan" icmp type { echo-reply, echo-request } counter packets 0 bytes 0 accept # handle 15
icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, echo-request, echo-reply } counter packets 0 bytes 0 accept # handle 17
icmpv6 type { nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } ip6 hoplimit 255 accept comment "IPv6 neighbour discovery" # handle 19
iifname "wan" udp dport 546 ip6 saddr fe80::/10 accept comment "DHCPv6 replies from Zen" # handle 20
udp dport 51820 ct state new accept comment "Accept new incoming wireguard connections" # handle 21
ip saddr 10.50.50.0/24 jump wg0_input # handle 22
udp dport 53 accept comment "Accept DNS Traffic UDP" # handle 23
tcp dport 53 accept comment "Accept DNS Traffic TCP" # handle 24
tcp dport { 853, 8443 } ip daddr { 10.0.10.5, 217.155.8.194 } accept comment "Accept DoT and DoH Traffic" # handle 27
tcp dport { 853, 8443 } ip6 daddr { 2a02:8013:7f3d:10::5, fdab:abab:abab:10::5 } accept comment "Accept DoT and DoH Traffic" # handle 30
ip saddr { 10.0.10.0/24, 10.1.1.0/24, 10.20.30.0/24, 10.50.50.0/24, 10.55.55.0/24, 10.60.60.0/24, 10.65.65.0/24 } tcp dport 8053 accept comment "Accept Jarl traffic TCP" # handle 32
ip saddr { 10.0.10.0/24, 10.1.1.0/24, 10.20.30.0/24, 10.50.50.0/24, 10.55.55.0/24, 10.60.60.0/24, 10.65.65.0/24 } tcp dport 8067 accept comment "Accept Steward traffic TCP" # handle 34
ip saddr { 10.0.10.0/24, 10.1.1.0/24, 10.20.30.0/24, 10.50.50.0/24, 10.55.55.0/24, 10.60.60.0/24, 10.65.65.0/24 } tcp dport 22 accept # handle 36
ip6 saddr 2a02:8013:7f3d:10::/64 udp dport 53 accept comment "Accept DNS Traffic UDP (IPv6)" # handle 37
ip6 saddr 2a02:8013:7f3d:10::/64 tcp dport 53 accept comment "Accept DNS Traffic TCP (IPv6)" # handle 38
ip6 saddr 2a02:8013:7f3d:10::/64 tcp dport 22 accept # handle 39
iifname "enp2s0" udp dport 67 accept comment "Accept DHCP" # handle 40
iifname "enp2s0" udp dport 547 ip6 saddr fe80::/10 accept comment "Accept DHCPv6" # handle 41
ip saddr 10.20.30.58 tcp dport 6556 accept comment "From Checkmk To Agent" # handle 42
}
chain wg0_input { # handle 3
udp dport 53 accept # handle 43
tcp dport 53 accept # handle 44
ip saddr { 10.50.50.3, 10.50.50.6 } tcp dport 22 accept # handle 46
ip saddr 10.50.50.0/24 ip daddr 10.50.50.1 drop comment "Block WireGuard clients from accessing the server itself" # handle 47
return # handle 48
}
chain prerouting { # handle 4
type nat hook prerouting priority dstnat; policy accept;
ip daddr 217.155.8.194 tcp dport 8096 dnat ip to 10.0.10.57:443 comment "Port Forward Jellyfin" # handle 49
ip daddr 217.155.8.194 tcp dport 443 dnat ip to 10.0.10.37:443 comment "Port Forward Haproxy" # handle 50
ip daddr 217.155.8.194 tcp dport 50300 dnat ip to 10.0.10.35:50300 comment "Port Forward soulseek" # handle 51
ip daddr 217.155.8.194 tcp dport 7777 dnat ip to 10.0.10.76 comment "Port Forward terraria" # handle 52
ip daddr 217.155.8.194 tcp dport 25 dnat ip to 10.0.10.25 comment "Port Forward SMTP" # handle 53
}
chain postrouting { # handle 5
type nat hook postrouting priority srcnat; policy accept;
oifname "wan" ip saddr { 10.0.10.0/24, 10.1.1.0/24, 10.20.30.0/24, 10.55.55.0/24, 10.60.60.0/24, 10.65.65.0/24 } masquerade comment "external masquerade" # handle 55
oifname != "wg0" ip saddr 10.50.50.0/24 masquerade # handle 56
ip saddr 10.0.10.0/24 ip daddr { 10.0.10.25, 10.0.10.35, 10.0.10.37, 10.0.10.57, 10.0.10.76 } ct status dnat masquerade comment "Hairpin NAT same-subnet" # handle 58
}
chain forward { # handle 6
type filter hook forward priority filter; policy drop;
oifname "wan" tcp flags syn tcp option maxseg size set rt mtu comment "PPPoE MSS clamping" # handle 59
ct state established,related accept # handle 60
iifname != "wan" ct state invalid ip daddr { 10.0.10.0/24, 10.1.1.0/24, 10.20.30.0/24, 10.50.50.0/24, 10.55.55.0/24, 10.60.60.0/24, 10.65.65.0/24 } accept comment "Needed due to asymmetric routing (internal only)" # handle 62
ct state invalid drop # handle 63
iifname "enp2s0" oifname "wan" ip6 saddr 2a02:8013:7f3d:10::/64 accept comment "LAN IPv6 out" # handle 64
iifname "wan" ip daddr 10.0.10.57 tcp dport 443 ct status dnat accept comment "Forward jellyfin traffic" # handle 65
iifname "wan" ip daddr 10.0.10.37 tcp dport 443 ct status dnat accept comment "Forward Haproxy traffic" # handle 66
iifname "wan" ip daddr 10.0.10.35 tcp dport 50300 ct status dnat accept comment "Forward slskd traffic" # handle 67
iifname "wan" ip daddr 10.0.10.76 tcp dport 7777 ct status dnat accept comment "Forward terraria traffic" # handle 68
iifname "wan" ip daddr 10.0.10.25 tcp dport 25 ct status dnat accept comment "Forward SMTP" # handle 69
ip6 daddr 2a02:8013:7f3d:10::57 tcp dport 443 accept # handle 70
iifname "wan" counter packets 0 bytes 0 drop comment "Nothing else gets in from the internet" # handle 71
ip daddr 10.50.50.0/24 drop comment "Drop client-to-client traffic for wg0 network" # handle 72
icmp type { echo-reply, echo-request } accept # handle 74
ip saddr 10.50.50.0/24 accept comment "Forward from wg0 network" # handle 75
ip saddr { 10.0.10.0/24, 10.1.1.0/24, 10.20.30.0/24, 10.50.50.0/24, 10.55.55.0/24, 10.60.60.0/24, 10.65.65.0/24 } accept comment "Forward from trusted IPs" # handle 77
}
chain output { # handle 7
type filter hook output priority filter; policy accept;
}
}