POST /rules/{ref}/reset-quota takes an optional address (one entry of a
per-address quota starts again) and give (bytes taken off what was used,
one address's or everyone's, until the next reset). The firewall keeps
the adjustment for its next apply, which writes the new usage into the
ruleset; housecarld applies at once and saves the usage. hc rule
reset-quota --address/--give; Reset and Give back in the web UI, per
address in the usage dialog.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
492 lines
19 KiB
Go
492 lines
19 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)
|
|
}
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|