Files
abrown38andClaude Opus 5.5 e9a24b14e3 Refuse link-local addresses in the antispoof lists
fe80::/10 in the IPv6 antispoof list (or your prefixes) dropped the
ISP's router adverts and DHCPv6 replies in the early chain, before the
ICMPv6 and DHCPv6 client built-ins could accept them: the router lost
its IPv6 default route. Validation now refuses it
(antispoof_link_local), the settings page says so, and the importer
leaves it out with a note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:00:00 +00:00

347 lines
13 KiB
Go

package importer
import (
"fmt"
"os"
"os/exec"
"strings"
"testing"
"housecarl/internal/model"
"housecarl/internal/render"
"housecarl/internal/validate"
)
// The maintainer's router ruleset (testdata/router.nft), as nft 1.1.3
// lists it in JSON (router.json) and text (router.txt).
func importRouter(t *testing.T) *Result {
t.Helper()
js, _ := os.ReadFile("testdata/router.json")
txt, _ := os.ReadFile("testdata/router.txt")
defs, _ := os.ReadFile("testdata/router.nft")
res, err := Import(Input{JSON: js, Text: string(txt), Defines: string(defs)})
if err != nil {
t.Fatal(err)
}
return res
}
func TestImportRouter(t *testing.T) {
res := importRouter(t)
st := res.State
for _, n := range res.Notes {
t.Log("note:", n)
}
for _, r := range res.Raw {
t.Log("raw:", r)
}
// Zones, named by the defines; wan is external.
wan := st.Zone("wan")
if wan == nil || !wan.External || wan.Interfaces[0] != "wan" {
t.Errorf("zone wan: %+v", wan)
}
if z := st.Zone("dhcp"); z == nil || z.Interfaces[0] != "enp2s0" {
t.Errorf("zone dhcp: %+v (zones %+v)", z, st.Zones)
}
// Sets named by the defines.
if s := st.Set("trusted_ips"); s == nil || len(s.Entries) != 7 {
t.Errorf("set trusted_ips: %+v", s)
}
if s := st.Set("masq_to_internet"); s == nil {
t.Errorf("no set masq_to_internet (sets %v)", setNames(st))
}
// Built-ins: the ones at the head of their chains, up to the invalid
// drop, which stays a rule (below): the ICMP, ICMPv6 and DHCPv6
// accepts after it stay rules too, in their order, so nothing the
// drop stopped gets through ahead of it.
b := st.Settings.Builtins
if !b.Loopback || !b.Established || b.ICMPv6 || b.PingInternal || b.DHCPv6Client || !b.MSSClamp || !b.Antispoof {
t.Errorf("built-ins %+v", b)
}
var input []string
for _, r := range st.Rules {
if r.Chain == model.ChainInput {
input = append(input, strings.Join(r.CtState, ",")+"|"+strings.Join(r.Services, ",")+"|"+r.Raw)
}
}
if len(input) < 5 || input[0] != "invalid||" || !strings.HasPrefix(input[1], "|icmp/echo-reply,icmp/echo-request|") ||
!strings.Contains(input[2], "icmpv6/packet-too-big") || !strings.Contains(input[3], "ip6 hoplimit 255") || !strings.Contains(input[4], "udp/546") {
t.Errorf("input rules start %q", input)
}
// The forward chain's asymmetric-routing rule sits between the
// established and invalid rules: invalid stays rules, in order.
if b.DropInvalid {
t.Error("drop_invalid became a built-in, which would put it before the asymmetric-routing rule")
}
var fwd []string
for _, r := range st.Rules {
if r.Chain == model.ChainForward {
fwd = append(fwd, r.Comment+"|"+strings.Join(r.CtState, ","))
}
}
if len(fwd) < 2 || fwd[0] != "Needed due to asymmetric routing (internal only)|invalid" || fwd[1] != "|invalid" {
t.Errorf("forward rules start %q", fwd)
}
if st.Settings.OwnPrefixes[0] != "2a02:8013:7f3d::/48" || len(st.Settings.AntispoofV4) != 6 {
t.Errorf("antispoof %+v %+v", st.Settings.OwnPrefixes, st.Settings.AntispoofV4)
}
// Port forwards with their hairpin; the forward-chain accepts are
// folded in.
if len(st.Forwards) != 5 {
t.Fatalf("forwards %+v", st.Forwards)
}
jf := st.Forwards[0]
if jf.Name != "jellyfin" || jf.Ports != "8096" || jf.To != "10.0.10.57" || jf.ToPort != "443" || jf.PublicAddresses[0] != "217.155.8.194" ||
!jf.Hairpin || jf.HairpinSources[0] != "internal_net" {
t.Errorf("jellyfin %+v", jf)
}
for _, r := range st.Rules {
if r.Chain == model.ChainForward && strings.Contains(r.Comment, "Forward") && len(r.CtStatus) > 0 {
t.Errorf("forward accept not folded in: %+v", r)
}
}
// Source NAT: the masquerades.
if len(st.SNATs) != 2 || st.SNATs[0].OutZones[0] != "wan" || st.SNATs[0].Sources[0] != "masq_to_internet" ||
!st.SNATs[1].NotOut || st.SNATs[1].OutZones[0] != "wg0" {
t.Errorf("snats %+v %+v", st.SNATs[0], st.SNATs[1])
}
// The jump to wg0_input is inlined: its rules carry the source.
var wgDNS bool
for _, r := range st.Rules {
if r.Chain == model.ChainInput && len(r.Sources) == 1 && r.Sources[0] == "wg0_net" && strings.Join(r.Services, ",") == "udp/53" {
wgDNS = true
}
}
if !wgDNS {
t.Errorf("wg0_input not inlined")
}
// The jump's and the rule's sources both: the rule's lie within the
// jump's, so they say it all, without a note.
var wgSSH bool
for _, r := range st.Rules {
if r.Chain == model.ChainInput && strings.Join(r.Services, ",") == "tcp/22" && strings.Join(r.Sources, ",") == "10.50.50.3,10.50.50.6" {
wgSSH = true
}
}
if !wgSSH || strings.Contains(strings.Join(res.Notes, "\n"), "jump") {
t.Errorf("the WireGuard SSH rule: found %v, notes %q", wgSSH, res.Notes)
}
// The imported configuration is valid, and renders.
if ps := validate.Check(st, nil); validate.HasErrors(ps) {
t.Errorf("problems: %+v", validate.Errors(ps))
}
text := render.Render(st, render.Options{}).Text
for _, want := range []string{
`ip daddr 217.155.8.194 tcp dport 8096 counter dnat ip to 10.0.10.57:443`,
`ip saddr @internal_net_v4 ip daddr 10.0.10.57 tcp dport 443 ct status dnat counter masquerade`,
`oifname "wan" ip saddr @masq_to_internet_v4 counter masquerade`,
`oifname "wan" tcp flags syn tcp option maxseg size set rt mtu`,
`ip saddr @trusted_ips_v4 tcp dport 8053 counter accept comment "hc:r`,
} {
if !strings.Contains(text, want) {
t.Errorf("render: missing %q", want)
}
}
if t.Failed() {
t.Log(text)
}
}
func setNames(st *model.State) []string {
var out []string
for _, s := range st.Sets {
out = append(out, s.Name)
}
return out
}
func TestImportNeedsATable(t *testing.T) {
js := []byte(`{"nftables":[{"table":{"family":"inet","name":"main"}},{"table":{"family":"ip","name":"nat"}}]}`)
if _, err := Import(Input{JSON: js}); err == nil || !strings.Contains(err.Error(), "several tables") {
t.Errorf("several tables: %v", err)
}
if _, err := Import(Input{JSON: js, Table: "ip nat"}); err != nil {
t.Errorf("--table: %v", err)
}
}
func TestForwardNames(t *testing.T) {
im := &importer{st: &model.State{}}
for _, c := range []string{"Port forward: Jellyfin", "8096 to media", "web", "web", "-"} {
im.st.Forwards = append(im.st.Forwards, &model.PortForward{Name: forwardName(c)})
}
im.nameForwards()
var got []string
for _, f := range im.st.Forwards {
got = append(got, f.Name)
}
if want := "jellyfin fwd-8096-to-media web web-2 "; strings.Join(got, " ") != want {
t.Errorf("names %q, want %q", strings.Join(got, " "), want)
}
}
// TestImportedRulesetPassesNft: the ruleset rendered from the import is
// accepted by the real nft (HOUSECARL_NFT, as in internal/render).
func TestImportedRulesetPassesNft(t *testing.T) {
cmd := strings.Fields(os.Getenv("HOUSECARL_NFT"))
if len(cmd) == 0 {
t.Skip("HOUSECARL_NFT not set")
}
rs := render.Render(importRouter(t).State, render.Options{})
c := exec.Command(cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
c.Stdin = strings.NewReader(rs.Text)
if out, err := c.CombinedOutput(); err != nil {
t.Errorf("nft -c: %v\n%s", err, out)
}
}
// TestImportMaps: dnat maps become port forwards and verdict maps rules,
// one per element (testdata/maps.nft); a map that can't be expanded is
// left out with a note, never kept as a raw rule naming it.
func TestImportMaps(t *testing.T) {
js, _ := os.ReadFile("testdata/maps.json")
txt, _ := os.ReadFile("testdata/maps.txt")
res, err := Import(Input{JSON: js, Text: string(txt)})
if err != nil {
t.Fatal(err)
}
var fwds []string
for _, f := range res.State.Forwards {
fwds = append(fwds, fmt.Sprintf("%s %s/%s->%s:%s", f.Name, strings.Join(f.Protocols, "+"), f.Ports, f.To, f.ToPort))
}
var rules []string
for _, r := range res.State.Rules {
rules = append(rules, fmt.Sprintf("%s in=%s from=%s %s %s%s", r.Chain, strings.Join(r.InZones, ","), strings.Join(r.Sources, ","),
strings.Join(r.Services, ","), r.Action, r.Raw))
}
want := []string{
"portforwards-80 tcp/80->192.168.1.50:8080",
"portforwards-443 tcp/443->192.168.1.50:",
"hosts-2222 tcp/2222->192.168.1.60:",
" udp/5000->192.168.1.70:5001",
" udp/6000->192.168.1.71:",
}
if got := strings.Join(fwds, "\n"); got != strings.Join(want, "\n") {
t.Errorf("forwards:\n%s\nwant:\n%s", got, strings.Join(want, "\n"))
}
// In order; the jump in the verdict map is inlined.
want = []string{
"input in= from= accept",
"input in=lan from= tcp/22 accept",
"input in=lan from= tcp/23 drop",
"input in=lan from=192.168.1.0/24 tcp/80 accept",
"input in=wan from= udp/53 accept",
"input in=wan from= udp/123 drop",
"input in= from=10.0.0.0/8 accept",
"input in= from=192.0.2.1 drop",
}
if got := strings.Join(rules, "\n"); got != strings.Join(want, "\n") {
t.Errorf("rules:\n%s\nwant:\n%s", got, strings.Join(want, "\n"))
}
notes := strings.Join(res.Notes, "\n")
for _, n := range []string{"map portforwards: its 2 elements became 2 port forwards", "map hosts: its element became a port forward",
"uses the map @pairs, which couldn't be expanded"} {
if !strings.Contains(notes, n) {
t.Errorf("note %q missing:\n%s", n, notes)
}
}
if len(res.Raw) > 0 {
t.Errorf("raw rules: %v", res.Raw)
}
if ps := validate.Errors(validate.Check(res.State, nil)); len(ps) > 0 {
t.Errorf("invalid: %+v", ps)
}
if cmd := strings.Fields(os.Getenv("HOUSECARL_NFT")); len(cmd) > 0 {
c := exec.Command(cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
c.Stdin = strings.NewReader(render.Render(res.State, render.Options{}).Text)
if out, err := c.CombinedOutput(); err != nil {
t.Errorf("nft -c: %v\n%s", err, out)
}
}
}
// TestImportMeters: meters (per-address limits, connection counts and
// quotas), byte rates and quotas become rules' limits and quotas
// (testdata/meters.nft); the meters' sets aren't imported as address sets.
func TestImportMeters(t *testing.T) {
js, _ := os.ReadFile("testdata/meters.json")
txt, _ := os.ReadFile("testdata/meters.txt")
res, err := Import(Input{JSON: js, Text: string(txt)})
if err != nil {
t.Fatal(err)
}
var got []string
for _, r := range res.State.Rules {
s := r.Chain
if l := r.Limit; l != nil {
s += fmt.Sprintf(" limit %d %s/%s burst %d over=%v src=%v dst=%v", l.Rate, l.Unit, l.Per, l.Burst, l.Over, l.PerSource, l.PerDestination)
}
if r.ConnLimit > 0 {
s += fmt.Sprintf(" conn %d", r.ConnLimit)
}
if q := r.Quota; q != nil {
s += fmt.Sprintf(" quota %d over=%v src=%v", q.Bytes, q.Over, q.PerSource)
}
if r.GroupV4 != 0 || r.GroupV6 != 0 {
s += fmt.Sprintf(" group %d/%d", r.GroupV4, r.GroupV6)
}
s += " " + r.Action + r.Raw
got = append(got, s)
}
want := []string{
"input accept", // established: only in input here, so a rule
"input limit 3 /minute burst 5 over=true src=true dst=false drop",
"input limit 3 /minute burst 5 over=true src=true dst=false group 0/64 drop",
"input accept",
"forward conn 20 drop",
"forward limit 10240 kbytes/second burst 512 over=true src=false dst=false drop",
"forward quota 1048576000 over=true src=true group 24/0 drop",
"forward quota 104857600000 over=true src=false drop",
"forward accept",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Errorf("rules:\n%s\nwant:\n%s\nnotes: %v", strings.Join(got, "\n"), strings.Join(want, "\n"), res.Notes)
}
for _, s := range res.State.Sets {
if s.Name != "trusted" {
t.Errorf("a meter became a set: %s", s.Name)
}
}
if len(res.Raw) > 0 {
t.Errorf("raw rules: %v", res.Raw)
}
if ps := validate.Errors(validate.Check(res.State, nil)); len(ps) > 0 {
t.Errorf("invalid: %+v", ps)
}
if cmd := strings.Fields(os.Getenv("HOUSECARL_NFT")); len(cmd) > 0 {
c := exec.Command(cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
c.Stdin = strings.NewReader(render.Render(res.State, render.Options{}).Text)
if out, err := c.CombinedOutput(); err != nil {
t.Errorf("nft -c: %v\n%s", err, out)
}
}
}
// TestImportAntispoofLinkLocal: link-local in an IPv6 antispoof drop is
// left out (with a note), since it would drop the ISP's router adverts.
func TestImportAntispoofLinkLocal(t *testing.T) {
js, _ := os.ReadFile("testdata/router.json")
txt, _ := os.ReadFile("testdata/router.txt")
defs, _ := os.ReadFile("testdata/router.nft")
own := `{"prefix": {"addr": "2a02:8013:7f3d::", "len": 48}}`
if !strings.Contains(string(js), own) {
t.Fatal("router.json has no own-prefix drop")
}
js = []byte(strings.Replace(string(js), own, `{"set": [`+own+`, {"prefix": {"addr": "fe80::", "len": 10}}]}`, 1))
res, err := Import(Input{JSON: js, Text: string(txt), Defines: string(defs)})
if err != nil {
t.Fatal(err)
}
if p := res.State.Settings.OwnPrefixes; len(p) != 1 || p[0] != "2a02:8013:7f3d::/48" {
t.Errorf("own prefixes %v", p)
}
if !strings.Contains(strings.Join(res.Notes, "\n"), "fe80::/10 left out") {
t.Errorf("no note: %q", res.Notes)
}
}