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

759 lines
29 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
}
// canonical is a rule the way ufw prints it back, as captured (testdata/ufw-comment-only.txt): the
// short form `allow 5671/tcp` for a rule on no interface, the long form `allow in on mesh0 to any
// port 5432 proto tcp` for one on an interface; the comment last.
func canonical(args []string) (rule, commentText string) {
var route, in, port, proto string
for i := 0; i < len(args); i++ {
switch args[i] {
case "route":
route = "route "
case "in":
in = args[i+2]
i += 2
case "port":
port = args[i+1]
i++
case "proto":
proto = args[i+1]
i++
case "comment":
commentText = args[i+1]
i++
}
}
if in != "" {
return route + "allow in on " + in + " to any port " + port + " proto " + proto, commentText
}
return route + "allow " + port + "/" + proto, commentText
}
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:
rule, note := canonical(args)
line := rule
if note != "" {
line += " comment '" + note + "'"
}
// As the real ufw does (testdata/ufw-comment-only.txt): a rule differing from one it holds
// only in its comment is the same rule, and its comment is replaced.
for i, r := range f.rules {
if bare, _, _ := strings.Cut(r, " comment '"); bare == rule {
f.rules[i] = line
return "Rule updated\nRule updated (v6)\n", nil
}
}
f.rules = append(f.rules, line)
return "Rule added\nRule added (v6)\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.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.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.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.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, nil); 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, ForwardPolicies(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, nil); err != nil || f.active {
t.Fatalf("disable: %v, active %v", err, f.active)
}
}
// Captured on a lab machine with fail2ban banning one documentation address in its sshd jail,
// once with its nftables backend and once with its iptables backend (iptables-nft), ufw inactive.
func captured(t *testing.T, name string) string {
t.Helper()
raw, err := os.ReadFile("testdata/" + name)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func TestFail2bansBansAreNotAFirewall(t *testing.T) {
for _, name := range []string{"fail2ban-nftables.nft", "fail2ban-iptables.nft"} {
ruleset := captured(t, name)
if !strings.Contains(ruleset, "192.0.2.55") {
t.Fatalf("%s holds no ban", name)
}
if got := Refusing(ruleset, false); len(got) != 0 {
t.Errorf("%s: fail2ban's bans read as a firewall: %v", name, got)
}
kind, what, err := Detect(context.Background(), (&fakeUFW{ruleset: ruleset}).run)
if err != nil || kind != None {
t.Errorf("%s: a machine with only fail2ban detected as %s (%s) %v", name, kind, what, err)
}
}
if got := RefusingLegacy(captured(t, "fail2ban-iptables-S.txt")); len(got) != 0 {
t.Errorf("fail2ban's iptables bans read as a firewall: %v", got)
}
}
func TestARefusalOfEveryoneButSomeIsStillAFirewall(t *testing.T) {
// A ban names the sources it refuses. A table that refuses every source but some, or every
// port but some, closes what the mesh would open, whatever its policy says.
for name, table := range map[string]string{
"all but a range": "table inet own {\n\tchain input {\n\t\ttype filter hook input priority filter; policy accept;\n\t\tip saddr != 10.0.0.0/8 drop\n\t}\n}\n",
"all but ssh": "table inet own {\n\tchain input {\n\t\ttype filter hook input priority filter; policy accept;\n\t\ttcp dport != 22 drop\n\t}\n}\n",
"iptables reject": "# Warning: table ip filter is managed by iptables-nft, do not touch!\ntable ip filter {\n\tchain INPUT {\n\t\ttype filter hook input priority filter; policy accept;\n\t\tcounter packets 0 bytes 0 xt target \"REJECT\"\n\t}\n}\n",
"ban beside a dropping policy": "table inet own {\n\tchain input {\n\t\ttype filter hook input priority filter; policy drop;\n\t\tip saddr 192.0.2.9 drop\n\t}\n}\n",
} {
if got := Refusing(dockerOnly(t)+table, false); len(got) == 0 {
t.Errorf("%s: not counted as a firewall", name)
}
}
legacy := "-P INPUT ACCEPT\n-N own\n-A INPUT -j own\n-A own ! -s 10.0.0.0/8 -j DROP\n"
if got := RefusingLegacy(legacy); len(got) == 0 {
t.Error("a legacy refusal of all but a range was not counted")
}
}
// Defends novox/hq ADR 0103: an opening a found rule already answers is not added, because ufw
// takes two rules differing only in their comment for one (testdata/ufw-comment-only.txt).
func TestUfwTakesTheMeshsRuleAndTheOperatorsForOne(t *testing.T) {
// The capture: each mesh rule answered "Rule updated" beside the operator's equivalent.
raw := captured(t, "ufw-comment-only.txt")
if strings.Count(raw, "Rule updated\n") != 3 {
t.Fatalf("the capture no longer shows ufw updating an equivalent rule:\n%s", raw)
}
for _, c := range []struct {
operators string
o *declaration.Opening
}{
{"route allow 8080/tcp", opening("adoption.opening-tcp-8080-forwarded", 20001, "everywhere", "forwarded", 8080)},
{"allow 5671/tcp", opening("adoption.opening-tcp-5671-incoming", 5671, "everywhere", "incoming", 0)},
{"allow in on mesh0 to any port 5432 proto tcp comment 'operator note'", opening("adoption.opening-tcp-5432-incoming", 5432, "mesh", "incoming", 0)},
} {
theirs, ok := parseRule(c.operators)
mine, ok2 := parseRule(strings.Join(Rule(c.o), " ") + " comment '" + Mark(c.o) + "'")
if !ok || !ok2 || !theirs.sameAs(mine) {
t.Errorf("%q and the mesh's %v are one rule to ufw, and read as two", c.operators, Rule(c.o))
}
if !theirs.admits(c.o) {
t.Errorf("%q does not read as answering %s", c.operators, c.o.Target())
}
}
}
func TestEveryCapturedRuleFormIsRead(t *testing.T) {
want := map[string]string{
"allow 22/tcp": "tcp 22 in= from=any", "allow 9200": " 9200 in= from=any",
"allow from 192.0.2.0/24 to any port 9300 proto tcp": "tcp 9300 in= from=192.0.2.0/24",
"allow in on eth0 to any port 9301 proto tcp": "tcp 9301 in=eth0 from=any",
"allow 9500:9510/tcp": "tcp 9500:9510 in= from=any",
"allow 80,443/tcp": "tcp 80,443 in= from=any",
"allow in on mesh0 to any port 5432 proto tcp": "tcp 5432 in=mesh0 from=any",
"route allow 8080/tcp": "tcp 8080 in= from=any",
"allow 9900/tcp": "tcp 9900 in= from=any",
"allow out 5671/tcp": "tcp 5671 in= from=any",
"deny out 5672/tcp": "tcp 5672 in= from=any",
"allow out on eth0 to any port 5673 proto tcp": "tcp 5673 in= from=any",
"allow log 9001/tcp": "tcp 9001 in= from=any",
"route allow log 8084/tcp": "tcp 8084 in= from=any",
"allow in on mesh0 log-all to any port 9002 proto tcp": "tcp 9002 in=mesh0 from=any",
}
rules, err := added(context.Background(), func(context.Context, string, ...string) (string, error) {
return captured(t, "ufw-forms.txt"), nil
})
if err != nil || len(rules) != 21 {
t.Fatalf("read %d rules: %v", len(rules), err)
}
for _, rule := range rules {
r, ok := parseRule(rule)
if !ok {
t.Errorf("a rule ufw printed was not read: %q", rule)
continue
}
if w, listed := want[rule]; listed {
if got := r.proto + " " + r.port + " in=" + r.in + " from=" + r.from; got != w {
t.Errorf("%q read as %q, want %q", rule, got, w)
}
}
}
}
func TestAnOpeningAFoundRuleAnswersIsNotAddedAndItsRemovalLeavesTheRule(t *testing.T) {
for _, c := range []struct {
name, operators string
o *declaration.Opening
}{
{"forwarded, the same rule", "route allow 8080/tcp", opening("adoption.fwd", 20001, "everywhere", "forwarded", 8080)},
{"incoming, the same rule", "allow 5671/tcp", opening("adoption.bus", 5671, "everywhere", "incoming", 0)},
{"with a comment of its own", "allow in on mesh0 to any port 5432 proto tcp comment 'operator note'", opening("adoption.store", 5432, "mesh", "incoming", 0)},
{"broader: from anywhere", "allow 5432/tcp", opening("adoption.store", 5432, "mesh", "incoming", 0)},
{"broader: any protocol, a range", "allow 5000:5100", opening("adoption.registry", 5000, "everywhere", "incoming", 0)},
} {
f := &fakeUFW{installed: true, active: true, rules: []string{"allow 22/tcp", c.operators}}
done, err := Converge(context.Background(), f.run, c.o)
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
if done.SatisfiedBy != c.operators || done.Action != "unchanged" || f.added() != 0 {
t.Errorf("%s: %+v, asked %v", c.name, done, f.asked)
}
if n, err := Remove(context.Background(), f.run, c.o.ID); err != nil || n != 0 {
t.Errorf("%s: removing the opening removed %d: %v", c.name, n, err)
}
if len(f.rules) != 2 || f.rules[1] != c.operators {
t.Errorf("%s: the operator's rule did not survive: %v", c.name, f.rules)
}
}
}
func TestARuleThatDoesNotAnswerTheOpeningLeavesItToBeAdded(t *testing.T) {
for _, operators := range []string{
"allow from 192.0.2.0/24 to any port 5671 proto tcp", // narrower: from one range
"allow in on eth0 to any port 5671 proto tcp", // narrower: one interface
"allow 5671/udp", // another protocol
"route allow 5671/tcp", // another path
"allow to 192.0.2.1 port 5671 proto tcp", // one address
} {
f := &fakeUFW{installed: true, active: true, rules: []string{operators}}
done, err := Converge(context.Background(), f.run, opening("adoption.bus", 5671, "everywhere", "incoming", 0))
if err != nil || done.Action != "created" || done.SatisfiedBy != "" {
t.Errorf("%q: %+v %v", operators, done, err)
}
}
}
func TestAnOpeningWhoseFoundRuleIsGoneIsAddedAgain(t *testing.T) {
f := &fakeUFW{installed: true, active: true, rules: []string{"allow 5671/tcp"}}
o := opening("adoption.bus", 5671, "everywhere", "incoming", 0)
if done, err := Converge(context.Background(), f.run, o); err != nil || done.SatisfiedBy == "" {
t.Fatalf("%+v %v", done, err)
}
f.rules = nil // the operator deleted theirs
if done, err := Converge(context.Background(), f.run, o); err != nil || done.Action != "created" {
t.Fatalf("the opening was not added once nothing answered it: %+v %v", done, err)
}
}
func TestARuleUfwWouldMergeThatDoesOtherThanAllowRefusesTheOpening(t *testing.T) {
// ufw takes two rules differing only in action or log type for one, and adding the mesh's
// would turn the operator's refusal into an allow (novox/hq ADR 0103).
for _, operators := range []string{
"deny 5671/tcp",
"reject 5671/tcp",
"limit 5671/tcp",
"allow log 5671/tcp",
"allow log-all proto tcp to any port 5671",
"deny in log to any port 5671 proto tcp comment 'operator note'",
} {
f := &fakeUFW{installed: true, active: true, rules: []string{operators}}
_, err := Converge(context.Background(), f.run, opening("adoption.bus", 5671, "everywhere", "incoming", 0))
if err == nil || !strings.Contains(err.Error(), operators) {
t.Errorf("%q: the conflict was not refused naming the rule: %v", operators, err)
}
if f.added() != 0 || len(f.rules) != 1 || f.rules[0] != operators {
t.Errorf("%q: something was added or changed: %v %v", operators, f.asked, f.rules)
}
}
}
func TestALogTypeIsReadInEitherPlace(t *testing.T) {
for rule, want := range map[string]string{
"allow log 22/tcp": "allow log 22 tcp in=",
"allow in log-all on mesh0 to any port 5432 proto tcp": "allow log-all 5432 tcp in=mesh0",
"route deny log in on mesh0 to any port 80 proto tcp": "deny log 80 tcp in=mesh0",
"allow 22/tcp comment 'log'": "allow 22 tcp in=",
} {
r, ok := parseRule(rule)
if got := r.action + " " + r.log + " " + r.port + " " + r.proto + " in=" + r.in; !ok || got != want {
t.Errorf("%q read as %q (%v), want %q", rule, got, ok, want)
}
}
}
func TestAnOutgoingRuleNeverAnswersAnOpening(t *testing.T) {
// `ufw allow out 5671/tcp` lets this machine reach others; nothing arrives through it, and
// ufw keeps it as a rule of its own — captured in testdata/ufw-direction.txt.
raw := captured(t, "ufw-direction.txt")
if !strings.Contains(raw, "ufw allow out 9007/tcp\nufw allow 9007/tcp") {
t.Fatalf("the capture no longer shows an outgoing rule standing beside an incoming one:\n%s", raw)
}
for _, operators := range []string{"allow out 5671/tcp", "allow out on eth0 to any port 5671 proto tcp",
"deny out 5671/tcp"} {
f := &fakeUFW{installed: true, active: true, rules: []string{operators}}
done, err := Converge(context.Background(), f.run, opening("adoption.bus", 5671, "everywhere", "incoming", 0))
if err != nil {
t.Errorf("%q: an outgoing rule was taken for a conflict: %v", operators, err)
continue
}
if done.Action != "created" || done.SatisfiedBy != "" {
t.Errorf("%q: an outgoing rule answered an incoming opening: %+v", operators, done)
}
}
}
func TestIncomingIsUfwsDefaultDirection(t *testing.T) {
// Captured: `deny in 9006/tcp` and `allow 9006/tcp` are one rule to ufw, so the mesh must read
// them as one too, or it would take an operator's refusal over.
raw := captured(t, "ufw-direction.txt")
if !strings.Contains(raw, "ufw allow 9005/tcp") || strings.Contains(raw, "ufw deny 9006/tcp") {
t.Fatalf("the capture no longer shows `in` as the default direction:\n%s", raw)
}
f := &fakeUFW{installed: true, active: true, rules: []string{"deny in to any port 5671 proto tcp"}}
if _, err := Converge(context.Background(), f.run, opening("adoption.bus", 5671, "everywhere", "incoming", 0)); err == nil {
t.Error("an incoming refusal ufw would merge was not refused")
}
}