Fixes from an audit of the importer, each of which could change what an imported ruleset does: two base chains on one hook imported their rules twice (and hoisted the second's built-ins), now in priority order with the stricter policy; raw rules from a jumped-to chain lost the jump's condition, now put in front; a jump and a rule matching one field are combined only when one's addresses lie within the other's; a second match on a field replaced the first, and != on hop limit and TCP flags read as ==, now kept raw; a forward accept without ct status dnat no longer folds into the port forward, nor an interface-limited IPv6 accept into an open pinhole; raw rules' set references are renamed (NAME_v4/_v6) or written out, so nftables takes them; DNATs with matches a forward can't hold are left out with a note. Sets with flags timeout become dynamic sets; a DNAT of several ports one forward per port. Zone names: --zone IFACE=NAME (and on the Import page), VLAN defines, more suffixes, and a note on where each name came from. testdata/audit.nft covers the cases, checked with nft 1.1.3. make test and make e2e (246 checks) pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9.5 KiB
Importing an nftables ruleset
hc import turns the ruleset you have into Housecarl's configuration,
and hc takeover deletes the old table once Housecarl's replaces it.
Until then both apply.
1. Look first
On the firewall, as root:
sudo hc import --defines /etc/nftables.conf -n
hc runs nft -j list ruleset (and nft -a list ruleset, for the text
of rules it can't translate) and sends both to housecarld. -n (dry run)
shows what it would make, without saving anything: zones, sets,
services, rules, port forwards and NAT, then notes on every guess it
made, and the rules it kept raw.
--defines reads your ruleset file's define lines, so sets and zones
get your names (How things are named).
--zone enp2s0=lan names a zone yourself.
From another machine, give the JSON as a file, and the text with
--text:
ssh fw 'sudo nft -j list ruleset' > ruleset.json
ssh fw 'sudo nft -a list ruleset' > ruleset.txt
hc import ruleset.json --text ruleset.txt --defines nftables.conf -n
With several tables, --table "inet main" picks one; by default every
table but Housecarl's is read. The web UI has the same under Server →
Import a ruleset.
How things are named
Zones are named in this order:
- As you say:
--zone enp2s0=lan(repeat it; Zone names on the web UI's Import page). Interfaces given the same name share one zone (--zone enp2s0=lan --zone enp3s0=lan). - After a define holding the interface, with a trailing
_int,_if,_iface,_ifname,_interface,_dev,_deviceor_nictaken off:define wan_int = "ppp0"makes the zonewan,define iot_if = enp2s0.20(a VLAN) the zoneiot. - After the interface itself:
enp2s0.
If the name is taken, the zone is called INTERFACE_zone. A define
names what it was for in your old ruleset, which isn't always what the
network is (define dhcp_int = enp2s0 makes dhcp, for your LAN). A
note lists each zone and where its name came from; rename one with
--zone and import again, or afterwards with hc zone update dhcp --name lan (its rules follow). Name zones for what the network is for:
lan, iot, guest, servers, vpn, wan.
Address sets: a named set of the table keeps its name; a define
holding a list of addresses, or one prefix, gives its name to a set
(define trusted = { 10.0.10.0/24, 10.1.1.0/24 } makes trusted,
wherever those addresses appear together). A single address stays an
address. When two defines hold the same addresses, the first by name
wins.
Services: a define holding several ports names a service (define web_ports = { 80, 443 } makes web_ports); one port stays a port
(tcp/22).
Port forwards are named after their comment, without a leading
"Port forward" (comment "Port Forward Jellyfin" makes jellyfin);
several ports in one DNAT become one forward each (web-80, web-443),
and a DNAT map's elements are named after the map (forwards-443).
Names that aren't valid are made so: other characters become _ (-
in forward names), and a name must start with a letter.
What becomes what
- Interfaces become zones. Zones are marked external when the rules show it: MSS clamping on the way out, DHCPv6 client replies, ping accepted from everywhere else.
- Sets and defines of addresses become address sets; port lists become services.
- Jumps and gotos to your own chains are flattened: the jump's condition is added to each rule of the chain, in place. When the jump and a rule both match sources (or destinations), the narrower is kept if one lies within the other; otherwise, and for any rule there that stays raw, the jump's condition is written in front of the raw rule's text, so it still only applies where the jump did.
- Rules keep their order. A rule that is a built-in (loopback, established, invalid, the ICMPv6 rules, MSS clamping, the DHCPv6 client rule, antispoofing) becomes that built-in only when it sits where the built-in goes, at the head of every chain it covers. Otherwise it stays a rule where it was, so the ruleset does what it did. Since built-ins come before every rule, once one at a chain's head stays a rule, the ones after it there stay rules too, in order: an invalid drop that stays a rule keeps the ICMPv6 accepts after it behind it, as they were. Built-ins your ruleset doesn't have are turned off.
- DNAT rules become port forwards, TCP and UDP of the same port
merged into one, one per port when a rule has several. The
forward-chain accepts that let them through (
ct status dnat) become part of the port forward; an accept to the same host and port withoutct status dnatlets in more than forwarded connections, and stays a rule. A masquerade for inside sources to the same host becomes its hairpin. An IPv6 accept to the same port, from everywhere, becomes the forward's IPv6 pinhole when there is only one candidate. A DNAT to an IPv6 address, or with matches a port forward has no field for, is left out with a note. - Maps are expanded, one element at a time. A DNAT map
(
dnat ip to tcp dport map @forwards, named or written in the rule) becomes one port forward per element; forwards from a named map are called after it (forwards-443). A verdict map (tcp dport vmap { 22 : accept, 23 : drop }) becomes one rule per element, in place, withjumps inlined as above. A lookup matches at most one element, so the rules do the same. A map keyed by several fields (ip saddr . tcp dport) can't be expanded: a rule using a named one is left out with a note, to be added by hand. - Masquerade and SNAT become source NAT; redirect becomes a redirect.
- Meters (
update @m { ip saddr limit rate over 3/minute },add @m { ip saddr ct count over 20 }, quotas in sets) become the rule's per-source (or per-destination) limit, connection limit or quota; a mask in the key (ip saddr and 255.255.255.0) becomes its grouping. Byte rates and quotas are imported too; a quota imported resets only by hand until you give it a period. A rule with a quota or a byte rate that accepts stays a raw rule (rules.md). - Sets with
flags timeout(filled by a program or a rule) become dynamic sets, without their entries of the moment: whatever fills them must fill Housecarl's from then on, as a note says. - Policies become the chains' policies.
- Anything else becomes a raw rule with its original text, in place:
a match Housecarl has no field for, two matches on the same field
(
ip saddr 10.0.0.0/8 ip saddr != 10.0.10.0/24),!=where Housecarl only has==, a set of interface names. Its set references are made to work in Housecarl's table: an imported address set becomes@NAME_v4or@NAME_v6, any other set (interface names, ports) is written out with its elements. A raw rule naming a set that can't be (a meter, an empty set) is left out with a note. - Logging rules log to Housecarl's log (
hc log), not the kernel's.
Several base chains on one hook (input at priority -10 and at 0) are
put one after the other, by priority, into Housecarl's one chain, with
the stricter policy; a note says to check it, since a packet had to get
past each, and an accept in the first no longer ends there. A prerouting
filter chain after connection tracking becomes the early chain, which
runs before it: a note says so. Chains on hooks Housecarl doesn't manage
(ingress, a bridge table) are left out, with a note.
2. Import
sudo hc import --defines /etc/nftables.conf
sudo hc ruleset # what Housecarl now applies
sudo hc status # problems, and the other tables
Housecarl's table is now in force alongside the old one: a packet
must be accepted by both. Nothing should change for your traffic. If
Housecarl already has a configuration, --replace replaces it.
Imported over the network (not through the local socket), the import is on probation like any change: confirm it.
3. Take over
Once the rules look right (and their hits in the web UI agree), delete the old table:
sudo hc takeover "inet main"
From now on only Housecarl's rules apply. Until you do, the dashboard
and hc status warn that the imported table still applies
(takeover_pending); they also warn while nftables.service is enabled
(nftables_service). Over the network, this is on
probation too: unless confirmed, the old table comes back.
4. The boot
Housecarl's ruleset is loaded at boot by housecarl-ruleset.service.
Debian's nftables.service loads /etc/nftables.conf, which usually
starts with flush ruleset: that would delete Housecarl's table, or
bring the old one back. Retire it:
sudo systemctl disable nftables.service
sudo mv /etc/nftables.conf /etc/nftables.conf.before-housecarl
Disable it, but don't stop it (no systemctl stop nftables, no
disable --now): Debian's unit stops by running nft flush ruleset,
which deletes every table, Housecarl's and Docker's included, at once.
Disabled, it simply doesn't run at the next boot.
housecarl-ruleset.service runs after nftables.service in any case,
so a leftover flush ruleset can't undo it. Docker, Incus and libvirt
keep adding their own tables as before.
Checking the result
hc rule list input(andforward) side by side withnft -a list table inet main: the same rules, in the same order, with built-ins above.- The notes: each says what to check.
hc countersafter a while: a rule with no hits that had hits before is worth a look.