Skip to content

feat(diag): show applied and pending firewall rules - #53

Open
Behnam-RK wants to merge 1 commit into
feat/two-step-wizardfrom
feat/diagnostics-firewall-rules
Open

feat(diag): show applied and pending firewall rules#53
Behnam-RK wants to merge 1 commit into
feat/two-step-wizardfrom
feat/diagnostics-firewall-rules

Conversation

@Behnam-RK

Copy link
Copy Markdown
Owner

Stacked on #52#51#50#49.

Diagnostics gains a Firewall rules section with three sources, because they answer three different questions and are not interchangeable:

Question Cost
Applied by dezhban What did dezhban install, and when? Free, no root, every platform
In the kernel now What does the firewall actually hold? One password prompt, on demand
Would apply What does FULL BLOCK / guard / switch do? Free, no root, no firewall effects

Each carries a plain-language caption saying what that posture does to your traffic — a ruleset is not self-explanatory to the person most likely to be reading it.

Recording what was applied

internal/applied writes the exact ruleset text handed to the backend, timestamped, beside state.json at 0644 like the state file — so the unprivileged menubar app can read it without root. It holds nothing print-rules would not print for free.

Recorded by wrapping the runner's Backend, not by adding applied.Save beside each Apply. The run loop applies from nineteen places, and a record that is only as complete as the last person to remember it is worse than none — a surface would show a stale posture with no way to tell. Wrapping makes a new call site recorded by construction.

The wrapper adds no goroutine and no writer: every method is called from the run-loop goroutine by the same code that called the backend before, so CLAUDE.md's single-writer invariant is untouched. The write is an atomic replace of a small file — bounded work on the goroutine that owns window expiry and geo ticks, which is why it must stay that shape.

Two properties worth calling out:

  • It records only after a successful Apply. A failed apply leaves the previous ruleset live, so recording the attempt would describe rules that were never installed — the one thing a reader of this file must be able to rely on not happening.
  • Unblock and Cleanup clear it, even when they fail. A record surviving teardown would be read as the live posture: a pane saying "guard is enforcing" over a wide-open network.

It wraps runner.Backend (the narrow enforcement interface), not firewall.FirewallBackend, so the diagnostic read below does not end up on the seam enforcement uses.

Reading the kernel back

FirewallBackend gains InstalledRules() (string, bool, error), implemented for all three backends:

  • pfpfctl -a dezhban -s rules, plus a warning line when the main ruleset no longer references the anchor (loaded but never descended into — the same gap IsBlocked checks).
  • nft — reuses the existing listTable, plus a warning when the output chain's policy has drifted off drop.
  • WFPGet-NetFirewallRule -Group dezhban plus each profile's DefaultOutboundAction, since on Windows that is where the blocking actually lives.

Every one is scoped to dezhban's own anchor/table/group, so this can never become a way to dump a user's unrelated firewall configuration. It is a read: it does not go through Apply and does not touch the single-writer rule, so any goroutine or process may call it. It needs root, which is why nothing calls it on a tick and the daemon never calls it at all.

Drift is reported, never repaired

When dezhban has a record of applying rules and the kernel holds none, both the CLI and the pane say so — and offer no repair. The run loop's VerifyInterval tick already re-applies missing rules; a repair button would be a second writer of the firewall.

Neither surface diffs the two texts. pfctl -s rules renders a normalised form of what was loaded, so a byte comparison would report drift on every healthy host. The texts are shown for a person to read, and the drift flag is the narrow, reliable signal.

CLI

dezhban print-rules --applied            # what dezhban recorded installing
sudo dezhban print-rules --installed     # what the firewall itself holds

--json on either. Passing both is refused with an explanation, because they are two different sources rather than two views of one. Without root, --installed fails with the sudo hint rather than a bare permission error.

"Nothing recorded" exits 0, not 1 — a daemon in standby has applied nothing, and that must be distinguishable from a failure.

Also

The Diagnostics previews are lazy: expanding a posture spawns its print-rules subprocess, collapsed ones spawn nothing. Rendering all three on every visit to the pane would be three processes nobody asked for.

Verification

  • go build, go vet, go test — pass, including GOOS=linux and GOOS=windows vet for the two backends this machine cannot run.
  • New internal/applied tests: round trip, 0644 (the GUI has to read it), missing-is-not-an-error, Remove idempotent, corrupt-is-discarded-not-fatal.
  • New internal/runner tests: what gets recorded matches RenderRules for the same policy, a failed apply leaves the previous record intact, Unblock/Cleanup clear it, an empty path returns the backend unwrapped, and a nil logger does not panic (Run never defaults Log).
  • swift test — 205 tests. RulesetsTests covers Go's RFC 3339 fractional timestamps, which Foundation's .iso8601 strategy rejects outright — that would have turned a good record into "no rules recorded" while the guard was enforcing.
  • print-rules --applied and --installed exercised directly; the unprivileged --installed path produces the intended refusal and hint.
  • build-app.sh assembles cleanly.

The parts CI cannot reach are in docs/contribute/testing.md under a new "Firewall rules (Diagnostics)" section — most importantly: teardown clears the record, the readback changes nothing, and flushing the anchor by hand produces a warning with no repair button while the daemon's own verify tick heals it.

🤖 Generated with Claude Code

Three sources, because they answer three different questions and are not
interchangeable.

What dezhban recorded installing. internal/applied writes the exact ruleset text
handed to the backend, timestamped, beside state.json at 0644 like the state file
— so the unprivileged menubar app can read it. Recorded by wrapping the runner's
Backend rather than by calling Save at each Apply: the run loop applies from
nineteen places, and a record only as complete as the last person to remember it
is worse than none. The wrapper adds no goroutine and no writer, so the
single-writer invariant is untouched, and it records only after a successful
Apply — a failed one leaves the previous ruleset live, and describing rules that
were never installed is the one thing a reader of this file must be able to rely
on not happening. Unblock and Cleanup clear it, so a stale ruleset can never be
read as the live posture.

What the kernel holds. FirewallBackend gains InstalledRules, implemented for pf,
nft and WFP, each scoped to dezhban's own anchor/table/group so it can never
become a way to dump unrelated firewall state. It is a read: it does not go
through Apply and does not touch the single-writer rule. It needs root, which is
why nothing calls it on a tick. pf and nft additionally flag the loaded-but-not-
evaluated cases their IsBlocked already checks for.

What each posture would apply, which print-rules already rendered purely.

A record with no kernel rules is reported and never repaired — the run loop's
verify tick already owns that, and a second repairer would be a second writer.
Neither surface diffs the two texts: the kernel renders its own normalised form
of what was loaded, so a byte comparison would report drift on every healthy
host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Behnam-RK
Behnam-RK force-pushed the feat/two-step-wizard branch from 76b09e9 to 496be74 Compare August 22, 2026 07:11
@Behnam-RK
Behnam-RK force-pushed the feat/diagnostics-firewall-rules branch from b000516 to 0dd295f Compare August 22, 2026 07:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant