Skip to content
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,31 @@ current as you land changes.

## [Unreleased]

### 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. The readback also reports
**loaded is not enforcing**: pf switched off, an anchor the main ruleset no
longer references, or an nft chain whose policy drifted off `drop` all leave
dezhban's rules present and filtering nothing. That gets its own row, and its
own `enforcing` field in `--json`, because it is the state where every other
signal reads healthy.
- **`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.

## [0.12.0] - 2026-09-06

### Added
Expand Down
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ command file when no daemon answers. Everything else — `status`, `detect-vpn`,
[docs/usage/cli.md](docs/usage/cli.md); the upgrade design in full:
[docs/usage/upgrade.md](docs/usage/upgrade.md).

`print-rules --installed` is the one read-only command that still needs root on
unix, because it asks the kernel for dezhban's own rules
(`FirewallBackend.InstalledRules`) rather than rendering them. It is deliberately
NOT in the privileged set: that set auto-re-execs under sudo via `requireRoot`,
and silently elevating a diagnostic read is not something a read should do — it
prints the `sudo` hint and exits instead. It installs and changes nothing, and on
Windows it needs no elevation at all.

## Rules that must not be broken

The design depends on these invariants (rationale in
Expand Down
74 changes: 74 additions & 0 deletions cmd/dezhban/applied_wiring_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package main

import (
"go/ast"
"go/parser"
"go/token"
"testing"
)

// The three commands that change the firewall WITHOUT the run loop must each
// keep the applied record honest. internal/runner's decorator covers every
// Apply the loop makes; these bypass it — `panic` most importantly, since it is
// deliberately independent of the running service, so nothing else will ever
// clear the record it leaves behind.
//
// An AST guard rather than a behavioural test because all three demand root and
// a real firewall: the helpers themselves are covered in
// print_rules_flags_test.go, but deleting a CALL to one left the whole suite
// green, which made the fix that added them unprotected. Same technique, and
// same reason, as TestNoTestInPackageMainIsParallel.
func TestEveryDirectFirewallPathKeepsTheRecordHonest(t *testing.T) {
// The COUNT matters, not just presence: cmdBlock applies from two branches
// (--force and the default plan), and asserting "calls it at all" stayed
// green when either one alone lost its call — the exact deletion this guard
// claims to catch.
want := []struct {
fn string
callee string
calls int
}{
{"cmdBlock", "recordAppliedBestEffort", 2},
{"cmdUnblock", "clearAppliedRecordBestEffort", 1},
{"cmdPanic", "clearAppliedRecordBestEffort", 1},
}

fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main.go", nil, 0)
if err != nil {
t.Fatalf("parse main.go: %v", err)
}

fns := map[string]*ast.FuncDecl{}
for _, decl := range f.Decls {
if fn, ok := decl.(*ast.FuncDecl); ok && fn.Recv == nil {
fns[fn.Name.Name] = fn
}
}

for _, w := range want {
fn, ok := fns[w.fn]
if !ok {
t.Errorf("%s not found in main.go — this guard would pass vacuously", w.fn)
continue
}
got := 0
ast.Inspect(fn, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == w.callee {
got++
}
return true
})
if got != w.calls {
t.Errorf("%s calls %s %d time(s), want %d.\n"+
"If you MOVED the call into a helper this guard is simply out of date — update it.\n"+
"If you REMOVED it, that path changes the firewall without keeping the applied\n"+
"record honest, and a surface will report a posture that is not in force.",
w.fn, w.callee, got, w.calls)
}
}
}
6 changes: 4 additions & 2 deletions cmd/dezhban/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ _dezhban() {
return
fi
case "$cur" in
-*) COMPREPLY=( $(compgen -W "--config --mode --force --guard --dry-run --once --json --discover --simulate-country --verbose -v --no-sudo --no-daemon" -- "$cur") ) ;;
-*) COMPREPLY=( $(compgen -W "--config --mode --applied --installed --force --guard --dry-run --once --json --discover --simulate-country --verbose -v --no-sudo --no-daemon" -- "$cur") ) ;;
esac
}
complete -F _dezhban dezhban
Expand All @@ -83,7 +83,7 @@ _dezhban() {
config) compadd -- path show get set reset edit; return ;;
token) compadd -- status enroll forget; return ;;
esac
compadd -- --config --mode --force --guard --dry-run --once --json --discover --simulate-country --verbose --no-sudo --no-daemon
compadd -- --config --mode --applied --installed --force --guard --dry-run --once --json --discover --simulate-country --verbose --no-sudo --no-daemon
}
compdef _dezhban dezhban
`
Expand All @@ -95,6 +95,8 @@ complete -c dezhban -n '__fish_use_subcommand' -a '` + completionCommands + `'
# flag values
complete -c dezhban -l mode -x -a 'guard fullblock switch'
complete -c dezhban -l config -r
complete -c dezhban -l applied
complete -c dezhban -l installed
complete -c dezhban -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
complete -c dezhban -n '__fish_seen_subcommand_from config' -a 'path show get set reset edit'
complete -c dezhban -n '__fish_seen_subcommand_from token' -a 'status enroll forget'
Expand Down
Loading