feat: standalone macOS installer + passwordless routine ops (control socket)#19
Conversation
Adds internal/control: the daemon's live control channel, so routine posture changes no longer need a fresh root elevation on every call. The socket is root-owned, mode 0660, group-owned by a trusted group (macOS: "admin"). With no peer credentials available in stdlib, filesystem permissions ARE the authorization gate, so New fails closed: a socket whose ownership can't be established is unlinked rather than served. The server never touches the firewall Backend — it parses, gates, and hands each request to the run loop over a channel, preserving the invariant that the run-loop goroutine is the sole caller of Backend.Apply. Ping is answered by the accept goroutine alone, so liveness stays cheap and can't block on the loop. No behavior change yet: nothing constructs a Server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW
Wires internal/control into both state machines as one more select case. The socket's accept goroutine only forwards requests over a channel; every op is serviced inline on the run loop, so the run-loop goroutine remains the sole caller of Backend.Apply — the invariant that keeps rule application serialized. block/unblock now set a manualBlock flag that HOLDS the posture: while set, the geo state machine is suspended, so an allowed reading cannot silently lift a block the operator asked for (and the recovery probe won't open egress to observe a country nobody is acting on). Only an explicit unblock clears it. Switch ops are gated on AllowSwitchOps (config control.allowSwitchOps, default true) — they are the one op that can relax the guard, so they get their own switch. block/unblock refuse outright while a window is open rather than tearing it down behind the operator's back. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW
…ocket Adds the `control` config block (enabled, socket, group, allowSwitchOps) and wires the daemon to serve it. Defaults are the product decision: enabled=true, group=admin on macOS, allowSwitchOps=true — so a normal admin never types a password for a routine op. Off macOS the group defaults to empty (root-only): there is no portable "the admins of this machine" group, and guessing wrong would hand the socket to the wrong people. block, unblock and switch now ask the daemon first and fall back to the existing root path when nothing is listening, so the CLI still works with the service stopped. A daemon REFUSAL is final and is never retried as root — otherwise the daemon's own gating (open switch window, allowSwitchOps=false) would be bypassable by anyone who can type sudo. Untouched on purpose: panic (must work with no daemon), block --force/--guard (deliberate low-level overrides), and install/uninstall/start/stop (a daemon cannot manage its own lifecycle). --no-daemon / DEZHBAN_NO_DAEMON opts out. `status` now reports whether routine ops will need a password. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW
Block, Unblock and Switch now run the CLI unprivileged, so a running daemon handles them over its control socket with no admin prompt. The Swift side never speaks the socket protocol — the Go CLI stays the single client, so a menu click and a terminal command take exactly the same path. Escalation survives for the one case that needs it: with no daemon listening, the op falls back to the osascript admin prompt and acts on the firewall directly. A daemon REFUSAL (exit 3) is never escalated — the daemon gates these ops deliberately, and re-running as root would route around a decision, not an obstacle. Service lifecycle (install/uninstall/start/stop) and panic keep the prompt: a daemon cannot manage its own lifecycle, and panic must work when none is running. Menu tooltips now say which of the two a click will be. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW
make pkg-macos builds dezhban-<version>.pkg: a universal CLI to /usr/local/bin, the universal menubar app to /Applications, and a postinstall that registers the launchd service. One admin prompt — the Installer's own — and after it, routine ops need no password at all (the control socket does that). The postinstall deliberately does NOT start enforcement. A kill switch armed against a config the user hasn't chosen yet is how you lock someone out of their own machine; setup and start stay explicit steps. A .pkg has no native uninstaller, so one ships in the payload. Its ordering is load-bearing: `panic` runs FIRST, force-removing the firewall rules even with no daemon alive, and only then does it unregister and delete. Every teardown step is non-fatal so an already-gone service can't strand the rest. The app is built universal by lipo'ing two single-arch swift builds rather than one multi-arch build: the latter needs xcbuild from a full Xcode, and this project builds with the Command Line Tools alone. The release workflow installs the pkg on the runner, asserts the service is registered but NOT started, then runs the uninstaller and asserts it left nothing behind — a broken installer fails the release instead of shipping. Unsigned (no Apple Developer certificate); INSTALLER_SIGN_IDENTITY and NOTARIZE_PROFILE are wired through build-pkg.sh for when there is one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW
…tray .idea tryControl loaded the RAW --config flag via config.Load. That flag is empty in the normal invocation (`dezhban block`), so it read built-in defaults instead of the user's real config: a customized control.socket or control.group was ignored, the fast path missed the daemon, and the password prompt this feature exists to remove came quietly back. Use loadConfig, which applies the same $DEZHBAN_CONFIG → system path resolution as every other command. Regression test covers the no-flag case. --no-daemon was only stripped per-command, so `dezhban --no-daemon block` failed with "unknown command" while `dezhban --no-sudo block` worked. Make it a global flag handled in stripVerbose, exactly like --no-sudo, and match sudoDisabled's truthy-env parsing. build-app.sh's EXIT trap ended in a failing test on the non-universal path, which under `set -e` can drag the script's exit status down with it. .idea/ was swept in by `git add -A`; it was never tracked. Untrack and ignore it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW
Three user-reported bugs, two of which turn out to be one root cause. /var/db/dezhban was created 0700 by the pf backend, and MkdirAll no-ops on an existing directory, so the mode stuck. Everything the daemon publishes is read from OUTSIDE the daemon by the unprivileged logged-in user — state.json by the menubar app, control.sock by every routine op — and an untraversable directory severs both silently: the app reports "Kill switch stopped" and "no posture reported" while the daemon enforces perfectly, and the control socket is unreachable, so block/unblock fall back to sudo. That is the passwordless feature failing closed into exactly the UX it was built to remove. The directory is now 0755 (state.DirMode) and state.EnsureDir repairs a too-tight existing one at daemon startup. Confidentiality was never in the directory bit — command.json and pf.state are 0600. Separately, the GUI elevated once per command, so applying the VPN panel cost seven password prompts plus two for the restart. `config set` now takes N key=value pairs in one validated, atomic write (which also retires the hand-ordering that kept the file legal between per-key writes), and runPrivileged(batch:) runs a whole sequence under one prompt. The VPN panel is one prompt; install is one instead of two; uninstall one instead of three. The menubar icon was tinted a fixed gray when stopped — invisible on a dark menu bar. Resting states now use the menu bar's own adaptive color; only warning states keep an explicit tint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW
`dezhban stop` on an installed-but-not-running service failed: launchd's load/unload are edge triggers, so unloading a job that was never loaded dies with a bare "Unload failed: 5: Input/output error". Being asked to reach a state you are already in is not a failure. This is what broke the GUI's config-apply. The panel composed the restart out of two shell commands, so the failing `stop` aborted the batch under `set -e` and the `start` never ran — the config was written and the daemon was left down. Composing it in the caller put the judgement about the in-between state in the wrong place, so `restart` is now one command that owns it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW
The restart check read whatever was in state.json, but the daemon writes a final posture="stopped" snapshot on clean shutdown (runner.publishStopped), so the file is not empty between the stop and the start. The panel read the dead daemon's goodbye note and called the restart a success — including when the new daemon never came up, the one case the wait exists to catch. It now only accepts a snapshot stamped after the restart began. cmdRestart ran stop then start with no settle wait. `launchctl unload` can return before launchd drops the job, and the new idempotence check skips the load when it still sees the service running — reporting a clean restart while leaving the daemon down. It now waits (bounded) for the stop to take effect. svc.Installed() collapsed every status error into "not installed", which would send the user to `install` on an already-installed service and straight into kardianos's "Init already exists". Only a definite ErrNotInstalled counts now. Also: `restart` parses its flags instead of ignoring argv, and parseSetPairs gets a table test for the legacy/batch disambiguation and the empty-value case the GUI sends on every apply. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW
…transport
Reported: VPN connected, IP resolved to GB (allowed), started the kill switch,
lost all internet — "ping: cannot resolve google.com".
The guard's standing ruleset is `pass on lo0; pass out on {utun4}; block drop
out all`. That last rule covers the physical interface, and the physical
interface is what carries WireGuard's own encrypted transport. With no endpoint
allowed through it, arming the guard cut the tunnel's handshake and keepalives:
the VPN died and nothing flowed at all. It is not a leak-proof guard, it is a
blackout — and an unrecoverable one, because the socket endpoint discovery would
have learned the server from died with it.
runVPN already refused to start on zero endpoints, but `relaxed`
(autodetect || switchEnabled) waived it. That waiver is correct only for the
ZERO-TUNNEL case: no VPN connected, so a total cut is the right standing posture
and a switch window recovers it. With a tunnel already up it is neither. Refuse,
and name the fix.
Why discovery found nothing: it reads CONNECTED sockets from netstat, and
WireGuard (like other NetworkExtension clients) sends from an UNCONNECTED UDP
socket — no foreign address, nothing to read, ever. `vpn import` reads the
endpoint straight out of the VPN's own config; that is the answer, not more
retrying.
doctor's entire job is catching this and it exited 0 with "(none resolved)". It
now reports a LOCKOUT RISK and exits non-zero — as it also now does for
tunnel-internal endpoints, which it printed as MISCONFIGURED and then exited 0 on.
Also: the menubar icon is no longer tinted. Gray-when-stopped and
green-when-enforcing were both unreadable on a dark menu bar. It is a plain
template image now, drawn in the menu bar's own color, with the posture carried
by the symbol.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW
…ts as VPN endpoints Touch ID: the app elevated through NSAppleScript's `do shell script ... with administrator privileges`, whose dialog is password-only and always has been. It now goes through Authorization Services — the API behind the System Settings padlock and the pkg installer — whose prompt offers "Touch ID or password". The AuthorizationRef is held for the life of the app and the system grants a grace period, so a second privileged action a moment later is usually silent. AuthorizationExecuteWithPrivileges is resolved by dlsym (it is deprecated and so not exposed to Swift); if a future macOS removes it we get nil and fall back to the old dialog rather than failing to launch. The alternative — an SMAppService root helper — is a permanently installed root XPC service, far more attack surface than "run `dezhban start` occasionally" is worth. Discovery: it accepted ANY socket bound to a physical-interface IP with a public peer, on the premise that a full-tunnel VPN routes everything else through the tunnel, so the only physical-side socket must be the VPN's. That premise is false — apps bind to the physical link all the time. On a live machine it returned GitHub, Cloudflare, Anthropic and Google as "VPN endpoints", and those went straight into the guard's pass list: the kill switch punched permanent holes to arbitrary hosts (a leak) while still blocking the real VPN server (a blackout). Both failure modes at once, from a diagnostic-grade heuristic wired into an enforcement-grade allowlist. Discovery now requires the socket to be owned by a process that is plausibly a VPN transport (lsof gives the pid; ps gives the exe). An unattributable socket is not an endpoint. A false positive is a hole in the kill switch; a false negative just means the user names the endpoint by hand — so the default answer is no. This does NOT make WireGuard discoverable: it sends from an unconnected UDP socket, so it has no peer for any socket table to report, and no heuristic can recover one. The endpoint must be named (`vpn import` reads it out of the VPN's own config). Verified against the live tunnel: discovery now correctly finds nothing, and the startup refusal fires with an actionable message. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW
There was a problem hiding this comment.
Pull request overview
This PR adds a standalone macOS installer and introduces a daemon control socket to make routine operations (block/unblock/switch) passwordless by delegating them to the running daemon, while keeping panic and service lifecycle root-only. It also includes several safety/UX fixes discovered during real-world dogfooding (lockout-risk refusal, state dir permissions repair, idempotent stop/start + restart, improved endpoint discovery attribution, and GUI elevation batching/Touch ID path).
Changes:
- Add a standalone macOS
.pkginstaller (plus embedded uninstaller) and release-workflow smoke test that installs/uninstalls the pkg on a macOS runner. - Add
internal/control(root-owned, group-authorized unix socket) +controlconfig block, and wire CLI/daemon/menubar to use it for passwordless routine ops with safe fallbacks. - Improve safety and UX: lockout-risk refusal when tunnel is up but endpoint unknown, state dir mode repair, idempotent service stop/start + new
restart, batchedconfig set, and Touch ID-capable GUI elevation.
Reviewed changes
Copilot reviewed 48 out of 49 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Updates install instructions to highlight the macOS pkg + passwordless routine ops. |
| packaging/macos/uninstall.sh | Adds pkg-shipped uninstaller with “panic first” ordering. |
| packaging/macos/scripts/postinstall | Registers LaunchDaemon on install without starting enforcement. |
| packaging/macos/resources/welcome.txt | Installer welcome text describing one-prompt install + non-enforcing default. |
| packaging/macos/resources/conclusion.txt | Installer conclusion text with next steps and uninstall instructions. |
| packaging/macos/distribution.xml | Product archive definition (domains/options/min macOS). |
| packaging/macos/build-pkg.sh | Script to build universal CLI + app into a product .pkg (optional signing seams). |
| packaging/macos/app-component.plist | Ensures app bundle installs into /Applications (not relocatable). |
| Makefile | Adds pkg-macos target to build installer end-to-end. |
| macos-gui/Sources/DezhbanMenu/VPNConfigPanel.swift | Switches to one atomic config set batch + optional restart flow. |
| macos-gui/Sources/DezhbanMenu/Elevation.swift | Adds Authorization Services elevation path (Touch ID capable) with fallback. |
| macos-gui/Sources/DezhbanMenu/DezhbanCLI.swift | Splits command tiers (run/routine/privileged), adds batching and control reachability probing. |
| macos-gui/Sources/DezhbanMenu/AppDelegate.swift | Routes routine ops via daemon socket first; adds “password expectation” tooltips; removes icon tinting. |
| macos-gui/build-app.sh | Adds optional universal app build (arm64 + x86_64) using lipo. |
| internal/svc/program.go | Adds service Installed/Running helpers for idempotent lifecycle ops. |
| internal/state/state.go | Introduces state dir mode constant + repairs too-restrictive mode at daemon startup. |
| internal/state/state_test.go | Tests for EnsureDir creation + mode repair behavior. |
| internal/runner/runner.go | Adds control socket integration + manual-block hold + lockout-risk refusal on startup. |
| internal/runner/runner_test.go | Adds coverage for “tunnel up but endpoint unknown” refusal and legal standing posture case. |
| internal/runner/control_test.go | Adds end-to-end tests for control socket behavior and invariants. |
| internal/netdetect/discover_darwin.go | Hardens endpoint discovery by requiring process attribution (reduce false positives/leaks). |
| internal/netdetect/discover_darwin_test.go | Adds tests for transport allowlist and lsof address parsing. |
| internal/firewall/pf_darwin.go | Ensures state dir is created with traversable mode (fixes GUI/socket reachability). |
| internal/control/server.go | Implements unix socket server that forwards requests to runner loop. |
| internal/control/protocol.go | Defines request/response protocol and timeout limits. |
| internal/control/perms_windows.go | Windows no-op permission hook (feature not supported there yet). |
| internal/control/perms_unix.go | Applies chown/chmod security boundary on the unix socket. |
| internal/control/control_test.go | Tests server/client behavior, limits, and fail-closed semantics. |
| internal/control/client.go | Implements client with ErrUnavailable/ErrForbidden classification for proper fallback behavior. |
| internal/config/control_other.go | Sets non-darwin default control group to root-only (opt-in). |
| internal/config/control_darwin.go | Sets macOS default control group to admin. |
| internal/config/config.go | Adds control config block and round-trip serialization support. |
| internal/command/command.go | Updates docs to reflect command file + socket being complementary. |
| docs/usage.md | Documents password behavior, Touch ID elevation path, multi-pair config set, and control socket. |
| docs/troubleshooting.md | Adds troubleshooting for tunnel-up/no-endpoint blackout and state-dir permission issues. |
| docs/releasing.md | Updates release doc for pkg + unsigned artifacts + future signing seam. |
| docs/config.md | Documents the control block, defaults, and tightening options. |
| docs/architecture.md | Documents dual control channels and the socket’s security model/invariants. |
| cmd/dezhban/vpn_cmd.go | Routes switch through control socket first (fallback to root command file). |
| cmd/dezhban/main.go | Adds --no-daemon, integrates control socket for routine ops, adds restart, and improves doctor exit codes. |
| cmd/dezhban/control_client.go | Adds CLI-side control socket resolution, status line, and refusal exit code. |
| cmd/dezhban/control_client_test.go | Tests CLI control path fallback/refusal handling and defaults. |
| cmd/dezhban/config_cmd.go | Adds multi-pair config set (atomic, one validation/write). |
| cmd/dezhban/config_cmd_test.go | Tests multi-pair semantics, rejection atomicity, and legacy compatibility. |
| cmd/dezhban/completion.go | Adds restart to shell completion command list. |
| CLAUDE.md | Updates documented invariants to include socket-triggered switch window and new commands. |
| CHANGELOG.md | Documents installer/control socket features and multiple related fixes. |
| .gitignore | Ignores JetBrains .idea/. |
| .github/workflows/release.yml | Builds pkg + app zip on macOS, smoke-tests install/uninstall, and publishes pkg as a release asset. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…l loops
Rolling a new build onto a test machine took ~6 manual commands with no single
entry point. Two loops now cover it:
task dev:all fast: rebuild CLI+GUI, swap them in place, restart the daemon,
relaunch the app. Seconds, not minutes; bypasses packaging.
task pkg:cycle full: cross-compile, universal app, .pkg, install, open the app
— the exact path a user takes. pkg:fresh uninstalls first.
All Makefile targets carry over 1:1 (namespaced: build:all, gui:build, ...).
Bare `task` lists everything with descriptions, so the runner self-documents.
pkg:build's Go and Swift builds now run in parallel.
scripts/*.sh internally ran `make build`, so they'd have broken with the
Makefile gone. They now call `go build` directly rather than `task`: panic.sh
is the lockout escape hatch, and recovery must not depend on dev tooling being
installed.
CI: three `make` call sites switch to task via arduino/setup-task. Published
asset names are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U16yja1ptc45uhMBLoVZ7V
… dir mode, stop re-prompting on a cancelled elevation Three findings from PR #19 review, all real. control: the socket was bound at its published path and only then chown/chmod'd, so between the bind and secureSocket it was live under a umask-derived mode — with a permissive umask, a world-writable control socket. Filesystem permissions are the entire authorization gate here, so that window is a hole. It now binds inside a fresh 0700 root-only staging dir, secures the socket there, and renames it into place: a rename moves the directory entry, not the bound inode, so the listener keeps serving and the socket first becomes reachable already root:group 0660. Windows keeps the direct bind (no perms model to establish, and it cannot rename a bound AF_UNIX socket). state: EnsureDir only repaired dirs missing DirMode bits, so a too-PERMISSIVE dir (0777) sailed through. That is the more dangerous direction: a group/world-writable state dir lets a local user unlink control.sock and bind their own in its place. The mode is now enforced exactly, in both directions. gui: Elevation.run returned nil for every failure, and elevate() read nil as "path unusable" and fell back to AppleScript — so cancelling the Touch ID prompt put up a second, password-only dialog for the request the user had just declined. It now returns an Outcome distinguishing .cancelled (an answer: report it, never re-ask) from .unavailable (the user saw nothing: falling back is the first ask). Fixes a self-deadlock on that path too: the cancel branch called invalidate() while already holding the non-reentrant NSLock. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U16yja1ptc45uhMBLoVZ7V
Found reviewing #19. None touch the trust model — all three are edges in the socket's lifecycle, and two of them fail in the same direction: they quietly reintroduce the password prompt the socket exists to remove. Reply deadline. serve() armed one connection-wide deadline (connDeadline, 5s), but its own worst case is handoffTimeout+replyTimeout (12s). A slow Backend.Apply tripped the deadline mid-wait and the reply write failed, so the caller saw an EOF rather than the daemon's answer — and tryControl reads a transport error as "daemon unreachable" and falls back to sudo. A merely busy daemon therefore prompted for a password. Bound the read on its own, give the reply a fresh write deadline, and derive the client's patience from the server's worst case so it cannot hang up just as the answer arrives. Accept backoff. A persistent Accept error (EMFILE) fails instantly and forever; the bare `continue` spun the goroutine at 100% CPU inside a daemon whose whole job is staying responsive enough to cut the network. Back off 5ms→1s, reset on the first success. Identity-checked unlink. listenSecure publishes by renaming OVER the path — deliberately, to clear crash leftovers, but it replaces a *live* socket just as happily. A daemon starting while an older one shuts down had its socket removed by the older one's Stop(), leaving it serving on an unreachable inode with every routine op falling back to a password. The new `restart` is exactly the stop-then-start that makes this reachable. Remove only the inode we published (os.SameFile). Both regression tests were confirmed to fail against the pre-fix code. Docs: architecture.md now states the socket's actual cost — "an admin could sudo anyway" elides that sudo demands a password and the socket does not, so the gate is every *process* running as an admin, not the human — and names what the 0755 state dir exposes (public IP, country, tunnel names, VPN endpoint via 0644 state.json/learned.json; command.json stays 0600 and is re-verified on read). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U16yja1ptc45uhMBLoVZ7V
…wrong fix Copilot review on #19 suggested replacing errors.Is(err, fs.ErrPermission) with os.IsPermission in classifyDialErr, on the theory that errors.Is cannot see an EACCES wrapped in a *net.OpError. It is exactly backwards, and the suggestion would break the thing it claims to fix: errors.Is(err, fs.ErrPermission) → true (OpError unwraps to os.SyscallError to syscall.Errno, which implements Is() for EACCES/EPERM) os.IsPermission(err) → false (does not unwrap through OpError) Under the suggested change a socket the caller may not open is misclassified as ErrUnavailable — "no daemon" — and the CLI silently falls back to a password prompt, which is the confusion the control socket exists to remove. Verified both directions; the test fails against the suggested version. No production change: this locks in behaviour that is already correct, because the next reader may find the suggestion as plausible as the bot did. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U16yja1ptc45uhMBLoVZ7V
Response to the Copilot reviewsWorked through all four inline comments. One is a real bug (already fixed); three are false positives, and one of those would actively break the code if applied. Each verdict below is empirical, not an opinion. ✅
|
…wrong fix Copilot review on #19 suggested replacing errors.Is(err, fs.ErrPermission) with os.IsPermission in classifyDialErr, on the theory that errors.Is cannot see an EACCES wrapped in a *net.OpError. It is exactly backwards, and the suggestion would break the thing it claims to fix: errors.Is(err, fs.ErrPermission) → true (OpError unwraps to os.SyscallError to syscall.Errno, which implements Is() for EACCES/EPERM) os.IsPermission(err) → false (does not unwrap through OpError) Under the suggested change a socket the caller may not open is misclassified as ErrUnavailable — "no daemon" — and the CLI silently falls back to a password prompt, which is the confusion the control socket exists to remove. Verified both directions; the test fails against the suggested version. No production change: this locks in behaviour that is already correct, because the next reader may find the suggestion as plausible as the bot did. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U16yja1ptc45uhMBLoVZ7V
fa2ae16 to
923222c
Compare
| dir := filepath.Dir(path) | ||
| if err := os.MkdirAll(dir, 0o755); err != nil { | ||
| return nil, fmt.Errorf("control: create dir %q: %w", dir, err) | ||
| } |
| path := controlSocketPath(cfg) | ||
| if !control.Ping(path) { | ||
| return fmt.Sprintf("unreachable (%s) — daemon not running; routine ops need sudo", path) | ||
| } |
…tatus, drop windows from CI Two of Copilot's post-merge review comments on #19 were real; the rest were already closed by the later commits on that branch, or wrong. control.New created the socket's parent directory but never judged an EXISTING one. MkdirAll is a no-op on a directory that is already there, so the 0755 it requests says nothing about what it found — and only the DEFAULT path is sound by construction (state.EnsureDir owns the state dir's mode); a configured control.socket can point anywhere. The socket's own 0660 root:admin mode gates who may CONNECT; its parent gates who may UNLINK, and a local user who can unlink the socket can bind their own in its place and answer block/unblock/open-switch however they like. Since filesystem permissions are the entire authorization model here, the container's bits are part of that model: checkDirSecure now fails the control feature closed on a group/world-writable non-sticky dir, or one owned by neither root nor us. Sticky is the deliberate exception — it is exactly the bit that restricts unlink to the owner, which is what makes a 1777 /tmp-style dir safe. controlStatus used control.Ping, which collapses every failure into false — so the one user who most needs a real answer, a caller outside control.group, was told "daemon not running" while the socket sat right there, openable by everyone but them. ErrForbidden is modeled precisely so that case can be named; status now names it. CI: drop windows-latest from the test matrix. Windows is an unfinished target — the control socket's authorization model IS unix mode+ownership, which Windows has no equivalent of (secureSocket is a no-op there), so the tests that pin that boundary cannot pass and the passwordless path is not a supported Windows feature today. Running them anyway just produces a red X meaning "Windows isn't done", which we already know. GOOS=windows go vet still runs on the ubuntu job and build:all still cross-compiles the binary, so the code cannot bit-rot before Windows support is picked up for real; the tests are the spec it will have to meet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAQrDzbNQmPVPesJa9zrUv
What this does
Ships a standalone macOS installer and makes the operations you actually perform every day stop asking for a password.
dezhban-<version>.pkg(make pkg-macos) installs the universal CLI, the universal menubar app, and the launchd service in one step, with a single admin prompt at install time. It registers the service but deliberately does not start enforcement — a kill switch armed against a config you haven't chosen yet is how you get locked out of your own machine, sosudo dezhban setupandsudo dezhban startstay explicit. A.pkghas no native uninstaller, so one ships inside it (sudo sh /usr/local/share/dezhban/uninstall.sh); it runspanicfirst, restoring connectivity before anything is torn down.The passwordless half is a control socket (
internal/control): the daemon listens on a root-owned, admin-group unix socket, andblock/unblock/switchare performed by the running daemon — so they need no password, from either the CLI or the menu bar.panicand the service lifecycle deliberately stay root-only:panicis the lockout escape hatch and must work when no daemon is alive, and a daemon cannot start or stop itself.The pkg is unsigned (no Apple Developer certificate).
build-pkg.shhas the signing seams (INSTALLER_SIGN_IDENTITY,NOTARIZE_PROFILE);docs/releasing.mdcovers the three ways to get past Gatekeeper.The security trade, stated plainly
dezhban is stdlib-only, so there are no peer credentials on the socket — filesystem permissions are the entire authorization gate. Any process running as a member of
admincan issue those commands without a password.That is bounded on purpose:
block/unblockonly move between postures the daemon's own state machine already sanctions, andswitchkeeps its bounded window (default 2m, cap 5m, auto-revert). It is tightenable three ways —control.allowSwitchOps: false,control.group: "",control.enabled: false— all documented indocs/config.md, anddezhban statusprints which mode you're in."An admin could
sudoanyway" is not the whole answer, and it shouldn't be the one this PR hides behind.sudodemands a password; the socket does not. So the gate is not "the humans who administer this machine" — it is every process running as one of them. A malicious binary the admin user runs, with no elevation and no prompt, can open a switch window and relax the guard for up to five minutes; before this, that took a password the malware didn't have. We ship it because the window is bounded and auto-reverting, and because a password prompt on every routine block/unblock is how a kill switch ends up switched off entirely — but the cost is real, anddocs/architecture.mdnow names it instead of implying there isn't one.This amends a documented invariant ("the switch window is root-triggered"). CLAUDE.md and
docs/architecture.mdare rewritten to say so rather than quietly contradicting it.Bugs found and fixed while dogfooding this
Four real ones, from actually installing and running it:
The guard could be armed in a state that cut the tunnel's own transport. With a VPN connected but no known server address, the guard's
block drop out allcovers the physical interface — which is exactly what carries the VPN's encrypted transport. Arming it killed the tunnel and every packet with it, unrecoverably (the socket endpoint discovery would have learned the server from died too). Observed live: WireGuard connected, IP resolved to an allowed country, kill switch started, total blackout.vpn.autodetectwas wrongly excusing this; that allowance exists for the zero-tunnel case, where a total cut is correct and a switch window recovers it. The daemon now refuses to start with a live tunnel and no endpoint.doctorreports it as a LOCKOUT RISK and exits non-zero — it previously printed(none resolved)and exited 0, which for a tool whose entire job is catching lockouts is the worst possible outcome. (It also now exits non-zero on tunnel-internal endpoints, which it already flagged asMISCONFIGUREDand then exited 0 on.)Worth knowing: endpoint auto-discovery reads connected sockets, and WireGuard — like other NetworkExtension clients — sends from an unconnected UDP socket. It has no foreign address and can never be discovered. It must be named (
vpn importreads it straight out of the VPN's own config)./var/db/dezhbanwas created0700, which silently broke everything that reads out of it as the logged-in user: the menubar app couldn't readstate.json(so it showed "Kill switch stopped" while the daemon was enforcing perfectly), and the control socket was unreachable through the directory — so every routine op fell back to a password prompt. The passwordless feature was failing closed into exactly the UX it exists to remove. Now0755, withstate.EnsureDirrepairing an existing too-tight one (MkdirAllno-ops on an existing dir, which is how the wrong mode stuck). The directory bit was never what protected the contents: the one file in there that is a capability —command.json, which can open a switch window — is0600root-owned, and the daemon re-verifies its owner and mode on every read rather than trusting the directory. What the0755does expose is posture metadata:state.jsonandlearned.jsonare0644(the menubar app runs as the logged-in user and has to read them), so any local user can now see your public IP, resolved country, tunnel interface names, and VPN server endpoint. No credentials, but on a multi-user host that is readable by everyone on it — the trade is stated indocs/architecture.md.The menubar app asked for a password once per config field — applying the VPN panel meant seven elevations plus two for the restart.
config setnow takes Nkey=valuepairs in one validated, atomic write (which also retires the hand-ordering that kept the file legal between per-key writes — there is no "between" anymore), and the GUI runs a whole sequence under one elevation. One prompt. Install drops from two prompts to one, uninstall from three to one.stopfailed on a service that wasn't running, because launchd'sunloadis an edge trigger and dies with a bareInput/output error. That aborted the GUI's config-apply halfway.start/stopare idempotent now, and there's a realdezhban restartthat owns the in-between state instead of leaving that judgement to the caller.Also: the menubar icon is no longer tinted — gray-when-stopped and green-when-enforcing were both unreadable on a dark menu bar. It's a plain template image now, with posture carried by the symbol.
Fixed in review
Three more in the socket's shutdown and timeout edges — none in the trust model, all with regression tests confirmed to fail against the pre-fix code:
A slow daemon silently dropped its reply.
serve()armed one connection-wide deadline (connDeadline, 5s), but its own worst case ishandoffTimeout + replyTimeout(12s). AnApplytaking longer than 5s tripped the deadline mid-wait and the reply write failed — and sincetryControlreads a transport error as daemon unreachable, a merely-slow daemon fell back to sudo. The password prompt this feature exists to remove, reintroduced by a busy one. The read is bounded on its own now; the reply carries a fresh write deadline, and the client's patience is derived from the server's worst case so it can't hang up as the answer lands.The accept loop busy-spun on a persistent error.
EMFILEfails instantly and forever, and the barecontinueturned that into a 100%-CPU spin inside the daemon whose one job is staying responsive enough to cut the network. Now backs off 5ms→1s, resetting on the first success.Stop()could unlink a different daemon's socket. Publishing renames over the path — deliberately, to clear crash leftovers, but it replaces a live socket just as happily. A daemon starting while an older one shuts down gets its socket deleted by the older one'sos.Remove, then serves on an inode nobody can reach while every routine op quietly falls back to a password. The newrestartis exactly the sequence that makes this reachable. The unlink is identity-checked against the published inode now.Invariants held
Backend.Applystill happens on the single run-loop goroutine. The socket's accept goroutine only forwards requests over a channel; it never touches the backend.blocknow holds — the geo state machine is suspended until youunblock, so an allowed reading can't quietly undo an operator's block.Testing
196 tests pass. The release workflow now installs the pkg on a runner, asserts the service was registered but not started, runs the shipped uninstaller, and asserts nothing is left behind — so a broken installer fails the release instead of shipping.
Manually verified on macOS 15 (Apple Silicon): install, VPN config apply, restart, uninstall, and the
doctorlockout diagnosis against a live WireGuard tunnel.🤖 Generated with Claude Code
https://claude.ai/code/session_01RzzechyWjqiiJdNaooDoxW