Files
abrown38andClaude Opus 5.5 68ba2d06c5 Sets from Jarl: sets holding the addresses of names in Jarl
A set can take its addresses from Jarl: every A and AAAA record of its
names (NAME@VIEW), CNAMEs followed within Jarl's zones in the same view.
housecarld reads them every jarl.refresh and after each change, keeps
only the addresses within the zones and networks the set allows, and
keeps what it last read (saved in the database and the boot ruleset)
while Jarl can't be reached. A change of the addresses applies the
ruleset again without a history entry and is audited (set.jarl).

hc set add --jarl --within, hc set jarl; the kind "from Jarl" and
Addresses… on the Sets page; GET /sets/{name}/jarl and /jarl/refresh;
validation codes set_jarl_*, status warnings jarl_*; docs/jarl.md.
e2e phase with scripts/fakejarl.

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

664 lines
26 KiB
Go

package nft
import (
"context"
"fmt"
"net/netip"
"os"
"os/exec"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
"housecarl/internal/model"
"housecarl/internal/render"
)
func TestParseTable(t *testing.T) {
data := []byte(`{"nftables": [{"metainfo": {"version": "1.1.3"}},
{"set": {"family": "inet", "name": "guests_v4", "table": "housecarl", "type": "ipv4_addr", "flags": ["timeout"],
"elem": [{"elem": {"val": "10.0.30.5", "timeout": 1800, "expires": 1799}}, "10.0.30.6"]}},
{"set": {"family": "inet", "name": "meter_r4_v4", "table": "housecarl", "elem": ["10.0.0.1"]}},
{"rule": {"family": "inet", "table": "housecarl", "chain": "input", "comment": "hc:r12 ssh",
"expr": [{"counter": {"packets": 5, "bytes": 300}}, {"accept": null}]}},
{"rule": {"family": "inet", "table": "housecarl", "chain": "input", "comment": "hc:r12 ssh",
"expr": [{"counter": {"packets": 2, "bytes": 100}}, {"accept": null}]}},
{"rule": {"family": "inet", "table": "housecarl", "chain": "input", "comment": "not ours",
"expr": [{"counter": {"packets": 9, "bytes": 900}}]}}]}`)
tb, err := ParseTable(data)
if err != nil {
t.Fatal(err)
}
if c := tb.Counters["r12"]; c.Packets != 7 || c.Bytes != 400 || len(tb.Counters) != 1 {
t.Errorf("counters %+v", tb.Counters)
}
if e := tb.Elements["guests"]; len(e) != 2 || e[0] != (render.Element{Addr: "10.0.30.5", Timeout: 1799}) || e[1].Addr != "10.0.30.6" || len(tb.Elements) != 1 {
t.Errorf("elements %+v", tb.Elements)
}
}
func TestExplain(t *testing.T) {
rs := &render.Ruleset{Lines: map[int]string{10: "input #4 (ssh)"}}
err := explain(&Error{Output: "/dev/stdin:11:3-12: Error: syntax error, unexpected junk\n\t\tjunk\n"}, rs)
if err.Error() != "input #4 (ssh): syntax error, unexpected junk" {
t.Errorf("explained: %v", err)
}
}
// TestFirewallInContainer applies rulesets in a container with nft
// (HOUSECARL_NFT_IMAGE, e.g. housecarl-nft: Debian 13 with nftables):
// dynamic sets keep their entries across a reload, counters come back,
// and a broken rule is reported against its object.
func TestFirewallInContainer(t *testing.T) {
image := os.Getenv("HOUSECARL_NFT_IMAGE")
if image == "" {
t.Skip("HOUSECARL_NFT_IMAGE not set")
}
name := "housecarl-nft-test"
exec.Command("docker", "rm", "-f", name).Run()
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
t.Fatalf("starting the container: %v %s", err, out)
}
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}}
ctx := context.Background()
st := model.NewState()
st.Zones = []*model.Zone{{ID: 1, Name: "wan", Interfaces: []string{"eth0"}, External: true}}
st.Sets = []*model.AddressSet{{ID: 2, Name: "guests", Dynamic: true, Timeout: 3600}}
st.Rules = []*model.Rule{{ID: 3, Chain: model.ChainOutput, Position: 1, Action: model.ActionAccept, Match: model.Match{Destinations: []string{"guests"}}}}
if err := fw.Check(ctx, st); err != nil {
t.Fatalf("check: %v", err)
}
if _, err := fw.Table(ctx); err != ErrNoTable {
t.Errorf("before applying, the table: %v", err)
}
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("apply: %v", err)
}
if err := fw.Runner.AddElement(ctx, "guests", "127.0.0.2", 600); err != nil {
t.Fatal(err)
}
if err := fw.Runner.AddElement(ctx, "guests", "::2", 0); err != nil {
t.Fatal(err)
}
exec.Command("docker", "exec", name, "ping", "-c", "2", "-W", "1", "127.0.0.2").Run()
// Reapplying keeps the entries.
st.Settings.ForwardPolicy = model.PolicyAccept
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("reapply: %v", err)
}
tb, err := fw.Table(ctx)
if err != nil {
t.Fatal(err)
}
var got []string
for _, e := range tb.Elements["guests"] {
got = append(got, e.Addr)
if e.Addr == "127.0.0.2" && (e.Timeout < 500 || e.Timeout > 600) {
t.Errorf("timeout after reload: %+v", e)
}
}
if strings.Join(got, ",") != "127.0.0.2,::2" {
t.Errorf("entries after reload: %v", got)
}
if _, ok := tb.Counters["r3"]; !ok {
t.Errorf("counters %+v", tb.Counters)
}
if tb.Counters["b-established"].Packets == 0 && tb.Counters["r3"].Packets == 0 {
t.Logf("no packets counted (ping may be missing): %+v", tb.Counters)
}
// A broken raw rule: refused, named, and nothing changes.
st.Rules = append(st.Rules, &model.Rule{ID: 4, Chain: model.ChainInput, Position: 1, Action: model.ActionAccept, Raw: "tcp dport bogus accept", Comment: "broken"})
err = fw.Check(ctx, st)
if err == nil || !strings.Contains(err.Error(), "input #4 (broken)") {
t.Errorf("check of a broken rule: %v", err)
}
if err := fw.Apply(ctx, st); err == nil {
t.Error("a broken ruleset was applied")
}
tb, _ = fw.Table(ctx)
if len(tb.Elements["guests"]) != 2 {
t.Errorf("a refused apply changed the table: %+v", tb.Elements)
}
// The table is Housecarl's alone: another one is left alone.
exec.Command("docker", "exec", name, "nft", "add", "table", "inet", "other").Run()
fw.Apply(ctx, st.Clone())
if out, _ := exec.Command("docker", "exec", name, "nft", "list", "tables").CombinedOutput(); !strings.Contains(string(out), "inet other") {
t.Errorf("another table was touched: %s", out)
}
}
// fakeJarlSets gives fixed Jarl set addresses.
type fakeJarlSets struct {
entries, applied map[string][]string
}
func (f *fakeJarlSets) Entries(*model.State) map[string][]string { return f.entries }
func (f *fakeJarlSets) Applied(e map[string][]string) { f.applied = e }
// TestJarlSetsInContainer: a Jarl set gets its addresses with each apply,
// and the new ones replace the old.
func TestJarlSetsInContainer(t *testing.T) {
image := os.Getenv("HOUSECARL_NFT_IMAGE")
if image == "" {
t.Skip("HOUSECARL_NFT_IMAGE not set")
}
name := "housecarl-nft-jarl-test"
exec.Command("docker", "rm", "-f", name).Run()
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
t.Fatalf("starting the container: %v %s", err, out)
}
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
js := &fakeJarlSets{entries: map[string][]string{"nas": {"10.0.0.5", "10.0.0.6", "2001:db8::5"}}}
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}, JarlSets: js}
ctx := context.Background()
st := model.NewState()
st.Zones = []*model.Zone{{ID: 1, Name: "lan", Interfaces: []string{"eth0"}}}
st.Sets = []*model.AddressSet{{ID: 2, Name: "nas", Jarl: &model.JarlSource{Names: []model.JarlName{{Name: "nas.example.com"}}, Within: []string{"lan"}}}}
st.Rules = []*model.Rule{{ID: 3, Chain: model.ChainOutput, Position: 1, Action: model.ActionAccept, Match: model.Match{Destinations: []string{"nas"}}}}
elems := func() string {
tb, err := fw.Table(ctx)
if err != nil {
t.Fatal(err)
}
var got []string
for _, e := range tb.Elements["nas"] {
got = append(got, e.Addr)
}
return strings.Join(got, ",")
}
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("apply: %v", err)
}
if got := elems(); got != "10.0.0.5,10.0.0.6,2001:db8::5" || len(js.applied["nas"]) != 3 {
t.Errorf("after apply: %s (applied %v)", got, js.applied)
}
js.entries = map[string][]string{"nas": {"10.0.0.7"}}
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("reapply: %v", err)
}
if got := elems(); got != "10.0.0.7" {
t.Errorf("after the addresses changed: %s", got)
}
}
// TestQuotasInContainer: quotas keep what they used across a reload,
// reset by hand, and come back after a reboot from what was saved.
func TestQuotasInContainer(t *testing.T) {
image := os.Getenv("HOUSECARL_NFT_IMAGE")
if image == "" {
t.Skip("HOUSECARL_NFT_IMAGE not set")
}
name := "housecarl-nft-quota-test"
exec.Command("docker", "rm", "-f", name).Run()
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
t.Fatalf("starting the container: %v %s", err, out)
}
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}}
ctx := context.Background()
st := model.NewState()
// A total quota on pings to 127.0.0.2, and one per destination on
// pings to 127.0.0.3.
st.Rules = []*model.Rule{
{ID: 1, Chain: model.ChainOutput, Position: 1, Action: model.ActionDrop, Match: model.Match{Destinations: []string{"127.0.0.2"}},
Quota: &model.Quota{Bytes: 10 << 20, Over: true}},
{ID: 2, Chain: model.ChainOutput, Position: 2, Action: model.ActionDrop, Match: model.Match{Destinations: []string{"127.0.0.3"}},
Quota: &model.Quota{Bytes: 10 << 20, Over: true, PerDestination: true}},
}
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("apply: %v", err)
}
ping := func(addr string) {
exec.Command("docker", "exec", name, "ping", "-c", "3", "-s", "1000", "-W", "1", addr).Run()
}
ping("127.0.0.2")
ping("127.0.0.3")
used := func() (int64, int64) {
tb, err := fw.Table(ctx)
if err != nil {
t.Fatal(err)
}
var dev int64
for _, e := range tb.QuotaSets["quota_r2_v4"] {
if e.Addr == "127.0.0.3" {
dev = e.Used
}
}
return tb.Quotas["quota_r1"], dev
}
total, dev := used()
if total < 3000 || dev < 3000 {
t.Fatalf("used: total %d, per destination %d", total, dev)
}
// A reload (a change) keeps what was used.
st.Rules[0].Comment = "changed"
if err := fw.Apply(ctx, st); err != nil {
t.Fatal(err)
}
if t2, d2 := used(); t2 < total || d2 < dev {
t.Errorf("after a reload: total %d (was %d), per destination %d (was %d)", t2, total, d2, dev)
}
// Some given back, and one address started again, by the next apply.
total, dev = used()
fw.AdjustQuota(QuotaAdjust{Name: "quota_r1", Give: 1000})
fw.AdjustQuota(QuotaAdjust{Name: "quota_r2", Addr: "127.0.0.3", Give: 1 << 62})
if err := fw.Apply(ctx, st); err != nil {
t.Fatal(err)
}
if t2, d2 := used(); t2 > total-1000 || t2 < total-2000 || d2 != 0 {
t.Errorf("after giving back: total %d (was %d, gave 1000), per destination %d (was %d, reset)", t2, total, d2, dev)
}
// Counted in total now: what the addresses used carries over.
ping("127.0.0.3")
_, dev = used()
st.Rules[1].Quota.PerDestination = false
if err := fw.Apply(ctx, st); err != nil {
t.Fatal(err)
}
tb, _ := fw.Table(ctx)
if tb.Quotas["quota_r2"] < dev || dev == 0 {
t.Errorf("per destination to a total: %d, was %d", tb.Quotas["quota_r2"], dev)
}
st.Rules[1].Quota.PerDestination = true
if err := fw.Apply(ctx, st); err != nil {
t.Fatal(err)
}
// Reset by hand.
if err := fw.ResetQuota(ctx, 1, false); err != nil {
t.Fatal(err)
}
if err := fw.ResetQuota(ctx, 2, true); err != nil {
t.Fatal(err)
}
if t3, d3 := used(); t3 != 0 || d3 != 0 {
t.Errorf("after a reset: total %d, per destination %d", t3, d3)
}
// A reboot: the table is gone (the boot ruleset has no usage); the
// saved usage comes back.
ping("127.0.0.2")
ping("127.0.0.3")
tb, _ = fw.Table(ctx)
saved := tb.QuotaUsage
exec.Command("docker", "exec", name, "nft", "delete", "table", "inet", "housecarl").Run()
fw.RestoreQuotas(&saved)
if err := fw.Apply(ctx, st); err != nil {
t.Fatal(err)
}
if t4, d4 := used(); t4 < saved.Quotas["quota_r1"] || t4 == 0 || d4 == 0 {
t.Errorf("after a reboot: total %d, per destination %d (saved %+v)", t4, d4, saved)
}
}
// TestQuotaAdjust: giving back takes off what was used, never below
// zero; an entry with nothing left goes.
func TestQuotaAdjust(t *testing.T) {
u := QuotaUsage{Quotas: map[string]int64{"quota_r1": 5000, "quota_r9": 10},
QuotaSets: map[string][]render.Element{"quota_r2_v4": {{Addr: "10.0.0.1", Used: 3000}, {Addr: "10.0.0.2", Used: 4000}},
"quota_r2_v6": {{Addr: "2001:db8::", Used: 100}}}}
u.apply([]QuotaAdjust{{Name: "quota_r1", Give: 2000}, {Name: "quota_r9", Give: 2000}, {Name: "quota_r2", Addr: "10.0.0.2", Give: 1500},
{Name: "quota_r2", Give: 100}})
if u.Quotas["quota_r1"] != 3000 || u.Quotas["quota_r9"] != 0 {
t.Errorf("named: %v", u.Quotas)
}
want := map[string][]render.Element{"quota_r2_v4": {{Addr: "10.0.0.1", Used: 2900}, {Addr: "10.0.0.2", Used: 2400}}, "quota_r2_v6": nil}
if !reflect.DeepEqual(u.QuotaSets, want) {
t.Errorf("sets: %+v", u.QuotaSets)
}
}
// TestQuotaCarry: usage follows a rule counting its quota differently.
func TestQuotaCarry(t *testing.T) {
perSource := &model.Rule{ID: 2, Quota: &model.Quota{Bytes: 1 << 30, PerSource: true}}
usage := func() QuotaUsage {
return QuotaUsage{Quotas: map[string]int64{}, QuotaSets: map[string][]render.Element{
"quota_r2_v4": {{Addr: "10.0.0.1", Used: 100}, {Addr: "10.0.0.2", Used: 50}},
"quota_r2_v6": {{Addr: "2001:db8:0:1::", Used: 7}, {Addr: "2001:db8:0:2::", Used: 3}}}}
}
was := map[int64]*model.Rule{2: perSource}
with := func(f func(r *model.Rule)) *model.State {
r := *perSource
q := *perSource.Quota
r.Quota = &q
f(&r)
return &model.State{Rules: []*model.Rule{&r}}
}
u := usage()
u.carry(was, with(func(r *model.Rule) { r.Quota.PerSource = false }))
if u.Quotas["quota_r2"] != 160 || len(u.QuotaSets["quota_r2_v4"]) != 0 {
t.Errorf("to a total: %+v", u)
}
u = usage()
u.carry(was, with(func(r *model.Rule) { r.GroupV4, r.GroupV6 = 24, 48 }))
want := map[string][]render.Element{"quota_r2_v4": {{Addr: "10.0.0.0", Used: 150}}, "quota_r2_v6": {{Addr: "2001:db8::", Used: 10}}}
if !reflect.DeepEqual(u.QuotaSets, want) {
t.Errorf("wider groups: %+v", u.QuotaSets)
}
u = usage()
u.carry(was, with(func(r *model.Rule) { r.Quota.PerSource, r.Quota.PerDestination = false, true }))
if len(u.QuotaSets) != 0 {
t.Errorf("per destination now: %+v", u.QuotaSets)
}
u = usage()
u.carry(nil, with(func(r *model.Rule) { r.Quota.PerSource = false }))
if len(u.QuotaSets["quota_r2_v4"]) != 2 || u.Quotas["quota_r2"] != 0 {
t.Errorf("nothing to compare with (the first apply): %+v", u)
}
}
// TestDeviceCountsInContainer: the per-device counters are read back and
// survive a reload.
func TestDeviceCountsInContainer(t *testing.T) {
image := os.Getenv("HOUSECARL_NFT_IMAGE")
if image == "" {
t.Skip("HOUSECARL_NFT_IMAGE not set")
}
name := "housecarl-nft-stats-test"
exec.Command("docker", "rm", "-f", name).Run()
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
t.Fatalf("starting the container: %v %s", err, out)
}
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}}
ctx := context.Background()
st := model.NewState()
st.Zones = []*model.Zone{{ID: 1, Name: "wan", Interfaces: []string{"eth0"}, External: true}}
st.Settings.StatsPerDevice = true
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("apply: %v", err)
}
if out, err := exec.Command("docker", "exec", name, "nft", "add", "element", "inet", "housecarl", "stats_up_v4", "{ 10.0.10.5 counter packets 7 bytes 7000 }").CombinedOutput(); err != nil {
t.Fatalf("adding an element: %v %s", err, out)
}
read := func() render.Element {
tb, err := fw.Table(ctx)
if err != nil {
t.Fatal(err)
}
for _, e := range tb.DeviceSets["stats_up_v4"] {
if e.Addr == "10.0.10.5" {
return e
}
}
t.Fatalf("no 10.0.10.5 in %+v", tb.DeviceSets)
return render.Element{}
}
if e := read(); e.Packets != 7 || e.Bytes != 7000 || e.Timeout <= 0 {
t.Fatalf("read back %+v", e)
}
st.Settings.InputPolicy = model.PolicyAccept // a change: a reload
if err := fw.Apply(ctx, st); err != nil {
t.Fatal(err)
}
if e := read(); e.Packets != 7 || e.Bytes != 7000 {
t.Errorf("after a reload: %+v", e)
}
}
// TestFlowtableStale: the flowtable in force goes stale when one of its
// devices appears, goes, or comes back as a new interface.
func TestFlowtableStale(t *testing.T) {
idx := map[string]int{"eth1": 3}
old := ifIndex
ifIndex = func(name string) int { return idx[name] }
t.Cleanup(func() { ifIndex = old })
exists := func() map[string]bool {
out := map[string]bool{}
for n, i := range idx {
out[n] = i > 0
}
return out
}
// "true" stands in for nft: every apply succeeds, and lists nothing.
fw := &Firewall{Runner: &Runner{Command: []string{"true"}}, Interfaces: exists}
st := model.NewState()
st.Settings.FlowtableDevices = []string{"ppp0", "eth1"}
if fw.FlowtableStale(st) {
t.Error("stale before anything was applied")
}
if err := fw.Apply(context.Background(), st); err != nil {
t.Fatal(err)
}
if fw.FlowtableStale(st) {
t.Error("stale right after applying")
}
idx["ppp0"] = 7 // PPPoE came up
if !fw.FlowtableStale(st) {
t.Error("ppp0 appeared, yet not stale")
}
fw.Apply(context.Background(), st)
idx["ppp0"] = 9 // and reconnected: a new interface by the same name
if !fw.FlowtableStale(st) {
t.Error("ppp0 came back as a new interface, yet not stale")
}
fw.Apply(context.Background(), st)
idx["eth9"] = 4 // not a flowtable device
if fw.FlowtableStale(st) {
t.Error("stale over an interface the flowtable doesn't use")
}
}
// TestBootInContainer: the boot ruleset loads although its flowtable
// devices don't exist yet, the fallback loads and is recognised, and
// housecarld's apply replaces it.
func TestBootInContainer(t *testing.T) {
image := os.Getenv("HOUSECARL_NFT_IMAGE")
if image == "" {
t.Skip("HOUSECARL_NFT_IMAGE not set")
}
name := "housecarl-nft-boot-test"
exec.Command("docker", "rm", "-f", name).Run()
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
t.Fatalf("starting the container: %v %s", err, out)
}
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
nft := func(script string) error {
c := exec.Command("docker", "exec", "-i", name, "nft", "-f", "/dev/stdin")
c.Stdin = strings.NewReader(script)
if out, err := c.CombinedOutput(); err != nil {
return fmt.Errorf("%v: %s", err, out)
}
return nil
}
dir := t.TempDir()
// ppp0 exists when housecarld saves, but not at boot.
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}, BootFile: filepath.Join(dir, "ruleset.nft"),
Interfaces: func() map[string]bool { return map[string]bool{"ppp0": true, "eth0": true} }}
st := model.NewState()
st.Zones = []*model.Zone{{ID: 1, Name: "wan", Interfaces: []string{"ppp0"}, External: true}, {ID: 2, Name: "lan", Interfaces: []string{"eth0"}}}
st.SNATs = []*model.SourceNAT{{ID: 3, Match: model.Match{OutZones: []string{"wan"}}}}
st.Settings.FlowtableDevices = []string{"ppp0", "eth0"}
if err := fw.SaveBoot(st); err != nil {
t.Fatal(err)
}
boot, _ := os.ReadFile(fw.BootFile)
if strings.Contains(string(boot), "flowtable") {
t.Errorf("the boot ruleset has a flowtable:\n%s", boot)
}
fallback, err := os.ReadFile(filepath.Join(dir, "ruleset-fallback.nft"))
if err != nil {
t.Fatal(err)
}
if err := nft(string(boot)); err != nil {
t.Fatalf("the boot ruleset doesn't load: %v", err)
}
ctx := context.Background()
if tb, err := fw.Table(ctx); err != nil || tb.Fallback {
t.Errorf("after the boot ruleset: %+v %v", tb, err)
}
if err := nft(string(fallback)); err != nil {
t.Fatalf("the fallback doesn't load: %v", err)
}
if tb, err := fw.Table(ctx); err != nil || !tb.Fallback {
t.Errorf("the fallback isn't recognised: %+v %v", tb, err)
}
fw.Interfaces = func() map[string]bool { return map[string]bool{"eth0": true} }
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("apply over the fallback: %v", err)
}
if tb, err := fw.Table(ctx); err != nil || tb.Fallback {
t.Errorf("the fallback stayed after an apply: %+v %v", tb, err)
}
}
// TestShadowInContainer: in shadow mode a drop counts but doesn't drop,
// the ruleset for leaving shadow mode is checked too, and the boot files
// are shadow mode's.
func TestShadowInContainer(t *testing.T) {
image := os.Getenv("HOUSECARL_NFT_IMAGE")
if image == "" {
t.Skip("HOUSECARL_NFT_IMAGE not set")
}
name := "housecarl-nft-shadow-test"
exec.Command("docker", "rm", "-f", name).Run()
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
t.Fatalf("starting the container: %v %s", err, out)
}
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
dir := t.TempDir()
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}, Shadow: true, BootFile: filepath.Join(dir, "ruleset.nft")}
ctx := context.Background()
st := model.NewState()
st.Settings.OutputPolicy = model.PolicyDrop
st.Settings.InputPolicy = model.PolicyAccept
st.Settings.Builtins.Loopback = false // it would accept the ping first
st.Rules = []*model.Rule{{ID: 3, Chain: model.ChainOutput, Position: 1, Action: model.ActionReject, Match: model.Match{Destinations: []string{"127.0.0.2"}}}}
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("apply: %v", err)
}
ping := func() error {
return exec.Command("docker", "exec", name, "ping", "-c", "2", "-W", "1", "127.0.0.2").Run()
}
if err := ping(); err != nil {
t.Errorf("shadow mode blocked a ping: %v", err)
}
tb, err := fw.Table(ctx)
if err != nil {
t.Fatal(err)
}
if tb.Counters["r3"].Packets == 0 {
t.Errorf("what would be rejected isn't counted: %+v", tb.Counters)
}
// A rule shadow mode leaves out, which nftables refuses when enforcing.
broken := st.Clone()
broken.Rules = append(broken.Rules, &model.Rule{ID: 4, Chain: model.ChainInput, Position: 1, Raw: "tcp dport 22 jump nowhere", Comment: "jump"})
if err := fw.Check(ctx, broken); err == nil || !strings.Contains(err.Error(), "shadow mode is off") || !strings.Contains(err.Error(), "input #4 (jump)") {
t.Errorf("the ruleset for leaving shadow mode wasn't checked: %v", err)
}
// Enforcing, the same rule rejects.
enforcing := &Firewall{Runner: fw.Runner}
if err := enforcing.Apply(ctx, st); err != nil {
t.Fatal(err)
}
if ping() == nil {
t.Error("enforcing didn't reject")
}
// The boot files: both load, and filter nothing.
if err := fw.SaveBoot(st); err != nil {
t.Fatal(err)
}
for _, f := range []string{"ruleset.nft", "ruleset-fallback.nft"} {
b, _ := os.ReadFile(filepath.Join(dir, f))
c := exec.Command("docker", "exec", "-i", name, "nft", "-f", "/dev/stdin")
c.Stdin = strings.NewReader(string(b))
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("%s doesn't load: %v %s", f, err, out)
}
if err := ping(); err != nil {
t.Errorf("%s blocked a ping: %v", f, err)
}
}
if tb, err := fw.Table(ctx); err != nil || !tb.Fallback {
t.Errorf("the shadow fallback isn't recognised: %+v %v", tb, err)
}
}
// TestPruneCounters: the totals of objects gone (an import with --replace
// gives every object a new ID) are forgotten; built-ins, policies and
// disabled objects keep theirs.
func TestPruneCounters(t *testing.T) {
base := map[string]Counter{}
for _, m := range []string{"r1", "r2", "r3", "f4", "f4-hairpin", "f4-6", "f5-hairpin", "s6", "d7", "d8", "b-established", "p-input", "fallback-input"} {
base[m] = Counter{Packets: 1}
}
st := model.NewState()
st.Rules = []*model.Rule{{ID: 1}, {ID: 3, Disabled: true}}
st.Forwards = []*model.PortForward{{ID: 4}}
st.Redirects = []*model.Redirect{{ID: 8}}
pruneCounters(base, st)
var got []string
for m := range base {
got = append(got, m)
}
sort.Strings(got)
if strings.Join(got, " ") != "b-established d8 f4 f4-6 f4-hairpin fallback-input p-input r1 r3" {
t.Errorf("kept %v", got)
}
}
// TestBansInContainer: a ban goes in, a second one renews its time, a
// reload keeps the bans the database has, and lifting one already gone
// isn't an error.
func TestBansInContainer(t *testing.T) {
image := os.Getenv("HOUSECARL_NFT_IMAGE")
if image == "" {
t.Skip("HOUSECARL_NFT_IMAGE not set")
}
name := "housecarl-nft-bans"
exec.Command("docker", "rm", "-f", name).Run()
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
t.Fatalf("starting the container: %v %s", err, out)
}
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
var active []render.Ban
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}, Bans: func() []render.Ban { return active }}
ctx := context.Background()
st := model.NewState()
st.Zones = []*model.Zone{{ID: 1, Name: "wan", Interfaces: []string{"eth0"}, External: true}}
st.BanLists = []*model.BanList{{ID: 2, Name: "router", Scope: model.BanScopeFirewall}}
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("apply: %v", err)
}
list := func(set string) string {
out, _ := exec.Command("docker", "exec", name, "nft", "list", "set", "inet", "housecarl", set).CombinedOutput()
return string(out)
}
b := render.Ban{List: 2, Addr: netip.MustParsePrefix("203.0.113.9/32"), Timeout: 60}
if err := fw.AddBan(ctx, b); err != nil {
t.Fatal(err)
}
b.Timeout = 7200
if err := fw.AddBan(ctx, b); err != nil {
t.Fatalf("renewing: %v", err)
}
if got := list("ban_2_v4"); !strings.Contains(got, "203.0.113.9 timeout 2h") {
t.Errorf("renewed ban: %s", got)
}
svc := render.Ban{List: 2, Addr: netip.MustParsePrefix("2001:db8:1:2::/64"), Proto: "tcp", Port: 22}
if err := fw.AddBan(ctx, svc); err != nil {
t.Fatal(err)
}
active = []render.Ban{svc}
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("reapply: %v", err)
}
if got := list("ban_2_v6_svc"); !strings.Contains(got, "2001:db8:1:2:: . 6 . 22") {
t.Errorf("after a reload, the database's ban: %s", got)
}
if got := list("ban_2_v4"); strings.Contains(got, "203.0.113.9") {
t.Errorf("after a reload, a ban the database doesn't have: %s", got)
}
if err := fw.DeleteBan(ctx, svc); err != nil {
t.Fatal(err)
}
if err := fw.DeleteBan(ctx, svc); err != nil {
t.Errorf("lifting a ban already gone: %v", err)
}
if got := list("ban_2_v6_svc"); strings.Contains(got, "2001:db8") {
t.Errorf("lifted ban: %s", got)
}
}