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
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,33 @@ current as you land changes.

## [Unreleased]

### Added

- **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 +59,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
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
}
115 changes: 115 additions & 0 deletions docs/adr/0015-complete-purge-semantics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# ADR-0015: What a complete purge removes, and what it deliberately does not

**Date**: 2026-08-21
**Status**: accepted
**Deciders**: Behnam RK

## Context

`packaging/macos/uninstall.sh` runs as root and removes what root owns: the
binary, `Dezhban.app`, `/etc/dezhban`, `/var/db/dezhban`, the launchd plist, the
pkg receipts. Everything belonging to the logged-in user survived it — the app's
preference domain, the control token in the login keychain, and the login-item
registration.

That was not merely untidy. `FirstRunDecision.offer` is
`!isComplete && !vpnKnown`, where `isComplete` reads
`dezhban.firstRunCompleted` from the app's preference domain. Because uninstall
never cleared that domain, a machine that had been uninstalled and reinstalled —
with an empty `/etc/dezhban` and no VPN configured — still answered "the wizard
has been completed", so the setup flow never appeared. The reported bug ("setup
didn't auto-start on first run") and the missing feature ("I want a complete
uninstall") are the same defect from two ends. On the machine where this was
diagnosed, a second preference domain from a superseded bundle identifier
(`com.dezhban.DezhbanMenu`) was also still present.

Root cannot do this work. A login keychain item's ACL is bound to the user's
session, and a login item is registered per user through `SMAppService`. Both
require the user's own process.

## Decision

Purge is split by who can perform it, and the app owns the half root cannot.

**Dezhban.app** gains Settings → **Remove Dezhban…**. It removes, in this
account's session: the control token and its capability-probe item (via
`ControlToken.purge()`), the login-item registration (both the ADR-0014 agent
and any surviving `mainApp` registration), the saved-window-state directories,
and the preference domains — current and legacy. It then opens **Terminal.app**
running `sudo sh /usr/local/share/dezhban/uninstall.sh` and quits.

**`uninstall.sh`** additionally deletes both preference domains for `$SUDO_USER`
on a best-effort basis, and prints the two per-user items it cannot do
(keychain, login item) with the commands that finish the job.

Explicitly **not** removed: other user accounts' state, and notification
authorization.

## Alternatives considered

### Alternative 1: keep the first-run gate, make the config the only truth

Drop `isComplete` from `FirstRunDecision.offer`, so the wizard is offered
whenever no VPN is known.

- **Pros**: fixes the reported bug in one line, with no purge work at all.
- **Cons**: makes a modal wizard appear on every launch for anyone who
deliberately runs without a configured VPN, and leaves the keychain token,
login item and preference domains behind on every uninstall regardless.
- **Why not**: it treats the symptom. Settings already has "Run Setup Again…"
as the manual route, so the gate is not the thing standing between a user and
the wizard — a stale flag no uninstaller ever cleared is.

### Alternative 2: `uninstall.sh` loops over `/Users` and does everything

- **Pros**: one code path; works for a CLI-only install; reaches every account.
- **Cons**: `sudo -u` into an account that is not logged in cannot unlock its
login keychain, so the keychain step fails or prompts unpredictably; and a
script that deletes other people's data because root ran it is a scope no
uninstaller should claim.
- **Why not**: it cannot actually do the keychain half, which is the half that
needed a session in the first place.

### Alternative 3: a progress sheet in the app instead of Terminal

- **Pros**: feels native; no window the user has to close afterwards.
- **Cons**: `uninstall.sh` quits Dezhban and deletes its bundle partway
through, so the sheet dies mid-teardown. The user could not distinguish a
finished uninstall from one that stopped after `panic` removed the rules.
- **Why not**: a kill switch's removal has one step you must be able to watch
succeed — the rule teardown — and this hides exactly that one.

## Consequences

### Positive

- A reinstall genuinely looks like a fresh machine, so the first-run wizard
appears when it should.
- The keychain token and login item no longer outlive the app they belong to.
A surviving login item makes macOS keep trying to launch a deleted bundle.
- The teardown is visible. The user watches `panic` remove the rules in a window
that outlives the app.

### Negative

- Removal now spans two surfaces, and the app must run at least once for the
per-user half to happen. Someone who deletes `Dezhban.app` in Finder and then
runs `uninstall.sh` gets the preference domains (via `$SUDO_USER`) but keeps
the keychain item and login item. The script prints both commands rather than
leaving that silent.
- Terminal.app must exist and be scriptable. When it is not, the app says so and
prints the command instead of quitting into a half-removed install.

### Risks

- **The confirmation is destructive and irreversible.** It is a critical-style
alert that names every category removed, Cancel is the default button, and the
return key cannot trigger removal. "Keep my dezhban configuration in
/etc/dezhban" maps to the script's existing `KEEP_CONFIG=1`.
- **Preference deletion could be undone by the app's own shutdown**, since
AppKit writes defaults as it winds down. Mitigated by clearing the domains
last, immediately before `NSApp.terminate`, and by the script repeating the
deletion for `$SUDO_USER`.
- **Scope creep.** This record exists so a later change cannot quietly widen
purge to other accounts or to data dezhban did not create. Widening it needs a
new ADR.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ New records use [template.md](template.md) and take the next free number.
| [0012](0012-app-checked-biometrics-on-unsigned-builds.md) | App-checked biometrics on unsigned builds, rather than no biometrics | accepted, implemented |
| [0013](0013-geo-provider-pass-opt-out.md) | The geo-provider pass gets an opt-out, not a redesign | accepted, implemented |
| [0014](0014-login-item-launch-marker.md) | The login item carries an explicit launch marker | accepted, implemented |
| [0015](0015-complete-purge-semantics.md) | What a complete purge removes, and what it deliberately does not | accepted, implemented |

