// Package firewall speaks the firewall found on an adopted node, in that firewall's own terms // (novox/hq ADR 0100). // // **The found firewall stays in force.** On an adopted node the mesh loads nothing that drops by // default or holds an accept; what it needs reachable it converges as openings through what it // found, marks each rule as its own, and removes only what it marked. It never resets or flushes: // the rules the machine already had are the operator's, and they are what keeps it serving. package firewall import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "os/exec" "regexp" "strconv" "strings" "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/system" ) // Runner executes a command. type Runner = system.Runner // Kind is what firewall a machine has, as far as the mesh is concerned. type Kind string const ( // UFW is an active ufw — the one kind found on the machines measured, and the one spoken. UFW Kind = "ufw" // None is a machine where nothing refuses anything, which needs no openings. None Kind = "none" // Unsupported is a firewall no host speaks yet. A machine with one is refused adoption: the // mesh could neither open what it needs nor know what it would be closing. Unsupported Kind = "unsupported" ) // deletion is the arguments that delete a rule as `ufw show added` printed it. A route rule is // deleted with `route delete …`: ufw refuses `delete route …` as invalid syntax. And ufw answers // success when asked to delete a rule it does not hold, so every deletion is read back. func deletion(rule string) []string { w := words(rule) if len(w) > 0 && w[0] == "route" { return append([]string{"route", "delete"}, w[1:]...) } return append([]string{"delete"}, w...) } // MeshInterface is the private network's interface, the way an opening from the mesh is known. // It must be the controller's overlay interface name. const MeshInterface = "mesh0" // Detect says which firewall this machine has. For Unsupported the string names it. func Detect(ctx context.Context, run Runner) (Kind, string, error) { if out, err := run(ctx, "firewall-cmd", "--state"); err == nil && strings.TrimSpace(out) == "running" { return Unsupported, "firewalld", nil } ufwActive := false if out, err := run(ctx, "ufw", "status"); err == nil { ufwActive = statusActive(out) } out, err := run(ctx, "nft", "list", "ruleset") switch { case err == nil: if refusing := Refusing(out, ufwActive); len(refusing) > 0 { return Unsupported, "nftables rules that refuse traffic, in " + strings.Join(refusing, ", "), nil } case !missing(err): return "", "", fmt.Errorf("cannot read this machine's packet filter to know what it has: %w", err) } if !ufwActive { // iptables with the legacy backend is invisible to nft. for _, legacy := range []string{"iptables-legacy", "ip6tables-legacy"} { out, err := run(ctx, legacy, "-S") if err != nil { continue } if refusing := RefusingLegacy(out); len(refusing) > 0 { return Unsupported, legacy + " rules that refuse traffic, in " + strings.Join(refusing, ", "), nil } } } if ufwActive { return UFW, "ufw", nil } return None, "", nil } func missing(err error) bool { return errors.Is(err, exec.ErrNotFound) } func statusActive(out string) bool { for _, line := range strings.Split(out, "\n") { if strings.HasPrefix(strings.TrimSpace(line), "Status:") { return strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "Status:")) == "active" } } return false } // Refusing names the tables of an `nft list ruleset` holding something that refuses traffic — a // drop or reject, or a base chain whose policy drops — and that is neither the mesh's own nor the // container runtime's. With ufw active, the tables iptables-nft manages are ufw's and the runtime's // and are not counted. // // **A ban is not a firewall.** fail2ban refuses the sources it banned and passes everything else; // captured on a lab machine with both of its backends (testdata/fail2ban-nftables.nft, // testdata/fail2ban-iptables.nft). The mesh opens nothing through it and it closes nothing the // mesh needs, so a refusal that names the sources it refuses, in a table or a chain that accepts // nothing and is entered only from chains whose policy accepts, is not counted. func Refusing(ruleset string, ufwActive bool) []string { type rule struct{ table, chain, line string } type chainOf struct { base, dropping, accepts bool policyLine string jumpedFrom []string } chains := map[string]*chainOf{} // by "table\x00chain" tableAccepts := map[string]bool{} var tables []string var refusals []rule managed := map[string]bool{} var table, chain string get := func(t, c string) *chainOf { k := t + "\x00" + c if chains[k] == nil { chains[k] = &chainOf{} } return chains[k] } for _, raw := range strings.Split(ruleset, "\n") { line := strings.TrimSpace(raw) switch { case strings.HasPrefix(line, "# Warning: table ") && strings.Contains(line, "managed by iptables-nft"): name := strings.TrimPrefix(line, "# Warning: table ") name, _, _ = strings.Cut(name, " is managed") managed[name] = true continue case strings.HasPrefix(line, "table "): table = strings.TrimSuffix(strings.TrimSpace(strings.TrimPrefix(line, "table ")), "{") table = strings.TrimSpace(table) tables = append(tables, table) chain = "" continue case strings.HasPrefix(line, "chain "): chain = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "chain "), "{")) get(table, chain) continue case strings.HasPrefix(line, "set ") || strings.HasPrefix(line, "map ") || strings.HasPrefix(line, "flowtable "): chain = "" continue case line == "" || line == "}" || strings.HasPrefix(line, "#") || chain == "": continue } c := get(table, chain) if strings.HasPrefix(line, "type ") { c.base = true c.policyLine = line c.dropping = strings.Contains(line, "policy drop") continue } for _, verb := range []string{"jump ", "goto "} { if i := strings.Index(line, verb); i >= 0 { target := strings.Fields(line[i+len(verb):]) if len(target) > 0 { get(table, target[0]).jumpedFrom = append(get(table, target[0]).jumpedFrom, chain) } } } if accepts(line) { c.accepts = true tableAccepts[table] = true } if verdictRefuses(line) { refusals = append(refusals, rule{table, chain, line}) } } skipped := func(table string) bool { if table == "inet mesh" || table == "inet mesh_guard" { return true } return (managed[table] || iptablesTable(table)) && ufwActive } // onlyBans is whether a refusal only refuses the sources it names: in a table that accepts // nothing and whose base chains all accept by default, or in a chain that accepts nothing and // is entered only from base chains that accept by default. onlyBans := func(r rule) bool { if !bansSources(r.line) { return false } allAccepting := true for k, c := range chains { if strings.HasPrefix(k, r.table+"\x00") && c.base && !strings.Contains(c.policyLine, "policy accept") { allAccepting = false } } if !tableAccepts[r.table] && allAccepting { return true } c := get(r.table, r.chain) if c.base || c.accepts || len(c.jumpedFrom) == 0 { return false } for _, from := range c.jumpedFrom { caller := get(r.table, from) if !caller.base || !strings.Contains(caller.policyLine, "policy accept") { return false } } return true } counted := map[string]bool{} for k, c := range chains { t, name, _ := strings.Cut(k, "\x00") if skipped(t) || !c.dropping { continue } if (managed[t] || iptablesTable(t)) && runtimes(t, name, c.policyLine) { continue } counted[t] = true } for _, r := range refusals { if skipped(r.table) || counted[r.table] { continue } if (managed[r.table] || iptablesTable(r.table)) && runtimes(r.table, r.chain, r.line) { continue } if onlyBans(r) { continue } counted[r.table] = true } var refusing []string for _, t := range tables { if counted[t] { counted[t] = false refusing = append(refusing, "table "+t) } } return refusing } // iptablesTable is whether a table is one iptables-nft writes. Named rather than read from the // warning nft prints above it, because nft does not print that for every such table: a captured // ruleset carried it on ip filter and not on ip raw, where the runtime keeps its drops. func iptablesTable(table string) bool { family, name, _ := strings.Cut(table, " ") if family != "ip" && family != "ip6" { return false } switch name { case "filter", "nat", "raw", "mangle", "security": return true } return false } // runtimes is whether a refusal in an iptables-nft table is the container runtime's own: in its // DOCKER chains, its forward policy, or its guard against reaching a container's address directly // from outside its bridge, in the raw table. func runtimes(table, chain, line string) bool { _, name, _ := strings.Cut(table, " ") switch { case strings.HasPrefix(chain, "DOCKER"): return true case name == "filter" && chain == "FORWARD" && strings.HasPrefix(line, "type "): return true case name == "raw" && chain == "PREROUTING": return strings.Contains(line, "daddr") && strings.Contains(line, "iifname !=") } return false } // iptables-nft prints a REJECT target it cannot translate as `xt target "REJECT"`, measured in // testdata/fail2ban-iptables.nft; a refusal written that way is a refusal too. var verdict = regexp.MustCompile(`(^|\s)(drop|reject)(\s|$)|xt target "(DROP|REJECT)"`) func verdictRefuses(line string) bool { return verdict.MatchString(line) } var acceptVerdict = regexp.MustCompile(`(^|\s)accept(\s|;|$)|xt target "ACCEPT"`) func accepts(line string) bool { return acceptVerdict.MatchString(line) } // bansSources is whether a refusal names the sources it refuses — a set or an address — rather // than refusing everyone but some. func bansSources(line string) bool { f := strings.Fields(line) for i, w := range f { if (w == "saddr" || w == "-s") && i+1 < len(f) && f[i+1] != "!=" && !strings.HasPrefix(f[i+1], "!") { return i == 0 || f[i-1] != "!" } } return false } // RefusingLegacy names the chains of an `iptables-legacy -S` that refuse traffic outside the // container runtime's own. A ban — a refusal of the sources it names, in a chain that accepts // nothing and is entered only from built-in chains whose policy accepts — is not counted, as in // Refusing (testdata/fail2ban-iptables-S.txt). func RefusingLegacy(rules string) []string { policy := map[string]string{} accepting := map[string]bool{} jumpedFrom := map[string][]string{} for _, line := range strings.Split(rules, "\n") { fields := strings.Fields(line) if len(fields) < 3 { continue } switch fields[0] { case "-P": policy[fields[1]] = fields[2] case "-A": for i, f := range fields { if (f == "-j" || f == "-g") && i+1 < len(fields) { switch fields[i+1] { case "ACCEPT": accepting[fields[1]] = true case "DROP", "REJECT", "RETURN", "LOG": default: jumpedFrom[fields[i+1]] = append(jumpedFrom[fields[i+1]], fields[1]) } } } } } ban := func(chain, line string) bool { if !bansSources(line) || accepting[chain] || len(jumpedFrom[chain]) == 0 { return false } for _, from := range jumpedFrom[chain] { if policy[from] != "ACCEPT" { return false } } return true } var refusing []string seen := map[string]bool{} for _, line := range strings.Split(rules, "\n") { fields := strings.Fields(line) if len(fields) < 3 { continue } chain := fields[1] refuses := false switch fields[0] { case "-P": refuses = fields[2] == "DROP" && chain != "FORWARD" case "-A": for i, f := range fields { if f == "-j" && i+1 < len(fields) && (fields[i+1] == "DROP" || fields[i+1] == "REJECT") { refuses = !strings.HasPrefix(chain, "DOCKER") && !ban(chain, line) } } } if refuses && !seen[chain] { seen[chain] = true refusing = append(refusing, "chain "+chain) } } return refusing } // --- ufw --------------------------------------------------------------------------------------- // Mark is the comment every rule the mesh adds carries: whose it is, which opening, and a digest // of the rule itself, so a rule the opening no longer describes is recognised as stale without the // host having to know how ufw prints a rule back. func Mark(o *declaration.Opening) string { sum := sha256.Sum256([]byte(strings.Join(Rule(o), " "))) return marker(o.ID) + " " + hex.EncodeToString(sum[:])[:8] } func marker(id string) string { return "mesh-host " + id } // markedFor is whether a comment is the mesh's, for this opening. func markedFor(comment, id string) bool { return comment == marker(id) || strings.HasPrefix(comment, marker(id)+" ") } // Rule is the ufw rule an opening becomes, without its comment. // // incoming from everywhere allow proto tcp to any port P // incoming from the mesh allow in on mesh0 proto tcp to any port P // forwarded route allow [in on mesh0] proto tcp to any port // // A forwarded opening names the container's port because ufw's route rules are matched after the // runtime's destination translation. func Rule(o *declaration.Opening) []string { var rule []string port := o.Port if o.Path == declaration.PathForwarded { rule = append(rule, "route") port = o.To } rule = append(rule, "allow") if o.From == declaration.FromMesh { rule = append(rule, "in", "on", MeshInterface) } return append(rule, "proto", o.Protocol, "to", "any", "port", strconv.Itoa(port)) } var commentOf = regexp.MustCompile(`comment '([^']*)'`) // added is every rule `ufw show added` lists, each without its leading "ufw". func added(ctx context.Context, run Runner) ([]string, error) { out, err := run(ctx, "ufw", "show", "added") if err != nil { return nil, fmt.Errorf("reading ufw's rules: %w", err) } var rules []string for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "ufw ") { rules = append(rules, strings.TrimPrefix(line, "ufw ")) } } return rules, nil } func comment(rule string) string { m := commentOf.FindStringSubmatch(rule) if m == nil { return "" } return m[1] } // words splits a rule as ufw printed it into arguments, keeping a quoted comment whole. func words(rule string) []string { var out []string var cur strings.Builder quoted, any := false, false for _, r := range rule { switch { case r == '\'': quoted = !quoted any = true case r == ' ' && !quoted: if any { out = append(out, cur.String()) cur.Reset() any = false } default: cur.WriteRune(r) any = true } } if any { out = append(out, cur.String()) } return out } // A ufw rule, as `ufw show added` prints it or as it is given, reduced to what ufw compares. // // **ufw treats two rules that differ only in their comment as one rule.** Measured on a lab // machine (testdata/ufw-comment-only.txt): adding `route allow proto tcp to any port 8080 comment // 'mesh-host …'` beside an operator's `route allow 8080/tcp` answers "Rule updated", and the // operator's rule now carries the mesh's mark — so removing the opening later would delete the // operator's rule. The same holds for an incoming rule and for one with a comment of its own. type ufwRule struct { route bool action, in, out 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 } // parseRule reads a rule in either of ufw's forms — the short `allow 22/tcp` and the long `allow // 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. func parseRule(rule string) (ufwRule, bool) { 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 if i < len(w) && w[i] == "route" { r.route = true i++ } if i >= len(w) { return r, false } switch w[i] { case "allow", "deny", "reject", "limit": r.action = w[i] default: return r, false } i++ for i < len(w) && (w[i] == "in" || w[i] == "out") { dir := w[i] i++ iface := "" if i+1 < len(w) && w[i] == "on" { iface = w[i+1] i += 2 } r.dir = dir if dir == "in" { r.in = iface } else { r.out = iface } } if i < len(w) && w[i] != "from" && w[i] != "to" && w[i] != "proto" && w[i] != "comment" && w[i] != "app" && w[i] != "log" && w[i] != "log-all" { // The short form: a port with its protocol, a bare port, or an application's name. port, proto, hasProto := strings.Cut(w[i], "/") if isPorts(port) { r.port = port if hasProto { r.proto = proto } } else { r.app = w[i] } i++ } for ; i < len(w); i++ { next := func() string { if i+1 < len(w) { i++ return w[i] } return "" } switch w[i] { case "from": r.from = next() if i+1 < len(w) && w[i+1] == "port" { i++ r.fromPort = next() } case "to": r.to = next() if i+1 < len(w) && w[i+1] == "port" { i++ r.port = next() } case "port": r.port = next() case "proto": r.proto = next() case "app": r.app = next() case "comment": next() case "log", "log-all": default: return r, false } } 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 } func isPorts(s string) bool { if s == "" { return false } for _, c := range s { if (c < '0' || c > '9') && c != ':' && c != ',' { return false } } return true } // 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 { r.comment, o.comment = "", "" r.action, o.action = "", "" r.log, o.log = "", "" return r == o } // admits is whether a rule already lets through what an opening says: the same path, allowed from // any source to any address of this machine, on the opening's port and protocol — or on any // 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.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 } if r.in != "" && r.in != want.in { return false } return portsInclude(r.port, want.port) } // portsInclude is whether a ufw port list — 80, 80,443, or 8000:8100 — names a port. func portsInclude(list, port string) bool { p, err := strconv.Atoi(port) if err != nil { return false } for _, part := range strings.Split(list, ",") { lo, hi, isRange := strings.Cut(part, ":") a, err := strconv.Atoi(lo) if err != nil { continue } b := a if isRange { if b, err = strconv.Atoi(hi); err != nil { continue } } if a <= p && p <= b { return true } } return false } // Converged is what converging an opening did. SatisfiedBy names the rule already there that // answers the opening, when one does; the mesh then adds nothing, and so will remove nothing. type Converged struct { Action string SatisfiedBy string } // Converge makes one opening true in ufw: its marked rule present, and any rule marked for it that // no longer describes it deleted. Nothing unmarked is touched. The outcome is created, updated or // unchanged, read back from ufw rather than assumed. // // **An opening a rule already answers is not added** (novox/hq ADR 0103). If ufw holds a rule not // marked for this opening that already admits what it says — the operator's, or one the mesh // added for another opening — the opening is satisfied by it: adding the mesh's would take that // rule over if it differs only in its comment, and removing the opening would then delete it. func Converge(ctx context.Context, run Runner, o *declaration.Opening) (Converged, error) { rules, err := added(ctx, run) if err != nil { return Converged{}, err } mark := Mark(o) present := false var stale []string satisfiedBy := "" want, _ := parseRule(strings.Join(Rule(o), " ")) for _, rule := range rules { c := comment(rule) switch { case c == mark: present = true case markedFor(c, o.ID): stale = append(stale, rule) default: 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 } } } if present && len(stale) == 0 { return Converged{Action: "unchanged"}, nil } for _, rule := range stale { if _, err := run(ctx, "ufw", deletion(rule)...); err != nil { return Converged{}, fmt.Errorf("deleting the mesh's stale ufw rule %q: %w", rule, err) } } if !present && satisfiedBy != "" { after, err := added(ctx, run) if err != nil { return Converged{}, err } for _, rule := range after { if markedFor(comment(rule), o.ID) { return Converged{}, fmt.Errorf("ufw still lists a stale rule marked for %s after deleting it", o.ID) } } action := "unchanged" if len(stale) > 0 { action = "updated" } return Converged{Action: action, SatisfiedBy: satisfiedBy}, nil } if !present { args := append(Rule(o), "comment", mark) if _, err := run(ctx, "ufw", args...); err != nil { return Converged{}, fmt.Errorf("adding the ufw rule for %s: %w", o.Target(), err) } } after, err := added(ctx, run) if err != nil { return Converged{}, err } found, leftover := false, 0 for _, rule := range after { c := comment(rule) if c == mark { found = true } else if markedFor(c, o.ID) { leftover++ } } if !found { return Converged{}, fmt.Errorf("ufw was asked for %s and does not list it afterwards", o.Target()) } if leftover > 0 { return Converged{}, fmt.Errorf("ufw still lists %d stale rule(s) marked for %s after deleting them", leftover, o.ID) } if len(stale) > 0 { return Converged{Action: "updated"}, nil } return Converged{Action: "created"}, nil } // Remove deletes the rules marked for one opening, and nothing else. func Remove(ctx context.Context, run Runner, id string) (int, error) { rules, err := added(ctx, run) if err != nil { return 0, err } removed := 0 for _, rule := range rules { if !markedFor(comment(rule), id) { continue } if _, err := run(ctx, "ufw", deletion(rule)...); err != nil { return removed, fmt.Errorf("deleting the mesh's ufw rule %q: %w", rule, err) } removed++ } after, err := added(ctx, run) if err != nil { return removed, err } for _, rule := range after { if markedFor(comment(rule), id) { return removed, fmt.Errorf("ufw still lists a rule marked for %s after deleting it", id) } } return removed, nil } // Enable turns ufw back on, as found, and reads back that it is. func Enable(ctx context.Context, run Runner) error { if _, err := run(ctx, "ufw", "--force", "enable"); err != nil { return fmt.Errorf("enabling ufw again: %w", err) } return expectActive(ctx, run, true) } // Disable retires ufw without flushing it: its configuration stays on disk, and the container // runtime's rules are not its to remove. // // **Nor is the forward policy ufw's to open.** Measured on a lab machine running the container // runtime with a published port (testdata/ufw-disable-iptables-before.txt and -after.txt): // `ufw disable` sets every built-in chain's policy to accept, the forward chain's among them. The // runtime had set that one to drop when it turned forwarding on, and it does not set it again while // forwarding stays on — not even on a restart. Left so, a retired ufw turns the machine into a // router for anyone who can reach it. So each family's forward policy is read before, and one that // was drop is put back and read back. before is ForwardPolicies as read before the first attempt. func Disable(ctx context.Context, run Runner, before map[string]string) error { if _, err := run(ctx, "ufw", "disable"); err != nil { return fmt.Errorf("disabling ufw: %w", err) } if err := expectActive(ctx, run, false); err != nil { return err } for _, tool := range []string{"iptables", "ip6tables"} { if before[tool] != "DROP" { continue } if now, ok := forwardPolicy(ctx, run, tool); ok && now == "DROP" { continue } if _, err := run(ctx, tool, "-P", "FORWARD", "DROP"); err != nil { return fmt.Errorf("ufw is disabled, and %s's forward policy, which was drop, could not be put back: %w", tool, err) } if now, ok := forwardPolicy(ctx, run, tool); !ok || now != "DROP" { return fmt.Errorf("ufw is disabled, and %s's forward policy was put back to drop and reads %q", tool, now) } } return nil } // ForwardPolicies reads each family's forward policy, by the tool that sets it. Read before ufw is // disabled and kept by the caller, so a retirement that fails half-way is retried with what the // machine had — not with what the half-done disable left. func ForwardPolicies(ctx context.Context, run Runner) map[string]string { out := map[string]string{} for _, tool := range []string{"iptables", "ip6tables"} { if policy, ok := forwardPolicy(ctx, run, tool); ok { out[tool] = policy } } return out } // forwardPolicy reads the forward chain's policy the way iptables prints it: "-P FORWARD DROP". // Not ok when the tool is absent or says nothing readable. func forwardPolicy(ctx context.Context, run Runner, tool string) (string, bool) { out, err := run(ctx, tool, "-S", "FORWARD") if err != nil { return "", false } for _, line := range strings.Split(out, "\n") { f := strings.Fields(line) if len(f) == 3 && f[0] == "-P" && f[1] == "FORWARD" { return f[2], true } } return "", false } func expectActive(ctx context.Context, run Runner, want bool) error { out, err := run(ctx, "ufw", "status") if err != nil { return fmt.Errorf("reading ufw's status back: %w", err) } if statusActive(out) != want { state := "inactive" if want { state = "active" } return fmt.Errorf("ufw was asked to be %s and says: %s", state, strings.TrimSpace(out)) } return nil }