shadow.enabled in housecarl.yaml loads Housecarl's ruleset so that it blocks, rejects and translates nothing: drops and rejects count and accept (an accept only ends Housecarl's chain), policies accept and log what they would drop, NAT only counts, MSS clamping and the flowtable are left out, and raw rules that do more than accept, drop or reject are left out with a warning. Comments mark what would happen ([would drop]). Filter chains run after the other firewall's (+10) or before (-10); NAT chains always first. Every change is checked by nftables in both forms. No probation; takeovers and closing connections are refused. The boot fallback filters nothing. internal/tablewatch puts the table back after a flush ruleset. Mode in status, healthz, whoami, the web UI, the log and the tracer; hc ruleset --enforcing. docs/shadow.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
440 lines
17 KiB
Go
440 lines
17 KiB
Go
package nft
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"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)
|
|
}
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|