From f1577685bdc4e565f27a35a805a0ed12a9fa9879 Mon Sep 17 00:00:00 2001 From: Edimar Cardoso Date: Sat, 26 Sep 2026 11:37:07 -0300 Subject: [PATCH 1/4] firewalld: read the state in one parallel wave of whole listings tui-firewall --check took about 8 s on a firewalld host, and sibling tools (tui-wireguard, tui-tailscale) call it on every reload. Each firewall-cmd is a Python interpreter plus D-Bus calls that firewalld answers one at a time, each checked with polkit, and Load ran 10 + 2 per policy object of them one after another, with the compat probe's --version before them. Load now starts nine reads together: --state, --list-all-zones and --list-all-policies (runtime and permanent), --get-services, --get-log-denied, --query-panic and --query-lockdown. The default zone and the active zones come from the "(default, active)" header flags of the runtime zone listing, which firewall-cmd computes from the same daemon calls --get-default-zone and --get-active-zones make; a firewalld that prints no flags, or lacks --list-all-policies, falls back to the old reads for that part only. --check also runs the version probe and the backend survey while the backend loads. The parsed model is unchanged: unit tests build it both ways from captured firewall-cmd 2.3.2 and 2.4.4 output and compare, and the smoke test checks the active zones against --get-active-zones and bounds the time --check takes. Version 0.6.2. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MbCfX3T8khyd9hsrN5kMo3 --- README.md | 12 +- cmd/tui-firewall/check.go | 14 +- cmd/tui-firewall/iptables_test.go | 4 +- cmd/tui-firewall/main.go | 22 +- internal/firewalld/firewalld.go | 68 ++-- internal/firewalld/load.go | 214 ++++++++++++ internal/firewalld/load_test.go | 323 ++++++++++++++++++ .../firewalld/testdata/list-all-policies.txt | 78 +++++ .../testdata/permanent-list-all-policies.txt | 78 +++++ test/smoke.sh | 21 ++ 10 files changed, 772 insertions(+), 62 deletions(-) create mode 100644 internal/firewalld/load.go create mode 100644 internal/firewalld/load_test.go create mode 100644 internal/firewalld/testdata/list-all-policies.txt create mode 100644 internal/firewalld/testdata/permanent-list-all-policies.txt diff --git a/README.md b/README.md index 907b479..43de8aa 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ Upgrades then arrive with the rest of your system updates. ### Any distribution, static binary ```sh -curl -fsSL https://github.com/tui-tools/tui-firewall/releases/download/v0.6.1/tui-firewall_0.6.1_linux_amd64.tar.gz | tar -xz tui-firewall +curl -fsSL https://github.com/tui-tools/tui-firewall/releases/download/v0.6.2/tui-firewall_0.6.2_linux_amd64.tar.gz | tar -xz tui-firewall sudo install -m0755 tui-firewall /usr/local/bin/tui-firewall ``` @@ -724,10 +724,12 @@ are capped, so a firewall under a scan cannot grow it without bound. **firewalld** -- Read `--state`, `--get-default-zone`, `--get-active-zones`, - `--list-all-zones` (runtime and permanent), `--get-services`, - `--get-log-denied`, `--query-panic`, `--query-lockdown` and the policy - objects from `--get-policies`. +- Read `--state`, `--list-all-zones` and `--list-all-policies` (runtime and + permanent), `--get-services`, `--get-log-denied`, `--query-panic` and + `--query-lockdown`, all nine started together. The default zone and the + active zones come from the `(default, active)` flags of the zone listing; a + firewalld that prints no flags, or has no `--list-all-policies`, is asked + `--get-default-zone`, `--get-active-zones` and `--get-policies` instead. - One group per zone, default zone first, then the other active zones; policy objects follow as further groups. - Every entry kind, each marked runtime-only or permanent-only where they diff --git a/cmd/tui-firewall/check.go b/cmd/tui-firewall/check.go index 735e16d..8f9b3b9 100644 --- a/cmd/tui-firewall/check.go +++ b/cmd/tui-firewall/check.go @@ -364,6 +364,15 @@ func countDumpRules(d iptables.Dump) int { return n } +// checkFacts is what --check reports beside the model: the probed backend +// version, what the detector saw of every backend, and why this one was +// chosen. They are gathered while the backend loads; see run. +type checkFacts struct { + compat compat.Result + backends []backends.State + selection string +} + // runCheck exercises the backend's real read path and prints the parsed model // as JSON. It returns an error when the backend cannot be read, which main // turns into a non-zero exit — so a caller can treat the exit code alone as @@ -372,8 +381,7 @@ func countDumpRules(d iptables.Dump) int { // A backend that cannot be read fails here, and that is the correct result: // the exit code alone says whether this machine's firewall is legible to the // tool. -func runCheck(backend firewall.Backend, backendCompat compat.Result, - states []backends.State, selection string, out io.Writer) error { +func runCheck(backend firewall.Backend, facts <-chan checkFacts, out io.Writer) error { ctx, cancel := context.WithTimeout(context.Background(), checkTimeout) defer cancel() @@ -381,6 +389,8 @@ func runCheck(backend firewall.Backend, backendCompat compat.Result, if err != nil { return fmt.Errorf("%s backend read failed: %w", backend.Name(), err) } + survey := <-facts + backendCompat, states, selection := survey.compat, survey.backends, survey.selection report := checkReport{ Tool: toolName, diff --git a/cmd/tui-firewall/iptables_test.go b/cmd/tui-firewall/iptables_test.go index 2eac16f..55b9675 100644 --- a/cmd/tui-firewall/iptables_test.go +++ b/cmd/tui-firewall/iptables_test.go @@ -136,7 +136,9 @@ func TestIptablesStagingAppliesThroughRestoreAndRollsBack(t *testing.T) { func TestIptablesCheckReportsTheBlock(t *testing.T) { fake := iptables.NewFake() var out bytes.Buffer - if err := runCheck(fake, compat.Result{}, backends.Inspect("demo"), "demo", &out); err != nil { + facts := make(chan checkFacts, 1) + facts <- checkFacts{compat: compat.Result{}, backends: backends.Inspect("demo"), selection: "demo"} + if err := runCheck(fake, facts, &out); err != nil { t.Fatalf("runCheck: %v", err) } var report struct { diff --git a/cmd/tui-firewall/main.go b/cmd/tui-firewall/main.go index 69a0f5b..cc808db 100644 --- a/cmd/tui-firewall/main.go +++ b/cmd/tui-firewall/main.go @@ -201,14 +201,24 @@ func run(args []string) error { // and never starts a terminal program. It is checked after the backend is // built so that a machine with no usable firewall fails here with the same // message the UI would have shown. - // The backend version is probed once, here, and used by both paths: the - // header shows it, --check reports it, and the smoke test records it. - backendCompat := probeCompat(context.Background(), backend.Name(), opts.demo.on) - + // The backend version is probed once and used by both paths: the header + // shows it, --check reports it, and the smoke test records it. if opts.check { - return runCheck(backend, backendCompat, backends.Inspect(backend.Name()), - selectionDetail(cfg, opts), os.Stdout) + // The version probe and the backend survey are separate processes + // that do not depend on the firewall read, so --check runs them while + // the backend loads instead of before it: sibling tools call --check + // on every reload, and its cost is what they wait for. + facts := make(chan checkFacts, 1) + go func() { + facts <- checkFacts{ + compat: probeCompat(context.Background(), backend.Name(), opts.demo.on), + backends: backends.Inspect(backend.Name()), + selection: selectionDetail(cfg, opts), + } + }() + return runCheck(backend, facts, os.Stdout) } + backendCompat := probeCompat(context.Background(), backend.Name(), opts.demo.on) a := newApp(backend, theme.New(), backendCompat) a.queueOpen(opts.open.ports, opts.comment) diff --git a/internal/firewalld/firewalld.go b/internal/firewalld/firewalld.go index 9315be4..09ab189 100644 --- a/internal/firewalld/firewalld.go +++ b/internal/firewalld/firewalld.go @@ -12,15 +12,23 @@ // `firewall-cmd` costs a process per read and buys a preview the user can // copy, paste and run themselves, which is the whole point of the tool. // +// A process per read is not free: every firewall-cmd is a Python interpreter +// that imports the firewalld client before its D-Bus calls, several hundred +// milliseconds on a small VM. So Load asks for whole listings rather than +// piecemeal answers, and starts its reads together; see loadSnapshot. +// // # The mapping // // firewalld is zone-based, and a zone holds several species of entry at once, // so it maps onto the generic model like this: // // - Model.Groups: one firewall.Group per zone, from `--list-all-zones`, -// ordered default zone first, then the other active zones -// (`--get-active-zones`), then the rest. Policy objects -// (`--get-policies`) follow as further groups, named with PolicyPrefix. +// ordered default zone first, then the other active zones, then the +// rest. Which zone is the default and which are active comes from the +// "(default, active)" flags of that same listing (`--get-default-zone` +// and `--get-active-zones` only on a firewalld that does not print +// them). Policy objects (`--list-all-policies`) follow as further +// groups, named with PolicyPrefix. // - Group.Default.Target: the zone target, exposed through the single // policy slot firewall.PolicyTarget. firewalld has no global // incoming/outgoing/routed policies, so those slots stay empty. @@ -112,10 +120,11 @@ func (r *Real) Run(ctx context.Context, change firewall.Change) (string, error) // // Only `--state` is fatal. Everything else degrades: a firewalld too old for // policy objects, or one that refuses a read, loses that part of the picture -// rather than the whole screen. +// rather than the whole screen. The reads themselves, and why they run +// together rather than one after another, are described on loadSnapshot. func (r *Real) Load(ctx context.Context) (firewall.Model, error) { - state, err := r.run.Read(ctx, Bin, "--state") - if err != nil || !strings.Contains(state, "running") { + snapshot, running := loadSnapshot(ctx, r.read) + if !running { // firewall-cmd cannot read anything while the daemon is down, so // there is no partial picture to show — only the reason. return firewall.Model{ @@ -124,51 +133,14 @@ func (r *Real) Load(ctx context.Context) (firewall.Model, error) { "`systemctl start firewalld` and press R", }, nil } - - snapshot := Snapshot{Running: true} - snapshot.DefaultZone = firstLine(r.read(ctx, "--get-default-zone")) - snapshot.Active = ParseActiveZones(r.read(ctx, "--get-active-zones")) - snapshot.Zones = ParseSections(r.read(ctx, "--list-all-zones")) - snapshot.PermanentZones = ParseSections(r.read(ctx, "--permanent", "--list-all-zones")) - snapshot.Services = ParseList(r.read(ctx, "--get-services")) - snapshot.LogDenied = firstLine(r.read(ctx, "--get-log-denied")) - snapshot.Panic = strings.TrimSpace(r.read(ctx, "--query-panic")) == "yes" - // Lockdown was removed in firewalld 2.2, where --query-lockdown exits 0 - // and prints a deprecation sentence instead of an answer. Reading the - // answer rather than the exit code is therefore the version-proof test: - // only a literal "yes" means the feature exists and is on. - snapshot.Lockdown = strings.TrimSpace(r.read(ctx, "--query-lockdown")) == "yes" - r.readPolicies(ctx, &snapshot) - return BuildModel(snapshot), nil } -// read runs one read and swallows its failure: every caller above treats a -// missing answer as an absent feature rather than a broken tool. -func (r *Real) read(ctx context.Context, args ...string) string { - out, err := r.run.Read(ctx, append([]string{Bin}, args...)...) - if err != nil { - return "" - } - return out -} - -// readPolicies fills in the policy objects, which firewalld grew in 0.9. On an -// older daemon `--get-policies` simply fails and the snapshot keeps none. -func (r *Real) readPolicies(ctx context.Context, snapshot *Snapshot) { - names := ParseList(r.read(ctx, "--get-policies")) - if len(names) > maxPolicies { - names = names[:maxPolicies] - } - for _, name := range names { - if err := checkAtom("policy", name); err != nil { - continue - } - snapshot.Policies = append(snapshot.Policies, - ParseSections(r.read(ctx, "--policy="+name, "--list-all"))...) - snapshot.PermanentPolicies = append(snapshot.PermanentPolicies, - ParseSections(r.read(ctx, "--permanent", "--policy="+name, "--list-all"))...) - } +// read runs one firewall-cmd read. It is the only place the real backend +// reaches the runner for a read, so loadSnapshot can be driven by a table of +// captured outputs in the tests. +func (r *Real) read(ctx context.Context, args ...string) (string, error) { + return r.run.Read(ctx, append([]string{Bin}, args...)...) } // firstLine trims a one-value answer. diff --git a/internal/firewalld/load.go b/internal/firewalld/load.go new file mode 100644 index 0000000..2b19781 --- /dev/null +++ b/internal/firewalld/load.go @@ -0,0 +1,214 @@ +package firewalld + +import ( + "context" + "strings" + "sync" +) + +// readFunc runs one firewall-cmd read, given the arguments after the binary +// name, and returns its output. The real backend passes the runner; the tests +// pass a table of captured outputs. +type readFunc func(ctx context.Context, args ...string) (string, error) + +// The reads Load starts together. Each is a separate firewall-cmd process, so +// what a load costs is decided here: the list is kept to whole listings, and +// none of them depends on the answer of another. +var ( + argsState = []string{"--state"} + argsZones = []string{"--list-all-zones"} + argsPermanentZones = []string{"--permanent", "--list-all-zones"} + argsServices = []string{"--get-services"} + argsLogDenied = []string{"--get-log-denied"} + argsPanic = []string{"--query-panic"} + argsLockdown = []string{"--query-lockdown"} + argsPolicies = []string{"--list-all-policies"} + argsPermanentPolicies = []string{"--permanent", "--list-all-policies"} + + // The fallbacks, read only on a firewalld whose listings do not already + // carry the answer. + argsDefaultZone = []string{"--get-default-zone"} + argsActiveZones = []string{"--get-active-zones"} + argsPolicyNames = []string{"--get-policies"} +) + +// answer is one read's result. +type answer struct { + out string + err error +} + +// text is the output of a read that succeeded, or "": every caller treats a +// missing answer as an absent feature rather than a broken tool. +func (a answer) text() string { + if a.err != nil { + return "" + } + return a.out +} + +// readAll runs every read at once and returns the answers in the same order. +// +// The reads are independent and read-only, and nearly all of the cost of one +// is the firewall-cmd process itself (interpreter start and imports) rather +// than firewalld answering it, so running them side by side turns the sum of +// their times into roughly the longest one. +func readAll(ctx context.Context, read readFunc, reads ...[]string) []answer { + answers := make([]answer, len(reads)) + var wg sync.WaitGroup + for i, args := range reads { + wg.Add(1) + go func() { + defer wg.Done() + out, err := read(ctx, args...) + answers[i] = answer{out: out, err: err} + }() + } + wg.Wait() + return answers +} + +// loadSnapshot reads the whole firewalld state and reports whether the daemon +// is running. When it is not, the snapshot is empty: firewall-cmd cannot read +// anything while the daemon is down. +// +// On a current firewalld (0.9 and later) this is one wave of nine reads, all +// started together: +// +// - `--state`, the only fatal one; +// - `--list-all-zones` and the same with `--permanent`. The runtime listing +// flags the default zone and the active ones in its headers +// ("public (default, active)"), computed by firewall-cmd from the same +// daemon calls `--get-default-zone` and `--get-active-zones` make, so +// those two are not asked separately; +// - `--list-all-policies` and the same with `--permanent`, in place of +// `--get-policies` plus two `--policy=X --list-all` per policy; +// - `--get-services`, `--get-log-denied`, `--query-panic` and +// `--query-lockdown`, which no listing carries. +// +// A firewalld that does not flag its zones, or does not know +// `--list-all-policies`, gets the older piecemeal reads for that part only, +// so the snapshot is the same either way. +func loadSnapshot(ctx context.Context, read readFunc) (Snapshot, bool) { + wave := readAll(ctx, read, + argsState, argsZones, argsPermanentZones, argsServices, argsLogDenied, + argsPanic, argsLockdown, argsPolicies, argsPermanentPolicies) + state, zones, permanentZones, services, logDenied, panicMode, lockdown, + policies, permanentPolicies := wave[0], wave[1], wave[2], wave[3], + wave[4], wave[5], wave[6], wave[7], wave[8] + + if state.err != nil || !strings.Contains(state.out, "running") { + return Snapshot{}, false + } + + snapshot := Snapshot{Running: true} + snapshot.Zones = ParseSections(zones.text()) + snapshot.PermanentZones = ParseSections(permanentZones.text()) + snapshot.Services = ParseList(services.text()) + snapshot.LogDenied = firstLine(logDenied.text()) + snapshot.Panic = strings.TrimSpace(panicMode.text()) == "yes" + // Lockdown was removed in firewalld 2.2, where --query-lockdown exits 0 + // and prints a deprecation sentence instead of an answer. Reading the + // answer rather than the exit code is therefore the version-proof test: + // only a literal "yes" means the feature exists and is on. + snapshot.Lockdown = strings.TrimSpace(lockdown.text()) == "yes" + + if defaultZone, active, ok := ZoneFlags(snapshot.Zones); ok { + snapshot.DefaultZone, snapshot.Active = defaultZone, active + } else { + fallback := readAll(ctx, read, argsDefaultZone, argsActiveZones) + snapshot.DefaultZone = firstLine(fallback[0].text()) + snapshot.Active = ParseActiveZones(fallback[1].text()) + } + + if policies.err == nil { + snapshot.Policies, snapshot.PermanentPolicies = PolicySections( + ParseSections(policies.text()), ParseSections(permanentPolicies.text())) + } else { + readPoliciesOneByOne(ctx, read, &snapshot) + } + return snapshot, true +} + +// ZoneFlags reads the default zone and the active zones from the headers of a +// runtime `--list-all-zones`. firewall-cmd writes "(default)" beside the zone +// `--get-default-zone` names and "(active)" beside every zone +// `--get-active-zones` lists, so the headers answer both questions without +// two more processes. ok is false when no zone carries the default flag, +// which is how a firewalld that prints no flags at all is recognised. +// +// The active zones carry the interfaces and sources of their own section, +// which is what `--get-active-zones` prints beneath each name. +func ZoneFlags(sections []Section) (defaultZone string, active []ActiveZone, ok bool) { + for _, s := range sections { + if s.Default { + defaultZone, ok = s.Name, true + } + if s.Active { + active = append(active, ActiveZone{ + Name: s.Name, + Default: s.Default, + Interfaces: s.Field(KeyInterfaces), + Sources: s.Field(KeySources), + }) + } + } + if !ok { + return "", nil, false + } + return defaultZone, active, true +} + +// PolicySections keeps the policy objects Load shows: the runtime ones, in +// the order firewalld lists them, at most maxPolicies of them and only those +// whose name is a valid argument, each with its permanent counterpart when +// there is one. It is exactly the set the older per-policy reads produced. +func PolicySections(runtime, permanent []Section) (kept, keptPermanent []Section) { + byName := index(permanent) + for _, s := range runtime { + if len(kept) == maxPolicies { + break + } + if err := checkAtom("policy", s.Name); err != nil { + continue + } + kept = append(kept, s) + if p, ok := byName[s.Name]; ok { + keptPermanent = append(keptPermanent, p) + } + } + return kept, keptPermanent +} + +// readPoliciesOneByOne is the policy read for a firewalld without +// `--list-all-policies`: the names, then each policy on its own. A firewalld +// older than 0.9 has no policy objects at all, `--get-policies` fails and the +// snapshot keeps none. +func readPoliciesOneByOne(ctx context.Context, read readFunc, snapshot *Snapshot) { + names, err := read(ctx, argsPolicyNames...) + if err != nil { + return + } + list := ParseList(names) + if len(list) > maxPolicies { + list = list[:maxPolicies] + } + var reads [][]string + var valid []string + for _, name := range list { + if err := checkAtom("policy", name); err != nil { + continue + } + valid = append(valid, name) + reads = append(reads, + []string{"--policy=" + name, "--list-all"}, + []string{"--permanent", "--policy=" + name, "--list-all"}) + } + answers := readAll(ctx, read, reads...) + for i := range valid { + snapshot.Policies = append(snapshot.Policies, + ParseSections(answers[2*i].text())...) + snapshot.PermanentPolicies = append(snapshot.PermanentPolicies, + ParseSections(answers[2*i+1].text())...) + } +} diff --git a/internal/firewalld/load_test.go b/internal/firewalld/load_test.go new file mode 100644 index 0000000..fb6fbb9 --- /dev/null +++ b/internal/firewalld/load_test.go @@ -0,0 +1,323 @@ +package firewalld + +import ( + "context" + "errors" + "fmt" + "reflect" + "regexp" + "strings" + "sync" + "testing" +) + +// host is a firewalld made of captured firewall-cmd output: each read is +// answered from a table keyed by its arguments, and every read is recorded so +// a test can say how many processes a load would have started. +type host struct { + mu sync.Mutex + answers map[string]string + // missing lists the reads this firewalld does not know; they fail the + // way an unknown option does. + missing map[string]bool + calls []string +} + +func (h *host) read(_ context.Context, args ...string) (string, error) { + key := strings.Join(args, " ") + h.mu.Lock() + h.calls = append(h.calls, key) + h.mu.Unlock() + if h.missing[key] { + return "usage: see firewall-cmd man page", errors.New("exit status 2") + } + if out, ok := h.answers[key]; ok { + return out, nil + } + // A per-policy read is answered from the full policy listing: on + // firewall-cmd 2.3.2 `--policy=X --list-all` prints byte for byte the + // block `--list-all-policies` prints for X (checked when the fixture was + // captured). + if name, ok := strings.CutPrefix(key, "--policy="); ok { + return policyBlock(h.answers["--list-all-policies"], strings.TrimSuffix(name, " --list-all")), nil + } + if name, ok := strings.CutPrefix(key, "--permanent --policy="); ok { + return policyBlock(h.answers["--permanent --list-all-policies"], strings.TrimSuffix(name, " --list-all")), nil + } + return "", fmt.Errorf("unexpected read %q", key) +} + +// policyBlock cuts one policy's block out of a `--list-all-policies` listing. +func policyBlock(listing, name string) string { + for _, block := range strings.Split(listing, "\n\n") { + header, _, _ := strings.Cut(block, "\n") + if strings.Fields(header)[0] == name { + return block + } + } + return "" +} + +func (h *host) count() int { + h.mu.Lock() + defer h.mu.Unlock() + return len(h.calls) +} + +func (h *host) asked(key string) bool { + h.mu.Lock() + defer h.mu.Unlock() + for _, c := range h.calls { + if c == key { + return true + } + } + return false +} + +// newHost builds a firewalld from one release's fixtures. +func newHost(t *testing.T, zones, active, defaultZone string) *host { + return &host{ + answers: map[string]string{ + "--state": "running", + "--get-default-zone": defaultZone, + "--get-active-zones": fixture(t, active), + "--list-all-zones": fixture(t, zones), + "--permanent --list-all-zones": fixture(t, "permanent-list-all-zones.txt"), + "--get-services": "dhcp dns http https ssh", + "--get-log-denied": "off", + "--query-panic": "no", + "--query-lockdown": "no", + "--get-policies": "allow-host-ipv6 docker-forwarding libvirt-routed-in libvirt-routed-out libvirt-to-host", + "--list-all-policies": fixture(t, "list-all-policies.txt"), + "--permanent --list-all-policies": fixture(t, "permanent-list-all-policies.txt"), + }, + missing: map[string]bool{}, + } +} + +// legacySnapshot is the Load of 0.6.1, read for read: one firewall-cmd after +// another, zones' default and active state from their own queries, and two +// reads per policy object. The new load has to produce the same model. +func legacySnapshot(ctx context.Context, read readFunc) Snapshot { + text := func(args ...string) string { + out, err := read(ctx, args...) + if err != nil { + return "" + } + return out + } + s := Snapshot{Running: true} + s.DefaultZone = firstLine(text("--get-default-zone")) + s.Active = ParseActiveZones(text("--get-active-zones")) + s.Zones = ParseSections(text("--list-all-zones")) + s.PermanentZones = ParseSections(text("--permanent", "--list-all-zones")) + s.Services = ParseList(text("--get-services")) + s.LogDenied = firstLine(text("--get-log-denied")) + s.Panic = strings.TrimSpace(text("--query-panic")) == "yes" + s.Lockdown = strings.TrimSpace(text("--query-lockdown")) == "yes" + names := ParseList(text("--get-policies")) + for _, name := range names { + s.Policies = append(s.Policies, ParseSections(text("--policy="+name, "--list-all"))...) + s.PermanentPolicies = append(s.PermanentPolicies, + ParseSections(text("--permanent", "--policy="+name, "--list-all"))...) + } + return s +} + +// releases are the two ends of the tested range, as captured. +var releases = []struct { + name, zones, active, defaultZone string +}{ + {"firewalld 2.3.2", "list-all-zones.txt", "get-active-zones.txt", "FedoraWorkstation"}, + {"firewalld 2.4.4", "list-all-zones-firewalld244.txt", "get-active-zones-firewalld244.txt", "public"}, +} + +func TestLoadSnapshotIsOneWaveOfNineReads(t *testing.T) { + for _, rel := range releases { + t.Run(rel.name, func(t *testing.T) { + h := newHost(t, rel.zones, rel.active, rel.defaultZone) + snapshot, running := loadSnapshot(context.Background(), h.read) + if !running { + t.Fatal("running = false") + } + if got := h.count(); got != 9 { + t.Errorf("reads = %d (%v), want 9", got, h.calls) + } + for _, fallback := range []string{"--get-default-zone", "--get-active-zones", "--get-policies"} { + if h.asked(fallback) { + t.Errorf("asked %s, which the listings already answer", fallback) + } + } + if snapshot.DefaultZone != rel.defaultZone { + t.Errorf("default zone = %q, want %q", snapshot.DefaultZone, rel.defaultZone) + } + }) + } +} + +func TestLoadSnapshotBuildsTheSameModelAsTheLegacyReads(t *testing.T) { + for _, rel := range releases { + t.Run(rel.name, func(t *testing.T) { + h := newHost(t, rel.zones, rel.active, rel.defaultZone) + want := BuildModel(legacySnapshot(context.Background(), h.read)) + snapshot, _ := loadSnapshot(context.Background(), h.read) + got := BuildModel(snapshot) + if !reflect.DeepEqual(got, want) { + t.Errorf("model differs from the legacy reads\n got: %+v\nwant: %+v", got, want) + } + if len(got.Groups) == 0 { + t.Fatal("no groups: the fixtures were not read") + } + }) + } +} + +// flagsRe matches the "(default, active)" suffix of a zone header. +var flagsRe = regexp.MustCompile(`(?m)^(\S+) \([^)]*\)$`) + +func TestLoadSnapshotAsksWhenTheZonesAreNotFlagged(t *testing.T) { + h := newHost(t, "list-all-zones.txt", "get-active-zones.txt", "FedoraWorkstation") + want := BuildModel(legacySnapshot(context.Background(), h.read)) + + // A firewalld that prints bare zone names: the default and the active + // zones must then come from their own queries. + h.answers["--list-all-zones"] = flagsRe.ReplaceAllString(h.answers["--list-all-zones"], "$1") + h.calls = nil + snapshot, _ := loadSnapshot(context.Background(), h.read) + if !h.asked("--get-default-zone") || !h.asked("--get-active-zones") { + t.Fatalf("fallback reads not made: %v", h.calls) + } + if got := BuildModel(snapshot); !reflect.DeepEqual(got, want) { + t.Errorf("model differs without the header flags\n got: %+v\nwant: %+v", got, want) + } +} + +func TestLoadSnapshotReadsPoliciesOneByOneWithoutListAllPolicies(t *testing.T) { + h := newHost(t, "list-all-zones.txt", "get-active-zones.txt", "FedoraWorkstation") + want := BuildModel(legacySnapshot(context.Background(), h.read)) + + // The per-policy answers are cut from the listings, so the listings stay + // in the table; only the options themselves are unknown. + h.missing["--list-all-policies"] = true + h.missing["--permanent --list-all-policies"] = true + h.calls = nil + snapshot, _ := loadSnapshot(context.Background(), h.read) + if !h.asked("--get-policies") || !h.asked("--policy=allow-host-ipv6 --list-all") { + t.Fatalf("per-policy reads not made: %v", h.calls) + } + if len(snapshot.Policies) != 5 || len(snapshot.PermanentPolicies) != 5 { + t.Errorf("policies = %d/%d, want 5/5", len(snapshot.Policies), len(snapshot.PermanentPolicies)) + } + if got := BuildModel(snapshot); !reflect.DeepEqual(got, want) { + t.Errorf("model differs with the per-policy reads\n got: %+v\nwant: %+v", got, want) + } +} + +func TestLoadSnapshotWithoutPolicyObjects(t *testing.T) { + // A firewalld older than 0.9: neither the listing nor the names exist. + h := newHost(t, "list-all-zones.txt", "get-active-zones.txt", "FedoraWorkstation") + h.missing["--list-all-policies"] = true + h.missing["--permanent --list-all-policies"] = true + h.missing["--get-policies"] = true + snapshot, running := loadSnapshot(context.Background(), h.read) + if !running || len(snapshot.Policies) != 0 || len(snapshot.Zones) == 0 { + t.Errorf("running=%v policies=%d zones=%d", running, len(snapshot.Policies), len(snapshot.Zones)) + } +} + +func TestLoadSnapshotStopsWhenTheDaemonIsDown(t *testing.T) { + h := newHost(t, "list-all-zones.txt", "get-active-zones.txt", "FedoraWorkstation") + h.missing["--state"] = true + snapshot, running := loadSnapshot(context.Background(), h.read) + if running { + t.Fatal("running = true with --state failing") + } + if !reflect.DeepEqual(snapshot, Snapshot{}) { + t.Errorf("snapshot = %+v, want empty", snapshot) + } + for _, fallback := range []string{"--get-default-zone", "--get-policies"} { + if h.asked(fallback) { + t.Errorf("asked %s after --state failed", fallback) + } + } +} + +func TestZoneFlagsMatchGetActiveZones(t *testing.T) { + for _, rel := range releases { + t.Run(rel.name, func(t *testing.T) { + defaultZone, active, ok := ZoneFlags(ParseSections(fixture(t, rel.zones))) + if !ok || defaultZone != rel.defaultZone { + t.Fatalf("ZoneFlags = %q, %v; want %q", defaultZone, ok, rel.defaultZone) + } + want := ParseActiveZones(fixture(t, rel.active)) + if len(active) != len(want) { + t.Fatalf("active = %d zones, want %d", len(active), len(want)) + } + for i := range want { + if active[i].Name != want[i].Name || active[i].Default != want[i].Default { + t.Errorf("active[%d] = %s/%v, want %s/%v", i, + active[i].Name, active[i].Default, want[i].Name, want[i].Default) + } + if !sameSet(active[i].Interfaces, want[i].Interfaces) { + t.Errorf("%s interfaces = %v, want %v", want[i].Name, + active[i].Interfaces, want[i].Interfaces) + } + } + }) + } +} + +func TestZoneFlagsWithoutFlags(t *testing.T) { + bare := flagsRe.ReplaceAllString(fixture(t, "list-all-zones.txt"), "$1") + if zone, active, ok := ZoneFlags(ParseSections(bare)); ok || zone != "" || active != nil { + t.Errorf("ZoneFlags on unflagged headers = %q, %v, %v", zone, active, ok) + } +} + +func TestPolicySectionsPairsAndBounds(t *testing.T) { + runtime := ParseSections(fixture(t, "list-all-policies.txt")) + permanent := ParseSections(fixture(t, "permanent-list-all-policies.txt")) + kept, keptPermanent := PolicySections(runtime, permanent[1:]) + if len(kept) != 5 { + t.Fatalf("kept = %d, want 5", len(kept)) + } + if kept[0].Name != "allow-host-ipv6" || !kept[0].Active { + t.Errorf("first = %+v", kept[0]) + } + // The policy missing from the permanent listing simply has no pair. + if len(keptPermanent) != 4 { + t.Errorf("permanent pairs = %d, want 4", len(keptPermanent)) + } + + var many []Section + for i := range maxPolicies + 8 { + many = append(many, newSection(fmt.Sprintf("p%02d", i), "")) + } + many = append([]Section{newSection("-bad", "")}, many...) + kept, _ = PolicySections(many, nil) + if len(kept) != maxPolicies || kept[0].Name != "p00" { + t.Errorf("kept %d starting at %q, want %d starting at p00", len(kept), kept[0].Name, maxPolicies) + } +} + +// sameSet compares two lists ignoring order: `--get-active-zones` prints a +// zone's interfaces in the daemon's order, the listing prints them sorted. +func sameSet(a, b []string) bool { + if len(a) != len(b) { + return false + } + seen := map[string]int{} + for _, v := range a { + seen[v]++ + } + for _, v := range b { + seen[v]-- + } + for _, n := range seen { + if n != 0 { + return false + } + } + return true +} diff --git a/internal/firewalld/testdata/list-all-policies.txt b/internal/firewalld/testdata/list-all-policies.txt new file mode 100644 index 0000000..757af9b --- /dev/null +++ b/internal/firewalld/testdata/list-all-policies.txt @@ -0,0 +1,78 @@ +allow-host-ipv6 (active) + priority: -15000 + target: CONTINUE + ingress-zones: ANY + egress-zones: HOST + services: + ports: + protocols: + masquerade: no + forward-ports: + source-ports: + icmp-blocks: + rich rules: + rule family="ipv6" icmp-type name="mld-listener-done" accept + rule family="ipv6" icmp-type name="mld-listener-query" accept + rule family="ipv6" icmp-type name="mld-listener-report" accept + rule family="ipv6" icmp-type name="mld2-listener-report" accept + rule family="ipv6" icmp-type name="neighbour-advertisement" accept + rule family="ipv6" icmp-type name="neighbour-solicitation" accept + rule family="ipv6" icmp-type name="redirect" accept + rule family="ipv6" icmp-type name="router-advertisement" accept + +docker-forwarding (active) + priority: -1 + target: ACCEPT + ingress-zones: ANY + egress-zones: docker + services: + ports: + protocols: + masquerade: no + forward-ports: + source-ports: + icmp-blocks: + rich rules: + +libvirt-routed-in + priority: -1 + target: ACCEPT + ingress-zones: ANY + egress-zones: libvirt-routed + services: + ports: + protocols: + masquerade: no + forward-ports: + source-ports: + icmp-blocks: + rich rules: + +libvirt-routed-out + priority: -1 + target: ACCEPT + ingress-zones: libvirt-routed + egress-zones: ANY + services: + ports: + protocols: + masquerade: no + forward-ports: + source-ports: + icmp-blocks: + rich rules: + +libvirt-to-host + priority: -1 + target: REJECT + ingress-zones: libvirt-routed + egress-zones: HOST + services: dhcp dhcpv6 dns ssh tftp + ports: + protocols: icmp ipv6-icmp + masquerade: no + forward-ports: + source-ports: + icmp-blocks: + rich rules: + diff --git a/internal/firewalld/testdata/permanent-list-all-policies.txt b/internal/firewalld/testdata/permanent-list-all-policies.txt new file mode 100644 index 0000000..c874d23 --- /dev/null +++ b/internal/firewalld/testdata/permanent-list-all-policies.txt @@ -0,0 +1,78 @@ +allow-host-ipv6 + priority: -15000 + target: CONTINUE + ingress-zones: ANY + egress-zones: HOST + services: + ports: + protocols: + masquerade: no + forward-ports: + source-ports: + icmp-blocks: + rich rules: + rule family="ipv6" icmp-type name="mld-listener-done" accept + rule family="ipv6" icmp-type name="mld-listener-query" accept + rule family="ipv6" icmp-type name="mld-listener-report" accept + rule family="ipv6" icmp-type name="mld2-listener-report" accept + rule family="ipv6" icmp-type name="neighbour-advertisement" accept + rule family="ipv6" icmp-type name="neighbour-solicitation" accept + rule family="ipv6" icmp-type name="redirect" accept + rule family="ipv6" icmp-type name="router-advertisement" accept + +docker-forwarding + priority: -1 + target: ACCEPT + ingress-zones: ANY + egress-zones: docker + services: + ports: + protocols: + masquerade: no + forward-ports: + source-ports: + icmp-blocks: + rich rules: + +libvirt-routed-in + priority: -1 + target: ACCEPT + ingress-zones: ANY + egress-zones: libvirt-routed + services: + ports: + protocols: + masquerade: no + forward-ports: + source-ports: + icmp-blocks: + rich rules: + +libvirt-routed-out + priority: -1 + target: ACCEPT + ingress-zones: libvirt-routed + egress-zones: ANY + services: + ports: + protocols: + masquerade: no + forward-ports: + source-ports: + icmp-blocks: + rich rules: + +libvirt-to-host + priority: -1 + target: REJECT + ingress-zones: libvirt-routed + egress-zones: HOST + services: dhcp dhcpv6 dns ssh tftp + ports: + protocols: icmp ipv6-icmp + masquerade: no + forward-ports: + source-ports: + icmp-blocks: + rich rules: + diff --git a/test/smoke.sh b/test/smoke.sh index ee45055..95e0e96 100755 --- a/test/smoke.sh +++ b/test/smoke.sh @@ -228,6 +228,27 @@ case "$backend" in check "the report names every backend it knows" \ "sudo -n $bin --check | tr -d ' \\n'" \ '"name":"ufw"' + + # 8. The active zones come from the "(active)" flags of --list-all-zones + # rather than from --get-active-zones of their own; they must still be + # exactly the zones --get-active-zones lists. + fw_active=$(sudo -n firewall-cmd --get-active-zones | grep -v '^[[:space:]]' | + awk '{print $1}' | sort | tr '\n' ' ') + check "the active zones are the ones --get-active-zones lists (${fw_active% })" \ + "sudo -n $bin --check | tr -d ' \\n' | grep -o '\"Name\":\"[^\"]*\",\"Title\":\"[^\"]*\",\"Description\":\"active' | sed 's/\"Name\":\"\\([^\"]*\\)\".*/\\1/' | sort | tr '\\n' ' '" \ + "^${fw_active}\$" + + # 9. --check is read on every reload by the sibling tools, so it has to + # stay cheap: one wave of firewall-cmd reads started together, not one + # process after another (it took about 8 s on this guest before). + # The bar is looser than the ~1 s it takes, so a busy host does not fail + # it, and far below the old cost, so a return to serial reads does. + start_ms=$(date +%s%3N) + sudo -n "$bin" --check >/dev/null 2>&1 + check_ms=$(( $(date +%s%3N) - start_ms )) + check "--check answers in under 3 s (took ${check_ms} ms)" \ + "test $check_ms -lt 3000 && echo fast" \ + '^fast$' ;; iptables) From fa260c6867b191f072b46e025f394b4fd30c92e1 Mon Sep 17 00:00:00 2001 From: Edimar Cardoso Date: Sat, 26 Sep 2026 11:37:14 -0300 Subject: [PATCH 2/4] firewalld: document the policy listing fixtures Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MbCfX3T8khyd9hsrN5kMo3 --- internal/firewalld/parse_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/firewalld/parse_test.go b/internal/firewalld/parse_test.go index 45e88ed..b848cb9 100644 --- a/internal/firewalld/parse_test.go +++ b/internal/firewalld/parse_test.go @@ -24,6 +24,12 @@ import ( // verbatim from the lab's Fedora 44 guest running firewall-cmd 2.4.4. Holding // both ends is what turns "the format did not change between 2.3.2 and 2.4.4" // from an assumption into a test. +// +// list-all-policies.txt and permanent-list-all-policies.txt are the policy +// listings of the same 2.3.2 machine, verbatim; the policy names are the ones +// Fedora, libvirt and docker ship. On that release `--policy=X --list-all` +// printed exactly the block the listing prints for X, which load_test.go +// relies on to replay the older per-policy reads. func fixture(t *testing.T, name string) string { t.Helper() data, err := os.ReadFile(filepath.Join("testdata", name)) //nolint:gosec // the name is a literal in the test above, and testdata is in the repository From ef8fc7ae4310ba1b213deff7ad7a013b90d18984 Mon Sep 17 00:00:00 2001 From: Edimar Cardoso Date: Sat, 26 Sep 2026 11:53:39 -0300 Subject: [PATCH 3/4] firewalld: drop --state from the read wave; document the --check cost The runtime zone listing is answered only by a running daemon, so its success already says what --state would; --state is now asked only when that listing fails for another reason, which saves one Python process per load. A stopped daemon is recognised from the listing's own "FirewallD is not running", because each firewall-cmd waits about 10 s for the daemon on D-Bus and asking --state afterwards would double that wait. The README's --check section now states what --check costs per backend and that callers polling it on every reload may keep the answer for a few seconds. The smoke timing uses EPOCHREALTIME, since the uutils date on newer distributions does not expand %3N. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MbCfX3T8khyd9hsrN5kMo3 --- README.md | 38 ++++++++++++++++++++++++----- internal/firewalld/firewalld.go | 5 ++-- internal/firewalld/load.go | 35 ++++++++++++++++++++------- internal/firewalld/load_test.go | 43 +++++++++++++++++++++++++++------ internal/firewalld/model.go | 3 ++- test/smoke.sh | 6 +++-- 6 files changed, 103 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 43de8aa..7d61500 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,30 @@ distinguishable from "ufw is not installed" without running the detection again. The version is probed for the selected backend only: probing the other would run a binary nobody asked this tool to touch. +What it costs is the backend's own reads, so it depends on the backend: + +| Backend | What `--check` runs | Typical time | +|---|---|---| +| ufw | `ufw status verbose`, `ufw status numbered`, `ufw app list` | well under half a second | +| nftables | `nft -j list ruleset` | well under half a second | +| iptables | `iptables-save -c` and `ip6tables-save -c` | well under half a second | +| firewalld | eight `firewall-cmd` reads started together, plus `firewall-cmd --version` | about 1 s on a 2-CPU Fedora 44 machine (firewalld 2.4.4) | + +firewalld is the slow one because every `firewall-cmd` is a Python +interpreter that imports the firewalld client before it makes its D-Bus +calls: 100 to 300 ms each, most of it start-up rather than firewalld +answering. Since 0.6.2 the tool asks for whole listings and starts them +together, so the cost is roughly that of the slowest one on an idle machine, +where 0.6.1 paid for 10 reads plus 2 per policy object one after another +(about 8 s on the lab's Fedora guest). With firewalld stopped every read +waits about 10 s for the daemon on D-Bus before giving up, so `--check` +takes that long to say it is not running. + +A tool that reads `--check` on every refresh, as tui-wireguard and +tui-tailscale do, can keep the answer for a few seconds rather than asking +again for each redraw: the firewall rarely changes between two reloads, and +the tool that changes it knows when it did. + [tui-lab](https://github.com/tui-tools/tui-lab) uses it to test this tool against real firewalls on Ubuntu, Fedora and Omarchy Server; the assertions live in [`test/smoke.sh`](test/smoke.sh). @@ -724,12 +748,14 @@ are capped, so a firewall under a scan cannot grow it without bound. **firewalld** -- Read `--state`, `--list-all-zones` and `--list-all-policies` (runtime and - permanent), `--get-services`, `--get-log-denied`, `--query-panic` and - `--query-lockdown`, all nine started together. The default zone and the - active zones come from the `(default, active)` flags of the zone listing; a - firewalld that prints no flags, or has no `--list-all-policies`, is asked - `--get-default-zone`, `--get-active-zones` and `--get-policies` instead. +- Read `--list-all-zones` and `--list-all-policies` (runtime and permanent), + `--get-services`, `--get-log-denied`, `--query-panic` and `--query-lockdown`, + all eight started together. The default zone and the active zones come from + the `(default, active)` flags of the zone listing; a firewalld that prints no + flags, or has no `--list-all-policies`, is asked `--get-default-zone`, + `--get-active-zones` and `--get-policies` instead, and `--state` is asked + only when the zone listing fails, to tell a stopped daemon from a refused + read. - One group per zone, default zone first, then the other active zones; policy objects follow as further groups. - Every entry kind, each marked runtime-only or permanent-only where they diff --git a/internal/firewalld/firewalld.go b/internal/firewalld/firewalld.go index 09ab189..329a3b3 100644 --- a/internal/firewalld/firewalld.go +++ b/internal/firewalld/firewalld.go @@ -43,7 +43,8 @@ // an entry present in only one is marked "runtime only" or // "permanent only". // - Model.Services: `--get-services`. -// - Model.Enabled: `--state`. Model.Logging: `--get-log-denied`. +// - Model.Enabled: the daemon answered the runtime zone listing (or +// `--state`, when it did not). Model.Logging: `--get-log-denied`. // - Model.Warning: panic mode, from `--query-panic`. // - Mutations: the runtime command and the same command with `--permanent`, // both shown in the confirm dialog. No reload, so no connection is @@ -118,7 +119,7 @@ func (r *Real) Run(ctx context.Context, change firewall.Change) (string, error) // Load reads the whole firewalld state: the daemon's own status, the runtime // and permanent zone listings, the policy objects and the global settings. // -// Only `--state` is fatal. Everything else degrades: a firewalld too old for +// Only a stopped daemon is fatal. Everything else degrades: a firewalld too old for // policy objects, or one that refuses a read, loses that part of the picture // rather than the whole screen. The reads themselves, and why they run // together rather than one after another, are described on loadSnapshot. diff --git a/internal/firewalld/load.go b/internal/firewalld/load.go index 2b19781..db46f55 100644 --- a/internal/firewalld/load.go +++ b/internal/firewalld/load.go @@ -15,7 +15,6 @@ type readFunc func(ctx context.Context, args ...string) (string, error) // what a load costs is decided here: the list is kept to whole listings, and // none of them depends on the answer of another. var ( - argsState = []string{"--state"} argsZones = []string{"--list-all-zones"} argsPermanentZones = []string{"--permanent", "--list-all-zones"} argsServices = []string{"--get-services"} @@ -27,11 +26,16 @@ var ( // The fallbacks, read only on a firewalld whose listings do not already // carry the answer. + argsState = []string{"--state"} argsDefaultZone = []string{"--get-default-zone"} argsActiveZones = []string{"--get-active-zones"} argsPolicyNames = []string{"--get-policies"} ) +// notRunning is what every firewall-cmd that needs the daemon prints, with +// exit code 252, when firewalld is stopped. +const notRunning = "FirewallD is not running" + // answer is one read's result. type answer struct { out string @@ -72,11 +76,13 @@ func readAll(ctx context.Context, read readFunc, reads ...[]string) []answer { // is running. When it is not, the snapshot is empty: firewall-cmd cannot read // anything while the daemon is down. // -// On a current firewalld (0.9 and later) this is one wave of nine reads, all +// On a current firewalld (0.9 and later) this is one wave of eight reads, all // started together: // -// - `--state`, the only fatal one; -// - `--list-all-zones` and the same with `--permanent`. The runtime listing +// - `--list-all-zones` and the same with `--permanent`. The runtime zone +// listing is only answered by a running daemon (firewall-cmd exits 252 +// "FirewallD is not running" otherwise), so its success stands in for +// `--state`, which is asked only when that listing fails. The runtime listing // flags the default zone and the active ones in its headers // ("public (default, active)"), computed by firewall-cmd from the same // daemon calls `--get-default-zone` and `--get-active-zones` make, so @@ -91,14 +97,25 @@ func readAll(ctx context.Context, read readFunc, reads ...[]string) []answer { // so the snapshot is the same either way. func loadSnapshot(ctx context.Context, read readFunc) (Snapshot, bool) { wave := readAll(ctx, read, - argsState, argsZones, argsPermanentZones, argsServices, argsLogDenied, + argsZones, argsPermanentZones, argsServices, argsLogDenied, argsPanic, argsLockdown, argsPolicies, argsPermanentPolicies) - state, zones, permanentZones, services, logDenied, panicMode, lockdown, + zones, permanentZones, services, logDenied, panicMode, lockdown, policies, permanentPolicies := wave[0], wave[1], wave[2], wave[3], - wave[4], wave[5], wave[6], wave[7], wave[8] + wave[4], wave[5], wave[6], wave[7] - if state.err != nil || !strings.Contains(state.out, "running") { - return Snapshot{}, false + if zones.err != nil { + // A stopped daemon says so on the listing itself, after waiting + // for it on D-Bus for about ten seconds; asking --state as well + // would wait that long a second time. + if strings.Contains(zones.out, notRunning) { + return Snapshot{}, false + } + // Otherwise the daemon may be up and have refused this one read: + // only --state tells the two apart, and only the first is fatal. + state, err := read(ctx, argsState...) + if err != nil || strings.TrimSpace(state) != "running" { + return Snapshot{}, false + } } snapshot := Snapshot{Running: true} diff --git a/internal/firewalld/load_test.go b/internal/firewalld/load_test.go index fb6fbb9..9e5b951 100644 --- a/internal/firewalld/load_test.go +++ b/internal/firewalld/load_test.go @@ -20,6 +20,9 @@ type host struct { // missing lists the reads this firewalld does not know; they fail the // way an unknown option does. missing map[string]bool + // stopped makes every read that needs the daemon fail the way it does + // when firewalld is not running. + stopped bool calls []string } @@ -28,6 +31,9 @@ func (h *host) read(_ context.Context, args ...string) (string, error) { h.mu.Lock() h.calls = append(h.calls, key) h.mu.Unlock() + if h.stopped { + return "Waiting on dbus connection...\nFirewallD is not running", errors.New("exit status 252") + } if h.missing[key] { return "usage: see firewall-cmd man page", errors.New("exit status 2") } @@ -133,7 +139,7 @@ var releases = []struct { {"firewalld 2.4.4", "list-all-zones-firewalld244.txt", "get-active-zones-firewalld244.txt", "public"}, } -func TestLoadSnapshotIsOneWaveOfNineReads(t *testing.T) { +func TestLoadSnapshotIsOneWaveOfEightReads(t *testing.T) { for _, rel := range releases { t.Run(rel.name, func(t *testing.T) { h := newHost(t, rel.zones, rel.active, rel.defaultZone) @@ -141,10 +147,10 @@ func TestLoadSnapshotIsOneWaveOfNineReads(t *testing.T) { if !running { t.Fatal("running = false") } - if got := h.count(); got != 9 { - t.Errorf("reads = %d (%v), want 9", got, h.calls) + if got := h.count(); got != 8 { + t.Errorf("reads = %d (%v), want 8", got, h.calls) } - for _, fallback := range []string{"--get-default-zone", "--get-active-zones", "--get-policies"} { + for _, fallback := range []string{"--state", "--get-default-zone", "--get-active-zones", "--get-policies"} { if h.asked(fallback) { t.Errorf("asked %s, which the listings already answer", fallback) } @@ -228,7 +234,7 @@ func TestLoadSnapshotWithoutPolicyObjects(t *testing.T) { func TestLoadSnapshotStopsWhenTheDaemonIsDown(t *testing.T) { h := newHost(t, "list-all-zones.txt", "get-active-zones.txt", "FedoraWorkstation") - h.missing["--state"] = true + h.stopped = true snapshot, running := loadSnapshot(context.Background(), h.read) if running { t.Fatal("running = true with --state failing") @@ -236,9 +242,11 @@ func TestLoadSnapshotStopsWhenTheDaemonIsDown(t *testing.T) { if !reflect.DeepEqual(snapshot, Snapshot{}) { t.Errorf("snapshot = %+v, want empty", snapshot) } - for _, fallback := range []string{"--get-default-zone", "--get-policies"} { + // Each read waits ~10 s for a stopped daemon, so nothing is asked after + // the wave: not --state, and none of the fallbacks. + for _, fallback := range []string{"--state", "--get-default-zone", "--get-policies"} { if h.asked(fallback) { - t.Errorf("asked %s after --state failed", fallback) + t.Errorf("asked %s of a stopped daemon", fallback) } } } @@ -321,3 +329,24 @@ func sameSet(a, b []string) bool { } return true } + +func TestLoadSnapshotAsksStateWhenTheZoneListingFails(t *testing.T) { + // The daemon is up but refused the zone listing: --state says so, and + // the rest of the picture is still shown. + h := newHost(t, "list-all-zones.txt", "get-active-zones.txt", "FedoraWorkstation") + h.missing["--list-all-zones"] = true + snapshot, running := loadSnapshot(context.Background(), h.read) + if !running || !h.asked("--state") { + t.Fatalf("running=%v asked --state=%v", running, h.asked("--state")) + } + if len(snapshot.Zones) != 0 || len(snapshot.PermanentZones) == 0 || len(snapshot.Services) == 0 { + t.Errorf("zones=%d permanent=%d services=%d", len(snapshot.Zones), + len(snapshot.PermanentZones), len(snapshot.Services)) + } + + // And a "not running" answer is still not running. + h.answers["--state"] = "not running" + if _, running := loadSnapshot(context.Background(), h.read); running { + t.Error("running = true with --state saying not running") + } +} diff --git a/internal/firewalld/model.go b/internal/firewalld/model.go index 4c03587..f43202f 100644 --- a/internal/firewalld/model.go +++ b/internal/firewalld/model.go @@ -59,7 +59,8 @@ func noteForScope(scope string) string { // the generic model. Keeping the reads and the mapping apart is what lets the // mapping be tested against captured output with no firewalld anywhere near. type Snapshot struct { - // Running reports `firewall-cmd --state`. + // Running reports that the daemon answered: the runtime zone listing + // succeeded, or `firewall-cmd --state` said "running". Running bool // DefaultZone is `--get-default-zone`. DefaultZone string diff --git a/test/smoke.sh b/test/smoke.sh index 95e0e96..e2b8a66 100755 --- a/test/smoke.sh +++ b/test/smoke.sh @@ -243,9 +243,11 @@ case "$backend" in # process after another (it took about 8 s on this guest before). # The bar is looser than the ~1 s it takes, so a busy host does not fail # it, and far below the old cost, so a return to serial reads does. - start_ms=$(date +%s%3N) + # EPOCHREALTIME rather than `date +%s%3N`, which the uutils date of + # newer distributions does not expand. + start=$EPOCHREALTIME sudo -n "$bin" --check >/dev/null 2>&1 - check_ms=$(( $(date +%s%3N) - start_ms )) + check_ms=$(awk -v a="$start" -v b="$EPOCHREALTIME" 'BEGIN { printf "%d", (b - a) * 1000 }') check "--check answers in under 3 s (took ${check_ms} ms)" \ "test $check_ms -lt 3000 && echo fast" \ '^fast$' From 91c1b119cc87f45fc97c5e1ee1d2b6c0868f3d45 Mon Sep 17 00:00:00 2001 From: Edimar Cardoso Date: Sat, 26 Sep 2026 15:23:38 -0300 Subject: [PATCH 4/4] README: state the --check cost measured on the lab's Fedora guest The lab's 2-vCPU Fedora 44 guest runs --check in about 1.7 s (was 8 s); the README and the smoke comment now quote that rather than the faster container stand-in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MbCfX3T8khyd9hsrN5kMo3 --- README.md | 2 +- test/smoke.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7d61500..64de1c0 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,7 @@ What it costs is the backend's own reads, so it depends on the backend: | ufw | `ufw status verbose`, `ufw status numbered`, `ufw app list` | well under half a second | | nftables | `nft -j list ruleset` | well under half a second | | iptables | `iptables-save -c` and `ip6tables-save -c` | well under half a second | -| firewalld | eight `firewall-cmd` reads started together, plus `firewall-cmd --version` | about 1 s on a 2-CPU Fedora 44 machine (firewalld 2.4.4) | +| firewalld | eight `firewall-cmd` reads started together, plus `firewall-cmd --version` | about 1.7 s on a 2-vCPU Fedora 44 VM (firewalld 2.4.4), down from 8 s | firewalld is the slow one because every `firewall-cmd` is a Python interpreter that imports the firewalld client before it makes its D-Bus diff --git a/test/smoke.sh b/test/smoke.sh index e2b8a66..7cc6553 100755 --- a/test/smoke.sh +++ b/test/smoke.sh @@ -241,7 +241,7 @@ case "$backend" in # 9. --check is read on every reload by the sibling tools, so it has to # stay cheap: one wave of firewall-cmd reads started together, not one # process after another (it took about 8 s on this guest before). - # The bar is looser than the ~1 s it takes, so a busy host does not fail + # The bar is looser than the ~1.7 s it takes, so a busy host does not fail # it, and far below the old cost, so a return to serial reads does. # EPOCHREALTIME rather than `date +%s%3N`, which the uutils date of # newer distributions does not expand.