Import: keep what rulesets mean, name zones as asked

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>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-20 08:43:42 +01:00
1 parent c17f5cd9f0
commit 47bae07d42
15 files changed
+823 -117

No files matched your search

+76 -15
View File
@@ -18,11 +18,9 @@ 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 get your
names (`define admins = { ... }` becomes the set `admins`).
Without it, sets are named after what they hold. Interfaces named by a
define name their zone too (`define wan_interface = "ppp0"` makes the
zone `wan`); otherwise a zone is named after its interface.
`--defines` reads your ruleset file's `define` lines, so sets and zones
get your names ([How things are named](#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`:
@@ -37,6 +35,46 @@ 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:
1. **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`).
2. **After a define holding the interface**, with a trailing `_int`,
`_if`, `_iface`, `_ifname`, `_interface`, `_dev`, `_device` or `_nic`
taken off: `define wan_int = "ppp0"` makes the zone `wan`, `define
iot_if = enp2s0.20` (a VLAN) the zone `iot`.
3. **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
@@ -45,7 +83,11 @@ Import a ruleset.
* **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.
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
@@ -56,10 +98,15 @@ Import a ruleset.
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. The forward-chain accepts that let them through
become part of the port forward, and a masquerade for inside sources
to the same host becomes its hairpin. An IPv6 accept to the same port
becomes the forward's IPv6 pinhole when there is only one candidate.
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 without
`ct status dnat` lets 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
@@ -78,13 +125,27 @@ Import a ruleset.
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](rules.md#bandwidth-and-quotas)).
* **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 raw rule naming a set of the old table needs the new set name:
Housecarl's are `NAME_v4` and `NAME_v6`, and a note says so.
* **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_v4` or `@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.
Chains on hooks Housecarl doesn't manage (ingress, a bridge table) are
left out, with a note.
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