Refuse an opening ufw would merge into a found rule that does other than a plain allow, and read log types in either place (hq ADR 0103)

This commit is contained in:
2026-09-22 18:29:06 +02:00
parent facf6af46a
commit b531c47486
2 changed files with 74 additions and 4 deletions
+38 -3
View File
@@ -479,6 +479,7 @@ func words(rule string) []string {
type ufwRule struct { type ufwRule struct {
route bool route bool
action, in, out string action, in, out string
log string
from, fromPort, to string from, fromPort, to string
port, proto, app string port, proto, app string
comment string comment string
@@ -488,8 +489,23 @@ type ufwRule struct {
// in on mesh0 to any port 5432 proto tcp` — into the fields ufw compares. Not ok for anything it // in on mesh0 to any port 5432 proto tcp` — into the fields ufw compares. Not ok for anything it
// does not recognise, which is then never taken to answer an opening. // does not recognise, which is then never taken to answer an opening.
func parseRule(rule string) (ufwRule, bool) { func parseRule(rule string) (ufwRule, bool) {
w := words(rule)
r := ufwRule{from: "any", to: "any", comment: comment(rule)} r := ufwRule{from: "any", to: "any", comment: comment(rule)}
// A log type may stand after the action or after the direction; either way it is a property
// of the rule, not of where it matches. The word after `comment` is the comment, whatever it
// says.
var w []string
all := words(rule)
for i := 0; i < len(all); i++ {
switch {
case all[i] == "comment" && i+1 < len(all):
w = append(w, all[i], all[i+1])
i++
case all[i] == "log" || all[i] == "log-all":
r.log = all[i]
default:
w = append(w, all[i])
}
}
i := 0 i := 0
if i < len(w) && w[i] == "route" { if i < len(w) && w[i] == "route" {
r.route = true r.route = true
@@ -585,9 +601,13 @@ func isPorts(s string) bool {
return true return true
} }
// sameAs is whether ufw would take two rules for one — everything but the comment equal. // sameAs is whether ufw would take two rules for one: everything equal but the comment, the
// action and the log type. Adding one beside the other updates it in place — its comment, and its
// action or log type — rather than adding a second.
func (r ufwRule) sameAs(o ufwRule) bool { func (r ufwRule) sameAs(o ufwRule) bool {
r.comment, o.comment = "", "" r.comment, o.comment = "", ""
r.action, o.action = "", ""
r.log, o.log = "", ""
return r == o return r == o
} }
@@ -658,6 +678,7 @@ func Converge(ctx context.Context, run Runner, o *declaration.Opening) (Converge
present := false present := false
var stale []string var stale []string
satisfiedBy := "" satisfiedBy := ""
want, _ := parseRule(strings.Join(Rule(o), " "))
for _, rule := range rules { for _, rule := range rules {
c := comment(rule) c := comment(rule)
switch { switch {
@@ -666,7 +687,21 @@ func Converge(ctx context.Context, run Runner, o *declaration.Opening) (Converge
case markedFor(c, o.ID): case markedFor(c, o.ID):
stale = append(stale, rule) stale = append(stale, rule)
default: default:
if parsed, ok := parseRule(rule); ok && satisfiedBy == "" && parsed.admits(o) { parsed, ok := parseRule(rule)
if !ok {
continue
}
// ufw would take the mesh's rule for this one and rewrite its action or log type:
// an operator's refusal, or a limit, would silently become an allow — and removing the
// opening would then delete it. A plain allow answers the opening; anything else is a
// conflict the operator decides (novox/hq ADR 0103).
if parsed.sameAs(want) && (parsed.action != "allow" || parsed.log != "") {
return Converged{}, fmt.Errorf("ufw holds %q, which ufw takes for the same rule as the "+
"mesh's opening for %s, differing in what it does; adding the opening would change "+
"it, so nothing was added. Change or remove that rule, or have the mesh stop "+
"declaring the opening", rule, o.Target())
}
if satisfiedBy == "" && parsed.admits(o) {
satisfiedBy = rule satisfiedBy = rule
} }
} }
+36 -1
View File
@@ -658,7 +658,6 @@ func TestARuleThatDoesNotAnswerTheOpeningLeavesItToBeAdded(t *testing.T) {
"allow from 192.0.2.0/24 to any port 5671 proto tcp", // narrower: from one range "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 in on eth0 to any port 5671 proto tcp", // narrower: one interface
"allow 5671/udp", // another protocol "allow 5671/udp", // another protocol
"deny 5671/tcp", // refuses
"route allow 5671/tcp", // another path "route allow 5671/tcp", // another path
"allow to 192.0.2.1 port 5671 proto tcp", // one address "allow to 192.0.2.1 port 5671 proto tcp", // one address
} { } {
@@ -681,3 +680,39 @@ func TestAnOpeningWhoseFoundRuleIsGoneIsAddedAgain(t *testing.T) {
t.Fatalf("the opening was not added once nothing answered it: %+v %v", done, err) 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)
}
}
}