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

+13 -2
View File
@@ -434,6 +434,7 @@ func rollbackCmd() *cobra.Command {
func importCmd() *cobra.Command {
var replace bool
var table, defines, textFile string
var zones []string
cmd := &cobra.Command{
Use: "import [FILE|-]",
Short: "Import an nftables ruleset (nft -j JSON) as Housecarl's configuration",
@@ -445,7 +446,9 @@ forwards and NAT. The input is nft's JSON; on the firewall:
Without a FILE, hc runs 'nft -j list ruleset' and 'nft -a list ruleset'
itself (root on the firewall); from elsewhere, give the JSON as FILE and
the text with --text. --defines reads the ruleset file's define lines, to
name the sets as you did (admins, lan_net, …). -n shows the result without
name the sets as you did (admins, lan_net, …), and the zones; --zone
enp2s0=lan names one yourself (repeat; interfaces given one name share a
zone). -n shows the result without
saving; what can't be expressed becomes raw rules, listed with notes.
Once it looks right, import without -n, check it with 'hc ruleset', and
'hc takeover inet main' deletes the old table.`,
@@ -473,7 +476,14 @@ Once it looks right, import without -n, check it with 'hc ruleset', and
return err
}
}
in := api.ImportInput{JSON: data, Text: string(text), Table: table, Replace: replace}
in := api.ImportInput{JSON: data, Text: string(text), Table: table, Replace: replace, Zones: map[string]string{}}
for _, z := range zones {
iface, name, ok := strings.Cut(z, "=")
if !ok || iface == "" || name == "" {
return fmt.Errorf("--zone %q: write it as INTERFACE=NAME (enp2s0=lan)", z)
}
in.Zones[iface] = name
}
if defines != "" {
b, err := os.ReadFile(defines)
if err != nil {
@@ -508,6 +518,7 @@ Once it looks right, import without -n, check it with 'hc ruleset', and
cmd.Flags().StringVar(&table, "table", "", "import only this table (\"inet main\"; default: all but Housecarl's)")
cmd.Flags().StringVar(&defines, "defines", "", "the ruleset file, whose defines name the sets")
cmd.Flags().StringVar(&textFile, "text", "", "'nft -a list ruleset' output, to keep what can't be expressed as raw rules")
cmd.Flags().StringArrayVar(&zones, "zone", nil, "name an interface's zone: INTERFACE=NAME (repeat)")
return cmd
}