> **0006 is the one to read first if you are touching the geo lookup.** It records why
> the obvious implementation silently defeats the exit-country check, and it exists
Expand Down
56 changes: 53 additions & 3 deletions docs/contribute/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -716,18 +716,38 @@ macOS only, privileged (`dezhban upgrade download`/`apply`). See
forms are bound to the same answers.
- [ ] Re-running it **without** naming profile files keeps the profiles already
imported (`dezhban vpn list`).
- [ ] **Two steps.** Step 1 is countries. Step 2 opens with "Use automatic VPN
detection?"; leaving it ticked ends the wizard there, unticking it asks for
tunnel interfaces, self-hosted config files, and endpoints. In a terminal
that second half arrives as a follow-up prompt, not the same screen.
- [ ] **A re-run on a pinned config keeps its pins.** With
`vpn.tunnelInterfaces` set, re-run and press Enter through everything:
automatic detection must arrive **already unticked**, and
`dezhban config show` must still list the same interfaces. This is what
replaced the old "Configure your VPN now?" escape, so it is the check that
matters most in this section.
- [ ] **A re-run under automatic detection keeps configured endpoints.** With
`vpn.endpoints` set and no pinned interfaces, re-run, leave automatic
detection ticked, finish: the endpoints are unchanged. The question was
never asked, so nothing may have been written.
- [ ] **Off macOS the endpoint question always appears.** On Linux or Windows,
leave automatic detection ticked — you must still be asked for an endpoint,
because there is no live discovery to find one.
- [ ] `dezhban setup --questions --json` runs with no TTY, no root, and no
config file present, and lists the same questions the wizard asks.

### First-run wizard (macOS app)

- [ ] With no VPN configured and `defaults delete com.dezhban.menu
- [ ] With no VPN configured and `defaults delete com.behnam-rk.dezhban.app
dezhban.firstRunCompleted`, launching the app opens the window **and** the
wizard. With a VPN already configured from the CLI, it does not — the
questions were already answered.
- [ ] The questions, their order, and the gating match `dezhban setup` run in a
terminal on the same host. Declining "Configure your VPN now?" skips the
whole VPN branch in both.
terminal on the same host. Unticking "Use automatic VPN detection?" reveals
the same three manual fields in both — in the app they appear **on the same
screen**, without paging forward.
- [ ] **Two steps, labelled as two.** The step counter reads "Step 1 of 2" and
"Step 2 of 2"; unticking automatic detection must not add a third.
- [ ] Saving writes through one `config set` (one password prompt, or none with
a token enrolled) and the values land in `dezhban config show`. Choosing
automatic detection leaves `vpn.tunnelInterfaces` **empty**.
Expand Down Expand Up @@ -997,6 +1017,36 @@ task gui:build && open dist/Dezhban.app
- [ ] **Staleness.** Kill the daemon → the icon goes gray after the 90 s staleness
window, and Overview switches to the guided "Stopped" state.

### Remove Dezhban (complete purge)

Destructive and one-way — run these on a machine you are willing to reinstall
on. See [ADR-0015](../adr/0015-complete-purge-semantics.md).

- [ ] **Cancel is the default.** Settings → Remove Dezhban…, press Return: the
sheet dismisses with nothing removed. The guard is still enforcing.
- [ ] **The per-user half actually goes.** Enroll Touch ID and enable "open at
login" first, then remove. After the app quits:
`defaults read com.behnam-rk.dezhban.app` fails with "domain does not
exist"; `security find-generic-password -s sh.dezhban.menu` reports
`SecKeychainSearchCopyNext: The specified item could not be found`;
Dezhban no longer appears in System Settings › General › Login Items.
- [ ] **The teardown is visible.** Terminal opens, `sudo` prompts, and the
transcript shows `panic` removing the rules BEFORE anything is deleted.
Confirm the network works throughout — a half-removed kill switch that
leaves a block-all rule loaded is the one outcome this must never produce.
- [ ] **KEEP_CONFIG.** With the checkbox ticked, `/etc/dezhban/dezhban.json`
survives; without it, `/etc/dezhban` is gone.
- [ ] **Reinstall looks fresh.** Reinstall, launch the app: the first-run wizard
opens. This is the bug the purge exists to fix.
- [ ] **Terminal unavailable degrades honestly.** Rename
`/usr/local/share/dezhban/uninstall.sh` and remove: the app reports that
dezhban is still installed and still enforcing, prints the command, and
does **not** quit.
- [ ] **The script's own per-user pass.** On a second account, run
`sudo sh /usr/local/share/dezhban/uninstall.sh` directly: that account's
preference domains are deleted, and the output names the keychain item and
login item it did not touch. A third account's settings are untouched.

### Actions

- [ ] **The action row explains itself before the click.** Titles are short
Expand Down
Loading