790 lines
31 KiB
Go
790 lines
31 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
|
|
// noNft is a machine with no nft binary; iptablesRules is what `iptables -S` prints there.
|
|
noNft bool
|
|
iptablesRules 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.iptablesRules != "" && len(args) == 1 && args[0] == "-S" {
|
|
if name == "ip6tables" {
|
|
return "", nil
|
|
}
|
|
return f.iptablesRules, nil
|
|
}
|
|
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":
|
|
if f.noNft {
|
|
return "", &exec.Error{Name: name, Err: exec.ErrNotFound}
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestAMachineWithIptablesRulesAndNoNftIsNotReadAsUnfiltered(t *testing.T) {
|
|
// nft is not installed, and iptables-nft holds a firewall of somebody's. Read as "nothing
|
|
// filters here" the mesh would adopt it, open nothing, and be unreachable (novox/hq ADR 0100).
|
|
rules := "-P INPUT DROP\n-P FORWARD DROP\n-P OUTPUT ACCEPT\n-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT\n"
|
|
f := &fakeUFW{noNft: true, iptablesRules: rules}
|
|
kind, what, err := Detect(context.Background(), f.run)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if kind != Unsupported {
|
|
t.Errorf("a machine filtered by iptables with no nft read as %s (%s)", kind, what)
|
|
}
|
|
// And a machine with nothing but the runtime's own rules and no nft is still unfiltered.
|
|
docker := "-P INPUT ACCEPT\n-P FORWARD DROP\n-P OUTPUT ACCEPT\n-N DOCKER\n-A DOCKER -i docker0 -j DROP\n"
|
|
if kind, _, err := Detect(context.Background(), (&fakeUFW{noNft: true, iptablesRules: docker}).run); err != nil || kind != None {
|
|
t.Errorf("a machine with only the runtime's rules read as %s: %v", kind, err)
|
|
}
|
|
}
|