diff --git a/CHANGELOG.md b/CHANGELOG.md index 074af2e..5d35d5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,23 @@ current as you land changes. ### Added +- **The firewall rules are visible in Diagnostics.** Three things, because they + answer three different questions: what dezhban **recorded installing** (and + when), what the **kernel actually holds** (read back on demand, needs your + password), and what **each posture would apply** — guard, full block, switch + window — rendered without applying anything. Each carries a plain-language + caption saying what that posture does to your traffic. When dezhban recorded + applying rules and the firewall holds none, the pane says so; it does not offer + to repair, because the running daemon's own verification tick already does + that and a second repairer would be a second writer. +- **`dezhban print-rules --applied` and `--installed`**, the CLI half of the + above. `--applied` reads a record dezhban now writes on every successful apply + (a 0644 file beside the state file — no root, same on every platform). + `--installed` asks the firewall itself, scoped to dezhban's own + anchor/table/group and needing root for that reason; it installs nothing and + repairs nothing. `--json` on either for machine output. The two texts will not + match byte for byte on a healthy host — the firewall renders its own + normalised form — so neither surface diffs them. - **Settings → Remove Dezhban…** — the complete uninstall, from the app. It removes what only your own login session can reach (the Touch ID key in the login keychain, the "open at login" registration, this app's preferences and diff --git a/cmd/dezhban/main.go b/cmd/dezhban/main.go index b608a77..b27eefe 100644 --- a/cmd/dezhban/main.go +++ b/cmd/dezhban/main.go @@ -25,6 +25,7 @@ import ( "syscall" "time" + "github.com/behnam-rk/dezhban/internal/applied" "github.com/behnam-rk/dezhban/internal/armed" "github.com/behnam-rk/dezhban/internal/command" "github.com/behnam-rk/dezhban/internal/config" @@ -67,7 +68,7 @@ Commands: status Show version, config, and current state validate Load and validate a config file (no root, no side effects) monitor Live read-only view: IP, country, tunnel state, endpoints, verdict - print-rules Print the firewall ruleset a block/guard would apply, without applying it + print-rules Print the firewall ruleset a block/guard would apply (--applied: what is applied now) doctor Diagnose VPN guard config (tunnels, endpoints, lockout risks) panic Force-remove dezhban's rules even if nothing is running install Register dezhban as a boot-persistent OS service @@ -795,6 +796,7 @@ func assembleOptions(cfg *config.Config, cfgPath string, log *slog.Logger, ov ru PollCommand: pollCommand, Publish: publish, BlockedCountries: cfg.BlockedCountries, + AppliedRulesPath: applied.Path(stateDir()), ReloadConfig: reload, WriteConfig: writeConfigKeysAt, AllowConfigOps: cfg.Control.AllowConfigOps, @@ -1810,8 +1812,23 @@ func cmdPrintRules(args []string) int { fs := flag.NewFlagSet("print-rules", flag.ExitOnError) cfgPath := fs.String("config", "", "path to config file (JSON)") mode := fs.String("mode", "guard", "policy to render: guard, fullblock, or switch") + appliedOnly := fs.Bool("applied", false, "print the ruleset dezhban last applied, instead of rendering one") + installed := fs.Bool("installed", false, "read dezhban's rules back out of the kernel (needs root)") + asJSON := fs.Bool("json", false, "machine-readable output (with --applied or --installed)") _ = fs.Parse(args) + if *appliedOnly && *installed { + fmt.Fprintln(os.Stderr, "--applied and --installed are two different sources; pick one.") + fmt.Fprintln(os.Stderr, "--applied is what dezhban recorded installing; --installed is what the kernel holds now.") + return 2 + } + if *appliedOnly { + return printAppliedRules(*asJSON) + } + if *installed { + return printInstalledRules(*asJSON) + } + cfg, err := loadConfig(*cfgPath) if err != nil { fmt.Fprintln(os.Stderr, "config error:", err) @@ -1831,6 +1848,138 @@ func cmdPrintRules(args []string) int { return 0 } +// printAppliedRules prints what the daemon recorded applying, as opposed to what +// a posture WOULD apply (which the rest of print-rules renders, purely). +// +// This is dezhban's own account, not a reading of the kernel: it is what the run +// loop handed the backend, timestamped, and it is the half that works +// unprivileged and identically on every platform. The label says so, because +// "the current rules" would be a claim this cannot make. +// +// Nothing recorded is an ordinary answer, not a failure — a daemon in standby +// has applied nothing, and neither has one that was never started. It exits 0 +// and says so, so a caller can tell that apart from an error. +func printAppliedRules(asJSON bool) int { + path := applied.Path(stateDir()) + rec, ok, err := applied.Load(path) + if err != nil { + fmt.Fprintln(os.Stderr, "could not read the applied-ruleset record:", err) + return 1 + } + if asJSON { + if !ok { + fmt.Println("null") + return 0 + } + out, err := json.MarshalIndent(rec, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, "encode failed:", err) + return 1 + } + fmt.Println(string(out)) + return 0 + } + if !ok { + fmt.Fprintf(os.Stderr, "no ruleset recorded at %s.\n", path) + fmt.Fprintln(os.Stderr, "dezhban records one on every apply; in standby it has applied nothing.") + return 0 + } + fmt.Fprintf(os.Stderr, "# %s ruleset dezhban applied at %s (mode %s)\n", + rec.Backend, rec.At.Local().Format(time.RFC3339), rec.Mode) + fmt.Fprintln(os.Stderr, "# This is what dezhban installed, not a reading of the kernel.") + fmt.Print(rec.Rules) + return 0 +} + +// installedRules is the machine shape of a kernel readback, paired with the +// record of what dezhban believes it applied so a consumer does not have to +// fetch and correlate the two itself. `Drift` is the finding. +type installedRules struct { + // Installed is the rule text read out of the kernel, empty when dezhban has + // no rules loaded. + Installed string `json:"installed"` + // Loaded is false when dezhban has no rules in the kernel at all — an + // ordinary answer (standby, nothing running), never an error. + Loaded bool `json:"loaded"` + // Applied is what the daemon recorded installing, absent when nothing was + // recorded. + Applied *applied.Record `json:"applied,omitempty"` + // Drift is true when dezhban has a record of what it applied and the kernel + // disagrees about whether rules are loaded at all. It deliberately does NOT + // diff 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 + // text is shown to a human for that reason. + Drift bool `json:"drift"` + // Backend names the syntax of Installed. + Backend string `json:"backend"` +} + +// printInstalledRules reads dezhban's rules back out of the kernel — the other +// half of the picture from --applied, which is only dezhban's own account. +// +// A READ: it installs nothing and changes nothing, so it does not touch the +// single-writer rule that governs Apply. It does need root, which is why it is +// on demand rather than on a tick — and why nothing in the daemon calls it. +// Repairing a discrepancy is not this command's job either: the run loop's +// verify tick already owns that, and a second repairer would be a second writer. +func printInstalledRules(asJSON bool) int { + rec, hasRecord, recErr := applied.Load(applied.Path(stateDir())) + if recErr != nil { + fmt.Fprintln(os.Stderr, "note: could not read the applied-ruleset record:", recErr) + } + backend, err := firewall.New() + if err != nil { + fmt.Fprintln(os.Stderr, "firewall backend unavailable:", err) + return 1 + } + text, loaded, err := backend.InstalledRules() + if err != nil { + fmt.Fprintln(os.Stderr, "could not read the installed rules:", err) + if !privilege.IsPrivileged() { + fmt.Fprintln(os.Stderr, "reading the firewall back needs root — try: sudo dezhban print-rules --installed") + } + return 1 + } + + out := installedRules{ + Installed: text, + Loaded: loaded, + Backend: firewall.RulesetKind, + Drift: hasRecord && !loaded, + } + if hasRecord { + out.Applied = &rec + } + if asJSON { + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, "encode failed:", err) + return 1 + } + fmt.Println(string(data)) + return 0 + } + + if out.Drift { + fmt.Fprintf(os.Stderr, "WARNING: dezhban recorded applying a %q ruleset at %s,\n", + rec.Mode, rec.At.Local().Format(time.RFC3339)) + fmt.Fprintln(os.Stderr, "but the kernel holds no dezhban rules. Something removed them.") + fmt.Fprintln(os.Stderr, "dezhban's own verification re-applies on its next tick; `dezhban status` will say.") + return 0 + } + if !loaded { + fmt.Fprintln(os.Stderr, "no dezhban rules are loaded (standby, or nothing running).") + return 0 + } + fmt.Fprintf(os.Stderr, "# %s rules currently loaded, read from the kernel\n", out.Backend) + if hasRecord { + fmt.Fprintf(os.Stderr, "# dezhban applied a %q ruleset at %s\n", + rec.Mode, rec.At.Local().Format(time.RFC3339)) + } + fmt.Print(text) + return 0 +} + // checkStatus classifies one doctorReport check for a machine consumer (the // macOS Diagnostics pane) without it having to parse Summary/Details prose. type checkStatus string diff --git a/docs/concepts/modes.md b/docs/concepts/modes.md index f434e66..3e6237d 100644 --- a/docs/concepts/modes.md +++ b/docs/concepts/modes.md @@ -444,3 +444,36 @@ dezhban print-rules --mode switch --config > Note these previews are static config, not the runtime posture: a config with > no tunnel previews as a full block here, while the running daemon idles > rule-free in STANDBY until a tunnel is actually observed up. + +## What is enforcing right now + +The previews above answer "what would this posture do?". Two other flags answer +"what is happening?", and they are deliberately different sources: + +```sh +dezhban print-rules --applied # what dezhban recorded installing, and when +sudo dezhban print-rules --installed # what the firewall itself holds +``` + +`--applied` reads a record the daemon writes on every successful apply, beside +the state file. It is dezhban's **own account** — the exact text it handed the +firewall, with the tunnel interfaces and endpoint addresses resolved at that +moment, which is why it can be more accurate than re-rendering the config after +the fact. It needs no root and works the same on every platform. It says nothing +about the kernel, and its label says so. + +`--installed` asks the firewall. It is scoped to dezhban's own +anchor/table/group, never a dump of unrelated firewall state, and it is a **read** +— it installs nothing and repairs nothing. It needs root, which is why nothing +runs it on a timer. + +When dezhban has a record of applying rules and the firewall holds none, +`--installed` reports it. That is the case something outside dezhban flushed the +firewall, and it is reported rather than repaired: the run loop's own +verification tick already re-applies missing rules, and a second repairer would +be a second writer. + +The two texts will **not** match byte for byte on a healthy host — the firewall +renders its own normalised form of what was loaded — so neither surface diffs +them. The macOS app shows all three (applied, in the kernel, and the per-posture +previews) in Diagnostics › Firewall rules. diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 6524da1..81813a5 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -1202,6 +1202,35 @@ end up typing a password. `dezhban doctor` prints in a terminal. - [ ] CLI missing → the guided "dezhban CLI not found" state, not a blank list. +### Firewall rules (Diagnostics) + +- [ ] **Applied appears without a password.** With the guard up, open + Diagnostics: "Applied by dezhban — Guard" shows a timestamp and the pf + ruleset, with no prompt. Compare it against + `dezhban print-rules --applied` in a terminal — same text. +- [ ] **It tracks the posture.** Drive a block with `--simulate-country IR`; the + applied row becomes "Full block" and the timestamp moves. Open a switch + window; it becomes "Switch window". +- [ ] **Teardown clears it.** `sudo dezhban stop` (or `panic`), then re-open + Diagnostics: the row reads "no ruleset recorded yet". A stale ruleset shown + as live over an open network is the failure this must never have. +- [ ] **The kernel readback asks for a password and only reads.** "Read from the + kernel…" prompts once and shows `pfctl -a dezhban -s rules` output. Confirm + nothing changed: `dezhban status` and the posture are identical before and + after, and running it with the guard DOWN reports "no dezhban rules are + loaded" rather than an error. +- [ ] **Drift is reported, not repaired.** With the guard up, flush the anchor by + hand (`sudo pfctl -a dezhban -F rules`), then "Read from the kernel…": the + pane must warn that dezhban applied rules the firewall no longer holds, and + must offer **no** repair button. Then confirm the daemon's own verify tick + re-applies them within `vpn.advanced.verifyInterval` and the log says so. +- [ ] **The previews cost nothing and need no root.** As an unprivileged user + with dezhban stopped, expand each of Guard / Full block / Switch window: + each renders, and each matches `dezhban print-rules --mode `. +- [ ] **Only what is opened is fetched.** Visiting Diagnostics with every + disclosure collapsed must spawn no `print-rules` subprocess (watch with + `sudo fs_usage -w -f exec | grep dezhban`, or Activity Monitor). + ### Help pane The pane's whole reason for existing is that it works while the guard has cut diff --git a/docs/usage/cli.md b/docs/usage/cli.md index e4c76ac..bf7d07b 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -9,7 +9,7 @@ Commands: unblock Remove dezhban's firewall rules (root) status Show version, config, service, and block state (--json for tooling) validate Load + validate a config file (no root, no effects) - print-rules Print the ruleset a block/guard would apply, without applying it + print-rules Print the ruleset a block/guard would apply (--applied/--installed: what is live) doctor Diagnose VPN guard config (tunnels, endpoints, lockout risks) monitor Live read-only view: IP, country, tunnel state, endpoints, verdict panic Force-remove dezhban's rules even with no daemon (root) @@ -51,7 +51,7 @@ daemon** over its control socket and need no password at all — provided | Command | Needs a password? | |---|---| | `block`, `unblock`, `switch`, `pause`, `resume` | **No** — the running daemon performs them (see [config.md](config.md#control-block)). Only if no daemon is listening do they fall back — `block`/`unblock` act on the firewall directly; `switch`/`pause`/`resume` write the root-owned command file, which itself needs a running daemon to consume it. Either way, root. | -| `status`, `validate`, `print-rules`, `doctor`, `monitor`, `detect-vpn` | **No** — read-only, no root, no firewall effects. | +| `status`, `validate`, `print-rules`, `doctor`, `monitor`, `detect-vpn` | **No** — read-only, no root, no firewall effects. The one exception is `print-rules --installed`, which reads the firewall itself and therefore needs root; it still installs and changes nothing. | | `install`, `uninstall`, `start`, `stop`, `restart` | Yes — a daemon can't install, start, or stop itself. Rare (install-time). | | `panic` | Yes — deliberately independent of the daemon, so the lockout escape hatch works when nothing else does. | | `run` | Yes — it *is* the daemon. | @@ -239,6 +239,8 @@ Inspect and validate before you risk a block — none of these touch the firewal ```sh dezhban validate --config # parse + validate, summarize dezhban print-rules --mode guard --config # exact ruleset, not applied +dezhban print-rules --applied # what dezhban recorded installing +sudo dezhban print-rules --installed # what the firewall itself holds dezhban doctor --config # tunnels, subnets, endpoint sanity dezhban doctor --discover --config # macOS: find the VPN's real server IP dezhban doctor --json --config # the same checks as structured JSON @@ -246,7 +248,22 @@ dezhban monitor --config # live: IP, country, tunne ``` `monitor` streams the live state the decision rests on; add `--once` for a single -snapshot. `print-rules --mode` takes `guard`, `fullblock`, or `switch`. `doctor +snapshot. `print-rules --mode` takes `guard`, `fullblock`, or `switch`, and +renders purely — no root, no firewall effects. + +`--applied` and `--installed` answer the other question, "what is enforcing right +now?", from two deliberately different sources. `--applied` reads a record +dezhban writes on every successful apply (a 0644 file beside the state file, so +the menubar app can read it without root): the exact text handed to the firewall, +timestamped, with the interfaces and endpoints resolved at that moment. +`--installed` asks the firewall — scoped to dezhban's own anchor/table/group, +never a dump of unrelated state — and needs root for that reason. It is a read: +it installs nothing and repairs nothing. When dezhban recorded applying rules and +the firewall holds none, `--installed` says so; repairing that is the running +daemon's verification tick's job, not this command's. Add `--json` to either for +machine output. The two texts will not match byte for byte on a healthy host, so +neither surface diffs them — see +[modes.md](../concepts/modes.md#what-is-enforcing-right-now). `doctor --json` prints the identical findings `doctor` reports in prose — `{checks: [{name, status, summary, details, fixes}], ok}` — for a consumer (the macOS app's Diagnostics pane) that needs to render them itself rather than parse diff --git a/gui/macos/Sources/DezhbanCore/Rulesets.swift b/gui/macos/Sources/DezhbanCore/Rulesets.swift new file mode 100644 index 0000000..220adf7 --- /dev/null +++ b/gui/macos/Sources/DezhbanCore/Rulesets.swift @@ -0,0 +1,133 @@ +import Foundation + +/// The firewall rules dezhban recorded applying — `print-rules --applied --json`, +/// mirroring Go's `applied.Record`. +/// +/// This is dezhban's own account of what it installed, not a reading of the +/// kernel, and every surface showing it must say so. The distinction is not +/// pedantry: something outside dezhban can flush a firewall, and a pane that +/// called this "the current rules" would go on claiming the guard was enforcing +/// over a wide-open network. +public struct AppliedRuleset: Codable, Hashable { + public let mode: String + public let at: Date + public let rules: String + /// The mechanism the text is written for — "pf", "nft", "wfp" — so a reader + /// does not have to infer a syntax from the platform. + public let backend: String + + public init(mode: String, at: Date, rules: String, backend: String) { + self.mode = mode + self.at = at + self.rules = rules + self.backend = backend + } + + /// Go writes RFC 3339 with fractional seconds; `.iso8601` alone rejects + /// those, which would turn a perfectly good record into "no rules recorded". + public static func decode(_ data: Data) -> AppliedRuleset? { + for strategy in [rfc3339Fractional, rfc3339] { + let d = JSONDecoder() + d.dateDecodingStrategy = .formatted(strategy) + if let v = try? d.decode(AppliedRuleset.self, from: data) { return v } + } + return nil + } + + private static let rfc3339Fractional: DateFormatter = formatter("yyyy-MM-dd'T'HH:mm:ss.SSSSSSZZZZZ") + private static let rfc3339: DateFormatter = formatter("yyyy-MM-dd'T'HH:mm:ssZZZZZ") + + private static func formatter(_ format: String) -> DateFormatter { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = format + f.timeZone = TimeZone(secondsFromGMT: 0) + return f + } +} + +/// What the kernel actually holds — `print-rules --installed --json`. +/// +/// The privileged half of the picture, taken on demand rather than on a tick. +/// It is a READ: it installs nothing, so it does not touch the rule that only +/// the run loop may apply. +public struct InstalledRuleset: Hashable { + public let installed: String + /// False when dezhban has no rules in the kernel at all. An ordinary + /// answer — standby, or nothing running — never an error. + public let loaded: Bool + public let applied: AppliedRuleset? + /// True when dezhban has a record of applying rules and the kernel holds + /// none. Deliberately NOT a text diff: the kernel renders a normalised form + /// of what was loaded, so comparing bytes would report drift on every + /// healthy host. The texts are shown side by side for a person to read. + public let drift: Bool + public let backend: String + + public init(installed: String, loaded: Bool, applied: AppliedRuleset?, + drift: Bool, backend: String) { + self.installed = installed + self.loaded = loaded + self.applied = applied + self.drift = drift + self.backend = backend + } + + /// Decoded by hand rather than through Codable so the nested `applied` + /// record can reuse AppliedRuleset's two-format date handling. + public static func decode(_ data: Data) -> InstalledRuleset? { + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + var nested: AppliedRuleset? + if let sub = obj["applied"], + let subData = try? JSONSerialization.data(withJSONObject: sub) { + nested = AppliedRuleset.decode(subData) + } + return InstalledRuleset( + installed: obj["installed"] as? String ?? "", + loaded: obj["loaded"] as? Bool ?? false, + applied: nested, + drift: obj["drift"] as? Bool ?? false, + backend: obj["backend"] as? String ?? "") + } +} + +/// The postures whose rulesets can be previewed without applying anything. +/// +/// These are the stable `print-rules --mode` identifiers, which CLAUDE.md pins +/// as part of the CLI contract — they are not display strings and must not be +/// renamed to read better. +public enum RulesetPreview: String, CaseIterable, Identifiable, Sendable { + case guardMode = "guard" + case fullBlock = "fullblock" + case switchWindow = "switch" + + public var id: String { rawValue } + + public var label: String { + switch self { + case .guardMode: return "Guard" + case .fullBlock: return "Full block" + case .switchWindow: return "Switch window" + } + } + + /// What this posture does to traffic, in one line — the caption beside the + /// rules, because a ruleset is not self-explanatory to the person most + /// likely to be reading it. + public var detail: String { + switch self { + case .guardMode: + return "The standing posture: only the VPN tunnel and the handshake to its server may leave. " + + "Everything else is dropped, so a tunnel drop cuts traffic with no leak window." + case .fullBlock: + return "What happens when the VPN's exit lands in a blocked country: the tunnel's own pass is " + + "removed too, so no traffic reaches that exit — but the handshake to the server stays " + + "open, so the VPN can still move." + case .switchWindow: + return "The bounded window you open deliberately to connect a new VPN. It closes early on a " + + "confirmed good exit, and always at its deadline." + } + } +} diff --git a/gui/macos/Sources/DezhbanMenu/AppState.swift b/gui/macos/Sources/DezhbanMenu/AppState.swift index f18f831..ed921ef 100644 --- a/gui/macos/Sources/DezhbanMenu/AppState.swift +++ b/gui/macos/Sources/DezhbanMenu/AppState.swift @@ -164,6 +164,15 @@ final class AppState: ObservableObject { @Published var doctorReport: DoctorReport? @Published var doctorError: String? @Published var doctorRunning = false + + /// The rules dezhban recorded applying, and the rules the kernel actually + /// holds. Two separate reads: the first is unprivileged and refreshed with + /// the rest of the pane, the second costs a password and only happens when + /// asked for. + @Published var appliedRules: AppliedRuleset? + @Published var installedRules: InstalledRuleset? + @Published var installedRulesError: String? + @Published var installedRulesRunning = false /// The sidebar's yellow dot: the last doctor report has something a person /// should look at. A dedicated Bool (not derived in the cell) so the /// sidebar can subscribe with removeDuplicates() and never reload at 1 Hz. @@ -327,6 +336,47 @@ final class AppState: ObservableObject { } } + /// Reads what dezhban recorded applying. Unprivileged and cheap — the record + /// is a small file beside state.json — so it refreshes with the rest of the + /// Diagnostics pane rather than on demand. + func refreshAppliedRules() { + guard cliFound else { return } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let rules = DezhbanCLI.readAppliedRules() + DispatchQueue.main.async { self?.appliedRules = rules } + } + } + + /// Reads dezhban's rules back out of the kernel. Costs an admin prompt, so + /// it is never automatic. + /// + /// A READ — it installs nothing, changes nothing, and does not go through + /// `Backend.Apply`, so it leaves the run loop's single-writer rule alone. + /// There is deliberately no repair here either: the run loop's verification + /// tick already re-applies rules that go missing, and a second repairer + /// would be a second writer. + func readInstalledRules() { + guard !installedRulesRunning, cliFound else { return } + installedRulesRunning = true + installedRulesError = nil + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let r = DezhbanCLI.runPrivileged(["print-rules", "--installed", "--json"]) + let decoded = r.ok ? r.output.data(using: .utf8).flatMap(InstalledRuleset.decode) : nil + DispatchQueue.main.async { + guard let self else { return } + self.installedRulesRunning = false + if let decoded { + self.installedRules = decoded + } else { + self.installedRules = nil + self.installedRulesError = r.output.isEmpty + ? "No output from `dezhban print-rules --installed`." + : r.output + } + } + } + } + /// The background-trigger form: runs doctor only when the last report is /// older than maxAge (or absent). The staleness gate is load-bearing — /// callers include the essential-class edge into warning/blocked, and a diff --git a/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift b/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift index 2f7fa80..0bb6bde 100644 --- a/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift +++ b/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift @@ -272,6 +272,35 @@ enum DezhbanCLI { return ProfilesInfo.decode(data) } + /// Reads what dezhban recorded applying, via `print-rules --applied --json`. + /// Unprivileged: the record is a 0644 file beside state.json, written so the + /// menubar app can read it without root. + /// + /// nil covers both "nothing recorded" (the CLI prints `null`) and a CLI too + /// old to know the flag. The pane says "nothing recorded yet" either way, + /// which is true in both cases — it must never claim rules that are not + /// there. + static func readAppliedRules() -> AppliedRuleset? { + guard let bin = binaryPath() else { return nil } + let r = exec(bin, ["print-rules", "--applied", "--json"]) + guard r.status == 0, let data = r.out.data(using: .utf8) else { return nil } + return AppliedRuleset.decode(data) + } + + /// Renders what one posture WOULD apply, via `print-rules --mode `. + /// Pure, unprivileged, and with no firewall effects — the same guarantee the + /// command carries in a terminal. + /// + /// stdout only (`exec`, not `.run`): autodetect writes a timestamped line to + /// stderr on every call, and folding that into the rules would make the text + /// differ from run to run for no reason. + static func renderRules(mode: RulesetPreview) -> String? { + guard let bin = binaryPath() else { return nil } + let r = exec(bin, ["print-rules", "--mode", mode.rawValue, "--config", resolvedConfigPath()]) + guard r.status == 0, !r.out.isEmpty else { return nil } + return r.out + } + /// Reads all three presets and which (if any) matches the current config, /// via `config preset list --json`. static func readPresets() -> [PresetSummary]? { diff --git a/gui/macos/Sources/DezhbanMenu/DiagnosticsView.swift b/gui/macos/Sources/DezhbanMenu/DiagnosticsView.swift index 52d06ab..b150960 100644 --- a/gui/macos/Sources/DezhbanMenu/DiagnosticsView.swift +++ b/gui/macos/Sources/DezhbanMenu/DiagnosticsView.swift @@ -23,6 +23,7 @@ struct DiagnosticsView: View { // refresh when what is there has gone stale. state.runDoctorIfStale(maxAge: 15 * 60) state.refreshVPNInventoryIfStale() + state.refreshAppliedRules() } } @@ -46,6 +47,7 @@ struct DiagnosticsView: View { private func run() { state.runDoctor(discover: discover) state.refreshVPNInventoryIfStale(maxAge: 0) + state.refreshAppliedRules() } @ViewBuilder @@ -77,6 +79,7 @@ struct DiagnosticsView: View { } } vpnInventorySection + firewallRulesSection if let report = state.doctorReport { Section { Label(report.ok ? "No lockout risk found" : "Found something to fix", @@ -100,6 +103,137 @@ struct DiagnosticsView: View { } } + // MARK: - firewall rules + + /// What the guard is doing to your traffic, in three parts, because they + /// answer three different questions and are not interchangeable: + /// + /// - **Applied** — what dezhban recorded installing, and when. Its own + /// account: cheap, unprivileged, and identical on every platform. + /// - **In the kernel** — what is actually loaded, read back on demand. + /// Costs a password, so it is never automatic. This is the half that can + /// see something outside dezhban having flushed the firewall. + /// - **Would apply** — the ruleset of each posture, rendered without + /// applying anything (`print-rules --mode`). The safe way to find out + /// what FULL BLOCK does before you are in it. + /// + /// The labels say which is which. "The current rules" would be a claim only + /// the middle one can make. + @ViewBuilder + private var firewallRulesSection: some View { + Section("Firewall rules") { + appliedRow + installedRow + previewRows + } + } + + @ViewBuilder + private var appliedRow: some View { + if let a = state.appliedRules { + rulesDisclosure( + title: "Applied by dezhban — \(postureLabel(a.mode))", + caption: "What dezhban installed at \(Self.stamp.string(from: a.at)), in \(a.backend) syntax. " + + "This is dezhban's own record, not a reading of the firewall.", + rules: a.rules) + } else { + Label("No ruleset recorded yet — dezhban writes one every time it applies rules. " + + "In standby it has applied none.", + systemImage: "doc.text") + .font(.callout) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var installedRow: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + Button("Read from the kernel…") { state.readInstalledRules() } + .disabled(state.installedRulesRunning || !state.cliFound) + .help("Asks the firewall itself what dezhban rules it holds. Needs your password. " + + "It only reads — nothing is installed, changed, or repaired.") + if state.installedRulesRunning { ProgressView().controlSize(.small) } + } + if let error = state.installedRulesError { + Label(error, systemImage: "exclamationmark.triangle.fill") + .font(.callout) + .foregroundStyle(.orange) + .textSelection(.enabled) + } + if let i = state.installedRules { + if i.drift { + // The finding, stated plainly. No repair button: the run + // loop's verification tick already re-applies rules that go + // missing, and a second repairer would be a second writer of + // the firewall. + Label("dezhban applied rules, but the firewall holds none. Something removed them. " + + "dezhban's own verification re-applies on its next check — this pane only reports.", + systemImage: "exclamationmark.triangle.fill") + .font(.callout) + .foregroundStyle(.orange) + } else if !i.loaded { + Label("No dezhban rules are loaded. That is expected in standby, or with dezhban stopped.", + systemImage: "info.circle") + .font(.callout) + .foregroundStyle(.secondary) + } else { + rulesDisclosure( + title: "In the kernel now", + caption: "Read back from the firewall, in \(i.backend) syntax. It will not match the " + + "applied text byte for byte — the firewall renders its own normalised form.", + rules: i.installed) + } + } + } + } + + @ViewBuilder + private var previewRows: some View { + ForEach(RulesetPreview.allCases) { mode in + rulesDisclosure( + title: "Would apply — \(mode.label)", + caption: mode.detail, + rules: nil, + load: { DezhbanCLI.renderRules(mode: mode) }) + } + } + + /// One collapsed ruleset. `rules` is text already in hand; `load` fetches it + /// the first time it is opened instead — the three previews each cost a + /// subprocess, and rendering all of them on every visit to this pane would + /// be three processes nobody asked for. + @ViewBuilder + private func rulesDisclosure(title: String, caption: String, + rules: String?, + load: (() -> String?)? = nil) -> some View { + DisclosureGroup { + RulesetBody(rules: rules, load: load) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.callout.weight(.medium)) + Text(caption) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + /// The posture strings are stable CLI identifiers, not display text, so they + /// are mapped rather than shown raw. An unknown one is shown as-is: a + /// daemon newer than this app is not a reason to hide what it said. + private func postureLabel(_ mode: String) -> String { + RulesetPreview(rawValue: mode)?.label ?? mode + } + + private static let stamp: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .none + f.timeStyle = .medium + return f + }() + /// The VPN inventory (`detect-vpn --json`): which tunnels and VPN apps /// detection can see, and which one is connected now. Hidden entirely when /// the CLI is too old for the subcommand — degrade by omission, never a @@ -256,3 +390,47 @@ struct DiagnosticsView: View { } } + +/// The body of one ruleset disclosure: monospaced, selectable, and scrollable in +/// its own right so a long ruleset cannot stretch the pane. +/// +/// It exists as a view rather than a `@ViewBuilder` function so `load` can run +/// once, on first appearance, and hold its result. The three posture previews +/// each cost a `print-rules` subprocess; rendering them eagerly would spawn +/// three processes on every visit to Diagnostics for text nobody may open. +private struct RulesetBody: View { + let rules: String? + let load: (() -> String?)? + + @State private var loaded: String? + @State private var failed = false + + var body: some View { + Group { + if let text = rules ?? loaded { + ScrollView([.horizontal, .vertical]) { + Text(text) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 260) + } else if failed { + Text("Couldn't render this ruleset. `dezhban print-rules` needs a config it can read.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ProgressView().controlSize(.small) + } + } + .onAppear { + guard rules == nil, loaded == nil, let load else { return } + DispatchQueue.global(qos: .userInitiated).async { + let text = load() + DispatchQueue.main.async { + if let text { loaded = text } else { failed = true } + } + } + } + } +} diff --git a/gui/macos/Tests/DezhbanCoreTests/RulesetsTests.swift b/gui/macos/Tests/DezhbanCoreTests/RulesetsTests.swift new file mode 100644 index 0000000..8a27d6d --- /dev/null +++ b/gui/macos/Tests/DezhbanCoreTests/RulesetsTests.swift @@ -0,0 +1,77 @@ +import Foundation +import Testing +@testable import DezhbanCore + +/// The producer side — what gets recorded, and when — is pinned by Go's +/// internal/applied and internal/runner tests. This is the consumer side: that +/// the app decodes what `print-rules --applied/--installed --json` emits. +struct RulesetsTests { + /// Go's encoding/json writes time.Time as RFC 3339 with fractional seconds. + /// Foundation's `.iso8601` strategy rejects those outright, which would turn + /// a perfectly good record into "no rules recorded" — a pane claiming the + /// guard had installed nothing while it was enforcing. + @Test func decodesGosFractionalTimestamps() throws { + let json = """ + {"version":1,"mode":"guard","at":"2026-08-21T14:02:11.123456+02:00", + "rules":"block drop out all\\n","backend":"pf"} + """ + let a = try #require(AppliedRuleset.decode(Data(json.utf8))) + #expect(a.mode == "guard") + #expect(a.backend == "pf") + #expect(a.rules == "block drop out all\n") + } + + /// Whole seconds, no fraction — what Go emits when the instant happens to + /// land on one. Both forms have to decode or the pane works only sometimes. + @Test func decodesWholeSecondTimestamps() throws { + let json = """ + {"version":1,"mode":"fullblock","at":"2026-08-21T14:02:11Z","rules":"x\\n","backend":"nft"} + """ + let a = try #require(AppliedRuleset.decode(Data(json.utf8))) + #expect(a.mode == "fullblock") + #expect(a.backend == "nft") + } + + /// `null` is what the CLI prints when nothing has been recorded — an + /// ordinary state, not a parse failure, and the caller shows "nothing + /// recorded yet" for both. + @Test func nullIsNotARecord() { + #expect(AppliedRuleset.decode(Data("null".utf8)) == nil) + } + + @Test func decodesAnInstalledReadbackWithItsNestedRecord() throws { + let json = """ + {"installed":"block drop out all\\n","loaded":true, + "applied":{"version":1,"mode":"guard","at":"2026-08-21T14:02:11.5Z", + "rules":"block drop out all\\n","backend":"pf"}, + "drift":false,"backend":"pf"} + """ + let i = try #require(InstalledRuleset.decode(Data(json.utf8))) + #expect(i.loaded) + #expect(!i.drift) + #expect(i.applied?.mode == "guard") + } + + /// Rules recorded, none in the kernel: the finding this readback exists to + /// surface. It must survive decoding intact — a drift flag lost in transit + /// is a tampering report nobody sees. + @Test func driftSurvivesDecoding() throws { + let json = """ + {"installed":"","loaded":false,"drift":true,"backend":"pf"} + """ + let i = try #require(InstalledRuleset.decode(Data(json.utf8))) + #expect(i.drift) + #expect(!i.loaded) + #expect(i.applied == nil) + } + + /// The preview modes are the stable `print-rules --mode` identifiers named + /// in CLAUDE.md. Renaming one to read better breaks the CLI contract. + @Test func previewModesAreTheStableCLIIdentifiers() { + #expect(RulesetPreview.allCases.map(\.rawValue) == ["guard", "fullblock", "switch"]) + for mode in RulesetPreview.allCases { + #expect(!mode.label.isEmpty) + #expect(!mode.detail.isEmpty) + } + } +} diff --git a/internal/applied/applied.go b/internal/applied/applied.go new file mode 100644 index 0000000..c528d44 --- /dev/null +++ b/internal/applied/applied.go @@ -0,0 +1,112 @@ +// Package applied records the firewall ruleset the daemon last installed, so a +// diagnostic surface can show what is actually being enforced rather than +// asking the reader to re-derive it. +// +// `dezhban print-rules --mode guard|fullblock|switch` already renders what each +// posture WOULD apply — pure, root-free, and available at any time. What was +// missing is the other half: which of those is live right now, rendered from the +// policy that was actually handed to the backend, including the tunnel +// interfaces and endpoint addresses resolved at that moment. Those change while +// the daemon runs, so re-rendering after the fact can quietly disagree with what +// the kernel holds. +// +// This is dezhban's own account of what it did, not a reading of the kernel. It +// is the cheap half of the picture and works identically on every platform; the +// GUI pairs it with an on-demand privileged readback, and a disagreement between +// the two is itself the finding. Deliberately NOT a substitute for the run +// loop's verify tick, which is what notices and repairs rules going missing. +// +// The record lives beside the state file (see cmd/dezhban.defaultStatePath), +// same convention as internal/learned and internal/armed: daemon-owned, +// machine-derived, never the user's config, and safe to discard — a missing or +// corrupt file just means "nothing recorded yet". Every write is a whole-file +// atomic replace, so a reader never sees a torn file. Mode 0644 like state.json: +// the unprivileged menubar app has to be able to read it, and it holds nothing +// `print-rules` would not print for free. +package applied + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/behnam-rk/dezhban/internal/atomicfile" + "github.com/behnam-rk/dezhban/internal/state" +) + +// version is the on-disk schema version. Bump on an incompatible change. +const version = 1 + +// FileName is the record's name within the state directory. +const FileName = "applied-rules.json" + +// Record is the whole applied-rules.json document. +type Record struct { + Version int `json:"version"` + // Mode is the posture string the ruleset installs — the same stable + // identifier print-rules --mode takes ("guard", "fullblock", "switch"). + Mode string `json:"mode"` + // At is when the apply succeeded. A reader shows it verbatim: "what dezhban + // applied at 14:02:11" is an honest label in a way "the current rules" is + // not, because nothing here observes the kernel. + At time.Time `json:"at"` + // Rules is the exact text handed to the backend. + Rules string `json:"rules"` + // Backend names the mechanism the text is written for ("pf", "nft", "wfp"), + // so a reader does not have to infer a syntax from the platform it happens + // to be running on. + Backend string `json:"backend"` +} + +// Path returns the record's path within the given state directory. +func Path(stateDir string) string { return filepath.Join(stateDir, FileName) } + +// Save writes the record atomically. Errors are the caller's to log and +// swallow: this is a diagnostic aid, and failing to record what was applied +// must never be a reason not to apply it. +func Save(path string, r Record) error { + r.Version = version + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return fmt.Errorf("encode %s: %w", FileName, err) + } + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, state.DirMode); err != nil { + return fmt.Errorf("create %s: %w", dir, err) + } + } + return atomicfile.Write(path, append(data, '\n'), 0o644) +} + +// Load reads the record. A missing file is (Record{}, false, nil) — "nothing +// recorded yet" is an ordinary state, not an error, and the surfaces that read +// this must say so rather than reporting a failure. +func Load(path string) (Record, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return Record{}, false, nil + } + if err != nil { + return Record{}, false, fmt.Errorf("read %s: %w", path, err) + } + var r Record + if err := json.Unmarshal(data, &r); err != nil { + // Same call as learned.json and armed.json: a corrupt record is + // discarded, never fatal. It describes the past, and the daemon's + // enforcement does not depend on it. + return Record{}, false, fmt.Errorf("parse %s: %w", path, err) + } + return r, true, nil +} + +// Remove deletes the record. Called when rules are torn down, so a stale +// ruleset cannot be read as current after an Unblock or Cleanup. A missing file +// is success. +func Remove(path string) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/internal/applied/applied_test.go b/internal/applied/applied_test.go new file mode 100644 index 0000000..d1a0792 --- /dev/null +++ b/internal/applied/applied_test.go @@ -0,0 +1,92 @@ +package applied + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestSaveLoadRoundTrip(t *testing.T) { + path := Path(t.TempDir()) + want := Record{ + Mode: "guard", + At: time.Date(2026, 8, 21, 14, 2, 11, 0, time.UTC), + Rules: "pass out quick on utun4 all\nblock drop out all\n", + Backend: "pf", + } + if err := Save(path, want); err != nil { + t.Fatalf("Save: %v", err) + } + got, ok, err := Load(path) + if err != nil || !ok { + t.Fatalf("Load: ok=%v err=%v", ok, err) + } + if got.Mode != want.Mode || got.Rules != want.Rules || got.Backend != want.Backend { + t.Errorf("round trip lost data: %+v", got) + } + if !got.At.Equal(want.At) { + t.Errorf("At = %v, want %v", got.At, want.At) + } + if got.Version != version { + t.Errorf("Version = %d, want %d", got.Version, version) + } +} + +// The GUI runs unprivileged and has to be able to read this, exactly like +// state.json. 0600 would make the pane useless to the surface it exists for. +func TestRecordIsWorldReadable(t *testing.T) { + path := Path(t.TempDir()) + if err := Save(path, Record{Mode: "guard"}); err != nil { + t.Fatalf("Save: %v", err) + } + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o644 { + t.Errorf("mode = %v, want 0644", fi.Mode().Perm()) + } +} + +// "Nothing recorded yet" is an ordinary state — a daemon in standby has applied +// nothing — and must not read as a failure to the surfaces that show it. +func TestMissingFileIsNotAnError(t *testing.T) { + _, ok, err := Load(filepath.Join(t.TempDir(), "nope.json")) + if ok || err != nil { + t.Errorf("ok=%v err=%v, want false/nil", ok, err) + } +} + +// A stale ruleset read as current after teardown would say the guard is +// enforcing when nothing is. +func TestRemoveClearsTheRecordAndIsIdempotent(t *testing.T) { + path := Path(t.TempDir()) + if err := Save(path, Record{Mode: "guard"}); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if err := Remove(path); err != nil { + t.Fatalf("Remove #%d: %v", i, err) + } + } + if _, ok, _ := Load(path); ok { + t.Error("record survived Remove") + } +} + +// Corrupt is discarded, never fatal: it describes the past, and enforcement +// does not depend on it. Same call as learned.json and armed.json. +func TestCorruptRecordIsDiscardedNotFatal(t *testing.T) { + path := Path(t.TempDir()) + if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + _, ok, err := Load(path) + if ok { + t.Error("a corrupt record was reported as usable") + } + if err == nil { + t.Error("a corrupt record should still be reported to the caller to log") + } +} diff --git a/internal/firewall/backend.go b/internal/firewall/backend.go index 51b2880..e4ef027 100644 --- a/internal/firewall/backend.go +++ b/internal/firewall/backend.go @@ -134,4 +134,17 @@ type FirewallBackend interface { // Cleanup is an always-safe, best-effort teardown for shutdown/panic. It // never returns fatally; failures are the caller's to log. Cleanup() error + // InstalledRules reads dezhban's rules back OUT of the kernel, as text, for + // a diagnostic surface to compare against what the daemon recorded applying + // (internal/applied). Scoped to dezhban's own tag/anchor/table like every + // other operation here — it must never dump unrelated firewall state. + // + // It is a READ. It installs nothing and changes nothing, so it does not + // belong to the single-writer rule that governs Apply: any goroutine, and + // any process, may call it. It does generally need root, which is why it is + // on demand rather than on a tick. + // + // The bool is false when dezhban has no rules loaded at all — an ordinary + // answer (standby, or nothing running), not an error. + InstalledRules() (string, bool, error) } diff --git a/internal/firewall/nft_linux.go b/internal/firewall/nft_linux.go index 37d9f12..e5feb4b 100644 --- a/internal/firewall/nft_linux.go +++ b/internal/firewall/nft_linux.go @@ -121,6 +121,31 @@ func (b *nftBackend) IsBlocked() (bool, error) { return outputChainPolicyIsDrop(out), nil } +// InstalledRules renders dezhban's own table back out of the kernel. +// +// Scoped to `inet dezhban` by listTable, so it reports our table and nothing +// else — it can never become a way to dump a user's unrelated nftables +// configuration. A read: it installs nothing, needs no lock here, and is safe +// from any goroutine or process. It does need root/CAP_NET_ADMIN, which is why +// nothing calls it on a tick. +// +// A table with an output chain whose policy has drifted off drop is loaded but +// not enforcing — the same gap IsBlocked checks — so the text says so, because +// whoever is reading it has to be able to see that. +func (b *nftBackend) InstalledRules() (string, bool, error) { + out, exists, err := b.listTable() + if err != nil || !exists { + return "", false, err + } + var sb strings.Builder + if !outputChainPolicyIsDrop(out) { + sb.WriteString("# WARNING: the output chain's policy is no longer drop —\n") + sb.WriteString("# this table is loaded but is not cutting anything.\n") + } + sb.WriteString(out) + return sb.String(), true, nil +} + // outputChainPolicyIsDrop reports whether nft's rendered `list table` output // still shows the output chain's hook policy as drop. Split out from // IsBlocked so it can be exercised in tests against captured `nft list table` diff --git a/internal/firewall/pf_darwin.go b/internal/firewall/pf_darwin.go index 1af410d..899de8e 100644 --- a/internal/firewall/pf_darwin.go +++ b/internal/firewall/pf_darwin.go @@ -202,6 +202,42 @@ func (b *pfBackend) IsBlocked() (bool, error) { return mainRulesetReferencesAnchor(main), nil } +// InstalledRules reads dezhban's anchor back out of the kernel. +// +// Scoped to `-a dezhban` exactly like every other operation here: it reports our +// own rules and nothing else, so it can never become a way to dump a user's +// unrelated pf configuration. The anchor reference line from the main ruleset is +// prepended when present, because a loaded anchor that the main ruleset does not +// reference is not being evaluated at all — the same gap IsBlocked checks for, +// and the reader of this text has to be able to see it. +// +// A read, not a write: it takes no lock in this package and is safe from any +// goroutine or process. It does need root, which is why nothing calls it on a +// tick. +func (b *pfBackend) InstalledRules() (string, bool, error) { + ctx, cancel := context.WithTimeout(context.Background(), pfctlTimeout) + defer cancel() + + rules, err := pfctlCtx(ctx, "", "-a", anchorName, "-s", "rules") + if err != nil { + return "", false, fmt.Errorf("read the dezhban anchor: %w", err) + } + if strings.TrimSpace(rules) == "" { + return "", false, nil + } + var b0 strings.Builder + if main, err := pfctlCtx(ctx, "", "-s", "rules"); err == nil { + if mainRulesetReferencesAnchor(main) { + b0.WriteString("# main ruleset references the dezhban anchor\n") + } else { + b0.WriteString("# WARNING: the main ruleset does NOT reference the dezhban anchor —\n") + b0.WriteString("# these rules are loaded but pf never descends into them.\n") + } + } + b0.WriteString(rules) + return b0.String(), true, nil +} + // mainRulesetReferencesAnchor reports whether pfctl's rendered main ruleset // still contains our anchor reference. Split out from IsBlocked so it can be // exercised in tests against captured `pfctl -s rules` output without diff --git a/internal/firewall/render_darwin.go b/internal/firewall/render_darwin.go index 0a7a61e..af3d8ed 100644 --- a/internal/firewall/render_darwin.go +++ b/internal/firewall/render_darwin.go @@ -11,3 +11,8 @@ package firewall func RenderRules(p Policy) (string, error) { return renderRuleset(p), nil } + +// RulesetKind names the mechanism RenderRules writes for, so a surface showing +// the text does not have to infer a syntax from the platform it happens to be +// running on. Here: the pf ruleset `pfctl -a dezhban -f -` loads. +const RulesetKind = "pf" diff --git a/internal/firewall/render_linux.go b/internal/firewall/render_linux.go index b729e31..e708c56 100644 --- a/internal/firewall/render_linux.go +++ b/internal/firewall/render_linux.go @@ -9,3 +9,8 @@ package firewall func RenderRules(p Policy) (string, error) { return renderNftRuleset(p), nil } + +// RulesetKind names the mechanism RenderRules writes for, so a surface showing +// the text does not have to infer a syntax from the platform it happens to be +// running on. Here: the nftables ruleset `nft -f -` loads. +const RulesetKind = "nft" diff --git a/internal/firewall/render_windows.go b/internal/firewall/render_windows.go index 662524e..59d094c 100644 --- a/internal/firewall/render_windows.go +++ b/internal/firewall/render_windows.go @@ -9,3 +9,8 @@ package firewall func RenderRules(p Policy) (string, error) { return renderBlockScript(p), nil } + +// RulesetKind names the mechanism RenderRules writes for, so a surface showing +// the text does not have to infer a syntax from the platform it happens to be +// running on. Here: the PowerShell that installs the WFP rules. +const RulesetKind = "wfp" diff --git a/internal/firewall/wfp_windows.go b/internal/firewall/wfp_windows.go index c1a18bc..e681e78 100644 --- a/internal/firewall/wfp_windows.go +++ b/internal/firewall/wfp_windows.go @@ -204,6 +204,34 @@ func (b *wfpBackend) IsBlocked() (bool, error) { return true, nil } +// InstalledRules renders dezhban's own firewall rules back out of Windows, plus +// each profile's default outbound action — which is where the actual blocking +// lives on this platform (see the Model note above renderBlockScript), so a list +// of allow rules without it would be a misleading half of the picture. +// +// Scoped to `-Group dezhban`, exactly like Remove-NetFirewallRule, so it reports +// our rules and nothing else. A read: it changes nothing and is safe from any +// goroutine or process. It does need an elevated shell, which is why nothing +// calls it on a tick. +func (b *wfpBackend) InstalledRules() (string, bool, error) { + script := strings.Join([]string{ + "$g = Get-NetFirewallRule -Group " + groupName + " -ErrorAction SilentlyContinue", + "if ($null -eq $g) { 'NONE'; exit 0 }", + "'# default outbound action per profile'", + "Get-NetFirewallProfile | Select-Object Name,DefaultOutboundAction | Format-Table -AutoSize | Out-String", + "'# dezhban rules'", + "$g | Select-Object DisplayName,Direction,Action,Enabled | Format-Table -AutoSize | Out-String", + }, "\n") + out, err := powershell(script) + if err != nil { + return "", false, fmt.Errorf("read the dezhban firewall group: %w", err) + } + if strings.TrimSpace(out) == "NONE" { + return "", false, nil + } + return out, true, nil +} + // queryBlockedAndDefaults combines the group-existence check and the // per-profile DefaultOutboundAction query into a single PowerShell // invocation. IsBlocked is called synchronously from the run loop's verifyC diff --git a/internal/runner/recording.go b/internal/runner/recording.go new file mode 100644 index 0000000..27350e9 --- /dev/null +++ b/internal/runner/recording.go @@ -0,0 +1,101 @@ +package runner + +import ( + "io" + "log/slog" + "time" + + "github.com/behnam-rk/dezhban/internal/applied" + "github.com/behnam-rk/dezhban/internal/firewall" +) + +// recordingBackend records what was applied, then gets out of the way. +// +// A decorator rather than a `applied.Save` beside each `Backend.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. +// +// It preserves the single-writer invariant exactly, because it adds no writer: +// every method is called from the run-loop goroutine, by the same code that +// called the wrapped backend before. That also means the fields below need no +// locking, and nothing here may be moved onto another goroutine. 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. +// +// Every failure to record is logged and swallowed. This is a diagnostic aid; +// failing to write down what was applied must never become a reason not to +// apply it, and must never turn a successful enforcement into a returned error. +type recordingBackend struct { + // Embedded so the wrapper stays exactly as narrow as the interface the run + // loop uses. Widening Backend to carry a diagnostic read would put a method + // on the enforcement seam that enforcement never calls. + Backend + path string + log *slog.Logger + // now is injected so a test can assert the recorded timestamp instead of + // asserting that some time passed. + now func() time.Time +} + +// newRecordingBackend wraps b when path is non-empty; otherwise it returns b +// unchanged, so a caller with no state directory (tests, Windows service +// harnesses) is unaffected. +func newRecordingBackend(b Backend, path string, log *slog.Logger) Backend { + if path == "" || b == nil { + return b + } + if log == nil { + // Run does not default a nil Log, and every method here logs on the + // failure path. A diagnostic aid must not be the thing that panics the + // daemon on the one day the disk is full. + log = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + return &recordingBackend{Backend: b, path: path, log: log, now: time.Now} +} + +func (r *recordingBackend) Apply(p firewall.Policy) error { + // Record only what actually landed. A failed Apply leaves the previous + // ruleset live, so overwriting the record first would describe rules that + // were never installed — the one thing a surface reading this must be able + // to rely on not happening. + if err := r.Backend.Apply(p); err != nil { + return err + } + rules, err := firewall.RenderRules(p) + if err != nil { + r.log.Warn("could not render the applied ruleset for the diagnostics record", "err", err) + return nil + } + rec := applied.Record{ + Mode: p.Mode.String(), + At: r.now(), + Rules: rules, + Backend: firewall.RulesetKind, + } + if err := applied.Save(r.path, rec); err != nil { + r.log.Warn("could not record the applied ruleset", "err", err, "path", r.path) + } + return nil +} + +func (r *recordingBackend) Unblock() error { + err := r.Backend.Unblock() + // Clear even when Unblock failed: the rules are in an unknown state, and a + // record that confidently names the old posture is worse than none. + r.clear() + return err +} + +func (r *recordingBackend) Cleanup() error { + err := r.Backend.Cleanup() + r.clear() + return err +} + +func (r *recordingBackend) clear() { + if err := applied.Remove(r.path); err != nil { + r.log.Warn("could not clear the applied-ruleset record", "err", err, "path", r.path) + } +} diff --git a/internal/runner/recording_test.go b/internal/runner/recording_test.go new file mode 100644 index 0000000..a4db03a --- /dev/null +++ b/internal/runner/recording_test.go @@ -0,0 +1,131 @@ +package runner + +import ( + "errors" + "net/netip" + "testing" + "time" + + "github.com/behnam-rk/dezhban/internal/applied" + "github.com/behnam-rk/dezhban/internal/firewall" +) + +func recordingAt(t *testing.T, at time.Time) (Backend, *fakeBackend, string) { + t.Helper() + inner := &fakeBackend{} + path := applied.Path(t.TempDir()) + b := newRecordingBackend(inner, path, discardLog()) + b.(*recordingBackend).now = func() time.Time { return at } + return b, inner, path +} + +func guardPolicy() firewall.Policy { + return firewall.Policy{ + Mode: firewall.ModeGuard, + TunnelIfaces: []string{"utun4"}, + VPNEndpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + } +} + +func TestRecordingBackendRecordsWhatItApplied(t *testing.T) { + at := time.Date(2026, 8, 21, 14, 2, 11, 0, time.UTC) + b, inner, path := recordingAt(t, at) + + if err := b.Apply(guardPolicy()); err != nil { + t.Fatalf("Apply: %v", err) + } + if len(inner.policies) != 1 { + t.Fatalf("the wrapped backend saw %d applies, want 1", len(inner.policies)) + } + + rec, ok, err := applied.Load(path) + if err != nil || !ok { + t.Fatalf("Load: ok=%v err=%v", ok, err) + } + if rec.Mode != "guard" { + t.Errorf("Mode = %q, want \"guard\"", rec.Mode) + } + if !rec.At.Equal(at) { + t.Errorf("At = %v, want %v", rec.At, at) + } + if rec.Backend != firewall.RulesetKind { + t.Errorf("Backend = %q, want %q", rec.Backend, firewall.RulesetKind) + } + // The recorded text must be what this policy renders, not a re-render of + // some later state: the resolved endpoint has to be in it. + want, err := firewall.RenderRules(guardPolicy()) + if err != nil { + t.Fatal(err) + } + if rec.Rules != want { + t.Errorf("recorded rules differ from RenderRules for the same policy") + } +} + +// A failed Apply leaves the PREVIOUS ruleset live. Recording the attempt would +// describe rules that were never installed — the one thing a reader of this +// file has to be able to rely on not happening. +func TestAFailedApplyRecordsNothing(t *testing.T) { + b, inner, path := recordingAt(t, time.Unix(0, 0)) + if err := b.Apply(guardPolicy()); err != nil { + t.Fatal(err) + } + first, _, _ := applied.Load(path) + + inner.applyErr = errors.New("pfctl exploded") + fullBlock := firewall.Policy{Mode: firewall.ModeFullBlock} + if err := b.Apply(fullBlock); err == nil { + t.Fatal("Apply returned nil for a failing backend") + } + + after, ok, _ := applied.Load(path) + if !ok { + t.Fatal("the previous record was destroyed by a failed apply") + } + if after.Mode != first.Mode || after.Rules != first.Rules { + t.Errorf("a failed apply overwrote the record: %q", after.Mode) + } +} + +// After teardown there are no rules. A record left behind would be read as the +// live posture — a surface saying "guard is enforcing" over an open network. +func TestUnblockAndCleanupClearTheRecord(t *testing.T) { + for _, tc := range []struct { + name string + call func(Backend) error + }{ + {"unblock", func(b Backend) error { return b.Unblock() }}, + {"cleanup", func(b Backend) error { return b.Cleanup() }}, + } { + t.Run(tc.name, func(t *testing.T) { + b, _, path := recordingAt(t, time.Unix(0, 0)) + if err := b.Apply(guardPolicy()); err != nil { + t.Fatal(err) + } + if err := tc.call(b); err != nil { + t.Fatal(err) + } + if _, ok, _ := applied.Load(path); ok { + t.Error("the record survived teardown") + } + }) + } +} + +// An empty path is "recording off" and must hand back the backend untouched, so +// a caller with no state directory pays nothing and behaves identically. +func TestNoPathMeansNoWrapper(t *testing.T) { + inner := &fakeBackend{} + if got := newRecordingBackend(inner, "", discardLog()); got != Backend(inner) { + t.Error("an empty path still wrapped the backend") + } +} + +// Run does not default a nil Log, and every failure path here logs. A +// diagnostic aid must not be what panics the daemon. +func TestANilLoggerDoesNotPanic(t *testing.T) { + b := newRecordingBackend(&fakeBackend{}, applied.Path(t.TempDir()), nil) + if err := b.Apply(guardPolicy()); err != nil { + t.Fatalf("Apply: %v", err) + } +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index ed17604..1537dc8 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -375,6 +375,12 @@ type Options struct { // BlockedCountries is copied verbatim into each published snapshot so an // observer can show what the daemon is configured to block. Informational only. BlockedCountries []string + // AppliedRulesPath, when non-empty, is where the ruleset text of each + // successful Apply is recorded (internal/applied) for the Diagnostics pane. + // Run wraps Backend to do it, so every Apply is covered including ones added + // later. Purely diagnostic and best-effort: a failed write is logged and the + // enforcement stands. Empty → nothing is recorded. + AppliedRulesPath string // ReloadC delivers replacement settings to the running loop, so a config // edit takes effect without a restart. Nil (the default) means reloading is @@ -607,6 +613,12 @@ func (o Options) pendingFlip(standby, windowOpen bool) *state.PendingFlip { // the daemon — that is the invariant that keeps the operator from being locked // out of their own network. func Run(ctx context.Context, o Options) error { + // Wrap BEFORE anything can apply — including the deferred Cleanup below, + // which has to clear the record rather than leave a ruleset on disk that a + // reader would take for live. Adds no goroutine and no writer: every call + // still comes from this loop. + o.Backend = newRecordingBackend(o.Backend, o.AppliedRulesPath, o.Log) + defer func() { if err := o.Backend.Cleanup(); err != nil { o.Log.Warn("cleanup failed; rules may persist (run `dezhban panic`)", "err", err)