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); 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) } } // 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", } rules, err := added(context.Background(), func(context.Context, string, ...string) (string, error) { return captured(t, "ufw-forms.txt"), nil }) if err != nil || len(rules) != 15 { 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 "deny 5671/tcp", // refuses "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) } }