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 } 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 "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 return "Firewall stopped and disabled on system startup\n", nil case args[0] == "delete": for i, r := range f.rules { if strings.Join(words(r), "\x00") == strings.Join(args[1:], "\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) } } }