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