From d3f25959683902b0c3257665dd10cdc2f9ffc50e Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 22 Sep 2026 19:51:35 +0200 Subject: [PATCH] Read a ufw rule's direction: an outgoing rule answers no opening, and incoming is the default ufw merges on (hq ADR 0103) --- internal/firewall/firewall.go | 19 ++++++- internal/firewall/firewall_test.go | 56 +++++++++++++++++--- internal/firewall/testdata/ufw-direction.txt | 21 ++++++++ internal/firewall/testdata/ufw-forms.txt | 6 +++ 4 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 internal/firewall/testdata/ufw-direction.txt diff --git a/internal/firewall/firewall.go b/internal/firewall/firewall.go index 262310d..fbf98be 100644 --- a/internal/firewall/firewall.go +++ b/internal/firewall/firewall.go @@ -479,7 +479,10 @@ func words(rule string) []string { type ufwRule struct { route bool action, in, out string - log string + // dir is which way the rule matches: "" (ufw's default, incoming and forwarded), "in" or + // "out". A rule on the outgoing path admits nothing that arrives. + dir string + log string from, fromPort, to string port, proto, app string comment string @@ -529,6 +532,7 @@ func parseRule(rule string) (ufwRule, bool) { iface = w[i+1] i += 2 } + r.dir = dir if dir == "in" { r.in = iface } else { @@ -586,6 +590,12 @@ func parseRule(rule string) (ufwRule, bool) { if r.proto == "any" { r.proto = "" } + // Incoming is ufw's default direction, and it prints `allow in 9005/tcp` back as + // `allow 9005/tcp` and merges the two — captured in testdata/ufw-direction.txt. An outgoing + // rule is its own rule and stays one. + if r.dir == "in" && r.in == "" { + r.dir = "" + } return r, true } @@ -616,10 +626,15 @@ func (r ufwRule) sameAs(o ufwRule) bool { // protocol — and on any interface, or the private network's for an opening from it. func (r ufwRule) admits(o *declaration.Opening) bool { want, ok := parseRule(strings.Join(Rule(o), " ")) - if !ok || r.route != want.route || r.action != "allow" || r.out != "" || r.app != "" || + if !ok || r.route != want.route || r.action != "allow" || r.app != "" || r.from != "any" || r.fromPort != "" || r.to != "any" { return false } + // A rule on the outgoing path lets this machine reach others; it admits nothing that arrives, + // so it never answers an opening. + if r.dir == "out" || r.out != "" { + return false + } if r.proto != "" && r.proto != want.proto { return false } diff --git a/internal/firewall/firewall_test.go b/internal/firewall/firewall_test.go index 02d14bf..426c7d5 100644 --- a/internal/firewall/firewall_test.go +++ b/internal/firewall/firewall_test.go @@ -597,18 +597,24 @@ func TestUfwTakesTheMeshsRuleAndTheOperatorsForOne(t *testing.T) { 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 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) != 15 { + if err != nil || len(rules) != 21 { t.Fatalf("read %d rules: %v", len(rules), err) } for _, rule := range rules { @@ -716,3 +722,37 @@ func TestALogTypeIsReadInEitherPlace(t *testing.T) { } } } + +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") + } +} diff --git a/internal/firewall/testdata/ufw-direction.txt b/internal/firewall/testdata/ufw-direction.txt new file mode 100644 index 0000000..0fa5371 --- /dev/null +++ b/internal/firewall/testdata/ufw-direction.txt @@ -0,0 +1,21 @@ +$ ufw allow in 9005/tcp +Rules updated +Rules updated (v6) +$ ufw deny in 9006/tcp +Rules updated +Rules updated (v6) +$ ufw allow 9006/tcp +Rules updated +Rules updated (v6) +$ ufw allow out 9007/tcp +Rules updated +Rules updated (v6) +$ ufw allow 9007/tcp +Rules updated +Rules updated (v6) +$ ufw show added +Added user rules (see 'ufw status' for running firewall): +ufw allow 9005/tcp +ufw allow 9006/tcp +ufw allow out 9007/tcp +ufw allow 9007/tcp diff --git a/internal/firewall/testdata/ufw-forms.txt b/internal/firewall/testdata/ufw-forms.txt index a39c9c0..7c89bca 100644 --- a/internal/firewall/testdata/ufw-forms.txt +++ b/internal/firewall/testdata/ufw-forms.txt @@ -14,3 +14,9 @@ ufw allow 9900/tcp ufw allow 80,443/tcp ufw allow in on mesh0 to any port 5432 proto tcp ufw route allow 8080/tcp +ufw allow out 5671/tcp +ufw deny out 5672/tcp +ufw allow out on eth0 to any port 5673 proto tcp +ufw allow log 9001/tcp +ufw route allow log 8084/tcp +ufw allow in on mesh0 log-all to any port 9002 proto tcp