Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
151 changes: 150 additions & 1 deletion cmd/dezhban/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
33 changes: 33 additions & 0 deletions docs/concepts/modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,3 +444,36 @@ dezhban print-rules --mode switch --config <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.
29 changes: 29 additions & 0 deletions docs/contribute/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <m>`.
- [ ] **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
Expand Down
23 changes: 20 additions & 3 deletions docs/usage/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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. |
Expand Down Expand Up @@ -239,14 +239,31 @@ Inspect and validate before you risk a block — none of these touch the firewal
```sh
dezhban validate --config <config> # parse + validate, summarize
dezhban print-rules --mode guard --config <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 <config> # tunnels, subnets, endpoint sanity
dezhban doctor --discover --config <config> # macOS: find the VPN's real server IP
dezhban doctor --json --config <config> # the same checks as structured JSON
dezhban monitor --config <config> # live: IP, country, tunnels, endpoints, verdict
```

`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
Expand Down
Loading