Files
mesh-host/internal/firewall/firewall_test.go
T

503 lines
17 KiB
Go

package firewall
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
)
// Defends novox/hq ADR 0100: the firewall found on an adopted node stays in force, the mesh opens
// what it needs through it in its own terms, and removes only what it marked.
func dockerOnly(t *testing.T) string {
t.Helper()
// Captured from a real machine running the container runtime and nothing else that filters:
// its nat, filter and raw tables as iptables-nft writes them.
raw, err := os.ReadFile("testdata/docker-only.nft")
if err != nil {
t.Fatal(err)
}
return string(raw)
}
const aDroppingTable = `
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
tcp dport 22 accept
}
}
`
const ufwChains = `
# Warning: table ip filter is managed by iptables-nft, do not touch!
table ip filter {
chain INPUT {
type filter hook input priority filter; policy drop;
counter packets 0 bytes 0 jump ufw-before-input
}
chain ufw-user-input {
tcp dport 22 counter packets 0 bytes 0 accept
}
chain ufw-reject-input {
counter packets 0 bytes 0 reject
}
}
`
const theMeshsOwn = `
table inet mesh {
chain input {
type filter hook input priority filter; policy drop;
iif lo accept
}
}
table inet mesh_guard {
chain prerouting {
type filter hook prerouting priority raw; policy accept;
iifname != "lo" tcp dport { 5432, 15672 } drop
}
}
`
func TestTheContainerRuntimesOwnRulesAreNotAFirewall(t *testing.T) {
if got := Refusing(dockerOnly(t), false); len(got) != 0 {
t.Errorf("the runtime's own rules read as a firewall: %v", got)
}
}
func TestTheMeshsOwnTablesAreNotAFirewall(t *testing.T) {
if got := Refusing(dockerOnly(t)+theMeshsOwn, false); len(got) != 0 {
t.Errorf("the mesh's own tables read as a found firewall: %v", got)
}
}
func TestATableThatDropsIsAFirewall(t *testing.T) {
got := Refusing(dockerOnly(t)+aDroppingTable, false)
if len(got) != 1 || got[0] != "table inet filter" {
t.Errorf("a dropping table was not named: %v", got)
}
}
func TestUfwsOwnChainsAreUfwsWhenItIsActive(t *testing.T) {
if got := Refusing(dockerOnly(t)+ufwChains, true); len(got) != 0 {
t.Errorf("ufw's own chains read as a second firewall: %v", got)
}
if got := Refusing(dockerOnly(t)+ufwChains, false); len(got) == 0 {
t.Error("iptables rules that refuse, with ufw not active, were not counted")
}
}
func TestLegacyIptablesThatRefusesIsAFirewall(t *testing.T) {
docker := "-P INPUT ACCEPT\n-P FORWARD DROP\n-P OUTPUT ACCEPT\n-N DOCKER\n-A DOCKER -i docker0 -j DROP\n"
if got := RefusingLegacy(docker); len(got) != 0 {
t.Errorf("the runtime's legacy rules read as a firewall: %v", got)
}
if got := RefusingLegacy(docker + "-A INPUT -p tcp --dport 25 -j REJECT\n"); len(got) != 1 {
t.Errorf("a legacy reject was not counted: %v", got)
}
}
// fakeUFW is ufw as far as the host can see it: a status, and user rules it prints back in its
// own canonical form — deliberately not the order the host wrote them in.
type fakeUFW struct {
active bool
installed bool
rules []string
ruleset string
firewalld bool
asked []string
// iptablesActive and iptablesInactive are what `iptables -S` prints with ufw active and
// after it is disabled; empty is a machine without iptables. forward is a policy set since.
iptablesActive, iptablesInactive string
forward string
}
// iptables answers `iptables -S FORWARD` from the captured output for ufw's state, and records
// a forward policy set with -P.
func (f *fakeUFW) iptables(name string, args []string) (string, error) {
if f.iptablesActive == "" {
return "", &exec.Error{Name: name, Err: exec.ErrNotFound}
}
if name == "ip6tables" {
return "", &exec.Error{Name: name, Err: exec.ErrNotFound}
}
if len(args) == 3 && args[0] == "-P" && args[1] == "FORWARD" {
f.forward = args[2]
return "", nil
}
captured := f.iptablesInactive
if f.active {
captured = f.iptablesActive
}
var out []string
for _, line := range strings.Split(captured, "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 && fields[1] == "FORWARD" {
if fields[0] == "-P" && f.forward != "" && !f.active {
line = "-P FORWARD " + f.forward
}
out = append(out, line)
}
}
return strings.Join(out, "\n") + "\n", nil
}
func canonical(args []string) string {
var route, in, port, proto, comment string
for i := 0; i < len(args); i++ {
switch args[i] {
case "route":
route = "route "
case "in":
in = "in on " + args[i+2] + " "
i += 2
case "port":
port = args[i+1]
i++
case "proto":
proto = args[i+1]
i++
case "comment":
comment = args[i+1]
i++
}
}
line := route + "allow " + in + port + "/" + proto
if comment != "" {
line += " comment '" + comment + "'"
}
return line
}
func (f *fakeUFW) run(_ context.Context, name string, args ...string) (string, error) {
f.asked = append(f.asked, name+" "+strings.Join(args, " "))
switch name {
case "firewall-cmd":
if f.firewalld {
return "running\n", nil
}
return "", &exec.Error{Name: name, Err: exec.ErrNotFound}
case "nft":
return f.ruleset, nil
case "iptables-legacy", "ip6tables-legacy":
return "", &exec.Error{Name: name, Err: exec.ErrNotFound}
case "iptables", "ip6tables":
return f.iptables(name, args)
case "ufw":
default:
return "", fmt.Errorf("unexpected %s", name)
}
if !f.installed {
return "", &exec.Error{Name: name, Err: exec.ErrNotFound}
}
switch {
case args[0] == "status":
if f.active {
return "Status: active\n\nTo Action From\n", nil
}
return "Status: inactive\n", nil
case args[0] == "show":
out := "Added user rules (see 'ufw status' for running firewall):\n"
for _, r := range f.rules {
out += "ufw " + r + "\n"
}
return out, nil
case args[0] == "--force" && args[1] == "enable":
f.active = true
return "Firewall is active and enabled on system startup\n", nil
case args[0] == "disable":
f.active = false
f.forward = ""
return "Firewall stopped and disabled on system startup\n", nil
case args[0] == "delete" && len(args) > 1 && args[1] == "route":
// As the real ufw answers it (captured in testdata/ufw-delete.txt): a route rule is
// deleted with `route delete`, never `delete route`.
return "", errors.New("ERROR: Invalid syntax")
case args[0] == "delete", args[0] == "route" && len(args) > 1 && args[1] == "delete":
rest := args[1:]
if args[0] == "route" {
rest = append([]string{"route"}, args[2:]...)
}
for i, r := range f.rules {
if strings.Join(words(r), "\x00") == strings.Join(rest, "\x00") {
f.rules = append(f.rules[:i], f.rules[i+1:]...)
return "Rule deleted\n", nil
}
}
return "", errors.New("Could not delete non-existent rule")
default:
f.rules = append(f.rules, canonical(args))
return "Rule added\n", nil
}
}
func (f *fakeUFW) added() int {
n := 0
for _, a := range f.asked {
if strings.HasPrefix(a, "ufw allow") || strings.HasPrefix(a, "ufw route") {
n++
}
}
return n
}
func opening(id string, port int, from, path string, to int) *declaration.Opening {
return &declaration.Opening{ID: id, Type: declaration.TypeOpening, Port: port, Protocol: "tcp",
From: from, Path: path, To: to}
}
func TestAnOpeningBecomesTheUfwRuleForItsPathAndOrigin(t *testing.T) {
for _, c := range []struct {
o *declaration.Opening
want string
}{
{opening("adoption.a", 5671, "everywhere", "incoming", 0), "allow proto tcp to any port 5671"},
{opening("adoption.b", 5432, "mesh", "incoming", 0), "allow in on mesh0 proto tcp to any port 5432"},
{opening("adoption.c", 20001, "everywhere", "forwarded", 8080), "route allow proto tcp to any port 8080"},
{opening("adoption.d", 20001, "mesh", "forwarded", 8080), "route allow in on mesh0 proto tcp to any port 8080"},
} {
if got := strings.Join(Rule(c.o), " "); got != c.want {
t.Errorf("%s: %q, want %q", c.o.ID, got, c.want)
}
}
}
func TestAnOpeningIsAddedOnceAndMarkedAsTheMeshs(t *testing.T) {
f := &fakeUFW{installed: true, active: true, rules: []string{"allow 22/tcp"}}
o := opening("adoption.opening-tcp-5671-incoming", 5671, "everywhere", "incoming", 0)
action, err := Converge(context.Background(), f.run, o)
if err != nil || action != "created" {
t.Fatalf("first converge: %q %v", action, err)
}
if !strings.Contains(f.rules[1], "comment 'mesh-host adoption.opening-tcp-5671-incoming ") {
t.Errorf("the rule is not marked as the mesh's: %v", f.rules)
}
action, err = Converge(context.Background(), f.run, o)
if err != nil || action != "unchanged" {
t.Fatalf("second converge: %q %v", action, err)
}
if f.added() != 1 {
t.Errorf("re-converging added again: %v", f.asked)
}
}
func TestAnOpeningLostToAReloadIsAddedAgain(t *testing.T) {
f := &fakeUFW{installed: true, active: true}
o := opening("adoption.x", 5671, "everywhere", "incoming", 0)
if _, err := Converge(context.Background(), f.run, o); err != nil {
t.Fatal(err)
}
f.rules = nil // what a reload that lost the rule leaves
action, err := Converge(context.Background(), f.run, o)
if err != nil || action != "created" || len(f.rules) != 1 {
t.Fatalf("a lost opening was not put back: %q %v %v", action, err, f.rules)
}
}
func TestAChangedOpeningReplacesOnlyItsOwnRule(t *testing.T) {
f := &fakeUFW{installed: true, active: true, rules: []string{"allow 22/tcp", "allow 8080/tcp comment 'someone else'"}}
if _, err := Converge(context.Background(), f.run, opening("adoption.x", 5671, "everywhere", "incoming", 0)); err != nil {
t.Fatal(err)
}
action, err := Converge(context.Background(), f.run, opening("adoption.x", 5671, "mesh", "incoming", 0))
if err != nil || action != "updated" {
t.Fatalf("%q %v", action, err)
}
if len(f.rules) != 3 || f.rules[0] != "allow 22/tcp" || f.rules[1] != "allow 8080/tcp comment 'someone else'" ||
!strings.Contains(f.rules[2], "in on mesh0") {
t.Errorf("rules afterwards: %v", f.rules)
}
}
func TestRemovingAnOpeningRemovesOnlyWhatWasMarkedForIt(t *testing.T) {
f := &fakeUFW{installed: true, active: true, rules: []string{"allow 22/tcp", "allow 8080/tcp"}}
for _, o := range []*declaration.Opening{
opening("adoption.a", 5671, "everywhere", "incoming", 0),
opening("adoption.ab", 5000, "everywhere", "incoming", 0),
} {
if _, err := Converge(context.Background(), f.run, o); err != nil {
t.Fatal(err)
}
}
n, err := Remove(context.Background(), f.run, "adoption.a")
if err != nil || n != 1 {
t.Fatalf("removed %d: %v", n, err)
}
if len(f.rules) != 3 || f.rules[0] != "allow 22/tcp" || f.rules[1] != "allow 8080/tcp" ||
!strings.Contains(f.rules[2], "adoption.ab") {
t.Errorf("more than the marked rule went: %v", f.rules)
}
}
func TestEnableAndDisableReadBack(t *testing.T) {
f := &fakeUFW{installed: true, active: true}
if err := Disable(context.Background(), f.run); err != nil || f.active {
t.Fatalf("disable: %v", err)
}
if err := Enable(context.Background(), f.run); err != nil || !f.active {
t.Fatalf("enable: %v", err)
}
for _, a := range f.asked {
if strings.Contains(a, "reset") || strings.Contains(a, "flush") {
t.Errorf("the found firewall was reset: %s", a)
}
}
}
func TestDetectingTheFoundFirewall(t *testing.T) {
for _, c := range []struct {
name string
f *fakeUFW
want Kind
}{
{"nothing but the runtime", &fakeUFW{ruleset: dockerOnly(t)}, None},
{"ufw active", &fakeUFW{installed: true, active: true, ruleset: dockerOnly(t) + ufwChains}, UFW},
{"ufw installed and inactive", &fakeUFW{installed: true, ruleset: dockerOnly(t)}, None},
{"firewalld", &fakeUFW{firewalld: true, ruleset: dockerOnly(t)}, Unsupported},
{"an nftables table of its own", &fakeUFW{ruleset: dockerOnly(t) + aDroppingTable}, Unsupported},
{"ufw beside an nftables table", &fakeUFW{installed: true, active: true, ruleset: dockerOnly(t) + ufwChains + aDroppingTable}, Unsupported},
} {
got, name, err := Detect(context.Background(), c.f.run)
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
if got != c.want {
t.Errorf("%s: detected %s (%s), want %s", c.name, got, name, c.want)
}
if got == Unsupported && name == "" {
t.Errorf("%s: an unsupported firewall was not named", c.name)
}
}
}
// The fixtures below were captured from a real ufw 0.36.2 on a lab machine, not written by hand:
// ufw prints a rule back in its own shorter form, so the mark in the comment is the only thing the
// host relies on.
func TestTheMarksAreReadFromWhatUfwReallyPrints(t *testing.T) {
raw, err := os.ReadFile("testdata/ufw-show-added.txt")
if err != nil {
t.Fatal(err)
}
run := func(ctx context.Context, name string, args ...string) (string, error) { return string(raw), nil }
rules, err := added(context.Background(), run)
if err != nil {
t.Fatal(err)
}
if len(rules) != 7 {
t.Fatalf("read %d rules, want 7: %q", len(rules), rules)
}
marked := 0
for _, r := range rules {
if strings.HasPrefix(comment(r), "mesh-host ") {
marked++
}
}
if marked != 5 {
t.Errorf("read %d marked rules, want 5", marked)
}
if !markedFor(comment(rules[5]), "adoption.opening-tcp-8443-forwarded") {
t.Errorf("the forwarded rule from the mesh lost its mark: %q", rules[5])
}
}
func TestEveryRealRuleIsDeletedInTheFormUfwAccepts(t *testing.T) {
raw, err := os.ReadFile("testdata/ufw-show-added.txt")
if err != nil {
t.Fatal(err)
}
run := func(ctx context.Context, name string, args ...string) (string, error) { return string(raw), nil }
rules, _ := added(context.Background(), run)
// Each of these was run on the lab machine and answered "Rule deleted" (testdata/ufw-delete.txt).
want := map[string]string{
"allow 5671/tcp comment 'mesh-host adoption.opening-tcp-5671-incoming 1a2b3c4d'": "delete allow 5671/tcp comment|mesh-host adoption.opening-tcp-5671-incoming 1a2b3c4d",
"allow in on mesh0 to any port 5432 proto tcp comment 'mesh-host adoption.opening-tcp-5432-incoming deadbeef'": "delete allow in on mesh0 to any port 5432 proto tcp comment|mesh-host adoption.opening-tcp-5432-incoming deadbeef",
"route allow 80/tcp comment 'mesh-host adoption.opening-tcp-8081-forwarded 0badf00d'": "route delete allow 80/tcp comment|mesh-host adoption.opening-tcp-8081-forwarded 0badf00d",
"route allow in on mesh0 to any port 443 proto tcp comment 'mesh-host adoption.opening-tcp-8443-forwarded cafe0001'": "route delete allow in on mesh0 to any port 443 proto tcp comment|mesh-host adoption.opening-tcp-8443-forwarded cafe0001",
}
seen := 0
for _, r := range rules {
w, ok := want[r]
if !ok {
continue
}
seen++
d := deletion(r)
got := strings.Join(d[:len(d)-1], " ") + "|" + d[len(d)-1]
if got != w {
t.Errorf("deleting %q\n got %s\n want %s", r, got, w)
}
}
if seen != len(want) {
t.Errorf("matched %d of %d captured rules", seen, len(want))
}
}
func TestARealUfwRulesetIsUfw(t *testing.T) {
raw, err := os.ReadFile("testdata/ufw-active.nft")
if err != nil {
t.Fatal(err)
}
status, err := os.ReadFile("testdata/ufw-status-active.txt")
if err != nil {
t.Fatal(err)
}
if !statusActive(string(status)) {
t.Fatal("the captured status does not read as active")
}
if refusing := Refusing(string(raw), true); len(refusing) > 0 {
t.Errorf("a machine with ufw active and nothing else read as refusing in %v", refusing)
}
if refusing := Refusing(string(raw), false); len(refusing) == 0 {
t.Error("ufw's drop chains, with ufw not known to be active, read as refusing nothing")
}
}
func TestRetiringUfwKeepsTheMachineFromRoutingForOthers(t *testing.T) {
// Captured on a lab machine running the container runtime with a published port: ufw active,
// then `ufw disable`. Disabling set the forward policy the runtime had set to drop to accept.
before, err := os.ReadFile("testdata/ufw-disable-iptables-before.txt")
if err != nil {
t.Fatal(err)
}
after, err := os.ReadFile("testdata/ufw-disable-iptables-after.txt")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(before), "-P FORWARD DROP") || !strings.Contains(string(after), "-P FORWARD ACCEPT") {
t.Fatal("the captures no longer show ufw disable opening the forward policy")
}
f := &fakeUFW{installed: true, active: true, iptablesActive: string(before), iptablesInactive: string(after)}
if err := Disable(context.Background(), f.run); err != nil {
t.Fatal(err)
}
if f.active {
t.Fatal("ufw is still active")
}
if f.forward != "DROP" {
t.Errorf("the forward policy was left open after ufw was retired: %v", f.asked)
}
for _, a := range f.asked {
if strings.Contains(a, "-F") || strings.Contains(a, "flush") || strings.Contains(a, "reset") {
t.Errorf("retiring ufw flushed something: %s", a)
}
}
}
func TestRetiringUfwOnAMachineWithoutIptablesStillRetiresIt(t *testing.T) {
f := &fakeUFW{installed: true, active: true}
if err := Disable(context.Background(), f.run); err != nil || f.active {
t.Fatalf("disable: %v, active %v", err, f.active)
}
}