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
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,50 @@ 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.
- **`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
saved window state), then opens Terminal running the root uninstaller and
quits — so you watch the firewall-rule teardown happen instead of trusting a
dialog that is about to be deleted. "Keep my dezhban configuration in
/etc/dezhban" maps to the uninstaller's existing `KEEP_CONFIG=1`. Other user
accounts are deliberately untouched
([ADR-0015](docs/adr/0015-complete-purge-semantics.md)).

### Changed

- **The setup wizard is two steps.** Blocked countries, then one "Use automatic
VPN detection?" tickbox with the manual fields — tunnel interfaces, self-hosted
config files, endpoints — revealed underneath it when you untick it. The
opening "Configure your VPN now?" question is gone; a run always writes the
VPN keys, so instead the detection answer is **seeded from your config** and a
config with pinned `vpn.tunnelInterfaces` starts on manual, meaning a re-run
clicked straight through preserves your pins. A question that is not asked
still writes no key, so leaving automatic detection on does not blank
endpoints you set by hand. Off macOS, where there is no live discovery, the
endpoint question is asked whichever mode you pick. Both wizards read the same
question set, so `dezhban setup` changes with the app — in a terminal step 2
appears as two consecutive prompts, since a form cannot react to an answer
given inside itself.
- **Contextual help lands on the key you asked about.** The **?** beside a
setting used to open one of four section anchors shared by every key in that
section; it now scrolls to that key's own row in the configuration reference.
Expand All @@ -34,6 +76,15 @@ current as you land changes.

### Fixed

- **The setup wizard appears again after a reinstall.** `uninstall.sh` removed
only root-owned state, so the app's `dezhban.firstRunCompleted` preference
outlived every uninstall — a machine with an empty `/etc/dezhban` and no VPN
still answered "the wizard has been completed", and stayed silent. The
uninstaller now clears the invoking user's preference domains (including the
dead `com.dezhban.DezhbanMenu` one from a superseded bundle identifier), and
the app's own Remove Dezhban… clears everything else this account holds. If
you are hitting this today, Settings → Run Setup Again… is the route that
needs no uninstall at all.
- **"Open minimized" now actually decides whether the window opens.** The app
used to infer a login launch from `NSApplication.launchIsDefaultUserInfoKey`,
which reported wrong in both directions — the window appeared at login with
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
61 changes: 45 additions & 16 deletions cmd/dezhban/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,27 +66,45 @@ func cmdSetup(args []string) int {
// Asked a screenful at a time, in Group order, so a gate can be evaluated
// against answers already given — which is exactly what makes the VPN
// branch a branch.
//
// Within a group, in waves. A huh form binds every field before any of them
// is answered, so a question gated on another question in the SAME group
// would be decided by that question's seeded default rather than by what
// the user just typed. The macOS app has no such problem — it re-evaluates
// gates as answers change and shows the whole step at once, which is what
// makes step 2 a single screen there — so rather than splitting the shared
// question set to suit one renderer, this one asks the ungated questions,
// re-evaluates, and asks whatever that opened up.
for _, group := range groupsOf(qs) {
var fields []huh.Field
for _, q := range qs {
if q.Group != group || !answers.ShouldAsk(q) {
continue
asked := map[string]bool{}
for {
var fields []huh.Field
for _, q := range qs {
if q.Group != group || asked[q.ID] || !answers.ShouldAsk(q) {
continue
}
// Defer anything still waiting on an unanswered question in
// this same group; the next wave picks it up.
if q.Gated() && !asked[q.RequiresID] && gateIsInGroup(qs, q, group) {
continue
}
asked[q.ID] = true
fields = append(fields, field(q, answers))
}
if len(fields) == 0 {
break
}
if err := runForm(huh.NewForm(huh.NewGroup(fields...))); err != nil {
return formExit(err)
}
fields = append(fields, field(q, answers))
}
if len(fields) == 0 {
continue
}
if err := runForm(huh.NewForm(huh.NewGroup(fields...))); err != nil {
return formExit(err)
}
}

// Import any named config files into profiles (best-effort; a bad file is
// reported but doesn't abort the wizard). Reading files is the caller's job,
// not internal/setup's.
var profiles []config.Profile
if answers.Bool("configureVPN") {
{
for _, f := range setup.SplitList(answers.Text("profileFiles")) {
eps, format, ierr := vpnimport.Extract(f)
if ierr != nil {
Expand All @@ -112,7 +130,7 @@ func cmdSetup(args []string) int {
}

// --- lockout guard: warn if an endpoint sits inside a tunnel subnet ---
if answers.Bool("configureVPN") {
{
if warn := setup.EndpointLockoutWarning(cfg); warn != "" {
var proceed bool
fmt.Fprintln(os.Stderr, warn)
Expand Down Expand Up @@ -176,9 +194,7 @@ func cmdSetup(args []string) int {
} else {
fmt.Println("later, enable it with: sudo dezhban install && sudo dezhban start")
}
if answers.Bool("configureVPN") {
fmt.Println("to connect a brand-new VPN whose server isn't known yet: dezhban switch, then connect it.")
}
fmt.Println("to connect a brand-new VPN whose server isn't known yet: dezhban switch, then connect it.")
return 0
}

Expand Down Expand Up @@ -304,3 +320,16 @@ func isInteractive() bool {
func isTerminal(f *os.File) bool {
return term.IsTerminal(f.Fd())
}

// gateIsInGroup reports whether the question q depends on lives in the same
// group — the case the wave loop above has to defer, because a huh form cannot
// react to an answer given inside itself. A gate pointing at an EARLIER group is
// already decided by the time this group runs and needs no deferral.
func gateIsInGroup(qs []setup.Question, q setup.Question, group int) bool {
for _, other := range qs {
if other.ID == q.RequiresID {
return other.Group == group
}
}
return false
}
Loading