Files
abrown38andClaude Opus 5.5 47bae07d42 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>
2026-10-20 08:43:42 +01:00

99 lines
3.9 KiB
Go

package importer
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"housecarl/internal/render"
"housecarl/internal/validate"
)
// TestImportAudit: rulesets the importer must not change the meaning of
// (testdata/audit.nft, as nft 1.1.3 lists it).
func TestImportAudit(t *testing.T) {
js, _ := os.ReadFile("testdata/audit.json")
txt, _ := os.ReadFile("testdata/audit.txt")
defs, _ := os.ReadFile("testdata/audit.nft")
res, err := Import(Input{JSON: js, Text: string(txt), Defines: string(defs), Zones: map[string]string{"enp2s0.20": "iot", "eth9": "x"}})
if err != nil {
t.Fatal(err)
}
st := res.State
notes := strings.Join(res.Notes, "\n")
text := render.Render(st, render.Options{}).Text
has := func(what, s string) {
t.Helper()
if !strings.Contains(text, s) {
t.Errorf("%s: no %q in\n%s", what, s, text)
}
}
// Two chains on the input hook: their rules once each, in priority
// order; established stays a rule after the first chain's rule.
if n := strings.Count(text, "192.0.2.66"); n != 1 {
t.Errorf("the early input chain's rule %d times", n)
}
if strings.Index(text, "192.0.2.66") > strings.Index(text, "ct state established,related counter accept comment \"hc:r") ||
!strings.Contains(notes, "both hook input") {
t.Errorf("chains on one hook:\n%s", text)
}
// A second match on a field, and != on the hop limit: raw, as written.
has("two source matches", "ip saddr 10.0.0.0/8 ip saddr != 10.0.10.0/24 tcp dport 22 drop")
has("hop limit", "ip6 hoplimit != 255 drop")
// A jump: a rule within it is the rule's own; one outside it, or raw,
// keeps the jump's match in front.
has("within the jump", `ip saddr 10.9.0.5 tcp dport 22 counter accept comment "hc:r`)
has("outside the jump", "ip saddr 10.9.0.0/24 ip saddr 10.8.0.1 tcp dport 23 accept")
has("raw under the jump", "ip saddr 10.9.0.0/24 meta mark 0x00000001 accept")
// Sets: a timeout set is dynamic; an interface set is written out in
// the raw rule that names it.
if s := st.Set("blocklist"); s == nil || !s.Dynamic || s.Timeout != 3600 {
t.Errorf("blocklist: %+v", s)
}
has("dynamic set", "ip saddr @blocklist_v4 counter drop")
has("interface set", `iifname { "enp2s0", "enp3s0" } accept`)
// An accept to a forwarded host that isn't only for forwarded
// connections stays a rule; a DNAT of two ports, two forwards.
has("not only forwarded", `ip daddr 10.0.10.37 tcp dport 443 counter accept comment "hc:r`)
if len(st.Forwards) != 2 || st.Forwards[0].Name != "web-80" || st.Forwards[1].Name != "web-443" {
t.Errorf("forwards %+v", st.Forwards)
}
// Zones: as given (a VLAN), from defines, and why.
if z := st.Zone("iot"); z == nil || z.Interfaces[0] != "enp2s0.20" {
t.Errorf("zone iot: %+v", st.Zones)
}
if st.Zone("lan") == nil || st.Zone("wan") == nil {
t.Errorf("zones %+v", st.Zones)
}
for _, want := range []string{"iot (enp2s0.20: as given)", "lan (enp2s0: the define lan_if)", "--zone eth9=x: no rule names"} {
if !strings.Contains(notes, want) {
t.Errorf("no note %q in\n%s", want, notes)
}
}
if ps := validate.Check(st, nil); validate.HasErrors(ps) {
t.Errorf("problems: %+v", validate.Errors(ps))
}
// And nftables takes it.
cmd := strings.Fields(os.Getenv("HOUSECARL_NFT"))
if len(cmd) == 0 {
return
}
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
c.Stdin = strings.NewReader(text)
if out, err := c.CombinedOutput(); err != nil {
t.Errorf("nft -c: %v\n%s\n%s", err, out, text)
}
}
// TestValidIface: interface names a define may hold, VLANs too.
func TestValidIface(t *testing.T) {
for s, want := range map[string]bool{"enp2s0": true, "wan": true, "enp2s0.20": true, "br0.4094": true, "veth*": true,
"10.0.10.5": false, "example.com": false, "a.b.c": false, "enp2s0.20.30": false, "averyveryverylongname": false} {
if validIface(s) != want {
t.Errorf("validIface(%q) = %v", s, !want)
}
}
}