model.QuotaCarry says what becomes of a quota's use when its rule changes how it counts; the firewall's apply follows it, comparing with the rules in force: per address to a total starts at the addresses' sum, wider groups add up their addresses, and what can't be carried (a total to per address, per source to per destination, finer groups) starts again, with the engine warning quota_restarts on that change. Before, per source to per destination kept the sources' use as destinations', and regrouping left old entries behind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
546 lines
21 KiB
Go
546 lines
21 KiB
Go
package nft
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|