diff --git a/CHANGELOG.md b/CHANGELOG.md index 6473d54..9de7363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,223 @@ current as you land changes. ## [Unreleased] +### Added + +- **`dezhban config schema` describes every setting**, so you can ask what a key + is instead of reading source. For each one it prints the label, its default, + what bounds it, whether `"0"` turns it off, whether a strictness preset writes + it, whether a running daemon adopts it without a restart, and where it is + documented. `--json` for tools; read-only, no root, and it reads no config file + — the schema is what the keys *are*, not what this host has set, so it answers + the same on a machine that has never been configured. + + Behind it, defaults are now data rather than prose. They used to be written + down in four places — the Go constants, the macOS app's placeholder hints, the + documentation tables, and the example configs — and they had already come + apart: the app advertised a 30s endpoint refresh and a 5s tunnel watch while + the shipped defaults were 1m and 1s. Every surface now derives them from one + table, which itself derives the values from the shipped defaults, so the same + drift cannot recur. + +- **`dezhban hold` keeps a deliberate disconnect cut.** dezhban cannot tell a VPN + you turned off from a VPN that fell over, so it treats every drop the same way + and opens a redial window — a relaxation you never asked for when you are the + one disconnecting. Arm hold the line first and the next drop stays cut, with + the icon red because traffic really is cut. `hold --status` reports it, + `hold --cancel` disarms it. + + It only ever **removes** a relaxation, so the three sanctioned triggers are + unchanged and there is no fourth — and it carries no `control.allow*` gate, + because there is no authority to withhold. One-shot on purpose: spent by the + drop it covers, disarmed once a tunnel is back, and forgotten if the daemon + restarts, so a flag left armed can never cost a later *accidental* drop the + redial help it should have had. `status --json` gains a `hold` object. + +- **A relaxed guard says so first, and names when your VPN dropped.** Every + window sentence now leads with the exposure and when it ends, instead of + opening with the machinery and leaving the consequence trailing after a dash: + *"Your real IP may be exposed until 3:04PM. Your VPN dropped at 3:03PM and the + guard relaxed so it can redial."* A guard holding a downed tunnel names the + drop time too. Both surfaces change together, because both display the same + rendered strings. + +- **The state file records when your VPN dropped.** `status --json` gains a + `drop` object (`at`) present from a tunnel drop until a tunnel is up + again. Until now the moment the guard cut traffic was unobservable on the + common path: the automatic redial window opens on the same edge, so the cut + snapshot was replaced within microseconds while observers read the file about + once a second — leaving both surfaces able to say only "a window is open". + +- **The macOS Settings pane is reorganised around what you are deciding**, not + around the shape of the config file. Sections are ordered which VPN to trust → + what gets blocked → when the guard may relax → local network → how closely it + watches → startup, each with a line saying why you would touch it. Headings say + the thing rather than the config block: "When the guard relaxes", not + "Windows". Autodetection is folded into "Your VPN", where those toggles were + always about the same decision. + +- **Duration settings in the macOS app are a menu of real choices**, not a text + field demanding Go's duration syntax. Each offers lengths derived from that + key's own default and its live cap, marks the recommended value, and provides + a Custom entry with immediate validity feedback instead of a modal alert after + Apply. Lowering a cap by hand narrows the menu, because the ceiling is read + from your config rather than a constant. + + Where `"0"` is a real, persisted opt-out — the switch window, the redial + window, the pause cap, and the anti-flap gate — an explicit **Off** is offered + and states its consequence in words. It is offered *only* for those keys: for + every other duration a `0` is coerced back to the default, so an Off there + would be a security choice that silently did nothing. + +- **`vpn.pauseMax` has a control in the macOS app**, under Windows alongside the + switch and redial windows. It was settable from the CLI and reachable by + editing the file, but the app offered no way to see or change how long a pause + may last. + +- **Pause offers realistic lengths, in both surfaces.** `dezhban pause --list` + prints them with what each one is for, and the macOS app's Pause item gains a + submenu of the same choices. They are defined once in the config core and read + from the daemon, so the two cannot drift apart. Lengths above `vpn.pauseMax` are listed as unavailable with the + cap as the reason rather than hidden — a cap you cannot see is one you keep + bumping into. Any duration up to the cap still works. + +- **The macOS app carries dezhban's documentation inside it.** A new Help pane + in the main window shows the same pages as the repository's `docs/` — quick + start, how the guard works, the postures, troubleshooting, and the full + configuration and command references — with a search box and a first-time + reading order. + + It reads them from inside the app bundle and never touches the network, which + is the entire point: the moment you most need to know what the guard is doing + to your traffic is often the moment it has cut all of it, and a help pane that + needed a working connection would be blank exactly then. The pages are + rendered from the repository's markdown when the app is built, so they always + match the version that documents them, and the pane refuses to follow any link + that leaves the bundle. + + Every setting in the Settings pane also carries a **?** button that opens Help + at that setting's own section. A tooltip has room for one sentence; why a + setting exists, what it costs, and what turning it off actually does often + needs a page — and each link lands on the heading, not the top of a long + reference. + +- **`dezhban setup --questions` says what the wizard would ask** — each question, + what it writes, its seeded answer, and which earlier answer unlocks it — + without asking anything. Read-only, no root, no terminal needed; `--json` is + the machine form. + + Behind it, the wizard's decisions moved out of the CLI into `internal/setup`, + which now owns the question set, the branching, and how answers become a + config. The CLI keeps only the presentation. A second wizard therefore cannot + ask different questions or apply the same answer differently — the same reason + the settings schema lives in one place. + +- **The macOS app has a setup wizard.** Launch it with nothing configured and it + walks the same questions `dezhban setup` asks — which countries to refuse, how + to find your VPN, whether to pin an interface — seeded from whatever config + you already have, with no terminal involved. It reads the question set from + the daemon rather than keeping its own, and saves through the same batched, + validated `config set` every other pane uses. + + It is offered only when dezhban does not know a VPN yet: if you set it up from + the CLI, the app does not ask you to do it again. Settings → **Run Setup + Again…** reopens it whenever you want, which is the guided way through those + decisions when you change VPN. + +### Changed + +- **The macOS menubar is a glance and the actions that are urgent when you look + at it** — the posture line, the switch or pause countdown, hold the line, and + Open Dezhban. **Block now and Unblock have moved into the window's Overview**: + someone who wants to cut their own internet can turn off Wi-Fi, so blocking by + hand is a power-user and debugging affordance rather than part of the routine + flow. + + **Panic now sits behind the Option key**, as the alternate to "Open Dezhban…" — + hold ⌥ and the item becomes "Panic — force unblock…", or press ⌘⌥O. It stays + in the menubar on purpose, because the moment it is needed is the moment the + main window may not open; it is one keystroke away rather than one slip away + in a menu people open to check a countdown. + +- **A pause longer than `vpn.pauseMax` is now refused and explained, instead of + silently shortened to the cap.** Asking for an hour against a 30-minute cap + used to grant thirty minutes and report success, which is indistinguishable + from having got what you asked for. It now fails, names the cap, and says how + to raise it. Every path refuses — the CLI, the control socket, and the + root-owned command file — so no client can get the old behaviour, and the + command file (the one that still works with `control.allowPauseOps: false`) + logs the same reason the socket would have replied with. A pause with *no* + duration given is unchanged: nobody asked for a particular length, so the + built-in default is still clamped to the cap. + +### Fixed + +- **Re-running `dezhban setup` no longer deletes your saved VPN profiles.** The + wizard collects profiles by importing config files you name, and it wrote that + list over the configured one — so running setup again to change, say, your log + level, and not naming those files a second time, silently dropped every + profile you had imported. Imported profiles are now merged into the saved + ones, replacing by name. + +- **The macOS app's Settings pane no longer advertises wrong defaults.** Its + field hints were literal strings that had drifted from the shipped values — it + suggested a 30s endpoint refresh and a 5s tunnel watch when the defaults are + 1m and 1s. Labels, hints, and help text now come from the daemon's own schema, + so the pane says what dezhban actually does. Against a CLI too old to report a + schema the pane falls back to a plainer label rather than a stale value: less + helpful, never wrong. + +- **The documentation bundled into the app now renders as written.** The subset + renderer degraded silently instead of refusing, so pages shipped wrong while + every test passed: Quick start — the first page of the guided track — opened + with three lines of raw HTML source shown as text, bold that the author had + wrapped across two lines left literal `**` in eight of the nine pages, nested + bullets flattened into their own parents, and an asterisk inside `` `code` `` + paired with an unrelated one to open emphasis that closed outside the tag. + Soft-wrapped lines are now joined before inline markup is read, a list item's + continuation stays inside the item, emphasis cannot reach into a code span, + and anything the renderer still cannot represent **fails the build** rather + than shipping — which is what the design claimed all along. + +- **A settings value can no longer be written under the wrong key.** The pane + staged its twenty-five values as an array destructured by position, with only + a count check — so inserting or reordering a key would have silently applied + one field's value to another setting. Values are now keyed throughout. + +- **`dezhban setup` no longer clears `vpn.autoDiscoverEndpoints` on Linux and + Windows.** Endpoint discovery is macOS-only, so the wizard does not ask about + it elsewhere — and an unasked question was read as "no" and written to the + config, quietly turning off a setting the user had set. The same class of bug + as the deleted profiles above: a question the wizard never put on screen now + leaves its key alone. + +- **A drop that did not happen today now says which day it was.** The drop + record is carried until a tunnel returns, so through an overnight outage or a + long FULL BLOCK "Your VPN dropped at 3:04PM" read as *a few minutes ago* and + understated how long the host had been cut. It now reads "at 3:04PM on Jul 26" + once the drop is no longer on the snapshot's own day. + +- **Links in the app's Help pane go somewhere.** The bundled pages cross-reference + the ADRs and the contributor docs thirty times over, and those deliberately do + not ship — so each of those links resolved to a file beside the bundle that does + not exist, and clicking one reported *"That link points outside the app: + file:///…/Contents/Resources/adr/0008-arm-at-boot.md"* with a Copy button that + copied exactly that. Every bundled page had them. A link to a document that is + not bundled now points at the repository, so the pane names a URL that works in + a browser; a link to a page that *is* bundled still opens it in place. The pane + still reaches the network for nothing. + +- **`dezhban hold` no longer reports success for a hold the daemon discards.** + Arming through the root-owned command file was a silent no-op when + `vpn.redialWindow` was `"0"` — nothing to suppress, nothing logged. The CLI + checks the config first, but skips that check when the file cannot be read, and + then printed "hold the line armed". The daemon now says why it ignored the + command, the same way the pause path does. + +- **An over-cap pause refusal now says how to fix it.** The daemon's log named the + cap but not the command to raise it, so the CLI and the daemon explained the + same refusal differently. + ### Changed — BREAKING - **"reconnect" is now "redial" everywhere.** The codebase used both words for the diff --git a/CLAUDE.md b/CLAUDE.md index 3a49a9e..f12883c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ dev tooling only, never the daemon path); non-TTY prints the grouped menu Subcommands: `run`, `block`, `unblock`, `status`, `panic`, `install`, `uninstall`, `start`, `stop`, `restart`, `detect-vpn`, `validate`, `print-rules`, `doctor`, `monitor`, -`switch`, `pause`, `resume`, `vpn`, `setup`, `config`, `token`, `completion`, +`switch`, `pause`, `resume`, `hold`, `vpn`, `setup`, `config`, `token`, `completion`, `upgrade`, `version`, `help` (also `--help`/`-h`; `--version` aliases `version`), plus three globals: `-v`/`--verbose`, `--no-sudo` (skip auto-elevation), `--no-daemon` (skip the control socket, act on the firewall directly). @@ -74,7 +74,8 @@ plus three globals: `-v`/`--verbose`, `--no-sudo` (skip auto-elevation), The **privileged set** — requires root/admin — is exactly: `run`, `block`, `unblock`, `panic`, `install`, `uninstall`, `start`, `stop`, `restart`, `switch`, `pause`, `resume`, `vpn add`/`remove`/`promote`/`forget`/`import` (but not -`vpn list`/`show`), `setup`, `config set`/`edit`/`preset apply`, `token enroll`/`forget` (but not +`vpn list`/`show`), `setup` (but not `setup --questions`, which asks nothing and only reports what +the wizard would ask), `config set`/`edit`/`preset apply`, `token enroll`/`forget` (but not `token status`), and `upgrade download`/`upgrade apply` (macOS only — `download`'s staging directory is root-owned so a local user can't swap the verified `.pkg` before `apply` installs it). `switch`, `pause`, and `resume` are usually passwordless in practice: they ask the running daemon @@ -82,7 +83,7 @@ over its control socket first (gated by `control.allowSwitchOps`/ `control.allowPauseOps` respectively) and only fall back to the root-owned command file when no daemon answers. Everything else — `status`, `detect-vpn`, `validate`, `print-rules`, `doctor`, `monitor`, `vpn list`/`show`, -`config show`/`path`/`preset list`/`preset show`/`preset diff`, `token status`, +`config show`/`path`/`schema`/`preset list`/`preset show`/`preset diff`, `token status`, `completion`, `upgrade check`, `version`, `help` — is read-only: no root, no firewall effects. Full reference: [docs/usage/cli.md](docs/usage/cli.md); the upgrade design in full: @@ -182,6 +183,18 @@ The design depends on these invariants (rationale in trigger at first open (`Run`'s `openWindow` closure) exist for exactly this reason. Never widen a window past its own cap, never add a FOURTH trigger without a new ADR, never let any of the three outlive its deadline. +- **"Hold the line" SUPPRESSES trigger 2; it is not a fourth trigger.** + `dezhban hold` arms a one-shot flag that makes the next tunnel drop stay cut + instead of opening the automatic redial window — it answers the one thing the + daemon cannot infer, whether a disconnect was deliberate. It only ever + *removes* a relaxation, which is why it needs no ADR and carries **no + `control.allow*` gate**: every such gate exists to withhold an authority, this + op grants none, and adding one would only hand an operator a way to switch off + the safer behaviour. Keep it one-shot and un-persisted — spent by the drop it + covers (`maybeAutoWindow`), disarmed on a tunnel-up edge, gone on restart. An + armed flag surviving a reboot would leave a later *accidental* drop with no + redial help, which is the one failure this feature must never cause. Anything + added here must likewise only subtract. - **All three windows are independently disableable, and "disabled" must survive `Normalize`.** `vpn.switchWindow: "0"` removes trigger (1); `vpn.redialWindow: "0"` removes trigger (2); `vpn.pauseMax: "0"` removes @@ -281,7 +294,23 @@ The design depends on these invariants (rationale in lies about its own status is worse than none. A doc path cited from Go/Swift source (`grep -rn "docs/" --include="*.go" --include="*.swift"`) is load-bearing: moving or merging a doc means fixing every such reference, not just the ones in - other docs. + other docs. **A page listed in `internal/help.Pages` is load-bearing the same + way**: it ships inside the macOS app, rendered from the repo's markdown at + build time by `tools/helpgen`, so the app's help matches the version it + documents and works with egress cut. `go test ./internal/help` fails when a + bundled page is moved, renamed, or written with markdown the subset renderer + cannot show — and every `Tunable.DocAnchor` is checked to resolve against a + real heading, so a contextual help link cannot rot into a silent no-op. + Concretely, a bundled page may not use **raw HTML**, **nested lists**, or a + **remote image**; all three fail the build by name. Soft-wrapped emphasis is + fine (blocks are joined before inline markup is read). **A relative link to a + doc that is not bundled is rewritten to point at the repository** — never left + as written, which resolved to a nonexistent file beside the bundle and made + every cross-reference to an ADR a dead click; `TestEveryLinkGoesSomewhere` + pins it. The rule that makes all of this safe is that the renderer + must **refuse** what it cannot represent, never degrade quietly: silent + degradation ships a wrong page while every test passes, which is exactly how + the banner on Quick start reached users. - **Every PR that changes user-visible behavior updates [CHANGELOG.md](CHANGELOG.md)'s `## [Unreleased]` section, in the same PR** — not as a follow-up. `[Unreleased]` *is* the next release's notes (see [docs/contribute/releasing.md](docs/contribute/releasing.md)); a PR diff --git a/cmd/dezhban/completion.go b/cmd/dezhban/completion.go index 685b78e..d5aa4cb 100644 --- a/cmd/dezhban/completion.go +++ b/cmd/dezhban/completion.go @@ -40,7 +40,7 @@ func cmdCompletion(args []string) int { // completionCommands is the subcommand list the scripts offer. Kept next to the // scripts so it is obvious to update when a command is added. -const completionCommands = "run block unblock status validate monitor print-rules doctor panic install uninstall start stop restart detect-vpn switch pause resume vpn setup config token completion upgrade version help" +const completionCommands = "run block unblock status validate monitor print-rules doctor panic install uninstall start stop restart detect-vpn switch pause resume hold vpn setup config token completion upgrade version help" const bashCompletion = `# dezhban bash completion _dezhban() { diff --git a/cmd/dezhban/config_cmd.go b/cmd/dezhban/config_cmd.go index 3fa3f35..865f1c2 100644 --- a/cmd/dezhban/config_cmd.go +++ b/cmd/dezhban/config_cmd.go @@ -19,7 +19,14 @@ import ( "github.com/behnam-rk/dezhban/internal/control" ) -const configUsage = `usage: dezhban config +// configUsage is assembled rather than written out, because its "Keys" section +// used to be a hand-maintained copy of the settable-key set — one more list to +// forget. It is now generated from config.Tunables(), the same table `config +// schema` prints and the app reads, so a new key appears in the help by existing. +var configUsage = configUsageProse + wrapKeys(config.TunableKeys()) + ` + (VPN profiles are managed with 'dezhban vpn add/remove', not 'config set')` + +const configUsageProse = `usage: dezhban config Subcommands: path Print the resolved config path @@ -39,6 +46,8 @@ Subcommands: preset apply Write a preset's values, validate, and save — same path as 'set', so it applies live where it can and reports what needs a restart + schema Describe every settable key: its default, cap, unit, whether + it can be turned off, and whether it applies live edit Open the config in $EDITOR (created from defaults if missing) Flags: @@ -47,22 +56,126 @@ Flags: Falls back to a privileged write if no daemon answers; a daemon that REFUSES is reported, never routed around. See 'dezhban token'. - --json ('preset list'/'preset show'/'preset diff' only) print - machine-readable JSON instead of prose + --json ('preset list'/'preset show'/'preset diff'/'schema' only) + print machine-readable JSON instead of prose Keys (dotted; list values are comma-separated): - pollInterval blockedCountries hysteresis providers providerQuorum logLevel - vpn.tunnelInterfaces vpn.endpoints vpn.autoDetect - vpn.autoDiscoverEndpoints vpn.allowPhysicalDNS vpn.allowLocalNetwork - vpn.autoArm vpn.armAtBoot vpn.switchWindow - vpn.redialWindow vpn.pauseMax vpn.endpointRefresh vpn.endpointGrace vpn.tunnelWatch - control.enabled control.socket control.group control.allowSwitchOps - control.allowPauseOps control.allowConfigOps - vpn.advanced.switchWindowMax vpn.advanced.redialWindowMax vpn.advanced.redialMinUptime - vpn.advanced.commandFreshness vpn.advanced.windowDiscoveryInterval vpn.advanced.tunnelPruneAfter - vpn.advanced.learnedEndpointTTL vpn.advanced.learnedMaxPerProfile vpn.advanced.promoteAfterRefreshes - vpn.advanced.endpointWarnThreshold vpn.advanced.windowProtocols vpn.advanced.windowPorts - (VPN profiles are managed with 'dezhban vpn add/remove', not 'config set')` +` + +// wrapKeys renders the settable-key list for the usage text: two-space indent, +// space-separated, wrapped near 78 columns so it reads in a standard terminal. +func wrapKeys(keys []string) string { + const width = 78 + var b strings.Builder + line := " " + for _, k := range keys { + if len(line)+1+len(k) > width { + b.WriteString(line) + b.WriteString("\n") + line = " " + } + line += " " + k + } + b.WriteString(line) + return b.String() +} + +// schemaEntry is what `config schema --json` emits: the declared Tunable +// verbatim (it already carries the lowerCamelCase tags every surface decodes) +// plus whether the key is one a strictness preset writes. +// +// Embedding rather than restating the eleven fields is deliberate — a parallel +// struct here would be exactly the kind of second copy this whole phase exists +// to delete. +type schemaEntry struct { + config.Tunable + // Preset reports that applying a strictness preset overwrites this key, so a + // surface can warn that changing it by hand drifts the config to Custom. + Preset bool `json:"preset"` +} + +// presetWritten reports which keys a strictness preset sets. Taken from the +// preset definitions themselves rather than a list here, so the two cannot +// disagree; every preset writes the same key set, so the first one answers for all. +func presetWritten() map[string]bool { + out := map[string]bool{} + for _, p := range config.Presets() { + for k := range p.Values { + out[k] = true + } + } + return out +} + +// configSchema describes every settable key. It deliberately reads no config +// file: the schema is what the keys ARE, not what this host has set, so it +// answers identically on a machine with no config yet — which is exactly when a +// first-run wizard needs it. +func configSchema(args []string) int { + args, jsonOut := stripJSONFlag(args) + if len(args) != 0 { + fmt.Fprintf(os.Stderr, "config schema takes no arguments (got %q)\n", strings.Join(args, " ")) + return 2 + } + + written := presetWritten() + tunables := config.Tunables() + + if jsonOut { + out := make([]schemaEntry, 0, len(tunables)) + for _, t := range tunables { + out = append(out, schemaEntry{Tunable: t, Preset: written[t.Key]}) + } + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, "config schema json:", err) + return 1 + } + fmt.Println(string(data)) + return 0 + } + + for _, t := range tunables { + fmt.Printf("%s\n", t.Key) + fmt.Printf(" %s (%s)\n", t.Label, t.Kind) + fmt.Printf(" %s\n", t.Help) + + facts := []string{"default " + quoteEmpty(t.Default)} + if t.Unit != "" { + facts = append(facts, "in "+t.Unit) + } + if t.CapKey != "" { + facts = append(facts, "capped by "+t.CapKey) + } + if t.Disablable { + facts = append(facts, `"0" turns it off`) + } + if t.Advanced { + facts = append(facts, "advanced") + } + if written[t.Key] { + facts = append(facts, "set by presets") + } + fmt.Printf(" %s\n", strings.Join(facts, "; ")) + + if t.RestartReason != "" { + fmt.Printf(" needs a restart: %s\n", t.RestartReason) + } else { + fmt.Printf(" applies live\n") + } + fmt.Printf(" docs: %s\n\n", t.DocAnchor) + } + return 0 +} + +// quoteEmpty renders an empty default as something visible, so "default " isn't +// mistaken for a truncated line. control.socket legitimately defaults to empty. +func quoteEmpty(v string) string { + if v == "" { + return `"" (unset)` + } + return v +} // configField is a get/set pair for one dotted config key. type configField struct { @@ -378,6 +491,8 @@ func cmdConfig(args []string) int { return configReset(cfgPath, rest, useToken) case "preset": return configPreset(cfgPath, rest, useToken) + case "schema": + return configSchema(rest) case "edit": return configEdit(cfgPath) case "-h", "--help", "help": diff --git a/cmd/dezhban/config_schema_test.go b/cmd/dezhban/config_schema_test.go new file mode 100644 index 0000000..208f24b --- /dev/null +++ b/cmd/dezhban/config_schema_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/behnam-rk/dezhban/internal/config" +) + +// TestConfigFieldsMatchTheDeclaredSchema is the reason `config schema` can be +// trusted. configFields is what `config set` will actually accept; Tunables is +// what every surface tells the user exists. A key in one and not the other is +// either a setting nobody can discover or a setting the app offers and the CLI +// rejects. +func TestConfigFieldsMatchTheDeclaredSchema(t *testing.T) { + declared := map[string]bool{} + for _, k := range config.TunableKeys() { + declared[k] = true + } + + for key := range configFields { + if !declared[key] { + t.Errorf("config set accepts %q but it has no Tunable — add one to internal/config/schema.go", key) + } + } + for key := range declared { + if _, ok := configFields[key]; !ok { + t.Errorf("%q is declared in the schema but config set cannot write it", key) + } + } +} + +// TestUsageListsEveryKey pins the generated help. The key list used to be typed +// out by hand and is now built from the schema; this catches a regression back +// to a hand-maintained copy. +func TestUsageListsEveryKey(t *testing.T) { + for _, key := range config.TunableKeys() { + if !strings.Contains(configUsage, " "+key) { + t.Errorf("config usage text omits %q", key) + } + } +} + +// TestSchemaDefaultsAgreeWithConfigGet cross-checks the two renderings of a +// value: KeyValues (which the schema's defaults come from) and configFields.get +// (which `config get` prints). On a default config nothing is disabled, so the +// two must agree exactly — a divergence would mean the app shows one default as +// a hint and `config get` reports another. +func TestSchemaDefaultsAgreeWithConfigGet(t *testing.T) { + cfg := config.Default() + config.Normalize(&cfg) + + for _, tun := range config.Tunables() { + field, ok := configFields[tun.Key] + if !ok { + continue // reported by TestConfigFieldsMatchTheDeclaredSchema + } + if got := field.get(&cfg); got != tun.Default { + t.Errorf("%s: config get renders %q but the schema's default is %q", + tun.Key, got, tun.Default) + } + } +} + +// TestConfigSchemaJSONCoversEveryKey exercises the command end to end, since the +// macOS app decodes exactly these bytes. +func TestConfigSchemaJSONCoversEveryKey(t *testing.T) { + var code int + out := captureStdout(t, func() { code = configSchema([]string{"--json"}) }) + if code != 0 { + t.Fatalf("config schema --json exited %d", code) + } + + var entries []struct { + Key string `json:"key"` + Label string `json:"label"` + Kind string `json:"kind"` + Default string `json:"default"` + CapKey string `json:"capKey"` + Disablable bool `json:"disablable"` + Advanced bool `json:"advanced"` + Preset bool `json:"preset"` + Help string `json:"help"` + DocAnchor string `json:"docAnchor"` + RestartReason string `json:"restartReason"` + } + if err := json.Unmarshal([]byte(out), &entries); err != nil { + t.Fatalf("config schema --json emitted undecodable JSON: %v", err) + } + + seen := map[string]bool{} + for _, e := range entries { + seen[e.Key] = true + if e.Label == "" || e.Kind == "" || e.Help == "" || e.DocAnchor == "" { + t.Errorf("%s: incomplete entry in JSON output", e.Key) + } + } + for _, key := range config.TunableKeys() { + if !seen[key] { + t.Errorf("config schema --json omits %q", key) + } + } + + // The embedded Tunable must flatten, not nest — the app decodes these fields + // at the top level of each entry. + if strings.Contains(out, `"Tunable"`) { + t.Error("config schema --json nested the embedded Tunable instead of flattening it") + } +} + +// TestConfigSchemaMarksPresetKeys — a surface warns that editing one of these by +// hand drifts the config off its preset, so the flag has to be right. +func TestConfigSchemaMarksPresetKeys(t *testing.T) { + written := presetWritten() + if len(written) == 0 { + t.Fatal("no preset writes any key") + } + for key := range written { + if _, ok := configFields[key]; !ok { + t.Errorf("preset writes %q, which config set cannot write", key) + } + } + // Spot-check a key presets deliberately leave alone: presets are strictness + // strategies, never identity. + if written["vpn.endpoints"] { + t.Error("a preset writes vpn.endpoints — presets must not touch identity data") + } +} + +// TestConfigSchemaRejectsArguments — it takes none, and a typo'd subcommand must +// not be silently ignored. +func TestConfigSchemaRejectsArguments(t *testing.T) { + if code := configSchema([]string{"pollInterval"}); code != 2 { + t.Errorf("config schema with an argument exited %d, want 2", code) + } +} + +// TestConfigSchemaNeedsNoConfigFile is the property a first-run wizard depends +// on: the schema describes what the keys ARE, so it must answer on a host that +// has no config yet. +func TestConfigSchemaNeedsNoConfigFile(t *testing.T) { + t.Setenv("DEZHBAN_CONFIG", "/nonexistent/dezhban/config.json") + var code int + out := captureStdout(t, func() { code = configSchema([]string{"--json"}) }) + if code != 0 { + t.Fatalf("config schema --json exited %d with no config file", code) + } + if !strings.Contains(out, `"key": "pollInterval"`) { + t.Error("config schema --json produced nothing useful without a config file") + } +} diff --git a/cmd/dezhban/main.go b/cmd/dezhban/main.go index 95cc171..9bbb977 100644 --- a/cmd/dezhban/main.go +++ b/cmd/dezhban/main.go @@ -77,6 +77,7 @@ Commands: switch Open a bounded window to connect a brand-new VPN (learns its server) pause Open a bounded pause: real ISP IP for a while, then re-arms itself resume End an open pause early + hold Keep the next VPN drop cut: no automatic redial window vpn Manage VPN profiles and learned endpoints (list/add/remove/import/…) setup Interactive wizard to create or update the config config Inspect or change the config without hand-editing JSON @@ -90,11 +91,12 @@ Global flags: --no-sudo Don't auto-elevate; print the root error instead --no-daemon Don't use the daemon's control socket; act on the firewall directly -block, unblock, switch, pause and resume ask the running daemon over its control -socket, which needs no password (see the "daemon control" line in dezhban -status). With no daemon listening, block/unblock fall back to acting on the -firewall directly; switch/pause/resume fall back to the root-owned command file, -which needs a running daemon to consume it — either way, needing root. +block, unblock, switch, pause, resume and hold ask the running daemon over its +control socket, which needs no password (see the "daemon control" line in +dezhban status). With no daemon listening, block/unblock fall back to acting on +the firewall directly; switch/pause/resume/hold fall back to the root-owned +command file, which needs a running daemon to consume it — either way, needing +root. Privileged commands re-run themselves under sudo automatically when not root (unix, interactive terminal). Use --no-sudo (or DEZHBAN_NO_SUDO=1) to opt out. @@ -147,6 +149,8 @@ func run(args []string) int { return cmdPause(rest) case "resume": return cmdResume(rest) + case "hold": + return cmdHold(rest) case "vpn": return cmdVPN(rest) case "setup": diff --git a/cmd/dezhban/setup.go b/cmd/dezhban/setup.go index 857d37c..499c244 100644 --- a/cmd/dezhban/setup.go +++ b/cmd/dezhban/setup.go @@ -1,15 +1,14 @@ package main import ( + "encoding/json" "errors" "flag" "fmt" - "net/netip" "os" "runtime" "strconv" "strings" - "time" "github.com/charmbracelet/huh" "github.com/charmbracelet/x/term" @@ -17,35 +16,26 @@ import ( "github.com/behnam-rk/dezhban/internal/config" "github.com/behnam-rk/dezhban/internal/firewall" "github.com/behnam-rk/dezhban/internal/netdetect" + "github.com/behnam-rk/dezhban/internal/setup" "github.com/behnam-rk/dezhban/internal/vpnimport" ) -// commonBlocked are the codes offered as checkboxes in the wizard; any others can -// be typed in the free-text field. -var commonBlocked = []struct{ label, code string }{ - {"Iran (IR)", "IR"}, - {"Russia (RU)", "RU"}, - {"China (CN)", "CN"}, - {"North Korea (KP)", "KP"}, - {"Syria (SY)", "SY"}, - {"Cuba (CU)", "CU"}, - {"Belarus (BY)", "BY"}, -} - // cmdSetup runs an interactive wizard that builds a config and writes it, so the // user never hand-edits JSON. It reuses the same detection/validation/preview // helpers as detect-vpn, validate and print-rules. Requires a TTY. +// +// WHAT it asks — the questions, their order, and which answers unlock which +// follow-ups — lives in internal/setup, not here. This file is presentation: +// huh fields, the ruleset preview, and the save/install prompts. The macOS +// app's own first-run wizard reads the same question set over +// `setup --questions --json`, so the two cannot drift apart. func cmdSetup(args []string) int { fs := flag.NewFlagSet("setup", flag.ExitOnError) cfgPath := fs.String("config", "", "path to write the config (default: canonical system path)") + questions := fs.Bool("questions", false, "print the wizard's questions and exit (read-only, no TTY needed)") + asJSON := fs.Bool("json", false, "with --questions, print them as JSON") _ = fs.Parse(args) - if !isInteractive() { - fmt.Fprintln(os.Stderr, "dezhban setup needs an interactive terminal.") - fmt.Fprintln(os.Stderr, "Non-interactive? Use 'dezhban config set ' or edit the file directly.") - return 1 - } - // Seed from the current config so setup edits rather than clobbers; fall back // to defaults if none exists or the current file is unreadable/invalid. cfg, err := loadConfig(*cfgPath) @@ -55,121 +45,50 @@ func cmdSetup(args []string) int { cfg = &d } - // --- wizard state (huh binds to string/bool/[]string) --- - pollInterval := cfg.PollInterval.String() - hysteresis := strconv.Itoa(cfg.Hysteresis) - logLevel := cfg.LogLevel - quorum := cfg.ProviderQuorum - configureVPN := true + detected, _ := netdetect.TunnelInterfaces() + qs := setup.Questions(setup.Options{ + Config: cfg, ConfigExisted: configExisted, + GOOS: runtime.GOOS, DetectedTunnels: detected, + }) - blockedSet := map[string]bool{} - for _, c := range cfg.BlockedCountries { - blockedSet[strings.ToUpper(c)] = true - } - var checkedCountries []string - countryOpts := make([]huh.Option[string], 0, len(commonBlocked)) - for _, cc := range commonBlocked { - opt := huh.NewOption(cc.label, cc.code) - if blockedSet[cc.code] { - opt = opt.Selected(true) - delete(blockedSet, cc.code) - } - countryOpts = append(countryOpts, opt) - } - // Any configured codes not in the common list seed the free-text field. - var extraCodes []string - for code := range blockedSet { - extraCodes = append(extraCodes, code) + // --questions is read-only and needs no terminal: it is how another surface + // (the macOS first-run wizard) asks what this wizard would ask. + if *questions { + return printQuestions(qs, *asJSON) } - otherCountries := strings.Join(extraCodes, ",") - basics := huh.NewForm(huh.NewGroup( - huh.NewInput().Title("Poll interval").Description("How often the country is checked, e.g. 30s."). - Value(&pollInterval).Validate(validDuration), - huh.NewMultiSelect[string]().Title("Blocked countries"). - Description("Traffic is cut when the detected country matches."). - Options(countryOpts...).Value(&checkedCountries), - huh.NewInput().Title("Other country codes").Description("Comma-separated ISO codes not listed above (optional)."). - Value(&otherCountries), - huh.NewSelect[string]().Title("Log level"). - Options(huh.NewOptions("debug", "info", "warn", "error")...).Value(&logLevel), - huh.NewConfirm().Title("Require provider quorum?").Description("Only act when a majority of providers agree."). - Value(&quorum), - huh.NewConfirm().Title("Configure your VPN now?"). - Description("dezhban only enforces once it knows your VPN's tunnel and server. Say no and it starts in standby — fully open, nothing blocked — until you run 'dezhban setup' again or edit the config."). - Value(&configureVPN), - )) - if err := runForm(basics); err != nil { - return formExit(err) + if !isInteractive() { + fmt.Fprintln(os.Stderr, "dezhban setup needs an interactive terminal.") + fmt.Fprintln(os.Stderr, "Non-interactive? Use 'dezhban config set ' or edit the file directly.") + return 1 } - // --- VPN branch --- - var tunnels []string - autoMode := true - endpoints := strings.Join(cfg.VPN.Endpoints, ",") - profileFiles := "" - macOS := runtime.GOOS == "darwin" - // Preserve an existing config's choice; only default ON for a brand-new macOS - // config (the recommended path). Re-running setup must never silently flip a - // user's explicit autoDiscoverEndpoints=false back to true — the confirm below - // (macOS only) makes it an explicit, seeded decision. - autoDiscover := cfg.VPN.AutoDiscoverEndpoints - if macOS && !configExisted { - autoDiscover = true - } - allowPhysicalDNS := cfg.VPN.AllowPhysicalDNS - var profiles []config.Profile - if configureVPN { - // Recommended path: automatic detection (no pinned interface names that go - // stale across redials). The old pin-specific-interfaces flow survives - // behind an advanced opt-out. - if err := runForm(huh.NewForm(huh.NewGroup( - huh.NewConfirm().Title("Use automatic VPN detection? (recommended)"). - Description("dezhban finds your tunnel and, on macOS, learns the server address itself — works with any VPN and survives redials."). - Value(&autoMode), - ))); err != nil { - return formExit(err) - } - if !autoMode { - detected, _ := netdetect.TunnelInterfaces() - if err := runForm(huh.NewForm(huh.NewGroup( - tunnelSelector(detected, cfg.VPN.TunnelInterfaces, &tunnels), - ))); err != nil { - return formExit(err) + answers := setup.NewAnswers(qs) + // 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. + for _, group := range groupsOf(qs) { + var fields []huh.Field + for _, q := range qs { + if q.Group != group || !answers.ShouldAsk(q) { + continue } + fields = append(fields, field(q, answers)) } - // Profiles + endpoints + redial DNS. On Linux/Windows (no live - // discovery) at least one profile/endpoint is the reliable path. - epTitle := "VPN endpoint(s)" - epDesc := "Server IP(s)/hostname(s), comma-separated. Optional on macOS (auto-discovered); needed elsewhere." - if !macOS { - epDesc = "Server IP(s)/hostname(s), comma-separated. Required on this platform (no live discovery)." + if len(fields) == 0 { + continue } - if err := runForm(huh.NewForm(huh.NewGroup( - huh.NewInput().Title("Self-hosted VPN config files"). - Description("Comma-separated paths to WireGuard/.conf, OpenVPN/.ovpn, or V2Ray JSON to import as profiles (optional)."). - Value(&profileFiles), - huh.NewInput().Title(epTitle).Description(epDesc).Value(&endpoints), - huh.NewConfirm().Title("Allow DNS on the physical link while the tunnel is down?"). - Description("Lets a VPN client re-resolve its server hostname to redial. Leaks only DNS-query metadata; your traffic stays blocked. Recommended if any endpoint is a hostname."). - Value(&allowPhysicalDNS), - ))); err != nil { + if err := runForm(huh.NewForm(huh.NewGroup(fields...))); err != nil { return formExit(err) } - // Live endpoint discovery is macOS-only. Make it an explicit, seeded choice - // so re-running setup never silently flips an existing preference. - if macOS { - if err := runForm(huh.NewForm(huh.NewGroup( - huh.NewConfirm().Title("Auto-discover the VPN server address? (recommended)"). - Description("dezhban watches the live tunnel socket to learn the server IP, so you don't pin one that changes. macOS only."). - Value(&autoDiscover), - ))); 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). - for _, f := range splitList(profileFiles) { + } + + // 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 { fmt.Fprintf(os.Stderr, " skipping %s: %v\n", f, ierr) @@ -181,15 +100,7 @@ func cmdSetup(args []string) int { } } - // --- assemble into the config --- - applyWizard(cfg, wizardInput{ - pollInterval: pollInterval, hysteresis: hysteresis, logLevel: logLevel, - quorum: quorum, - countries: append(checkedCountries, splitList(otherCountries)...), - configureVPN: configureVPN, autoMode: autoMode, tunnels: tunnels, - endpoints: splitList(endpoints), profiles: profiles, - autoDiscover: autoDiscover && macOS, allowPhysicalDNS: allowPhysicalDNS, - }) + setup.Apply(cfg, answers.Input(strconv.Itoa(cfg.Hysteresis), profiles)) config.Normalize(cfg) if err := cfg.Validate(); err != nil { @@ -199,8 +110,8 @@ func cmdSetup(args []string) int { } // --- lockout guard: warn if an endpoint sits inside a tunnel subnet --- - if configureVPN { - if warn := endpointLockoutWarning(cfg); warn != "" { + if answers.Bool("configureVPN") { + if warn := setup.EndpointLockoutWarning(cfg); warn != "" { var proceed bool fmt.Fprintln(os.Stderr, warn) if err := runForm(huh.NewForm(huh.NewGroup( @@ -263,104 +174,107 @@ func cmdSetup(args []string) int { } else { fmt.Println("later, enable it with: sudo dezhban install && sudo dezhban start") } - if configureVPN { + if answers.Bool("configureVPN") { fmt.Println("to connect a brand-new VPN whose server isn't known yet: sudo dezhban switch, then connect it.") } return 0 } -// tunnelSelector returns a MultiSelect over detected tunnels (preselecting the -// configured ones), or a free-text Input when nothing was detected. -func tunnelSelector(detected, configured []string, dst *[]string) huh.Field { - if len(detected) == 0 { - // No live tunnels — fall back to comma-separated entry via a shim. - joined := strings.Join(configured, ",") - return huh.NewInput().Title("Tunnel interface(s)"). - Description("None detected. Enter comma-separated names (e.g. utun4)."). - Value(&joined).Validate(func(string) error { - *dst = splitList(joined) - return nil - }) - } - cfgSet := map[string]bool{} - for _, t := range configured { - cfgSet[t] = true - } - opts := make([]huh.Option[string], 0, len(detected)) - for _, t := range detected { - opt := huh.NewOption(t, t) - if cfgSet[t] { - opt = opt.Selected(true) +// field renders one question as a huh field bound to its answer. +// +// The mapping is deliberately total over setup's kinds: a kind this does not +// know falls through to a text input, which is wrong-looking but still +// answerable — better than a question silently disappearing from the wizard. +func field(q setup.Question, a *setup.Answers) huh.Field { + switch q.Kind { + case setup.KindBool: + return huh.NewConfirm().Title(q.Title).Description(q.Description).Value(a.BoolPtr(q.ID)) + case setup.KindSelect: + opts := make([]huh.Option[string], 0, len(q.Options)) + for _, o := range q.Options { + opts = append(opts, huh.NewOption(o.Label, o.Value)) } - opts = append(opts, opt) + return huh.NewSelect[string]().Title(q.Title).Description(q.Description). + Options(opts...).Value(a.TextPtr(q.ID)) + case setup.KindMultiSelect: + selected := map[string]bool{} + for _, v := range q.Selected { + selected[v] = true + } + opts := make([]huh.Option[string], 0, len(q.Options)) + for _, o := range q.Options { + opt := huh.NewOption(o.Label, o.Value) + if selected[o.Value] { + opt = opt.Selected(true) + } + opts = append(opts, opt) + } + return huh.NewMultiSelect[string]().Title(q.Title).Description(q.Description). + Options(opts...).Value(a.ListPtr(q.ID)) + case setup.KindDuration: + return huh.NewInput().Title(q.Title).Description(q.Description). + Value(a.TextPtr(q.ID)).Validate(setup.ValidDuration) + default: + return huh.NewInput().Title(q.Title).Description(q.Description).Value(a.TextPtr(q.ID)) } - return huh.NewMultiSelect[string]().Title("Tunnel interface(s)"). - Description("Detected tunnels — pick the VPN's.").Options(opts...).Value(dst) -} - -// wizardInput carries the collected answers into the config. -type wizardInput struct { - pollInterval, hysteresis, logLevel string - quorum bool - countries []string - configureVPN bool - autoMode bool // automatic tunnel detection (no pinned interfaces) - tunnels, endpoints []string - profiles []config.Profile - autoDiscover bool - allowPhysicalDNS bool } -// applyWizard writes collected answers onto cfg. Validation happens after. -func applyWizard(cfg *config.Config, in wizardInput) { - if d, err := time.ParseDuration(in.pollInterval); err == nil { - cfg.PollInterval = d - } - if n, err := strconv.Atoi(strings.TrimSpace(in.hysteresis)); err == nil { - cfg.Hysteresis = n +// groupsOf lists the question groups in ascending order. +func groupsOf(qs []setup.Question) []int { + var out []int + seen := map[int]bool{} + for _, q := range qs { + if !seen[q.Group] { + seen[q.Group] = true + out = append(out, q.Group) + } } - cfg.LogLevel = in.logLevel - cfg.ProviderQuorum = in.quorum - cfg.BlockedCountries = in.countries // config.Normalize upper-cases + de-dupes on save - - if in.configureVPN { - if in.autoMode { - // Automatic detection: no pinned interface names (Normalize implies - // autodetect), plus live discovery where supported. - cfg.VPN.TunnelInterfaces = nil - } else { - cfg.VPN.TunnelInterfaces = in.tunnels + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j] < out[j-1]; j-- { + out[j], out[j-1] = out[j-1], out[j] } - cfg.VPN.Endpoints = in.endpoints - cfg.VPN.Profiles = in.profiles - cfg.VPN.AutoDiscoverEndpoints = in.autoDiscover - cfg.VPN.AllowPhysicalDNS = in.allowPhysicalDNS } + return out } -// endpointLockoutWarning returns a non-empty message if any IP endpoint sits -// inside a tunnel's own subnet — the #1 lockout cause. -func endpointLockoutWarning(cfg *config.Config) string { - var addrs []netip.Addr - for _, ep := range config.EffectiveEndpoints(cfg, nil) { - if a, err := netip.ParseAddr(ep); err == nil { - addrs = append(addrs, a) +// printQuestions answers "what would setup ask me?" without asking anything — +// read-only, no root, no terminal needed. `--json` is what another wizard reads. +func printQuestions(qs []setup.Question, asJSON bool) int { + if asJSON { + out, err := json.MarshalIndent(qs, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, "encode questions:", err) + return 1 } + fmt.Println(string(out)) + return 0 } - if len(addrs) == 0 { - return "" - } - bad, err := netdetect.CheckEndpointRouting(addrs, cfg.VPN.TunnelInterfaces) - if err != nil || len(bad) == 0 { - return "" - } - var b strings.Builder - b.WriteString("\n⚠ WARNING: endpoint(s) sit inside a tunnel subnet — this will likely lock you out:\n") - for _, r := range bad { - fmt.Fprintf(&b, " %s is within %s (%s)\n", r.Endpoint, r.Subnet, r.Iface) + for _, q := range qs { + fmt.Printf("%s (%s)\n", q.Title, q.Kind) + if q.Description != "" { + fmt.Printf(" %s\n", q.Description) + } + if q.Key != "" { + fmt.Printf(" writes: %s\n", q.Key) + } + if q.Default != "" { + fmt.Printf(" default: %s\n", q.Default) + } + if len(q.Selected) > 0 { + fmt.Printf(" selected: %s\n", strings.Join(q.Selected, ", ")) + } + if len(q.Options) > 0 { + vals := make([]string, 0, len(q.Options)) + for _, o := range q.Options { + vals = append(vals, o.Value) + } + fmt.Printf(" options: %s\n", strings.Join(vals, ", ")) + } + if q.Gated() { + fmt.Printf(" asked only when %s is %s\n", q.RequiresID, q.RequiresValue) + } } - b.WriteString(" Set the VPN server's PHYSICAL (public) address instead — see 'dezhban doctor --discover'.") - return b.String() + return 0 } // runForm runs a huh form with a consistent theme. @@ -378,18 +292,6 @@ func formExit(err error) int { return 1 } -// validDuration validates a huh Input holding a positive Go duration. -func validDuration(s string) error { - d, err := time.ParseDuration(strings.TrimSpace(s)) - if err != nil { - return errors.New("enter a duration like 30s or 5m") - } - if d <= 0 { - return errors.New("must be greater than zero") - } - return nil -} - // isInteractive reports whether both stdin and stdout are terminals. func isInteractive() bool { return isTerminal(os.Stdin) && isTerminal(os.Stdout) diff --git a/cmd/dezhban/setup_test.go b/cmd/dezhban/setup_test.go deleted file mode 100644 index 3292091..0000000 --- a/cmd/dezhban/setup_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "testing" - - "github.com/behnam-rk/dezhban/internal/config" -) - -// applyWizard in auto mode must produce a "connect any VPN" config: no pinned -// interfaces (autodetect implied on Normalize), profiles carried, and -// allowPhysicalDNS honored. -func TestApplyWizardAutoMode(t *testing.T) { - cfg := config.Default() - applyWizard(&cfg, wizardInput{ - pollInterval: "30s", hysteresis: "3", logLevel: "info", - configureVPN: true, autoMode: true, - tunnels: []string{"utun9"}, // must be ignored in auto mode - endpoints: []string{"vpn.example.com"}, - profiles: []config.Profile{{Name: "home", Endpoints: []string{"203.0.113.7"}}}, - autoDiscover: true, - allowPhysicalDNS: true, - }) - if len(cfg.VPN.TunnelInterfaces) != 0 { - t.Errorf("auto mode must not pin interfaces, got %v", cfg.VPN.TunnelInterfaces) - } - if !cfg.VPN.AllowPhysicalDNS { - t.Error("allowPhysicalDNS should be set") - } - if len(cfg.VPN.Profiles) != 1 || cfg.VPN.Profiles[0].Name != "home" { - t.Errorf("profiles not carried: %+v", cfg.VPN.Profiles) - } - // Normalize (run on save) implies autodetect when no interfaces are pinned. - config.Normalize(&cfg) - if !cfg.VPN.AutoDetect { - t.Error("autodetect should be implied for an auto-mode config") - } - if err := cfg.Validate(); err != nil { - t.Errorf("auto-mode config should validate: %v", err) - } -} - -// Advanced mode pins the chosen interfaces. -func TestApplyWizardAdvancedPin(t *testing.T) { - cfg := config.Default() - applyWizard(&cfg, wizardInput{ - pollInterval: "30s", hysteresis: "3", logLevel: "info", - configureVPN: true, autoMode: false, - tunnels: []string{"utun4"}, - endpoints: []string{"203.0.113.7"}, - }) - if len(cfg.VPN.TunnelInterfaces) != 1 || cfg.VPN.TunnelInterfaces[0] != "utun4" { - t.Errorf("advanced mode should pin utun4, got %v", cfg.VPN.TunnelInterfaces) - } -} diff --git a/cmd/dezhban/vpn_cmd.go b/cmd/dezhban/vpn_cmd.go index 7228285..28db2e9 100644 --- a/cmd/dezhban/vpn_cmd.go +++ b/cmd/dezhban/vpn_cmd.go @@ -3,6 +3,7 @@ package main import ( "crypto/rand" "encoding/hex" + "encoding/json" "flag" "fmt" "os" @@ -125,27 +126,163 @@ func cmdSwitch(args []string) int { // itself with no further action. For deliberately, temporarily using the real // ISP IP — e.g. a sanctioned-country-only service the VPN's exit can't reach — // without leaving protection off by mistake afterward. +// pauseList prints the offered pause lengths. Options above vpn.pauseMax are +// listed as unavailable with the reason rather than hidden: a user whose cap +// forbids two hours should learn that from the list, not from a refusal after +// they have chosen. +func pauseList(cfgPath string, jsonOut bool) int { + cfg, err := loadConfig(cfgPath) + if err != nil { + fmt.Fprintln(os.Stderr, "pause --list:", err) + return 1 + } + opts := config.PauseOptions(cfg) + + if jsonOut { + data, err := json.MarshalIndent(opts, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, "pause --list json:", err) + return 1 + } + fmt.Println(string(data)) + return 0 + } + + for _, o := range opts { + if o.Unavailable != "" { + fmt.Printf(" %-12s unavailable — %s\n", o.Label, o.Unavailable) + continue + } + fmt.Printf(" %-12s %-8s %s\n", o.Label, o.Value, o.Why) + } + if cfg.VPN.PauseMax > 0 { + fmt.Printf("\nAny duration up to %s works: dezhban pause 7m\n", cfg.VPN.PauseMax) + } + return 0 +} + +// cmdHold arms or disarms "hold the line": the next tunnel drop stays cut +// instead of opening an automatic redial window. +// +// dezhban cannot tell a deliberate disconnect from an accidental drop, and that +// ambiguity is what this answers. It is strictly MORE restrictive than the +// default — it only removes a relaxation — so it adds no fourth trigger, needs +// no ADR, and carries no control.allow* gate of its own: there is no authority +// here to withhold. +func cmdHold(args []string) int { + fs := flag.NewFlagSet("hold", flag.ExitOnError) + cfgPath := fs.String("config", "", "path to config file (JSON)") + cancel := fs.Bool("cancel", false, "disarm, so the next drop opens a redial window normally") + statusOnly := fs.Bool("status", false, "report whether it is armed, change nothing") + _ = fs.Parse(args) + + if *cancel && *statusOnly { + fmt.Fprintln(os.Stderr, "hold: --cancel and --status are mutually exclusive") + return 2 + } + + if *statusOnly { + return holdStatus(*cfgPath) + } + + // Refusing early beats reporting success for something that cannot take + // effect: with no automatic window there is nothing to suppress. + if !*cancel { + if cfg, err := loadConfig(*cfgPath); err == nil && cfg.VPN.RedialWindow <= 0 { + fmt.Fprintln(os.Stderr, "hold: the automatic redial window is already disabled "+ + "(vpn.redialWindow: \"0\"), so every drop already stays cut.") + return 1 + } + } + + op, fileOp := control.OpHoldArm, command.OpHoldArm + armedMsg := "hold the line armed — the next VPN drop stays cut, and no redial window opens.\n" + + "It disarms itself once a tunnel is up again, or with `dezhban hold --cancel`." + if *cancel { + op, fileOp = control.OpHoldCancel, command.OpHoldCancel + armedMsg = "hold the line disarmed — the next VPN drop opens a redial window as usual." + } + + // Passwordless path first, same shape as pause/switch: the daemon does it + // itself, and the root-owned command file is the fallback when no daemon + // answers. + if !noDaemon() { + if code, handled := tryControl(*cfgPath, control.Request{Op: op}); handled { + if code == 0 { + fmt.Println(armedMsg) + } + return code + } + } + + if !requireRoot("hold") { + return 1 + } + if err := command.Write(defaultCommandPath(), newCommand(fileOp, "", "")); err != nil { + fmt.Fprintln(os.Stderr, "hold:", err) + return 1 + } + fmt.Println(armedMsg) + return 0 +} + +// holdStatus reports from the published snapshot, like `switch --status`. +func holdStatus(cfgPath string) int { + snap, err := state.Read(defaultStatePath()) + if err != nil { + fmt.Fprintln(os.Stderr, "hold --status: no state file — is dezhban running?") + return 1 + } + if cfg, err := loadConfig(cfgPath); err == nil && cfg.VPN.RedialWindow <= 0 { + fmt.Println("hold the line: not needed — the automatic redial window is disabled " + + "(vpn.redialWindow: \"0\"), so every drop already stays cut.") + return 0 + } + if snap.Hold != nil && snap.Hold.Armed { + fmt.Printf("hold the line: ARMED since %s — the next VPN drop stays cut.\n", + snap.Hold.At.Format(time.Kitchen)) + return 0 + } + fmt.Println("hold the line: not armed — a VPN drop opens a redial window so it can redial.") + return 0 +} + func cmdPause(args []string) int { fs := flag.NewFlagSet("pause", flag.ExitOnError) cfgPath := fs.String("config", "", "path to config file (JSON)") + list := fs.Bool("list", false, "print the offered pause lengths and stop") + jsonOut := fs.Bool("json", false, "with --list, print machine-readable JSON") _ = fs.Parse(args) + + if *list { + return pauseList(*cfgPath, *jsonOut) + } + dur := "" if fs.NArg() > 0 { dur = fs.Arg(0) } - // Validate here rather than letting the daemon's clampPause silently fall - // back to its default on a typo — `dezhban pause 15x` must error, not - // quietly become a 15m exposure the operator never asked for. + // Validate here rather than letting the daemon fall back to its default on a + // typo — `dezhban pause 15x` must error, not quietly become a 15m exposure + // the operator never asked for. + var requested time.Duration if dur != "" { - if d, err := time.ParseDuration(dur); err != nil || d <= 0 { + d, err := time.ParseDuration(dur) + if err != nil || d <= 0 { fmt.Fprintf(os.Stderr, "pause: invalid duration %q (want e.g. \"15m\")\n", dur) return 2 } + requested = d } - if cfg, err := loadConfig(*cfgPath); err == nil && cfg.VPN.PauseMax <= 0 { - fmt.Fprintln(os.Stderr, "pause: disabled by vpn.pauseMax: \"0\". Set it to a duration (e.g. \"30m\") to enable.") - return 1 + // Refused here and again in the daemon, and in both places explained rather + // than clamped. A pause quietly shortened to the cap is a pause the operator + // did not ask for, and they would have no way to know. + if cfg, err := loadConfig(*cfgPath); err == nil { + if refusal := config.PauseRefusal(cfg, requested); refusal != "" { + fmt.Fprintln(os.Stderr, "pause:", refusal) + return 1 + } } // Passwordless path first: the daemon opens the pause itself when diff --git a/cmd/dezhban/vpn_cmd_test.go b/cmd/dezhban/vpn_cmd_test.go index 410a305..049cf0c 100644 --- a/cmd/dezhban/vpn_cmd_test.go +++ b/cmd/dezhban/vpn_cmd_test.go @@ -41,7 +41,7 @@ func TestPrintSwitchStatus(t *testing.T) { Open: true, Until: until, Trigger: state.TriggerManual, Profile: "home-wg", }, }, - want: "switch window: OPEN — Guard relaxed so a new VPN can connect — your real IP may be exposed until it closes (3:04PM). (profile \"home-wg\")\n" + + want: "switch window: OPEN — Your real IP may be exposed until 3:04PM. The guard is relaxed so a new VPN can connect. (profile \"home-wg\")\n" + "until: 2026-07-25T15:04:00Z\n", }, { @@ -50,7 +50,7 @@ func TestPrintSwitchStatus(t *testing.T) { Posture: "switch-window", Switch: &state.SwitchState{Open: true, Until: until, Trigger: state.TriggerAuto}, }, - want: "switch window: OPEN — Your VPN dropped. The guard is relaxed while it redials — your real IP may be exposed until it closes (3:04PM).\n" + + want: "switch window: OPEN — Your real IP may be exposed until 3:04PM. Your VPN dropped and the guard relaxed so it can redial.\n" + "until: 2026-07-25T15:04:00Z\n", }, { @@ -59,7 +59,7 @@ func TestPrintSwitchStatus(t *testing.T) { Posture: "switch-window", Switch: &state.SwitchState{Open: true, Until: until, Trigger: state.TriggerPause}, }, - want: "pause: OPEN — Using your real IP at your request. The guard re-arms automatically at 3:04PM. (end early with `dezhban resume`)\n" + + want: "pause: OPEN — You are using your real IP at your request. The guard re-arms automatically at 3:04PM. (end early with `dezhban resume`)\n" + "until: 2026-07-25T15:04:00Z\n", }, } diff --git a/docs/concepts/glossary.md b/docs/concepts/glossary.md index ea90444..468190b 100644 --- a/docs/concepts/glossary.md +++ b/docs/concepts/glossary.md @@ -102,6 +102,17 @@ gate (`control.allowPauseOps`), and `switch --cancel` refuses to touch it (use `resume`). See [ADR-0008](../adr/0008-arm-at-boot.md). User-facing: "Paused — the guard re-arms automatically at «time»." +**Hold the line** — an armed intent that the NEXT tunnel drop stays cut: no +redial window opens, so a deliberate disconnect does not get a relaxation nobody +asked for. **Not a fourth trigger.** It is the only thing in this section that +*removes* a relaxation rather than granting one, which is also why it has no +`control.allow*` gate — there is no authority to withhold. One-shot: spent by the +drop it covers, disarmed when a tunnel returns, and gone on restart, so a +forgotten flag can never leave a later accidental drop without redial help. +`dezhban hold` / `hold --cancel` / `hold --status`. User-facing: "The next VPN +drop stays cut." Contrast with Pause, which is its opposite in both directions: +pause means "let me use my real IP", hold the line means "keep me cut". + ## Network concepts **Tunnel interface** — the virtual network interface the VPN creates (`utun4`, `tun0`). diff --git a/docs/concepts/how-it-works.md b/docs/concepts/how-it-works.md index 2646d76..c5a4a1e 100644 --- a/docs/concepts/how-it-works.md +++ b/docs/concepts/how-it-works.md @@ -107,8 +107,9 @@ below in [Exit-country policing](#exit-country-policing). the daemon discovers the new server socket, runs a geo lookup through the tunnel, and on a confirmed non-blocked exit **snaps the window shut early**, learns the endpoint, and restores GUARD. Total interaction required: zero. -4. **Or nothing redials.** The window expires and the guard **fail-closes - and stays closed** — no second window until a tunnel actually comes back. +4. **Or nothing redials.** The window expires and the guard + **fail-closes and stays closed** — no second window until a tunnel + actually comes back. A flapping tunnel doesn't get windows at all (`vpn.advanced.redialMinUptime`). diff --git a/docs/contribute/architecture.md b/docs/contribute/architecture.md index 80afc64..20b263f 100644 --- a/docs/contribute/architecture.md +++ b/docs/contribute/architecture.md @@ -72,10 +72,31 @@ something to describe — absent in STANDBY, before any tunnel is known. "until": "2026-07-01T12:02:00Z", "profile": "newvpn", "trigger": "manual" // "manual" (operator command) | "auto" (redial window on a tunnel drop); absent from older daemons — treat as "manual" + }, + "drop": { // (vpn) present from a tunnel drop until a tunnel is up again + "at": "2026-07-01T12:01:30Z" // when — the posture fields say what is happening now + }, + "hold": { // (vpn) present only while "hold the line" is armed + "armed": true, // the NEXT tunnel drop stays cut — no automatic redial window + "at": "2026-07-01T12:00:00Z" } } ``` +`drop` is carried, not merely published once, and that is the only reason any +surface can report a drop. The run loop opens the automatic redial window on the +same tunnel-down edge, so the snapshot showing the guard holding a downed tunnel +is superseded within microseconds — and observers read this file about once a +second. Without carrying the record across the window, the CLI and the app could +only ever say "a window is open", never "your VPN dropped at 12:01:30 and the +guard cut traffic before relaxing so it could redial". + +Carrying it does **not** make an open window look like a cut: the window is a +relaxation, traffic is flowing, and the posture stays amber. `drop` says what +happened; `posture` says what is happening. `cut` is false when the drop +occurred in standby, where nothing was being enforced and claiming traffic was +cut would be false. + `lookupErr` and `exitUnknown` are mutually exclusive, and the split matters. A lookup that fails because **no tunnel is up** is not a fault — it is the normal state during a switch or redial window, in standby, and across any drop. That diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 1c73ce8..703ae21 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -451,6 +451,34 @@ macOS only, privileged (`dezhban upgrade download`/`apply`). See - [ ] A fresh `dezhban setup` on macOS produces an autodetect + auto-discovery config with **zero** concrete interface names, and offers to install and start the service. +- [ ] Re-running it on a configured host seeds every question with what the + config already says, and pressing Enter through the whole wizard writes a + config identical to the one you started from (`dezhban config show` before + and after). `internal/setup` pins this, but only the real run proves the + forms are bound to the same answers. +- [ ] Re-running it **without** naming profile files keeps the profiles already + imported (`dezhban vpn list`). +- [ ] `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 + 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. +- [ ] 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**. +- [ ] Cancelling with "Not now" writes nothing and offers the wizard again next + launch. +- [ ] Naming VPN config files imports them as profiles (`dezhban vpn list`); + cancelling that second prompt leaves the config saved and only the import + undone. +- [ ] Settings → "Run Setup Again…" reopens it seeded with current values. ## macOS app @@ -462,9 +490,14 @@ task gui:build && open dist/Dezhban.app ### Surfaces & window lifecycle -- [ ] **Menubar is the safety core only.** The dropdown shows exactly: one status - line, Open Dezhban… (⌘O), Block now, Unblock, the switch item (VPN mode), - Panic — force unblock…, Quit. Nothing else. +- [ ] **Menubar is a glance and the time-critical set only.** The dropdown shows + exactly: one status line, Open Dezhban… (⌘O), the switch/pause item with + its live countdown, hold the line, Quit. **No Block or Unblock** — those + live in the window's Overview. +- [ ] **Panic is behind ⌥.** Holding Option swaps "Open Dezhban…" for "Panic — + force unblock…"; ⌘⌥O fires it; releasing Option restores the open item. It + still works with the daemon stopped and with the main window unable to + open. - [ ] **Window opening.** "Open Dezhban…" and a Dock-icon click both open/focus the main window; a fresh app launch opens **no** window (menubar + Dock only); closing the window (⌘W) leaves the app and icon running. @@ -620,6 +653,36 @@ end up typing a password. `dezhban doctor` prints in a terminal. - [ ] CLI missing → the guided "dezhban CLI not found" state, not a blank list. +### Help pane + +The pane's whole reason for existing is that it works while the guard has cut +traffic, so the check that matters is the one CI cannot run: with egress gone. + +- [ ] With **FULL BLOCK active** (or the tunnel down and no window open), open + Help: pages render fully, the sidebar and search work, and nothing is + blank or spinning. Confirm no request leaves the machine — Little Snitch, + or `tcpdump`/`pfctl -s state` showing nothing new from `DezhbanMenu`. +- [ ] A link in a page to another bundled page moves the sidebar selection with + it, so the highlighted row is the page being read. +- [ ] A link that points off the bundle (an `https://` one in a doc) is refused + in the pane and reported with a Copy link button — it must not navigate. +- [ ] A link to a doc that is **not** bundled — the ADR references in Postures, + say — reports a `https://github.com/…` URL, not a `file:///…/Contents/…` + path. Every such link was a dead click reporting an internal path before + the renderer rewrote them. +- [ ] Pages are **styled** — headings, table borders, code backgrounds, and the + dark-mode palette. The bundled pages carry a `Content-Security-Policy`, and + a `file:` origin is opaque, so a CSP that is too strict would silently drop + `help.css` and leave the pane readable but unstyled. Only a real WKWebView + shows this; the Go tests cannot. +- [ ] Built from a checkout whose `docs/` was renamed under it, `task gui:build` + **fails** rather than producing an app whose Help pane is missing a page. +- [ ] The **?** beside a Settings field opens Help scrolled to that key's own + heading — not the top of the configuration reference. Spot-check one field + per section, including one under Advanced. +- [ ] Against a CLI too old to know `config schema`, the **?** buttons are absent + rather than present and inert. + ### Logs pane - [ ] "Show last hour" matches a hand-run `log show --last 1h --predicate diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 7635af1..d3cec02 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -54,7 +54,7 @@ daemon** over its control socket and need no password at all: | `panic` | Yes — deliberately independent of the daemon, so the lockout escape hatch works when nothing else does. | | `run` | Yes — it *is* the daemon. | | `setup`, `config set`/`edit`, `config preset apply` | Yes, but only for the config write itself. `preset apply` is a write like any other — see [Presets](#presets). | -| `config show`/`path`, `config preset list`/`show`/`diff` | **No** — read-only; they report the config, they don't change it. | +| `config show`/`path`/`schema`, `config preset list`/`show`/`diff`, `setup --questions` | **No** — read-only; they report the config (or what the wizard would ask), they don't change it. | | `token status` | **No** — reports whether a control token is enrolled; the answer is not itself a secret. | | `token enroll`, `token forget` | Yes — the token's hash lives in the daemon's root-owned state dir, because anything that could rewrite it could nominate its own token. Once, at setup. | | `upgrade check` | **No** — read-only, no root. | @@ -163,6 +163,7 @@ sudo dezhban setup # interactive wizard — builds/updates the c # detects tunnels, previews the ruleset, then writes it dezhban config path # print the resolved config path dezhban config show # print the effective config as JSON +dezhban config schema # describe every settable key (add --json for machine output) dezhban config get blockedCountries sudo dezhban config set blockedCountries IR,RU # set, validate, save sudo dezhban config reset vpn.switchWindow # restore a shipped default (--all: every tunable) @@ -199,6 +200,36 @@ succeeds and says so; the new values are read the next time it starts. validation, and ruleset preview as `detect-vpn`/`validate`/`print-rules`. Writes to the system path need root (hence `sudo`); a permission error prints a `sudo` hint. +`setup --questions` is the exception: it prints what the wizard *would* ask — +each question, what it writes, its seeded answer, and which earlier answer +unlocks it — and asks nothing. Read-only, no root, no terminal needed. +`--json` is the machine form, and is how the macOS app's own first-run wizard +gets the question set instead of keeping a second copy of it. + +```sh +dezhban setup --questions # what would you ask me? +dezhban setup --questions --json # the same, for another surface to render +``` + +### Asking what a key is + +`config schema` describes the keys themselves rather than your values: for each +one, its default, what bounds it, whether `"0"` turns it off, whether a preset +writes it, whether the running daemon can adopt it without a restart, and which +part of [config.md](config.md) documents it. + +```sh +dezhban config schema # every key, explained +dezhban config schema --json # the same, for tools +``` + +It reads no config file — the schema is what the keys *are*, not what this host +has set — so it answers the same on a machine that has never been configured. +That is what lets the macOS app label its settings, bound its sliders, and know +where an explicit **Off** is a real choice instead of hardcoding any of it. The +defaults it prints are derived from the shipped defaults themselves, so this +output cannot drift from what you actually get by setting nothing. + ### Presets A quick way to answer "how strict am I", without knowing which eight keys that @@ -311,7 +342,7 @@ carries the exact deadline, since the sentence dates a window only to a wall-clock time and a window can outlive both its date and its minute: ``` -switch window: OPEN — Guard relaxed so a new VPN can connect — your real IP may be exposed until it closes (3:04PM). +switch window: OPEN — Your real IP may be exposed until 3:04PM. The guard is relaxed so a new VPN can connect. until: 2026-07-25T15:04:00Z ``` @@ -321,6 +352,7 @@ For the times the *correct* traffic is the one the guard blocks — a domestic-o service that refuses a foreign VPN exit: ```sh +dezhban pause --list # the offered lengths, and what each is for sudo dezhban pause 15m # real IP for 15 minutes, capped by vpn.pauseMax sudo dezhban resume # end it early ``` @@ -330,6 +362,40 @@ duration and re-arms the guard by itself at the deadline, so there's nothing to remember to turn back on. See [modes.md](../concepts/modes.md#pause--deliberately-using-your-real-ip). +`--list` offers a short set of realistic lengths — the question is never "how +many seconds" but "how long do I need my real IP for" — and any duration up to +the cap still works if none of them fit. Lengths above `vpn.pauseMax` are shown +as **unavailable with the cap as the reason**, not hidden: a cap you cannot see +is a cap you will keep bumping into. + +A pause longer than the cap is **refused and explained, never shortened**. Asking +for an hour against a 30-minute cap fails with the cap named, rather than quietly +granting thirty minutes you did not ask for and cannot tell apart from the hour +you wanted. + +## Keep a deliberate disconnect cut + +dezhban cannot tell a VPN you turned off from a VPN that fell over, so by default +it treats every drop the same way: it opens a redial window so the client can get +back. When you are the one disconnecting, that is a relaxation you never asked +for. Arm **hold the line** first and the next drop stays cut: + +```sh +dezhban hold # the next VPN drop stays cut — no redial window +dezhban hold --status # armed or not +dezhban hold --cancel # back to the usual behaviour +``` + +It is the opposite of `pause` in both directions: pause says *let me use my real +IP*, hold the line says *keep me cut*. It only ever **removes** a relaxation, so +the three sanctioned triggers are unchanged and there is no fourth — which is +also why it needs no `control.allow*` gate of its own. + +It is one-shot on purpose: spent by the drop it covers, disarmed as soon as a +tunnel is up again, and forgotten if the daemon restarts. A flag that survived a +reboot would eventually cut an *accidental* drop off from the redial help it +should have had. + ## Shell completion ```sh @@ -400,36 +466,39 @@ Two surfaces, split by urgency: lockout-recovery actions; they never depend on the main window opening. Items enable/disable from the current state. - **Main window — everything else**, opened from the dropdown or by clicking the - Dock icon (never automatically at launch). Sidebar sections: - - **Overview** — live status hero (posture, IP/country, tunnel, endpoints, - every configured VPN profile with the matched one marked, switch-window - countdown, enforcement-error banner) plus the daily controls, Pause, and a - visually-separated Panic. With profiles configured, "Switching VPN…" - becomes a menu so a switch window can target one by name. Degraded states - are guided: CLI missing, service not installed, and daemon stopped each - render an explanation with the one relevant action inline (Install - service… / Guard up). - - **Settings** — startup ("Start the guard at boot" installs the launchd - system service so enforcement survives reboots; "Open this app at login" via - `SMAppService`; essential-event notifications), a **strictness preset - picker** (Strict/Balanced/Relaxed, each showing its cost, or "Custom" with - the keys that differ), tunnels/endpoints/autodetection, blocking (blocked - countries, poll interval), windows (switch/redial/endpoint grace), timing, - all applied through one validated `config set` batch, an **Advanced** - disclosure exposing every `vpn.advanced.*` key, **"Use Touch ID for - settings changes"** (see below), an explicit **Restart dezhban…**, and the - raw config file escape hatch (control socket, geo providers, allowlist are - JSON-only). - - **Diagnostics** — `doctor`'s findings (`--json`), rendered as status rows - with fixes inline instead of a text dump; an optional "Find my VPN's - server" checkbox runs it with `--discover`. Read-only, same guarantee as - running `dezhban doctor` in a terminal. - - **Logs** — a scoped `log show --last 1h`, a live `log stream` with Stop - (also opens Console.app), and the transcripts of window-triggered - panic/install/uninstall/apply/restart runs. - - **About** — version, config/binary paths, posture, service state, and which - elevation path (Touch ID-capable Authorization Services vs password-only - fallback) privileged actions will take. + Dock icon (never automatically at launch). + +The main window's sidebar sections: + +- **Overview** — live status hero (posture, IP/country, tunnel, endpoints, + every configured VPN profile with the matched one marked, switch-window + countdown, enforcement-error banner) plus the daily controls, Pause, and a + visually-separated Panic. With profiles configured, "Switching VPN…" + becomes a menu so a switch window can target one by name. Degraded states + are guided: CLI missing, service not installed, and daemon stopped each + render an explanation with the one relevant action inline (Install + service… / Guard up). +- **Settings** — startup ("Start the guard at boot" installs the launchd + system service so enforcement survives reboots; "Open this app at login" via + `SMAppService`; essential-event notifications), a **strictness preset + picker** (Strict/Balanced/Relaxed, each showing its cost, or "Custom" with + the keys that differ), tunnels/endpoints/autodetection, blocking (blocked + countries, poll interval), windows (switch/redial/endpoint grace), timing, + all applied through one validated `config set` batch, an **Advanced** + disclosure exposing every `vpn.advanced.*` key, **"Use Touch ID for + settings changes"** (see below), an explicit **Restart dezhban…**, and the + raw config file escape hatch (control socket, geo providers, allowlist are + JSON-only). +- **Diagnostics** — `doctor`'s findings (`--json`), rendered as status rows + with fixes inline instead of a text dump; an optional "Find my VPN's + server" checkbox runs it with `--discover`. Read-only, same guarantee as + running `dezhban doctor` in a terminal. +- **Logs** — a scoped `log show --last 1h`, a live `log stream` with Stop + (also opens Console.app), and the transcripts of window-triggered + panic/install/uninstall/apply/restart runs. +- **About** — version, config/binary paths, posture, service state, and which + elevation path (Touch ID-capable Authorization Services vs password-only + fallback) privileged actions will take. **Status icon** — full-color brand state icons (from `gui/artifacts/`), shown in both the menu bar and the Dock tile: teal allow/guard, red block/full-block, amber diff --git a/docs/usage/config.md b/docs/usage/config.md index 6cd4bfe..e78298b 100644 --- a/docs/usage/config.md +++ b/docs/usage/config.md @@ -329,7 +329,8 @@ the two differ, on both write paths (elevated and `--token-stdin`). | `promoteAfterRefreshes` | `3` | Consecutive sightings before a discovered endpoint is learned under normal guard. | | `redialMinUptime` | `15s` | Anti-flap gate on the automatic redial window: an auto-window opens only if the tunnel had been up at least this long (or a good exit was confirmed during that uptime). The first drop after startup is exempt — uptime before the daemon started is unknowable. `"0"` disables the gate. | | `endpointWarnThreshold` | `256` | Union size at which `doctor` warns about rule-list bloat. | -| `windowProtocols` / `windowPorts` | (empty = allow all) | Restrict the switch window to these protocols/ports instead of all outbound — only useful when every VPN you switch to uses a fixed port set (e.g. WireGuard on 51820). | +| `windowProtocols` | `[]` | Restrict a switch window to these protocols (e.g. `["udp"]`) instead of allowing all outbound. Empty allows all — only worth setting when every VPN you switch to uses a fixed protocol. | +| `windowPorts` | `[]` | Restrict a switch window to these ports (e.g. `[51820]`) instead of allowing all outbound. Empty allows all — only worth setting when every VPN you switch to uses a fixed port set (e.g. WireGuard on 51820). | ## Presets diff --git a/docs/usage/getting-started.md b/docs/usage/getting-started.md index c3bdef2..cbc0c0d 100644 --- a/docs/usage/getting-started.md +++ b/docs/usage/getting-started.md @@ -1,7 +1,3 @@ -

- Dezhban — system-wide network kill switch -

- # Quick start Get dezhban protecting your machine in about ten minutes — without locking @@ -55,6 +51,11 @@ a VPN config file, import it instead of typing endpoints: dezhban vpn import ~/wg0.conf # WireGuard .conf, OpenVPN .ovpn, or V2Ray JSON ``` +On macOS you can answer the same questions in the app instead: launch Dezhban +with nothing configured and the setup wizard opens by itself, seeded from +whatever config you already have. It is also in Settings → **Run Setup Again…**, +for when you change VPN. + Prefer to write the file yourself? Start from `configs/dezhban.example.json` and see the [config reference](config.md). @@ -188,6 +189,11 @@ you need it — the full runbook is in ## Where to go next +Everything below also ships **inside the macOS app** — open the main window and +pick **Help**. Those pages are rendered into the app when it is built, so they +match the version you are running and they work with every byte of egress cut, +which is exactly when you are most likely to need them. + | If you want to… | Read | |---|---| | Understand how the machine actually works | [how-it-works.md](../concepts/how-it-works.md) | diff --git a/gui/macos/Sources/DezhbanCore/ConfigSchema.swift b/gui/macos/Sources/DezhbanCore/ConfigSchema.swift new file mode 100644 index 0000000..50ef75c --- /dev/null +++ b/gui/macos/Sources/DezhbanCore/ConfigSchema.swift @@ -0,0 +1,115 @@ +import Foundation + +/// Mirrors Go's schemaEntry (`dezhban config schema --json`), which is +/// `internal/config.Tunable` plus a `preset` flag. +/// +/// This exists so the app stops restating what the daemon already knows. Before +/// it, every field's label, hint, and default were literal strings in +/// SettingsView — and they had drifted: the pane advertised a 30s endpoint +/// refresh and a 5s tunnel watch while the shipped defaults were 1m and 1s. +/// Anyone reading Settings was being told the wrong thing. +public struct ConfigTunable: Codable, Identifiable, Hashable { + public var id: String { key } + + /// The dotted key `config set` accepts — the identity everything joins on. + public let key: String + /// Human name for the concept, in the vocabulary of docs/concepts/glossary.md. + public let label: String + /// Value shape: duration, bool, int, list, or string. + public let kind: String + /// What this key is when the config does not set it, rendered the way + /// `config get` renders a live value. + public let defaultValue: String + /// The key that bounds this one, if any. A key rather than a number because + /// the ceiling is itself settable — a slider's top must be read from the + /// live config, never hardcoded. + public let capKey: String? + /// What an int counts. Empty for every other kind. + public let unit: String? + /// Whether "0" is an explicit, persisted off-switch for this key rather than + /// "reset me to the default". Only these keys may be offered an **Off** + /// choice: for the others Normalize restores the default, so an Off would + /// silently do nothing. + public let disablable: Bool + /// The `vpn.advanced.*` knobs, shown behind the Advanced disclosure. + public let advanced: Bool + /// Whether a strictness preset writes this key, so editing it by hand drifts + /// the config to Custom. + public let preset: Bool + public let help: String + /// Where this key is documented, as "#". + public let docAnchor: String + /// Why a running daemon cannot adopt this key in place; nil when it can. + public let restartReason: String? + + // `default` is a Swift keyword, so the stored property is renamed and mapped. + private enum CodingKeys: String, CodingKey { + case key, label, kind + case defaultValue = "default" + case capKey, unit, disablable, advanced, preset, help, docAnchor, restartReason + } + + /// True when a change to this key takes effect without restarting the daemon. + public var appliesLive: Bool { (restartReason ?? "").isEmpty } + + /// Where the Help pane should land for this key. Nil only if the schema + /// carried no anchor at all — Go's TestEveryTunableDocAnchorResolves keeps + /// that from shipping, and a control simply offers no link rather than a + /// dead one. + public var docTarget: HelpTarget? { HelpTarget(docAnchor: docAnchor) } + + /// Placeholder text for a text field: the label, then the real default. + /// + /// It says "default", not "e.g.", because it now IS the default rather than + /// an illustrative value someone typed once. + public var placeholder: String { + defaultValue.isEmpty ? label : "\(label) (default \(defaultValue))" + } +} + +/// The whole declared schema, indexed by key. +public struct ConfigSchema { + public let tunables: [ConfigTunable] + private let byKey: [String: ConfigTunable] + + public init(_ tunables: [ConfigTunable]) { + self.tunables = tunables + self.byKey = Dictionary(uniqueKeysWithValues: tunables.map { ($0.key, $0) }) + } + + public static func decode(_ data: Data) -> ConfigSchema? { + guard let entries = try? JSONDecoder().decode([ConfigTunable].self, from: data) else { return nil } + return ConfigSchema(entries) + } + + public subscript(key: String) -> ConfigTunable? { byKey[key] } + + public var isEmpty: Bool { tunables.isEmpty } + + /// Placeholder for a key, falling back to a plain label when the schema is + /// unavailable (an old CLI, or a failed read). A missing schema must degrade + /// to a less helpful hint, never to a wrong one — which is why there is no + /// hardcoded default to fall back to. + public func placeholder(for key: String, fallback: String) -> String { + self[key]?.placeholder ?? fallback + } + + /// The help line for a key, or nil when the schema is unavailable. + public func help(for key: String) -> String? { self[key]?.help } + + /// The live ceiling for a key: the current value of whatever `capKey` names, + /// looked up in the values the pane was seeded with. Nil when the key is + /// unbounded or the cap's value is not to hand. + /// + /// Resolved against seeded values rather than the cap's own default, because + /// lowering a cap by hand must actually lower the control's top. + public func cap(for key: String, in values: [String: String]) -> String? { + guard let capKey = self[key]?.capKey else { return nil } + return values[capKey] ?? self[capKey]?.defaultValue + } + + /// Every key the daemon says needs a restart, among those given. + public func restartRequired(among keys: [String]) -> [String] { + keys.filter { self[$0].map { !$0.appliesLive } ?? false } + } +} diff --git a/gui/macos/Sources/DezhbanCore/DurationChoices.swift b/gui/macos/Sources/DezhbanCore/DurationChoices.swift new file mode 100644 index 0000000..473e732 --- /dev/null +++ b/gui/macos/Sources/DezhbanCore/DurationChoices.swift @@ -0,0 +1,159 @@ +import Foundation + +/// Turns a duration setting into a short list of choices a person can pick from, +/// instead of a text field that demands they know Go's duration syntax. +/// +/// The choices are DERIVED from the key's own default and cap rather than listed +/// per key, so a new duration setting gets a usable control by existing, and a +/// changed default or a lowered cap moves the choices with it. Nothing here +/// hardcodes a value for any particular setting — that was the drift Phase M +/// removed, and re-adding it one layer up would be the same mistake. +public enum DurationChoices { + /// One offered value. + public struct Choice: Identifiable, Hashable { + public var id: String { value } + /// How it reads: "30 seconds", "Off". + public let label: String + /// The value as `config set` accepts it. "0" for Off. + public let value: String + /// Set for the shipped default, so a control can mark it. + public let isDefault: Bool + /// Set for Off, which is not a duration but a persisted opt-out. + public let isOff: Bool + } + + /// The multiples of the default to offer, either side of it. Chosen to span + /// a useful range in few enough steps to stay a menu rather than a form: + /// noticeably tighter, the default, and progressively looser. + private static let multipliers: [Double] = [0.5, 1, 2, 4, 8] + + /// Builds the choice list for one setting. + /// + /// - `defaultValue`/`cap` are Go duration strings from the daemon's schema; + /// an unparsable or absent cap simply means unbounded. + /// - `disablable` decides whether **Off** is offered. It must come from the + /// schema, never a guess: offering Off for a key whose `0` is coerced back + /// to the default would present a security choice that silently does + /// nothing. + public static func build(defaultValue: String, cap: String?, disablable: Bool) -> [Choice] { + guard let base = seconds(defaultValue), base > 0 else { + // No usable default to derive from. Off may still be a real choice, + // and Custom always is — better a short menu than a wrong one. + return disablable ? [offChoice] : [] + } + let ceiling = cap.flatMap(seconds).map { Int($0.rounded()) } + + var out: [Choice] = [] + if disablable { + out.append(offChoice) + } + + var seen = Set() + for m in multipliers { + let secs = Int((base * m).rounded()) + guard secs > 0 else { continue } + // A choice above the cap is not offered at all — unlike a pause + // length, this is not a menu of things you might want, it is a menu + // of things that will be accepted. An option the daemon would reject + // is noise. + if let ceiling, secs > ceiling { continue } + guard seen.insert(secs).inserted else { continue } + out.append(Choice(label: humanize(secs), value: goDuration(secs), + isDefault: secs == Int(base.rounded()), isOff: false)) + } + + // The cap itself is always worth offering: it is the most relaxed + // setting the operator has allowed, and reaching it by multiplying + // upward is luck. + if let ceiling, ceiling > 0, seen.insert(ceiling).inserted { + out.append(Choice(label: humanize(ceiling), value: goDuration(ceiling), + isDefault: false, isOff: false)) + } + return out + } + + private static let offChoice = Choice(label: "Off", value: "0", isDefault: false, isOff: true) + + /// Parses a Go duration into whole seconds. Returns nil for anything that is + /// not a duration — including "off", which callers handle separately. + public static func seconds(_ text: String) -> Double? { + let s = text.trimmingCharacters(in: .whitespaces) + guard !s.isEmpty, s != "off" else { return nil } + + var total = 0.0 + var number = "" + var unit = "" + var sawAny = false + + func flush() -> Bool { + guard let n = Double(number) else { return false } + let scale: Double + switch unit { + case "h": scale = 3600 + case "m": scale = 60 + case "s": scale = 1 + case "ms": scale = 0.001 + case "us", "µs", "μs": scale = 0.000_001 + case "ns": scale = 0.000_000_001 + default: return false + } + total += n * scale + sawAny = true + number = "" + unit = "" + return true + } + + for ch in s { + if ch.isNumber || ch == "." { + if !unit.isEmpty, !flush() { return nil } + number.append(ch) + } else { + guard !number.isEmpty else { return nil } + unit.append(ch) + } + } + guard unit.isEmpty || flush(), sawAny, number.isEmpty else { return nil } + return total + } + + /// Renders whole seconds the way Go's Duration.String would, so a value the + /// app writes reads back identically from `config get`. + public static func goDuration(_ secs: Int) -> String { + if secs == 0 { return "0s" } + var rest = secs + var out = "" + let h = rest / 3600 + rest %= 3600 + let m = rest / 60 + rest %= 60 + if h > 0 { out += "\(h)h" } + if h > 0 || m > 0 { out += "\(m)m" } + out += "\(rest)s" + return out + } + + /// Renders a duration in plain words, for a label a person reads rather than + /// parses. + public static func humanize(_ secs: Int) -> String { + func plural(_ n: Int, _ word: String) -> String { "\(n) \(word)\(n == 1 ? "" : "s")" } + switch secs { + case 0: return "Off" + case ..<60: return plural(secs, "second") + case ..<3600 where secs % 60 == 0: return plural(secs / 60, "minute") + case ..<86_400 where secs % 3600 == 0: return plural(secs / 3600, "hour") + default: + if secs % 3600 == 0 { return plural(secs / 3600, "hour") } + if secs % 60 == 0 { return plural(secs / 60, "minute") } + return goDuration(secs) + } + } + + /// True when `value` is the persisted "off" sentinel in any of the spellings + /// the round trip produces: the app writes "0", `config get` reports "0s", + /// and KeyValues renders "off". + public static func isOff(_ value: String) -> Bool { + let v = value.trimmingCharacters(in: .whitespaces) + return v == "0" || v == "0s" || v == "off" + } +} diff --git a/gui/macos/Sources/DezhbanCore/HelpIndex.swift b/gui/macos/Sources/DezhbanCore/HelpIndex.swift new file mode 100644 index 0000000..75baac1 --- /dev/null +++ b/gui/macos/Sources/DezhbanCore/HelpIndex.swift @@ -0,0 +1,229 @@ +import Foundation + +/// Mirrors Go's `help.Heading` — one heading in a rendered page. +public struct HelpHeading: Codable, Hashable { + public let level: Int + public let text: String + public let anchor: String + + public init(level: Int, text: String, anchor: String) { + self.level = level + self.text = text + self.anchor = anchor + } +} + +/// Mirrors Go's `help.IndexEntry` — one bundled page, as written to +/// `help-index.json` by `tools/helpgen` at build time. +public struct HelpPage: Codable, Identifiable, Hashable { + /// The docs-relative source path is the identity, because that is what a + /// `Tunable.DocAnchor` names ("usage/config.md#fields"). + public var id: String { source } + public let file: String + public let source: String + public let title: String + public let summary: String + /// Position in the first-time reading order, 1-based; 0 means reference + /// material — reachable and searchable, but not part of the guided track. + public let tutorial: Int + public let headings: [HelpHeading] + /// The page stripped to words, so search runs in the app with no second + /// pass over the HTML. + public let text: String + + private enum CodingKeys: String, CodingKey { + case file, source, title, summary, tutorial, headings, text + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + file = try c.decode(String.self, forKey: .file) + source = try c.decode(String.self, forKey: .source) + title = try c.decode(String.self, forKey: .title) + summary = try c.decodeIfPresent(String.self, forKey: .summary) ?? "" + // Go omits `tutorial` when it is zero (`omitempty`), which is the + // common case: most pages are reference, not tutorial. + tutorial = try c.decodeIfPresent(Int.self, forKey: .tutorial) ?? 0 + headings = try c.decodeIfPresent([HelpHeading].self, forKey: .headings) ?? [] + text = try c.decodeIfPresent(String.self, forKey: .text) ?? "" + } + + public init(file: String, source: String, title: String, summary: String, + tutorial: Int = 0, headings: [HelpHeading] = [], text: String = "") { + self.file = file + self.source = source + self.title = title + self.summary = summary + self.tutorial = tutorial + self.headings = headings + self.text = text + } + + /// True when this page has a heading with that fragment id — what a + /// contextual deep link depends on. + public func hasAnchor(_ anchor: String) -> Bool { + headings.contains { $0.anchor == anchor } + } +} + +/// One search result: the page, optionally a heading to land on, and a line +/// saying why it matched. +public struct HelpHit: Identifiable, Hashable { + public var id: String { page.source + "#" + (anchor ?? "") } + public let page: HelpPage + public let anchor: String? + public let context: String + + public init(page: HelpPage, anchor: String?, context: String) { + self.page = page + self.anchor = anchor + self.context = context + } +} + +/// Where a deep link points: a page, and optionally a heading within it. +/// Written as `"usage/config.md#fields"` in a `Tunable`'s `docAnchor`. +public struct HelpTarget: Hashable, Identifiable { + public var id: String { source + "#" + (anchor ?? "") } + public let source: String + public let anchor: String? + + public init(source: String, anchor: String? = nil) { + self.source = source + self.anchor = anchor + } + + /// Parses the `page#fragment` form a `Tunable.docAnchor` is written in. + public init?(docAnchor: String) { + let parts = docAnchor.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false) + guard let first = parts.first, !first.isEmpty else { return nil } + source = String(first) + anchor = parts.count > 1 && !parts[1].isEmpty ? String(parts[1]) : nil + } +} + +/// The documentation shipped inside the app: the rendered pages on disk plus +/// the index the sidebar and search are built from. +/// +/// It is deliberately read from the bundle rather than the network. The pane is +/// opened when someone needs to know what the guard is doing to their traffic, +/// which is often while it has cut all of it. +public struct HelpBundle { + /// The directory holding the HTML, help.css, and help-index.json. + public let directory: URL + public let pages: [HelpPage] + + public init(directory: URL, pages: [HelpPage]) { + self.directory = directory + self.pages = pages + } + + /// Loads a bundle from a directory containing help-index.json. + public static func load(directory: URL) -> HelpBundle? { + guard let data = try? Data(contentsOf: directory.appendingPathComponent("help-index.json")), + let pages = try? JSONDecoder().decode([HelpPage].self, from: data), + !pages.isEmpty + else { return nil } + return HelpBundle(directory: directory, pages: pages) + } + + /// Loads the copy inside Dezhban.app (`Contents/Resources/help`). Returns + /// nil when running from `swift run` outside a bundle, or against an app + /// built before the docs were bundled — callers show a short explanation + /// rather than an empty pane. + public static func bundled() -> HelpBundle? { + guard let resources = Bundle.main.resourceURL else { return nil } + return load(directory: resources.appendingPathComponent("help", isDirectory: true)) + } + + /// The guided reading order, first. + public var tutorial: [HelpPage] { + pages.filter { $0.tutorial > 0 }.sorted { $0.tutorial < $1.tutorial } + } + + /// Everything else, in manifest order — reference material a reader looks + /// something up in rather than reads through. + public var reference: [HelpPage] { + pages.filter { $0.tutorial == 0 } + } + + public func page(source: String) -> HelpPage? { + pages.first { $0.source == source } + } + + /// The on-disk file for a page. `WKWebView.loadFileURL` needs a real URL, + /// and this is the only place one is constructed. + public func url(for page: HelpPage) -> URL { + directory.appendingPathComponent(page.file) + } + + /// Resolves a deep link. Reports the anchor only when a heading actually + /// carries it — a stale anchor lands the reader at the top of the right + /// page instead of nowhere. + public func resolve(_ target: HelpTarget) -> (page: HelpPage, anchor: String?)? { + guard let page = page(source: target.source) else { return nil } + if let anchor = target.anchor, page.hasAnchor(anchor) { + return (page, anchor) + } + return (page, nil) + } + + /// Substring search over titles, summaries, headings, and body text, in + /// that order of confidence. Case-insensitive, no stemming: the corpus is + /// nine pages, and a reader typing "pause" wants the pause section, not a + /// ranked relevance model. + public func search(_ query: String) -> [HelpHit] { + let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !q.isEmpty else { return [] } + + var hits: [HelpHit] = [] + var ranks: [String: Int] = [:] + + for page in pages { + if page.title.lowercased().contains(q) { + hits.append(HelpHit(page: page, anchor: nil, context: page.summary)) + ranks[page.source + "#"] = 0 + continue + } + if page.summary.lowercased().contains(q) { + hits.append(HelpHit(page: page, anchor: nil, context: page.summary)) + ranks[page.source + "#"] = 1 + continue + } + if let heading = page.headings.first(where: { $0.text.lowercased().contains(q) }) { + let hit = HelpHit(page: page, anchor: heading.anchor, context: heading.text) + hits.append(hit) + ranks[hit.id] = 2 + continue + } + if let snippet = Self.snippet(in: page.text, around: q) { + hits.append(HelpHit(page: page, anchor: nil, context: snippet)) + ranks[page.source + "#"] = 3 + } + } + + return hits.sorted { a, b in + let ra = ranks[a.id] ?? 9, rb = ranks[b.id] ?? 9 + if ra != rb { return ra < rb } + // Within a rank, the guided track comes first: a reader who does + // not know the vocabulary yet is better served by "How it works" + // than by the configuration reference. + let ta = a.page.tutorial == 0 ? Int.max : a.page.tutorial + let tb = b.page.tutorial == 0 ? Int.max : b.page.tutorial + if ta != tb { return ta < tb } + return a.page.title < b.page.title + } + } + + /// One line of body text around the match, so a result says what it found + /// rather than just where. + static func snippet(in text: String, around query: String) -> String? { + for line in text.split(separator: "\n") { + if line.lowercased().contains(query) { + let s = line.trimmingCharacters(in: .whitespaces) + return s.count > 140 ? String(s.prefix(140)) + "…" : s + } + } + return nil + } +} diff --git a/gui/macos/Sources/DezhbanCore/PauseOptions.swift b/gui/macos/Sources/DezhbanCore/PauseOptions.swift new file mode 100644 index 0000000..c8819de --- /dev/null +++ b/gui/macos/Sources/DezhbanCore/PauseOptions.swift @@ -0,0 +1,32 @@ +import Foundation + +/// One offered pause length — mirrors Go's `config.PauseOption` +/// (`dezhban pause --list --json`). +/// +/// Defined by the daemon rather than here so the CLI and the app offer the same +/// choices. A picker that invented its own list would drift from `pause --list` +/// the first time either changed. +public struct PauseOption: Codable, Identifiable, Hashable { + public var id: String { value } + + /// How the length reads to a person: "15 minutes". + public let label: String + /// The duration as `dezhban pause` accepts it, passed straight through so + /// the app never formats a duration itself. + public let value: String + /// What this length is for, so the choice is made on the need rather than + /// on the number. + public let why: String + /// Non-empty when `vpn.pauseMax` forbids this length, and says so in words. + /// + /// Such an option is still SHOWN, disabled, with this as its tooltip. + /// Hiding it would leave the user with a wrong idea of their own cap, and + /// silently shortening it — the behaviour this replaced — is worse still. + public let unavailable: String? + + public var isAvailable: Bool { (unavailable ?? "").isEmpty } + + public static func decodeList(_ data: Data) -> [PauseOption]? { + try? JSONDecoder().decode([PauseOption].self, from: data) + } +} diff --git a/gui/macos/Sources/DezhbanCore/SettingsFields.swift b/gui/macos/Sources/DezhbanCore/SettingsFields.swift index a01b566..bb0080a 100644 --- a/gui/macos/Sources/DezhbanCore/SettingsFields.swift +++ b/gui/macos/Sources/DezhbanCore/SettingsFields.swift @@ -1,21 +1,30 @@ import Foundation /// The staged values for the Settings pane's batched `config set`, and the -/// dotted keys they correspond to. Pulled out of SettingsView so the -/// seed→pairs round trip — the exact place a silent index mismatch would write -/// one field's value under another field's key — can be unit-tested without -/// standing up SwiftUI state. +/// dotted keys they correspond to. +/// +/// Values are stored **keyed by dotted key**, not positionally. They used to be +/// twenty-five stored properties destructured out of an array by literal index, +/// with a `precondition` on the count alone — so inserting a key anywhere but the +/// end, or reordering two of them, silently wrote one field's value under another +/// field's key and passed every check. Keying the storage makes that +/// unrepresentable: a value can only ever be read back under the key it was +/// stored against. +/// +/// The named properties survive as computed accessors so the SwiftUI bindings +/// (`$fields.switchWindow`) keep working; they are now views onto the dictionary +/// rather than a second copy of it. public struct SettingsFields { - // Dotted keys from configFields (cmd/dezhban/config_cmd.go), in the same - // order `seed()`/`pairs()` destructure and rebuild them. The last twelve are - // the vpn.advanced.* block, staged into the same batch and shown behind the - // Settings pane's Advanced disclosure. + // Dotted keys from configFields (cmd/dezhban/config_cmd.go). This is the set + // the pane stages — a deliberate subset of every settable key, since identity + // data (profiles) and the control socket are managed elsewhere. Order is + // presentation order only; nothing correctness-bearing depends on it now. public static let keys = [ "vpn.tunnelInterfaces", "vpn.endpoints", "vpn.autoDetect", "vpn.autoDiscoverEndpoints", "vpn.autoArm", "vpn.allowLocalNetwork", "blockedCountries", "pollInterval", - "vpn.switchWindow", "vpn.redialWindow", "vpn.endpointGrace", + "vpn.switchWindow", "vpn.redialWindow", "vpn.pauseMax", "vpn.endpointGrace", "vpn.endpointRefresh", "vpn.tunnelWatch", "vpn.advanced.switchWindowMax", "vpn.advanced.redialWindowMax", "vpn.advanced.redialMinUptime", "vpn.advanced.commandFreshness", "vpn.advanced.windowDiscoveryInterval", "vpn.advanced.tunnelPruneAfter", @@ -23,140 +32,167 @@ public struct SettingsFields { "vpn.advanced.endpointWarnThreshold", "vpn.advanced.windowProtocols", "vpn.advanced.windowPorts", ] - public var tunnelInterfaces = "" - public var endpoints = "" - public var autoDetect = false - public var autoDiscover = false - public var autoArm = false - public var allowLocalNetwork = true - public var blockedCountries = "" - public var pollInterval = "" - public var switchWindow = "" - public var redialWindow = "" - public var endpointGrace = "" - public var endpointRefresh = "" - public var tunnelWatch = "" + /// Raw staged values, exactly as `config get` returned them and exactly as + /// `config set` will receive them. Bools travel as "true"/"false" because + /// that is what both ends of the round trip use. + private var values: [String: String] - public var advSwitchWindowMax = "" - public var advRedialWindowMax = "" - public var advRedialMinUptime = "" - public var advCommandFreshness = "" - public var advWindowDiscoveryInterval = "" - public var advTunnelPruneAfter = "" - public var advLearnedEndpointTTL = "" - public var advLearnedMaxPerProfile = "" - public var advPromoteAfterRefreshes = "" - public var advEndpointWarnThreshold = "" - public var advWindowProtocols = "" - public var advWindowPorts = "" + public init() { + values = Dictionary(uniqueKeysWithValues: Self.keys.map { ($0, "") }) + // Only bools need a non-empty resting value; an empty string would be + // written back as an invalid bool if the pane were applied unseeded. + for key in Self.boolKeys { values[key] = "false" } + values["vpn.allowLocalNetwork"] = "true" + } - public init() {} + /// Rebuilds a SettingsFields from what `config get` returned, keyed by the + /// key each value was read for. A key the caller did not read keeps its + /// resting value rather than silently taking a neighbour's. + public init(seeded: [String: String]) { + self.init() + for (key, value) in seeded where values[key] != nil { + values[key] = value + } + } - /// Rebuilds a SettingsFields from `keys`-ordered values, as returned by a - /// batch of `config get` calls (ConfigApply.seed). Out-of-range access is a - /// programmer error (a caller passing the wrong key list), so this traps - /// rather than silently seeding partial/wrong fields. - public init(seeded values: [String]) { - precondition(values.count == Self.keys.count, - "SettingsFields.init(seeded:) got \(values.count) values, want \(Self.keys.count)") - tunnelInterfaces = values[0] - endpoints = values[1] - autoDetect = (values[2] == "true") - autoDiscover = (values[3] == "true") - autoArm = (values[4] == "true") - allowLocalNetwork = (values[5] == "true") - blockedCountries = values[6] - pollInterval = values[7] - switchWindow = values[8] - redialWindow = values[9] - endpointGrace = values[10] - endpointRefresh = values[11] - tunnelWatch = values[12] + /// The keys whose values are booleans, so accessors and seeding agree on the + /// representation. + static let boolKeys: Set = [ + "vpn.autoDetect", "vpn.autoDiscoverEndpoints", "vpn.autoArm", "vpn.allowLocalNetwork", + ] - advSwitchWindowMax = values[13] - advRedialWindowMax = values[14] - advRedialMinUptime = values[15] - advCommandFreshness = values[16] - advWindowDiscoveryInterval = values[17] - advTunnelPruneAfter = values[18] - advLearnedEndpointTTL = values[19] - advLearnedMaxPerProfile = values[20] - advPromoteAfterRefreshes = values[21] - advEndpointWarnThreshold = values[22] - advWindowProtocols = values[23] - advWindowPorts = values[24] - } + /// Reads one staged value by key. Returns "" for a key this pane does not + /// stage, which is the same thing an unset field looks like. + public func value(for key: String) -> String { values[key] ?? "" } - /// The current values in `keys` order — the dirtiness check compares this - /// against what the pane was last seeded with. - public var currentValues: [String] { - [tunnelInterfaces, endpoints, - String(autoDetect), String(autoDiscover), String(autoArm), - String(allowLocalNetwork), - blockedCountries, pollInterval, - switchWindow, redialWindow, endpointGrace, - endpointRefresh, tunnelWatch, - advSwitchWindowMax, advRedialWindowMax, advRedialMinUptime, - advCommandFreshness, advWindowDiscoveryInterval, advTunnelPruneAfter, - advLearnedEndpointTTL, advLearnedMaxPerProfile, advPromoteAfterRefreshes, - advEndpointWarnThreshold, advWindowProtocols, advWindowPorts] + /// Stages one value by key. Ignores keys the pane does not carry, so a + /// schema-driven control cannot invent storage by writing to it. + public mutating func setValue(_ value: String, for key: String) { + guard values[key] != nil else { return } + values[key] = value } - /// Renders `key=value` pairs for one batched `config set`. Durations are - /// trimmed (whitespace typed into a text field is not part of the value); - /// everything else travels as-is — `config set`/Normalize (Go) does the - /// canonicalisation (upper-casing country codes, etc.), not this layer. + /// Every staged value, keyed. The dirtiness check compares this against what + /// the pane was last seeded with. + public var currentValues: [String: String] { values } + + /// Renders `key=value` pairs for one batched `config set`, in `keys` order so + /// the resulting command line is stable and diffable. Durations are trimmed + /// (whitespace typed into a text field is not part of the value); everything + /// else travels as-is — `config set`/Normalize (Go) does the canonicalisation + /// (upper-casing country codes, etc.), not this layer. public func pairs() -> [String] { - [ - "vpn.tunnelInterfaces=\(tunnelInterfaces)", - "vpn.endpoints=\(endpoints)", - "vpn.autoDetect=\(autoDetect)", - "vpn.autoDiscoverEndpoints=\(autoDiscover)", - "vpn.autoArm=\(autoArm)", - "vpn.allowLocalNetwork=\(allowLocalNetwork)", - "blockedCountries=\(blockedCountries.trimmingCharacters(in: .whitespaces))", - "pollInterval=\(pollInterval.trimmingCharacters(in: .whitespaces))", - "vpn.switchWindow=\(switchWindow.trimmingCharacters(in: .whitespaces))", - "vpn.redialWindow=\(redialWindow.trimmingCharacters(in: .whitespaces))", - "vpn.endpointGrace=\(endpointGrace.trimmingCharacters(in: .whitespaces))", - "vpn.endpointRefresh=\(endpointRefresh.trimmingCharacters(in: .whitespaces))", - "vpn.tunnelWatch=\(tunnelWatch.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.switchWindowMax=\(advSwitchWindowMax.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.redialWindowMax=\(advRedialWindowMax.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.redialMinUptime=\(advRedialMinUptime.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.commandFreshness=\(advCommandFreshness.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.windowDiscoveryInterval=\(advWindowDiscoveryInterval.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.tunnelPruneAfter=\(advTunnelPruneAfter.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.learnedEndpointTTL=\(advLearnedEndpointTTL.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.learnedMaxPerProfile=\(advLearnedMaxPerProfile.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.promoteAfterRefreshes=\(advPromoteAfterRefreshes.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.endpointWarnThreshold=\(advEndpointWarnThreshold.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.windowProtocols=\(advWindowProtocols.trimmingCharacters(in: .whitespaces))", - "vpn.advanced.windowPorts=\(advWindowPorts.trimmingCharacters(in: .whitespaces))", - ] + Self.keys.map { key in + "\(key)=\(value(for: key).trimmingCharacters(in: .whitespaces))" + } } - /// The duration-valued fields that must pass `looksLikeGoDuration` before - /// Apply spends a privileged round trip — label, then the trimmed value. - /// The three int-valued and two list-valued advanced fields aren't duration - /// strings, so (matching the existing precedent for `hysteresis`, which was - /// never validated this way either) they're left to the daemon's own - /// rejection if malformed. - public var durationFieldsForValidation: [(label: String, value: String)] { - [ - ("Geo IP lookup interval", pollInterval.trimmingCharacters(in: .whitespaces)), - ("Switch window", switchWindow.trimmingCharacters(in: .whitespaces)), - ("Redial window", redialWindow.trimmingCharacters(in: .whitespaces)), - ("Endpoint grace", endpointGrace.trimmingCharacters(in: .whitespaces)), - ("Endpoint refresh", endpointRefresh.trimmingCharacters(in: .whitespaces)), - ("Tunnel watch", tunnelWatch.trimmingCharacters(in: .whitespaces)), - ("Manual switch window cap", advSwitchWindowMax.trimmingCharacters(in: .whitespaces)), - ("Redial window cap", advRedialWindowMax.trimmingCharacters(in: .whitespaces)), - ("Redial anti-flap uptime", advRedialMinUptime.trimmingCharacters(in: .whitespaces)), - ("Command freshness", advCommandFreshness.trimmingCharacters(in: .whitespaces)), - ("Window discovery interval", advWindowDiscoveryInterval.trimmingCharacters(in: .whitespaces)), - ("Tunnel prune delay", advTunnelPruneAfter.trimmingCharacters(in: .whitespaces)), - ("Learned endpoint TTL", advLearnedEndpointTTL.trimmingCharacters(in: .whitespaces)), - ] + /// The duration-valued fields that must parse before Apply spends a + /// privileged round trip, as (label, trimmed value) pairs. + /// + /// Both which fields these are and what they are called now come from the + /// daemon's schema instead of a hand-kept list that had to be updated in + /// lockstep with the pane. Without a schema this returns nothing: refusing to + /// pre-validate is safe (the daemon still rejects a malformed duration and + /// says so), whereas guessing the field set would either miss one or invent + /// labels that disagree with the ones on screen. + public func durationFieldsForValidation(schema: ConfigSchema) -> [(label: String, value: String)] { + Self.keys.compactMap { key in + guard let tunable = schema[key], tunable.kind == "duration" else { return nil } + return (tunable.label, value(for: key).trimmingCharacters(in: .whitespaces)) + } + } + + // MARK: - Named accessors + // + // Views onto `values`, so SwiftUI bindings keep their existing spelling. + + private func string(_ key: String) -> String { values[key] ?? "" } + private mutating func setString(_ key: String, _ newValue: String) { values[key] = newValue } + private func bool(_ key: String) -> Bool { values[key] == "true" } + private mutating func setBool(_ key: String, _ newValue: Bool) { values[key] = String(newValue) } + + public var tunnelInterfaces: String { + get { string("vpn.tunnelInterfaces") } set { setString("vpn.tunnelInterfaces", newValue) } + } + public var endpoints: String { + get { string("vpn.endpoints") } set { setString("vpn.endpoints", newValue) } + } + public var autoDetect: Bool { + get { bool("vpn.autoDetect") } set { setBool("vpn.autoDetect", newValue) } + } + public var autoDiscover: Bool { + get { bool("vpn.autoDiscoverEndpoints") } set { setBool("vpn.autoDiscoverEndpoints", newValue) } + } + public var autoArm: Bool { + get { bool("vpn.autoArm") } set { setBool("vpn.autoArm", newValue) } + } + public var allowLocalNetwork: Bool { + get { bool("vpn.allowLocalNetwork") } set { setBool("vpn.allowLocalNetwork", newValue) } + } + public var blockedCountries: String { + get { string("blockedCountries") } set { setString("blockedCountries", newValue) } + } + public var pollInterval: String { + get { string("pollInterval") } set { setString("pollInterval", newValue) } + } + public var switchWindow: String { + get { string("vpn.switchWindow") } set { setString("vpn.switchWindow", newValue) } + } + public var redialWindow: String { + get { string("vpn.redialWindow") } set { setString("vpn.redialWindow", newValue) } + } + public var pauseMax: String { + get { string("vpn.pauseMax") } set { setString("vpn.pauseMax", newValue) } + } + public var endpointGrace: String { + get { string("vpn.endpointGrace") } set { setString("vpn.endpointGrace", newValue) } + } + public var endpointRefresh: String { + get { string("vpn.endpointRefresh") } set { setString("vpn.endpointRefresh", newValue) } + } + public var tunnelWatch: String { + get { string("vpn.tunnelWatch") } set { setString("vpn.tunnelWatch", newValue) } + } + + public var advSwitchWindowMax: String { + get { string("vpn.advanced.switchWindowMax") } set { setString("vpn.advanced.switchWindowMax", newValue) } + } + public var advRedialWindowMax: String { + get { string("vpn.advanced.redialWindowMax") } set { setString("vpn.advanced.redialWindowMax", newValue) } + } + public var advRedialMinUptime: String { + get { string("vpn.advanced.redialMinUptime") } set { setString("vpn.advanced.redialMinUptime", newValue) } + } + public var advCommandFreshness: String { + get { string("vpn.advanced.commandFreshness") } set { setString("vpn.advanced.commandFreshness", newValue) } + } + public var advWindowDiscoveryInterval: String { + get { string("vpn.advanced.windowDiscoveryInterval") } + set { setString("vpn.advanced.windowDiscoveryInterval", newValue) } + } + public var advTunnelPruneAfter: String { + get { string("vpn.advanced.tunnelPruneAfter") } set { setString("vpn.advanced.tunnelPruneAfter", newValue) } + } + public var advLearnedEndpointTTL: String { + get { string("vpn.advanced.learnedEndpointTTL") } set { setString("vpn.advanced.learnedEndpointTTL", newValue) } + } + public var advLearnedMaxPerProfile: String { + get { string("vpn.advanced.learnedMaxPerProfile") } + set { setString("vpn.advanced.learnedMaxPerProfile", newValue) } + } + public var advPromoteAfterRefreshes: String { + get { string("vpn.advanced.promoteAfterRefreshes") } + set { setString("vpn.advanced.promoteAfterRefreshes", newValue) } + } + public var advEndpointWarnThreshold: String { + get { string("vpn.advanced.endpointWarnThreshold") } + set { setString("vpn.advanced.endpointWarnThreshold", newValue) } + } + public var advWindowProtocols: String { + get { string("vpn.advanced.windowProtocols") } set { setString("vpn.advanced.windowProtocols", newValue) } + } + public var advWindowPorts: String { + get { string("vpn.advanced.windowPorts") } set { setString("vpn.advanced.windowPorts", newValue) } } } diff --git a/gui/macos/Sources/DezhbanCore/SetupQuestions.swift b/gui/macos/Sources/DezhbanCore/SetupQuestions.swift new file mode 100644 index 0000000..e183bd4 --- /dev/null +++ b/gui/macos/Sources/DezhbanCore/SetupQuestions.swift @@ -0,0 +1,192 @@ +import Foundation + +/// Mirrors Go's `setup.Option`. +public struct SetupOption: Codable, Identifiable, Hashable { + public var id: String { value } + public let label: String + public let value: String + + public init(label: String, value: String) { + self.label = label + self.value = value + } +} + +/// Mirrors Go's `setup.Question` (`dezhban setup --questions --json`). +/// +/// The app asks what the CLI wizard asks, in the same order, gated the same +/// way — because it asks the daemon rather than keeping its own list. A +/// question added in Go appears here without a Swift change. +public struct SetupQuestion: Codable, Identifiable, Hashable { + public var id: String { questionID } + /// `id` in the JSON; renamed because `id` is taken by Identifiable. + public let questionID: String + /// The dotted config key this answer writes, empty when the question only + /// steers the flow or is folded into another key's value. + public let key: String + public let kind: String + public let title: String + public let description: String + public let options: [SetupOption] + /// Seeded answer for every kind but multiselect. + public let defaultValue: String + /// Seeded answer for multiselect. + public let selected: [String] + public let group: Int + /// Gate: this question is asked only when `requiresID`'s answer equals + /// `requiresValue`. + public let requiresID: String + public let requiresValue: String + + private enum CodingKeys: String, CodingKey { + case questionID = "id" + case key, kind, title, description, options + case defaultValue = "default" + case selected, group + case requiresID = "requiresId" + case requiresValue + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + questionID = try c.decode(String.self, forKey: .questionID) + kind = try c.decode(String.self, forKey: .kind) + title = try c.decode(String.self, forKey: .title) + // Every remaining field is `omitempty` on the Go side. + key = try c.decodeIfPresent(String.self, forKey: .key) ?? "" + description = try c.decodeIfPresent(String.self, forKey: .description) ?? "" + options = try c.decodeIfPresent([SetupOption].self, forKey: .options) ?? [] + defaultValue = try c.decodeIfPresent(String.self, forKey: .defaultValue) ?? "" + selected = try c.decodeIfPresent([String].self, forKey: .selected) ?? [] + group = try c.decodeIfPresent(Int.self, forKey: .group) ?? 0 + requiresID = try c.decodeIfPresent(String.self, forKey: .requiresID) ?? "" + requiresValue = try c.decodeIfPresent(String.self, forKey: .requiresValue) ?? "" + } + + public init(questionID: String, key: String = "", kind: String, title: String, + description: String = "", options: [SetupOption] = [], + defaultValue: String = "", selected: [String] = [], group: Int = 1, + requiresID: String = "", requiresValue: String = "") { + self.questionID = questionID + self.key = key + self.kind = kind + self.title = title + self.description = description + self.options = options + self.defaultValue = defaultValue + self.selected = selected + self.group = group + self.requiresID = requiresID + self.requiresValue = requiresValue + } + + public var isGated: Bool { !requiresID.isEmpty } + + public static func decodeList(_ data: Data) -> [SetupQuestion]? { + try? JSONDecoder().decode([SetupQuestion].self, from: data) + } +} + +/// Question kinds, mirroring Go's `setup.Kind*` constants. +public enum SetupKind { + public static let duration = "duration" + public static let text = "text" + public static let list = "list" + public static let select = "select" + public static let multiSelect = "multiselect" + public static let bool = "bool" +} + +/// The answers collected so far, keyed by question id, plus the rules for +/// turning them into `config set` pairs. +/// +/// Values are strings for every kind — "true"/"false" for a bool, +/// comma-separated for a list — which is what `config set` takes and what Go's +/// `Answers.Set` accepts, so the two wizards agree on the wire as well as on +/// the questions. +public struct SetupAnswers { + public private(set) var values: [String: String] + + /// Seeds every question with its default, so a wizard the user clicks + /// straight through writes back what the config already says. + public init(questions: [SetupQuestion]) { + var seeded: [String: String] = [:] + for q in questions { + seeded[q.id] = q.kind == SetupKind.multiSelect + ? q.selected.joined(separator: ",") + : q.defaultValue + } + values = seeded + } + + public subscript(id: String) -> String { + get { values[id] ?? "" } + set { values[id] = newValue } + } + + public func bool(_ id: String) -> Bool { values[id] == "true" } + + public func list(_ id: String) -> [String] { + (values[id] ?? "").split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + /// Whether a question's gate is satisfied by the answers so far. Mirrors + /// Go's `Answers.ShouldAsk`. + public func shouldAsk(_ q: SetupQuestion) -> Bool { + guard q.isGated else { return true } + return self[q.requiresID] == q.requiresValue + } + + /// The `key=value` pairs one batched `config set` should write. + /// + /// Three rules are not derivable from a question's `key` alone, and they + /// mirror Go's `setup.Apply` exactly: + /// - the free-text country codes fold into `blockedCountries`; + /// - answering "no" to the VPN branch writes none of its keys, so a VPN + /// somebody already configured is left alone; + /// - choosing automatic detection CLEARS pinned interfaces rather than + /// skipping the key, because a leftover pin is what makes autodetect not + /// happen. + public func configPairs(for questions: [SetupQuestion]) -> [String] { + var pairs: [String] = [] + for q in questions where shouldAsk(q) && !q.key.isEmpty { + switch q.id { + case "blockedCountries": + let all = list("blockedCountries") + list("otherCountries") + pairs.append("blockedCountries=\(all.joined(separator: ","))") + case "tunnels": + pairs.append("vpn.tunnelInterfaces=\(list("tunnels").joined(separator: ","))") + default: + pairs.append("\(q.key)=\(self[q.id])") + } + } + if bool("configureVPN") && bool("autoMode") { + pairs.append("vpn.tunnelInterfaces=") + } + return pairs + } + + /// The VPN config files to import, which are not a config key at all — they + /// become profiles through `dezhban vpn import`. + public var profileFiles: [String] { list("profileFiles") } +} + +/// When the first-run wizard should be offered. +/// +/// Split from the UserDefaults flag that feeds it so the rule itself is +/// testable: whether the flag is set is bookkeeping, but whether an unasked +/// user should be asked is a decision — and getting it wrong means an app that +/// looks like it forgot the setup someone already did. +public enum FirstRunDecision { + /// - Parameters: + /// - isComplete: whether this account has completed the wizard before. + /// - vpnKnown: whether dezhban already knows a VPN server, from an + /// endpoint or a profile. Not "are tunnel interfaces set": those are + /// legitimately empty on an autodetect config, which is the recommended + /// one. + public static func offer(isComplete: Bool, vpnKnown: Bool) -> Bool { + !isComplete && !vpnKnown + } +} diff --git a/gui/macos/Sources/DezhbanCore/Snapshot.swift b/gui/macos/Sources/DezhbanCore/Snapshot.swift index 633f684..32a1ee6 100644 --- a/gui/macos/Sources/DezhbanCore/Snapshot.swift +++ b/gui/macos/Sources/DezhbanCore/Snapshot.swift @@ -25,6 +25,29 @@ public struct SwitchState: Codable { public var isPause: Bool { trigger == "pause" } } +/// The moment a healthy tunnel went down — mirrors Go's `state.DropRecord`. +/// Carried from the drop until a tunnel is up again, +/// including across the redial window that follows, which is the only reason any +/// surface can report the drop at all. +/// +/// It carries the moment and nothing else — see Go's DropRecord for why a "was +/// traffic cut" companion flag could not be rendered truthfully. +public struct DropRecord: Codable { + public let at: Date +} + +/// "Hold the line" is armed — mirrors Go's `state.HoldState`. The next tunnel +/// drop stays cut instead of opening an automatic redial window, so a deliberate +/// disconnect does not get a relaxation the user never asked for. +/// +/// It only ever suppresses, so it is not a fourth relaxation trigger and the +/// icon rules are unaffected: it makes the RED state reachable rather than +/// introducing a new colour. +public struct HoldState: Codable { + public let armed: Bool + public let at: Date +} + /// The daemon's posture at a point in time — mirrors Go's `state.Snapshot`. /// JSON keys match the lowerCamelCase struct tags in internal/state/state.go. public struct Snapshot: Codable { @@ -50,9 +73,14 @@ public struct Snapshot: Codable { public let `switch`: SwitchState? // present only while a switch window is open public let pending: PendingFlip? // present only while a posture change is being counted toward public let display: Display? // the rendered sentence — nil from an older daemon + public let drop: DropRecord? // present from a tunnel drop until a tunnel is up again + public let hold: HoldState? // present only while "hold the line" is armed /// Wall-clock age of this snapshot. public var age: TimeInterval { Date().timeIntervalSince(time) } + + /// The next VPN drop will stay cut rather than opening a redial window. + public var holdArmed: Bool { hold?.armed ?? false } } /// The rendered form of a Snapshot — mirrors Go's `render.Display` (carried via diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index b5fee27..507a294 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -7,10 +7,14 @@ import DezhbanCore /// main window renders from; the dropdown is rebuilt from the current snapshot /// each time it opens. /// -/// The dropdown deliberately carries only the emergency/time-critical set — -/// status line, Open Dezhban, Block/Unblock, the switch window, Panic, Quit. -/// Panic and Block must work even if the main window can't open, so they never -/// move behind it. Everything else lives in the window (MainWindow/MainView). +/// The dropdown is a glance and the handful of actions that are time-critical +/// at the moment you reach for it: the status line, Open Dezhban, the switch +/// window with its countdown, Pause, hold the line, and Quit — plus Panic +/// behind ⌥, which must work even if the main window cannot open, so it never +/// moves behind it. Manual block and unblock are deliberately NOT here: +/// somebody who wants to cut their own internet can turn off Wi-Fi, so blocking +/// by hand is a power-user affordance and lives in the window's Overview with +/// everything else (MainWindow/MainView). final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var statusItem: NSStatusItem! private let menu = NSMenu() @@ -60,6 +64,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } AppState.shared.refreshServiceState() AppState.shared.checkForUpdates() + AppState.shared.offerFirstRunIfNeeded() timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in self?.refresh() } @@ -235,20 +240,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { let open = addAction("Open Dezhban…", #selector(openMainWindow)) open.keyEquivalent = "o" + open.toolTip = "Everything else — settings, diagnostics, manual block, logs, help. Hold ⌥ for Panic." + + // Panic is the lockout escape hatch: it must never depend on the main + // window opening, so it keeps a menubar item. It sits behind ⌥ as the + // alternate to "Open Dezhban…" — one keystroke away in a fixed place, + // but not one slip away from a menu people open to check a countdown. + // + // An alternate must be adjacent to its sibling and share its key + // equivalent, differing only by the extra modifier: ⌘O opens the window, + // ⌥ swaps the item, ⌘⌥O panics. + let panic = addAction("Panic — force unblock…", #selector(confirmPanic), + enabled: DezhbanCLI.binaryPath() != nil) + panic.keyEquivalent = "o" + panic.keyEquivalentModifierMask = [.command, .option] + panic.isAlternate = true + panic.toolTip = "Removes every rule dezhban installed. Works with no daemon running." menu.addItem(.separator()) - // Manual block / unblock, gated on current posture. These go to the running - // daemon over its control socket, so they normally need no password — say so, - // and say the opposite when they'd have to fall back to a direct root action. - let blocked = s?.blocked ?? false - // With the guard holding a downed tunnel, Unblock doubles as the - // "my VPN is off on purpose — release the line" action. - let guardHolds = isRunning && PostureUI.guardHoldsDownedTunnel(s) - addAction("Block now", #selector(blockNow), enabled: isRunning && !blocked) - .toolTip = AppState.shared.routineHint("Cuts all egress and holds it until you unblock.") - addAction("Unblock", #selector(unblockNow), enabled: isRunning && (blocked || guardHolds)) - .toolTip = AppState.shared.routineHint("Releases a manual block and resumes monitoring.") + // Manual block and unblock are NOT here. Somebody who wants to cut their + // own internet can turn off Wi-Fi; blocking by hand is a power-user and + // debugging affordance, and it lives in the window's Overview with the + // rest of them. What stays is what is time-critical at the moment you + // reach for the menubar. // Switch window: connect a brand-new VPN whose server isn't known yet. // Time-critical mid-flow, and the countdown is glanceable — so it stays. @@ -275,18 +290,53 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // down, so pauseIsEnabled is true in exactly that case. let pauseAllowed = AppState.shared.pauseIsEnabled let pauseEnabled = isRunning && pauseAllowed - addAction("Pause — use my real IP", #selector(pauseNow), enabled: pauseEnabled) - .toolTip = { - if !pauseAllowed { return "Disabled — vpn.pauseMax is \"0\" in your config." } - if !isRunning { return "Unavailable — dezhban isn’t running. Start it first." } - return AppState.shared.routineHint( - "Deliberately drops to your real ISP IP, then re-arms the guard automatically.") - }() - } + let pauseItem = addAction("Pause — use my real IP", #selector(pauseNow), enabled: pauseEnabled) + pauseItem.toolTip = { + if !pauseAllowed { return "Disabled — vpn.pauseMax is \"0\" in your config." } + if !isRunning { return "Unavailable — dezhban isn’t running. Start it first." } + return AppState.shared.routineHint( + "Deliberately drops to your real ISP IP, then re-arms the guard automatically.") + }() + // The offered lengths come from the daemon (`pause --list --json`), + // so this menu and `dezhban pause --list` can never offer different + // choices. Clicking the parent still pauses for the built-in + // default; the submenu is for picking deliberately. + if pauseEnabled, let options = AppState.shared.pauseOptions, !options.isEmpty { + let submenu = NSMenu() + for option in options { + let item = NSMenuItem(title: option.label, + action: #selector(pauseForOption(_:)), + keyEquivalent: "") + item.target = self + item.representedObject = option.value + // An over-cap length is shown disabled with the reason, + // never hidden and never quietly shortened — a cap you + // cannot see is one you keep bumping into. + item.isEnabled = option.isAvailable + item.toolTip = option.isAvailable ? option.why : option.unavailable + submenu.addItem(item) + } + submenu.autoenablesItems = false + pauseItem.submenu = submenu + } - // Panic is the lockout escape hatch: it must never depend on the main - // window opening, so it keeps a first-class menubar item. - addAction("Panic — force unblock…", #selector(confirmPanic), enabled: DezhbanCLI.binaryPath() != nil) + // The mirror image of Pause, and placed beside it so the pair reads + // as the two answers to "I am about to change my VPN situation": + // pause = let me use my real IP, hold the line = keep me cut. + // It only ever suppresses a relaxation, so it is safe to offer + // whenever the daemon is running. + if s?.holdArmed ?? false { + addAction("Don’t hold the line", #selector(cancelHold), enabled: isRunning) + .toolTip = "Armed — the next VPN drop stays cut. Choose this to let it redial normally instead." + } else { + addAction("Hold the line — keep me cut", #selector(holdLine), enabled: isRunning) + .toolTip = isRunning + ? AppState.shared.routineHint( + "For when YOU are disconnecting: the next VPN drop stays cut instead of " + + "opening a window so it can redial.") + : "Unavailable — dezhban isn’t running. Start it first." + } + } menu.addItem(.separator()) addAction("Quit", #selector(quit)) @@ -314,12 +364,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // Routine posture ops: handled by the running daemon over its control socket, // with no password — semantics in AppActions.routine (refusals never escalate). - @objc private func blockNow() { AppActions.routine(["block"], "block") } - @objc private func unblockNow() { AppActions.routine(["unblock"], "unblock") } @objc private func openSwitch() { AppActions.routine(["switch", "--no-wait"], "open a switch window") } @objc private func cancelSwitch() { AppActions.routine(["switch", "--cancel"], "cancel the switch window") } @objc private func pauseNow() { AppActions.routine(["pause"], "pause the guard") } @objc private func resumeNow() { AppActions.routine(["resume"], "resume the guard") } + /// Pauses for a length chosen from the submenu. The value travels verbatim + /// from the daemon's own list, so the app never formats a duration itself. + @objc private func pauseForOption(_ sender: NSMenuItem) { + guard let value = sender.representedObject as? String else { return } + AppActions.routine(["pause", value], "pause the guard for \(sender.title)") + } + @objc private func holdLine() { AppActions.routine(["hold"], "hold the line") } + @objc private func cancelHold() { AppActions.routine(["hold", "--cancel"], "cancel hold the line") } /// Menubar panic: confirmation, then a direct privileged run with the result /// in an NSAlert (scrollable transcript) — deliberately NOT routed through diff --git a/gui/macos/Sources/DezhbanMenu/AppState.swift b/gui/macos/Sources/DezhbanMenu/AppState.swift index ad332e7..4425217 100644 --- a/gui/macos/Sources/DezhbanMenu/AppState.swift +++ b/gui/macos/Sources/DezhbanMenu/AppState.swift @@ -4,7 +4,7 @@ import DezhbanCore /// The main window's sidebar sections. enum SidebarSection: String, CaseIterable, Identifiable { - case overview, diagnostics, settings, logs, about + case overview, diagnostics, settings, help, logs, about var id: String { rawValue } @@ -13,6 +13,10 @@ enum SidebarSection: String, CaseIterable, Identifiable { case .overview: return "Overview" case .diagnostics: return "Diagnostics" case .settings: return "Settings" + // The repo's own docs, rendered into the app at build time — see + // internal/help. Sits next to Settings because that is where a reader + // asking "what does this setting cost me?" is standing. + case .help: return "Help" // Structured findings moved to the Diagnostics pane above; this pane // is transcripts only now (panic, install, config apply, `log` output). case .logs: return "Logs" @@ -25,6 +29,7 @@ enum SidebarSection: String, CaseIterable, Identifiable { case .overview: return "shield.lefthalf.filled" case .diagnostics: return "stethoscope" case .settings: return "gearshape" + case .help: return "book" case .logs: return "text.alignleft" case .about: return "info.circle" } @@ -113,10 +118,27 @@ final class AppState: ObservableObject { /// Advisory only, same convention as `controlIsReachable` — `dezhban pause` /// still refuses for real if this cache is stale. @Published var pauseIsEnabled = true + /// The pause lengths the daemon offers, and which of them this host's + /// vpn.pauseMax allows (nil: not yet read, or the CLI is too old to know + /// `pause --list`). Cached so building the menu never shells out — a + /// shell-out while the menu is opening would stall it. + /// + /// Read from the daemon rather than listed here so the menu and + /// `dezhban pause --list` cannot offer different choices. + @Published var pauseOptions: [PauseOption]? /// The configured VPN profiles (nil: not yet read, or config unreadable). /// Lives in config, not the daemon's Snapshot — see DezhbanCLI.readProfiles. @Published var profiles: ProfilesInfo? @Published var selectedSection: SidebarSection? = .overview + /// A page (and optionally a heading) the Help pane should open next — + /// how a contextual link from Settings names where it wants to land. The + /// pane consumes it and sets it back to nil, so selecting Help again later + /// does not jump back to a link the reader has moved on from. + @Published var helpTarget: HelpTarget? + /// Whether the first-run wizard is on screen. Presented as a sheet over the + /// main window, so there is one window and the wizard cannot be lost behind + /// it. + @Published var showFirstRun = false /// Last update check result (nil: none run yet, or the last one found /// nothing worth reporting — see UpdateChecker.check's doc comment on why /// a failure never surfaces as an error here). @@ -126,6 +148,38 @@ final class AppState: ObservableObject { var isLive: Bool { PostureUI.isLive(snapshot) } + /// Offers the first-run wizard when this account has never completed it AND + /// nothing is configured yet. + /// + /// The check is "does dezhban know a VPN server yet", not "is there a config + /// file": someone who set dezhban up from the CLI has already answered these + /// questions, and asking again the first time they open the app would look + /// like it had forgotten. An endpoint or a profile is the signal, because + /// tunnel interfaces are legitimately empty on an autodetect config — the + /// recommended one. Opens the window too: a fresh install with nothing + /// configured is the one launch where a window is the point. + func offerFirstRunIfNeeded() { + guard !FirstRun.isComplete, cliFound else { return } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let info = DezhbanCLI.readProfiles() + let configured = !(info?.defaultEndpoints.isEmpty ?? true) || !(info?.profiles.isEmpty ?? true) + DispatchQueue.main.async { + guard FirstRun.shouldOffer(vpnKnown: configured) else { return } + self?.showFirstRun = true + MainWindow.shared.open() + } + } + } + + /// Opens the Help pane at a specific place in the documentation, named the + /// way a `Tunable`'s docAnchor writes it ("usage/config.md#fields"). + /// A docAnchor that names nothing bundled still opens the pane — better a + /// reader lands in the documentation than on a dead control. + func openHelp(docAnchor: String) { + helpTarget = HelpTarget(docAnchor: docAnchor) + selectedSection = .help + } + /// Routes a finished transcript into the Logs pane and /// navigates there — the window-side output surface for long actions. func showInLogs(title: String, text: String) { @@ -160,11 +214,15 @@ final class AppState: ObservableObject { // control socket independently on top of that). let status = DezhbanCLI.readStatusJSON() let profiles = DezhbanCLI.readProfiles() + // Read here, with the rest, so the menu can build from a cache + // instead of shelling out while it is opening. + let pauseOptions = DezhbanCLI.readPauseOptions() DispatchQueue.main.async { self?.serviceIsInstalled = status?.serviceInstalled ?? false self?.controlIsReachable = status?.controlReachable ?? false self?.pauseIsEnabled = status?.pauseEnabled ?? false self?.profiles = profiles + self?.pauseOptions = pauseOptions } } } diff --git a/gui/macos/Sources/DezhbanMenu/ConfigApply.swift b/gui/macos/Sources/DezhbanMenu/ConfigApply.swift index 48bdae4..e13c598 100644 --- a/gui/macos/Sources/DezhbanMenu/ConfigApply.swift +++ b/gui/macos/Sources/DezhbanMenu/ConfigApply.swift @@ -11,11 +11,16 @@ enum ConfigApply { /// on-disk file is the source of truth, never a cached second-schema mirror. /// Short-circuits on the first failure so an error string can never be seeded /// into a field (and later written back as a value by Apply). Calls back on - /// the main queue with the resolved path, the values (in key order), or nil - /// plus the failure text — the one resolution done here is also the one the - /// caller needs for display, so it never has to resolve a second time itself. + /// the main queue with the resolved path, the values **keyed by the key each + /// was read for**, or nil plus the failure text — the one resolution done + /// here is also the one the caller needs for display, so it never has to + /// resolve a second time itself. + /// + /// Keyed rather than in key order: a positional result is only correct while + /// every consumer agrees on the ordering, and that agreement is exactly what + /// used to be able to break silently. static func seed(keys: [String], - completion: @escaping (_ path: String, _ values: [String]?, _ error: String?) -> Void) { + completion: @escaping (_ path: String, _ values: [String: String]?, _ error: String?) -> Void) { DispatchQueue.global(qos: .userInitiated).async { // Resolved HERE, not on the caller's main thread: resolving shells out, // and a shell-out on the main thread spins the run loop (DezhbanCLI.exec). @@ -27,7 +32,10 @@ enum ConfigApply { completion(cfgPath, nil, "Failed to read \(failed.key): \(detail)") return } - completion(cfgPath, results.map { $0.result.output.trimmingCharacters(in: .whitespacesAndNewlines) }, nil) + let values = Dictionary(uniqueKeysWithValues: results.map { + ($0.key, $0.result.output.trimmingCharacters(in: .whitespacesAndNewlines)) + }) + completion(cfgPath, values, nil) } } } diff --git a/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift b/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift index ce2c449..37e8593 100644 --- a/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift +++ b/gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift @@ -284,6 +284,46 @@ enum DezhbanCLI { return PresetSummary.decodeList(data) } + /// Reads what every settable key IS — its label, default, cap, whether it + /// can be turned off — via `config schema --json`. + /// + /// Deliberately passes no `--config`: the schema describes the keys, not this + /// host's values, so it answers the same before anything is configured. That + /// is what lets a first-run surface label its fields. + /// + /// Returns nil against a CLI too old to know the subcommand, which callers + /// must degrade gracefully for — a missing schema means less helpful hints, + /// never wrong ones. + static func readSchema() -> ConfigSchema? { + guard let bin = binaryPath() else { return nil } + let r = exec(bin, ["config", "schema", "--json"]) + guard r.status == 0, let data = r.out.data(using: .utf8) else { return nil } + return ConfigSchema.decode(data) + } + + /// Reads the setup wizard's questions — what it asks, in what order, gated + /// how, seeded from THIS host's config — via `setup --questions --json`. + /// Read-only, no root, and it asks nothing. + /// + /// Passes `--config` deliberately, unlike `readSchema`: the schema describes + /// keys in the abstract, but a wizard's job is to start from what is already + /// configured, so it must be seeded from the real file. + static func readSetupQuestions() -> [SetupQuestion]? { + guard let bin = binaryPath() else { return nil } + let r = exec(bin, ["setup", "--questions", "--json", "--config", resolvedConfigPath()]) + guard r.status == 0, let data = r.out.data(using: .utf8) else { return nil } + return SetupQuestion.decodeList(data) + } + + /// Reads the offered pause lengths and which of them this host's + /// vpn.pauseMax allows, via `pause --list --json`. Read-only, no root. + static func readPauseOptions() -> [PauseOption]? { + guard let bin = binaryPath() else { return nil } + let r = exec(bin, ["pause", "--list", "--json", "--config", resolvedConfigPath()]) + guard r.status == 0, let data = r.out.data(using: .utf8) else { return nil } + return PauseOption.decodeList(data) + } + /// Reads the keys that differ from `name` (or, if nil, the /// matched-or-nearest preset — the same default `preset diff` uses), /// via `config preset diff [name] --json`. diff --git a/gui/macos/Sources/DezhbanMenu/DurationField.swift b/gui/macos/Sources/DezhbanMenu/DurationField.swift new file mode 100644 index 0000000..34e2644 --- /dev/null +++ b/gui/macos/Sources/DezhbanMenu/DurationField.swift @@ -0,0 +1,125 @@ +import SwiftUI +import DezhbanCore + +/// A duration setting as a menu of real choices, with **Off** where that is a +/// real choice and a Custom entry for anything else. +/// +/// It replaces a bare text field that required knowing Go's duration syntax, and +/// whose only feedback was a modal after Apply. Every value it offers comes from +/// the daemon's schema — the key's own default, its live cap, and whether `"0"` +/// is a persisted opt-out — so nothing here holds an opinion about any +/// particular setting. +struct DurationField: View { + let key: String + /// Shown when the schema is unavailable: names the concept, states no value. + let fallbackLabel: String + let schema: ConfigSchema? + /// The pane's staged values, used to resolve this key's cap to whatever the + /// operator has actually set — not to the cap's own default. + let values: [String: String] + @Binding var text: String + let enabled: Bool + + /// True while the user is typing a value that is not one of the choices. + @State private var isCustom = false + + private var tunable: ConfigTunable? { schema?[key] } + private var label: String { tunable?.label ?? fallbackLabel } + + private var choices: [DurationChoices.Choice] { + guard let tunable else { return [] } + return DurationChoices.build(defaultValue: tunable.defaultValue, + cap: schema?.cap(for: key, in: values), + disablable: tunable.disablable) + } + + /// The choice the current value corresponds to, or nil when it is custom. + private var selected: DurationChoices.Choice? { + if DurationChoices.isOff(text) { + return choices.first(where: \.isOff) + } + guard let secs = DurationChoices.seconds(text) else { return nil } + return choices.first { DurationChoices.seconds($0.value) == secs } + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(label) + Spacer() + if choices.isEmpty { + // No schema, or nothing sensible to derive: fall back to the + // plain field rather than an empty menu. + TextField("", text: $text).frame(width: 160) + } else { + menu + } + } + if isCustom || (selected == nil && !text.isEmpty) { + HStack { + Spacer() + TextField("e.g. 45s, 2m30s", text: $text) + .frame(width: 160) + // Immediate, quiet feedback — the old design let a typo + // through to a modal alert after Apply had already been + // clicked. + Image(systemName: valid ? "checkmark.circle" : "exclamationmark.triangle.fill") + .foregroundStyle(valid ? Color.secondary : Color.orange) + .help(valid ? "A valid duration" : "Not a duration — try 45s, 2m30s, 1h") + } + } + if let consequence { + Text(consequence) + .font(.callout) + .foregroundStyle(.secondary) + } + } + .disabled(!enabled) + } + + private var valid: Bool { + DurationChoices.isOff(text) || DurationChoices.seconds(text) != nil + } + + @ViewBuilder + private var menu: some View { + Menu(selected?.label ?? (text.isEmpty ? "Choose…" : "Custom: \(text)")) { + ForEach(choices) { choice in + Button { + text = choice.value + isCustom = false + } label: { + Text(choice.isDefault ? "\(choice.label) (recommended)" : choice.label) + } + } + Divider() + Button("Custom…") { isCustom = true } + } + .frame(width: 200) + .help(tunable?.help ?? "") + } + + /// What this setting costs, stated where the choice is made rather than in a + /// tooltip nobody hovers. Off is the case worth spelling out: for these keys + /// it is a real, persisted decision with a real consequence. + private var consequence: String? { + guard let tunable else { return nil } + if DurationChoices.isOff(text) { + switch key { + case "vpn.switchWindow": + return "Off — nothing you type can relax the guard. Adding a new VPN means " + + "entering its server address by hand first." + case "vpn.redialWindow": + return "Off — a dropped VPN stays cut until it redials on its own. " + + "No exposure, but no automatic help either." + case "vpn.pauseMax": + return "Off — pausing is unavailable, so there is no way to use your real IP on purpose." + case "vpn.advanced.redialMinUptime": + return "Off — a flapping VPN can open a redial window on every drop." + default: + return "Off." + } + } + return tunable.help + } +} diff --git a/gui/macos/Sources/DezhbanMenu/FirstRunView.swift b/gui/macos/Sources/DezhbanMenu/FirstRunView.swift new file mode 100644 index 0000000..29d790f --- /dev/null +++ b/gui/macos/Sources/DezhbanMenu/FirstRunView.swift @@ -0,0 +1,284 @@ +import AppKit +import SwiftUI +import DezhbanCore + +/// The first-run wizard: the same questions `dezhban setup` asks, rendered +/// natively and answered without a terminal. +/// +/// It asks the daemon what to ask (`setup --questions --json`) rather than +/// keeping its own list, so the two wizards cannot drift; it writes through the +/// same batched, validated `config set` every other pane uses, so there is one +/// write path and one validation authority; and it never shells out to the huh +/// wizard, which needs a TTY the app does not have. +struct FirstRunView: View { + @EnvironmentObject var state: AppState + /// Called when the sheet should close, with whether anything was written. + let done: (Bool) -> Void + + @State private var questions: [SetupQuestion] = [] + @State private var answers = SetupAnswers(questions: []) + @State private var groupIndex = 0 + @State private var busy = false + @State private var error: String? + @State private var loaded = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + content + Divider() + footer + } + .frame(width: 560, height: 520) + .onAppear(perform: load) + } + + // MARK: - Chrome + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Set up dezhban").font(.title2.weight(.semibold)) + Text("dezhban blocks everything that isn’t your VPN. These are the same questions `dezhban setup` asks — you can change any of them later in Settings.") + .font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + .padding(16) + } + + @ViewBuilder + private var content: some View { + if let error { + guided(symbol: "exclamationmark.triangle", title: "Couldn’t start setup", message: error) + } else if !loaded { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if questions.isEmpty { + guided(symbol: "questionmark.circle", title: "Nothing to ask", + message: "This copy of the dezhban CLI is too old to describe its setup questions. Run `sudo dezhban setup` in a terminal instead.") + } else { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + ForEach(currentQuestions) { q in + question(q) + } + } + .padding(16) + } + } + } + + private var footer: some View { + HStack { + if busy { ProgressView().controlSize(.small) } + Text(stepLabel).font(.callout).foregroundStyle(.secondary) + Spacer() + Button("Not now") { done(false) } + .disabled(busy) + Button(isLastStep ? "Save and finish" : "Continue") { advance() } + .keyboardShortcut(.defaultAction) + .disabled(busy || questions.isEmpty || error != nil || !stepIsValid) + } + .padding(16) + } + + private var stepLabel: String { + guard !visibleGroups.isEmpty else { return "" } + return "Step \(min(groupIndex + 1, visibleGroups.count)) of \(visibleGroups.count)" + } + + private func guided(symbol: String, title: String, message: String) -> some View { + VStack(spacing: 12) { + Image(systemName: symbol).font(.system(size: 36)).foregroundStyle(.secondary) + Text(title).font(.title3.weight(.semibold)) + Text(message).multilineTextAlignment(.center).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(24) + } + + // MARK: - Questions + + /// The groups that still have something to ask, recomputed as answers + /// change — a gate closing removes a whole step rather than showing an + /// empty one. + private var visibleGroups: [Int] { + var seen = Set() + var out: [Int] = [] + for q in questions where answers.shouldAsk(q) { + if seen.insert(q.group).inserted { out.append(q.group) } + } + return out.sorted() + } + + private var currentQuestions: [SetupQuestion] { + guard groupIndex < visibleGroups.count else { return [] } + let group = visibleGroups[groupIndex] + return questions.filter { $0.group == group && answers.shouldAsk($0) } + } + + private var isLastStep: Bool { groupIndex >= visibleGroups.count - 1 } + + @ViewBuilder + private func question(_ q: SetupQuestion) -> some View { + VStack(alignment: .leading, spacing: 6) { + switch q.kind { + case SetupKind.bool: + Toggle(q.title, isOn: boolBinding(q.id)) + case SetupKind.select: + Picker(q.title, selection: stringBinding(q.id)) { + ForEach(q.options) { Text($0.label).tag($0.value) } + } + case SetupKind.multiSelect: + Text(q.title).font(.body.weight(.medium)) + ForEach(q.options) { opt in + Toggle(opt.label, isOn: memberBinding(q.id, opt.value)) + .toggleStyle(.checkbox) + } + default: + Text(q.title).font(.body.weight(.medium)) + TextField(q.id == "profileFiles" ? "Optional" : "", text: stringBinding(q.id)) + if q.id == "profileFiles" { + Button("Choose files…") { chooseProfileFiles() } + .controlSize(.small) + } + } + if !q.description.isEmpty { + Text(q.description).font(.callout).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if q.kind == SetupKind.duration, !DurationText.looksLikeGoDuration(answers[q.id]) { + Text("Enter a duration like 30s or 5m.").font(.callout).foregroundStyle(.red) + } + } + } + + private func stringBinding(_ id: String) -> Binding { + Binding(get: { answers[id] }, set: { answers[id] = $0 }) + } + + private func boolBinding(_ id: String) -> Binding { + Binding(get: { answers.bool(id) }, set: { answers[id] = $0 ? "true" : "false" }) + } + + /// A checkbox over one member of a comma-separated list answer. + private func memberBinding(_ id: String, _ value: String) -> Binding { + Binding( + get: { answers.list(id).contains(value) }, + set: { on in + var list = answers.list(id) + if on { + if !list.contains(value) { list.append(value) } + } else { + list.removeAll { $0 == value } + } + answers[id] = list.joined(separator: ",") + }) + } + + private func chooseProfileFiles() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.message = "Choose WireGuard (.conf), OpenVPN (.ovpn), or V2Ray (.json) files to import as profiles." + guard panel.runModal() == .OK else { return } + let paths = panel.urls.map(\.path) + answers["profileFiles"] = paths.joined(separator: ",") + } + + // MARK: - Flow + + private func load() { + guard !loaded else { return } + DispatchQueue.global(qos: .userInitiated).async { + let qs = DezhbanCLI.readSetupQuestions() + DispatchQueue.main.async { + loaded = true + guard let qs else { + error = DezhbanCLI.binaryPath() == nil + ? "The dezhban command-line tool isn’t installed, so there is nothing to configure yet." + : "`dezhban setup --questions` failed. Run `sudo dezhban setup` in a terminal instead." + return + } + questions = qs + answers = SetupAnswers(questions: qs) + } + } + } + + /// True when every duration on this step parses. A bad value blocks the + /// step it was typed on, while the field saying why is still on screen — + /// rather than surfacing as a modal after the last question. + private var stepIsValid: Bool { + !currentQuestions.contains { + $0.kind == SetupKind.duration && !DurationText.looksLikeGoDuration(answers[$0.id]) + } + } + + private func advance() { + guard stepIsValid else { return } + guard isLastStep else { + groupIndex += 1 + return + } + save() + } + + /// Writes every answer through ONE batched `config set` — the same + /// validated, token-gated path the Settings pane uses, so the wizard has no + /// write logic of its own and cannot bypass `config.Validate`. + /// + /// Named VPN config files are imported afterwards, because a profile is not + /// a config key: `vpn import` parses the file. That step is privileged and + /// asks separately; the config is already saved by then, so cancelling the + /// prompt loses the import, not the setup. + private func save() { + let pairs = answers.configPairs(for: questions) + guard !pairs.isEmpty else { + done(false) + return + } + busy = true + ConfigApply.apply(pairs: pairs, awaitPosture: false, title: "Setup") { outcome in + busy = false + guard outcome.ok else { + error = outcome.status + if let transcript = outcome.transcript, let title = outcome.transcriptTitle { + state.showInLogs(title: title, text: transcript) + } + return + } + let files = answers.profileFiles + FirstRun.markComplete() + guard !files.isEmpty else { + done(true) + return + } + AppActions.capturedSequence(files.map { ["vpn", "import", $0] }) { result in + if !result.ok { + state.showInLogs(title: "Setup — profile import failed", text: result.output) + } + done(true) + } + } + } +} + +/// Whether the first-run wizard has been offered on this Mac. +/// +/// A flag in UserDefaults, not in the config: the config belongs to the daemon +/// and is shared by every surface, while "has this user seen the wizard" is a +/// fact about this app on this account. Marked complete only after a successful +/// write, so a cancelled or failed setup is offered again. +enum FirstRun { + private static let key = "dezhban.firstRunCompleted" + + static var isComplete: Bool { UserDefaults.standard.bool(forKey: key) } + + static func markComplete() { UserDefaults.standard.set(true, forKey: key) } + + /// Offer the wizard when it has never been completed AND dezhban does not + /// know a VPN server yet. The rule lives in DezhbanCore so it is testable; + /// this only supplies the flag. + static func shouldOffer(vpnKnown: Bool) -> Bool { + FirstRunDecision.offer(isComplete: isComplete, vpnKnown: vpnKnown) + } +} diff --git a/gui/macos/Sources/DezhbanMenu/HelpView.swift b/gui/macos/Sources/DezhbanMenu/HelpView.swift new file mode 100644 index 0000000..4f3fd58 --- /dev/null +++ b/gui/macos/Sources/DezhbanMenu/HelpView.swift @@ -0,0 +1,334 @@ +import AppKit +import SwiftUI +import WebKit +import DezhbanCore + +/// The Help pane: dezhban's own documentation, rendered at build time by +/// `tools/helpgen` and read from inside the app bundle. +/// +/// It never touches the network. That is the whole point — the moment someone +/// most needs to know what the guard is doing to their traffic is often the +/// moment it has cut all of it, and a help pane that needs a working connection +/// would be blank exactly then. +struct HelpView: View { + @EnvironmentObject var state: AppState + + @State private var bundle: HelpBundle? = HelpBundle.bundled() + @State private var query = "" + @State private var selection: HelpPage.ID? + /// The heading to land on after the next load, from a contextual deep link. + @State private var pendingAnchor: String? + /// A link the page tried to follow that leaves the bundle. Shown rather + /// than silently dropped, so a dead-feeling click has an explanation. + @State private var blockedLink: URL? + + var body: some View { + Group { + if let bundle { + browser(bundle) + } else { + unavailable + } + } + .navigationTitle("Help") + .onAppear { openPendingTarget() } + .onChange(of: state.helpTarget) { _ in openPendingTarget() } + } + + // MARK: - Browser + + private func browser(_ bundle: HelpBundle) -> some View { + HSplitView { + sidebar(bundle) + .frame(minWidth: 200, idealWidth: 240, maxWidth: 340) + VStack(spacing: 0) { + if let page = current(bundle) { + HelpWebView(url: bundle.url(for: page), + readAccess: bundle.directory, + anchor: $pendingAnchor, + blockedLink: $blockedLink, + follow: { follow($0, in: bundle) }) + } else { + placeholder + } + if let blockedLink { + externalLinkNote(blockedLink) + } + } + .frame(minWidth: 420) + } + } + + private func sidebar(_ bundle: HelpBundle) -> some View { + VStack(spacing: 0) { + TextField("Search the documentation", text: $query) + .textFieldStyle(.roundedBorder) + .padding(10) + Divider() + List(selection: $selection) { + if query.isEmpty { + // The guided track first, in reading order: someone opening + // Help for the first time needs a starting point, not an + // alphabetical index. + Section("Start here") { + ForEach(bundle.tutorial) { row($0) } + } + Section("Reference") { + ForEach(bundle.reference) { row($0) } + } + } else { + let hits = bundle.search(query) + if hits.isEmpty { + Text("Nothing matched “\(query)”.") + .foregroundStyle(.secondary) + .font(.callout) + } else { + ForEach(hits) { hit in + hitRow(hit) + } + } + } + } + .listStyle(.sidebar) + } + } + + private func row(_ page: HelpPage) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(page.title).font(.body) + Text(page.summary).font(.caption).foregroundStyle(.secondary) + } + .padding(.vertical, 2) + .tag(page.id) + } + + /// A search result is a button rather than a selectable row: choosing one + /// carries an anchor as well as a page, and List selection only carries the + /// page. + private func hitRow(_ hit: HelpHit) -> some View { + Button { + pendingAnchor = hit.anchor + selection = hit.page.id + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(hit.page.title).font(.body) + if !hit.context.isEmpty { + Text(hit.context).font(.caption).foregroundStyle(.secondary) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.vertical, 2) + } + + private var placeholder: some View { + VStack(spacing: 12) { + Image(systemName: "book").font(.system(size: 40)).foregroundStyle(.secondary) + Text("Pick a page").font(.title3.weight(.semibold)) + Text("Everything here ships inside the app, so it works even while dezhban has cut your traffic.") + .multilineTextAlignment(.center).foregroundStyle(.secondary).frame(maxWidth: 420) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(24) + } + + /// Shown when the app was built without its documentation — a `swift run` + /// outside a bundle, or an app from before the docs were bundled. Says so + /// plainly instead of showing an empty pane. + private var unavailable: some View { + VStack(spacing: 12) { + Image(systemName: "book.closed").font(.system(size: 40)).foregroundStyle(.secondary) + Text("No documentation in this build").font(.title3.weight(.semibold)) + Text("This copy of Dezhban was built without its bundled help. The same pages are in the repository under docs/.") + .multilineTextAlignment(.center).foregroundStyle(.secondary).frame(maxWidth: 420) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(24) + } + + private func externalLinkNote(_ url: URL) -> some View { + HStack(spacing: 8) { + Image(systemName: "arrow.up.forward.square").foregroundStyle(.secondary) + Text("That link points outside the app: \(url.absoluteString)") + .font(.callout).foregroundStyle(.secondary).lineLimit(2).textSelection(.enabled) + Spacer() + Button("Copy link") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url.absoluteString, forType: .string) + } + Button("Dismiss") { blockedLink = nil } + } + .padding(10) + .background(.quaternary.opacity(0.4)) + } + + // MARK: - Navigation + + private func current(_ bundle: HelpBundle) -> HelpPage? { + guard let selection else { return nil } + return bundle.page(source: selection) + } + + /// A cross-page link inside the pane moves the sidebar selection rather + /// than letting the web view navigate on its own — otherwise the sidebar + /// would keep highlighting a page the reader had already left. + private func follow(_ url: URL, in bundle: HelpBundle) { + let file = url.lastPathComponent + guard let page = bundle.pages.first(where: { $0.file == file }) else { return } + pendingAnchor = url.fragment + selection = page.id + } + + /// Consumes a deep link left by another pane (see `AppState.openHelp`). + /// Cleared once acted on, so re-selecting the Help pane later does not jump + /// back to a link the reader has moved on from. + private func openPendingTarget() { + guard let bundle else { return } + guard let target = state.helpTarget, let (page, anchor) = bundle.resolve(target) else { + if selection == nil { selection = bundle.tutorial.first?.id ?? bundle.pages.first?.id } + return + } + pendingAnchor = anchor + selection = page.id + state.helpTarget = nil + } +} + +private extension URL { + /// The same URL without its fragment — what tells "scroll within this page" + /// apart from "go to another page". + var deletingFragment: URL { + var c = URLComponents(url: self, resolvingAgainstBaseURL: false) + c?.fragment = nil + return c?.url ?? self + } + + /// The same URL pointing at a heading within the page. WebKit scrolls to it + /// on load, which is why the pane needs no script of its own. + func appendingFragment(_ fragment: String) -> URL { + var c = URLComponents(url: self, resolvingAgainstBaseURL: false) + c?.fragment = fragment + return c?.url ?? self + } +} + +/// A `WKWebView` restricted to the bundled documentation. +/// +/// Two rules make this safe to point at rendered HTML: read access is granted +/// only to the help directory, and the navigation delegate allows nothing but +/// `file:` URLs inside it. A page therefore cannot reach the network even if a +/// doc gains an external link — which matters because this pane exists to work +/// while the guard has cut traffic, and a silently hanging request would read +/// as the app being broken. +struct HelpWebView: NSViewRepresentable { + let url: URL + let readAccess: URL + @Binding var anchor: String? + @Binding var blockedLink: URL? + /// Called for a link to another bundled page, so the owning view can move + /// its selection instead of the web view navigating behind the sidebar. + let follow: (URL) -> Void + + func makeNSView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + // Nothing bundled runs script; the pane only ever scrolls to an anchor, + // which the coordinator does with a URL fragment and WebKit's own + // scrolling — no script is injected either (see `load`). + config.defaultWebpagePreferences.allowsContentJavaScript = false + let web = WKWebView(frame: .zero, configuration: config) + web.navigationDelegate = context.coordinator + // The bundled stylesheet follows the system appearance; matching the + // area behind it stops a white flash in dark mode between loads. + web.underPageBackgroundColor = .textBackgroundColor + context.coordinator.load(web, url: url, readAccess: readAccess, anchor: anchor) + return web + } + + func updateNSView(_ web: WKWebView, context: Context) { + context.coordinator.parent = self + context.coordinator.load(web, url: url, readAccess: readAccess, anchor: anchor) + } + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + final class Coordinator: NSObject, WKNavigationDelegate { + var parent: HelpWebView + /// What was last handed to the web view, fragment included. + private var loaded: URL? + + init(_ parent: HelpWebView) { + self.parent = parent + } + + /// Loads a page, landing on a heading when one was asked for. + /// + /// The anchor rides in the URL's fragment and WebKit does the scrolling, + /// rather than the app injecting a `scrollIntoView` — which would not + /// run anyway with content JavaScript off, and which would put script + /// into a pane that has none. + func load(_ web: WKWebView, url: URL, readAccess: URL, anchor: String?) { + let target = anchor.map { url.appendingFragment($0) } ?? url + defer { clearAnchor() } + guard target != loaded else { return } + loaded = target + web.loadFileURL(target, allowingReadAccessTo: readAccess) + } + + /// The anchor is one-shot: it is spent by the navigation it triggered. + /// Written back on the next runloop turn because clearing a `@Binding` + /// from inside `updateNSView` would mutate state during a view update. + private func clearAnchor() { + guard parent.anchor != nil else { return } + DispatchQueue.main.async { [parent] in parent.anchor = nil } + } + + /// Whether `url` is `root` itself or something beneath it. + /// + /// Compared by path COMPONENT, not by string prefix: a sibling whose + /// name merely starts with the same characters — "help-old" next to + /// "help" — satisfies a prefix test while being inside nothing. The + /// bundle is generated and has no such sibling today, which is exactly + /// why the check should not depend on that staying true. + static func isInside(_ url: URL, _ root: URL) -> Bool { + let here = url.standardizedFileURL.pathComponents + let base = root.standardizedFileURL.pathComponents + guard here.count >= base.count else { return false } + return Array(here.prefix(base.count)) == base + } + + func webView(_ web: WKWebView, + decidePolicyFor action: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard let target = action.request.url else { + decisionHandler(.cancel) + return + } + // Anything that is not a local file in the help directory is + // refused — no http(s), no custom scheme, no path outside the + // bundle. A refused link is reported so the pane can say what + // happened rather than appearing to ignore the click. + guard target.isFileURL, Coordinator.isInside(target, parent.readAccess) else { + let blocked = target + DispatchQueue.main.async { [parent] in parent.blockedLink = blocked } + decisionHandler(.cancel) + return + } + // A link to a DIFFERENT bundled page is handed back to the owning + // view, which moves its selection — the web view following it on + // its own would leave the sidebar highlighting a page the reader + // has left. A link within the page (a bare fragment) is allowed + // straight through: it is a scroll, not a navigation. + if action.navigationType == .linkActivated, + target.deletingFragment != loaded?.deletingFragment { + let followed = target + DispatchQueue.main.async { [parent] in parent.follow(followed) } + decisionHandler(.cancel) + return + } + decisionHandler(.allow) + } + } +} diff --git a/gui/macos/Sources/DezhbanMenu/MainView.swift b/gui/macos/Sources/DezhbanMenu/MainView.swift index 9a671ae..ef433e9 100644 --- a/gui/macos/Sources/DezhbanMenu/MainView.swift +++ b/gui/macos/Sources/DezhbanMenu/MainView.swift @@ -1,6 +1,6 @@ import SwiftUI -/// The main window's root: sidebar navigation over the five sections. The +/// The main window's root: sidebar navigation over the window's sections. The /// selection lives in AppState so actions elsewhere (e.g. a window-triggered /// panic) can navigate to the Logs pane programmatically. struct MainView: View { @@ -20,9 +20,22 @@ struct MainView: View { case .overview: OverviewView() case .diagnostics: DiagnosticsView() case .settings: SettingsView() + case .help: HelpView() case .logs: LogsView(console: state.console) case .about: AboutView() } } + // A sheet rather than a second window: the wizard is a step you are in, + // not a place you can leave open behind the thing it configures. + .sheet(isPresented: $state.showFirstRun) { + FirstRunView { saved in + state.showFirstRun = false + if saved { + state.refreshServiceState() + state.selectedSection = .overview + } + } + .environmentObject(state) + } } } diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 1645488..a176d00 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -28,11 +28,17 @@ struct SettingsView: View { @State private var fields = SettingsFields() - /// The values this pane was last seeded with, in `SettingsFields.keys` order. - /// Comparing the live fields against these is how an unsaved edit is told - /// from a pane that is merely displaying what is on disk — which decides - /// whether it is safe to re-read the file underneath the user. - @State private var seededValues: [String] = [] + /// The values this pane was last seeded with, keyed by config key. Comparing + /// the live fields against these is how an unsaved edit is told from a pane + /// that is merely displaying what is on disk — which decides whether it is + /// safe to re-read the file underneath the user. + @State private var seededValues: [String: String] = [:] + + /// What every key IS, read once from `config schema --json`. Nil until it + /// loads, and nil forever against a CLI too old to know the subcommand — + /// every use falls back to a plainer label rather than a hardcoded value, + /// because a wrong hint is worse than a terse one. + @State private var schema: ConfigSchema? private var hasUnsavedEdits: Bool { !seededValues.isEmpty && fields.currentValues != seededValues @@ -57,7 +63,64 @@ struct SettingsView: View { Section("Strictness preset") { presetPicker } - Section("Startup") { + // Ordered by what a person is actually deciding: which VPN to + // trust, what to block, when the guard may relax, and only then + // the machinery. Headings say the thing rather than the config + // block it lives in, per docs/concepts/glossary.md. + Section { + schemaField("vpn.tunnelInterfaces", "Your VPN tunnel (comma-separated)", + text: $fields.tunnelInterfaces) + schemaField("vpn.endpoints", "VPN server addresses (comma-separated)", text: $fields.endpoints) + schemaToggle("vpn.autoDetect", "Find my VPN tunnel automatically", isOn: $fields.autoDetect) + schemaToggle("vpn.autoDiscoverEndpoints", "Find the VPN server address automatically", + isOn: $fields.autoDiscover) + schemaToggle("vpn.autoArm", "Arm the guard when a VPN connects", isOn: $fields.autoArm) + } header: { + sectionHeader("Your VPN", + "Which tunnel the guard trusts. Leave the two fields empty and let " + + "dezhban find them — that is what most setups want.") + } + + Section { + schemaField("blockedCountries", "Blocked countries (comma-separated)", + text: $fields.blockedCountries) + durationField("pollInterval", "Exit country check interval", text: $fields.pollInterval) + } header: { + sectionHeader("What gets blocked", + "If your VPN surfaces in one of these countries, everything is cut " + + "until it moves.") + } + + Section { + durationField("vpn.switchWindow", "Switch window", text: $fields.switchWindow) + durationField("vpn.redialWindow", "Redial window", text: $fields.redialWindow) + durationField("vpn.pauseMax", "Longest pause", text: $fields.pauseMax) + } header: { + sectionHeader("When the guard relaxes", + "The only three ways traffic is ever let out around the guard. Each is " + + "bounded, re-arms itself, and can be turned Off entirely.") + } + + Section { + schemaToggle("vpn.allowLocalNetwork", "Keep local devices reachable", + isOn: $fields.allowLocalNetwork) + } header: { + sectionHeader("Local network", + "Printers, NAS, your router — reachable while the guard is armed. Local " + + "destinations only, so nothing on the internet is opened.") + } + + Section { + durationField("vpn.endpointRefresh", "VPN server address refresh", text: $fields.endpointRefresh) + durationField("vpn.endpointGrace", "VPN server address grace", text: $fields.endpointGrace) + durationField("vpn.tunnelWatch", "Tunnel check interval", text: $fields.tunnelWatch) + } header: { + sectionHeader("How closely dezhban watches", + "How quickly a dropped tunnel is noticed, and how long a known VPN " + + "server stays reachable so it can redial.") + } + + Section { Toggle("Start the guard at boot (install the system service)", isOn: bootBinding) .disabled(bootBusy || !state.cliFound) .help("Installs dezhban as a background system service: the guard starts at boot — " @@ -67,67 +130,20 @@ struct SettingsView: View { .help("Registers the app as a login item (System Settings → General → Login Items). " + "This is only the status display — the guard itself is the system service above.") Toggle("Notify on essential events", isOn: notifyBinding) - .help("macOS notifications for the transitions that matter: guard armed, egress " - + "blocked, warnings (enforcement error / switch window open), standby, stopped. " + .help("macOS notifications for the transitions that matter: guard armed, traffic " + + "cut, warnings (enforcement error / window open), standby, stopped. " + "Nothing else.") Toggle("Check for updates automatically", isOn: checkUpdatesBinding) .help("Checks GitHub for a newer release at launch and every ~24h — never from the " + "background service, only here, in this app, on this schedule. Turn off to stop this " + "host contacting GitHub about updates entirely; \"Check Now\" in About still " + "works either way.") + } header: { + sectionHeader("Startup and updates", + "These take effect immediately — they are actions, not settings, so " + + "Apply does not touch them.") } - Section("VPN guard") { - TextField("Your VPN tunnel (comma-sep)", text: $fields.tunnelInterfaces) - .disabled(!canApply) - TextField("Endpoints (comma-sep)", text: $fields.endpoints) - .disabled(!canApply) - } - Section("Autodetection") { - Toggle("Find my VPN tunnel automatically", isOn: $fields.autoDetect) - .disabled(!canApply) - Toggle("Auto-discover endpoints (vpn.autoDiscoverEndpoints)", isOn: $fields.autoDiscover) - .disabled(!canApply) - Toggle("Auto-arm when a VPN connects (vpn.autoArm)", isOn: $fields.autoArm) - .disabled(!canApply) - .help("With no VPN connected dezhban idles in standby (nothing blocked) and arms " - + "the guard the moment a tunnel appears. It never disarms on a drop — that's the " - + "kill switch — only an explicit Unblock with the VPN off returns to standby.") - } - Section("Local network") { - Toggle("Keep local devices reachable", isOn: $fields.allowLocalNetwork) - .disabled(!canApply) - .help("Printers, NAS, your router's admin page, AirPlay and Chromecast, and local " - + "dev servers keep working while the guard is armed. This is not a hole in the " - + "kill switch: it allows local destinations only, so anything on the internet " - + "stays blocked. The one cost is on untrusted Wi-Fi (a café, a hotel), where it " - + "also lets other devices on that network reach you.") - } - Section("Blocking") { - TextField("Blocked countries (comma-sep, e.g. IR,RU,KP)", text: $fields.blockedCountries) - .disabled(!canApply) - TextField("Geo IP lookup interval (e.g. 15s)", text: $fields.pollInterval) - .disabled(!canApply) - .help("How often the current VPN exit's country is checked.") - } - Section("Windows") { - TextField("Switch window (e.g. 5s)", text: $fields.switchWindow) - .disabled(!canApply) - .help("Manual switch window (`dezhban switch`): 0 disables it, otherwise up to 3m.") - TextField("Redial window (e.g. 30s)", text: $fields.redialWindow) - .disabled(!canApply) - .help("Automatic window opened when a healthy tunnel drops, so the VPN client can " - + "redial: 0 disables it, otherwise up to 10m.") - TextField("Endpoint grace (e.g. 15m)", text: $fields.endpointGrace) - .disabled(!canApply) - .help("How long a discovered VPN server stays reachable after its connection " - + "disappears, so a dropped VPN can redial the same server.") - } - Section("Timing") { - TextField("Endpoint refresh (e.g. 30s)", text: $fields.endpointRefresh) - .disabled(!canApply) - TextField("Tunnel watch (e.g. 5s)", text: $fields.tunnelWatch) - .disabled(!canApply) - } + Section("Authorization") { Toggle("Use Touch ID for settings changes", isOn: tokenBinding) .disabled(tokenBusy || !biometryAvailable) @@ -144,40 +160,28 @@ struct SettingsView: View { } Section { DisclosureGroup("Advanced") { - Text("Touch only if you know why — these override recommended defaults.") + Text("Touch only if you know why. These override recommended defaults, and the " + + "three caps below bound how much exposure the settings above can ever " + + "cause — lowering one narrows the choices they offer.") .font(.callout) .foregroundStyle(.secondary) - TextField("Manual switch window cap (e.g. 3m)", text: $fields.advSwitchWindowMax) - .disabled(!canApply) - TextField("Redial window cap (e.g. 10m)", text: $fields.advRedialWindowMax) - .disabled(!canApply) - TextField("Redial anti-flap uptime (e.g. 15s; 0 disables the gate)", text: $fields.advRedialMinUptime) - .disabled(!canApply) - TextField("Command freshness (e.g. 30s)", text: $fields.advCommandFreshness) - .disabled(!canApply) - .help("How recent a control command must be to be acted on (replay guard).") - TextField("Window discovery interval (e.g. 1s)", text: $fields.advWindowDiscoveryInterval) - .disabled(!canApply) - .help("How often a new VPN server is looked for while a switch window is open.") - TextField("Tunnel prune delay (e.g. 60s)", text: $fields.advTunnelPruneAfter) - .disabled(!canApply) - .help("How long a dynamically-detected tunnel must be gone before it's dropped.") - TextField("Learned endpoint lifetime (e.g. 720h)", text: $fields.advLearnedEndpointTTL) - .disabled(!canApply) - .help("How long an unused learned endpoint is kept.") - TextField("Learned endpoints per profile (e.g. 16)", text: $fields.advLearnedMaxPerProfile) - .disabled(!canApply) - TextField("Promote after N sightings (e.g. 3)", text: $fields.advPromoteAfterRefreshes) - .disabled(!canApply) - .help("Consecutive sightings before a discovered endpoint is learned under normal guard.") - TextField("Endpoint-bloat warning threshold (e.g. 256)", text: $fields.advEndpointWarnThreshold) - .disabled(!canApply) - TextField("Switch-window protocols (comma-sep, e.g. udp,tcp)", text: $fields.advWindowProtocols) - .disabled(!canApply) - .help("Restricts a switch window to these protocols instead of allowing all outbound. Needs a restart to take effect.") - TextField("Switch-window ports (comma-sep, e.g. 51820,443)", text: $fields.advWindowPorts) - .disabled(!canApply) - .help("Restricts a switch window to these ports instead of allowing all outbound. Needs a restart to take effect.") + durationField("vpn.advanced.switchWindowMax", "Switch window cap", text: $fields.advSwitchWindowMax) + durationField("vpn.advanced.redialWindowMax", "Redial window cap", text: $fields.advRedialWindowMax) + durationField("vpn.advanced.redialMinUptime", "Redial anti-flap uptime", text: $fields.advRedialMinUptime) + durationField("vpn.advanced.commandFreshness", "Command freshness", text: $fields.advCommandFreshness) + durationField("vpn.advanced.windowDiscoveryInterval", "Window discovery interval", text: $fields.advWindowDiscoveryInterval) + durationField("vpn.advanced.tunnelPruneAfter", "Tunnel prune delay", text: $fields.advTunnelPruneAfter) + durationField("vpn.advanced.learnedEndpointTTL", "Learned address lifetime", text: $fields.advLearnedEndpointTTL) + schemaField("vpn.advanced.learnedMaxPerProfile", "Learned addresses per profile", + text: $fields.advLearnedMaxPerProfile) + schemaField("vpn.advanced.promoteAfterRefreshes", "Sightings before an address is learned", + text: $fields.advPromoteAfterRefreshes) + schemaField("vpn.advanced.endpointWarnThreshold", "Address-bloat warning threshold", + text: $fields.advEndpointWarnThreshold) + schemaField("vpn.advanced.windowProtocols", "Window protocols (comma-sep)", + text: $fields.advWindowProtocols) + schemaField("vpn.advanced.windowPorts", "Window ports (comma-sep)", + text: $fields.advWindowPorts) } } Section { @@ -193,6 +197,11 @@ struct SettingsView: View { Button("Open Config File…") { NSWorkspace.shared.open(URL(fileURLWithPath: configPath)) } + // The wizard is not first-run-only: it is the guided way + // back through the same decisions, for someone changing VPN + // rather than someone starting out. + Button("Run Setup Again…") { state.showFirstRun = true } + .help("Walk through the setup questions again, seeded with your current settings.") } footer: { Text("Some advanced options (control socket, geo providers, allowlist) live only in the config file.") .foregroundStyle(.secondary) @@ -359,6 +368,118 @@ struct SettingsView: View { } } + /// Reads the key schema once per pane opening. Off the main thread because + /// it shells out, like every other CLI read here. + /// + /// It is only re-read on open rather than watched: the schema is a property + /// of the installed binary, so the one thing that can change it — an upgrade + /// — also relaunches the app. + private func refreshSchema() { + DispatchQueue.global(qos: .userInitiated).async { + let loaded = DezhbanCLI.readSchema() + DispatchQueue.main.async { schema = loaded } + } + } + + /// Placeholder for a text field: the concept's name and its real default, + /// from the daemon. `fallback` is used only when the schema is unavailable, + /// and deliberately states no value — a stale hardcoded default is exactly + /// what this replaces. + private func hint(_ key: String, _ fallback: String) -> String { + schema?.placeholder(for: key, fallback: fallback) ?? fallback + } + + /// Help text for a field, from the daemon's schema. + private func helpText(_ key: String) -> String? { schema?.help(for: key) } + + /// A section heading with a line saying what the section is for. + /// + /// The headings name the thing rather than the config block it lives in — + /// "When the guard relaxes", not "Windows" — following + /// docs/concepts/glossary.md, which is the authority for user-facing words. + /// The description carries the part a tooltip cannot: why you would touch + /// this section at all. + private func sectionHeader(_ title: String, _ description: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + Text(description) + .font(.callout) + .foregroundStyle(.secondary) + .textCase(nil) + } + .padding(.bottom, 2) + } + + /// A duration setting as a menu of real choices rather than a text field + /// that demands Go's duration syntax. Bounds and Off-availability come from + /// the schema, and the cap is resolved against the values this pane is + /// actually holding, so lowering a cap by hand narrows the menu. + private func durationField(_ key: String, _ fallback: String, text: Binding) -> some View { + HStack(spacing: 6) { + DurationField(key: key, fallbackLabel: fallback, schema: schema, + values: fields.currentValues, text: text, enabled: canApply) + docLink(key) + } + } + + /// A text field for one config key, labelled and explained from the schema. + /// + /// `fallback` is the label to use when the schema is unavailable. It names + /// the concept but states no default, because the whole point of this change + /// is that the app no longer holds an opinion about what a default is. + @ViewBuilder + private func schemaField(_ key: String, _ fallback: String, text: Binding) -> some View { + let field = TextField(hint(key, fallback), text: text).disabled(!canApply) + HStack(spacing: 6) { + if let help = helpText(key) { + field.help(help) + } else { + field + } + docLink(key) + } + } + + /// A toggle for one boolean config key, labelled and explained from the schema. + @ViewBuilder + private func schemaToggle(_ key: String, _ fallback: String, isOn: Binding) -> some View { + let toggle = Toggle(schema?[key]?.label ?? fallback, isOn: isOn).disabled(!canApply) + HStack(spacing: 6) { + if let help = helpText(key) { + toggle.help(help) + } else { + toggle + } + docLink(key) + } + } + + /// Opens the Help pane at the section of the documentation that describes + /// this key. + /// + /// A tooltip has room for one sentence; the reason a setting exists, what it + /// costs, and what happens when it is off often needs a page. This is the + /// bridge between the two — and it lands on the *heading*, from the key's + /// own `docAnchor`, so the answer is on screen rather than somewhere in a + /// long reference page. + /// + /// Absent when the schema is unavailable (a CLI too old to know + /// `config schema`): a button that could only apologise is worse than none. + @ViewBuilder + private func docLink(_ key: String) -> some View { + if let tunable = schema?[key], !tunable.docAnchor.isEmpty { + Button { + state.openHelp(docAnchor: tunable.docAnchor) + } label: { + Image(systemName: "questionmark.circle") + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + .help("Read about \(tunable.label) in the documentation") + .accessibilityLabel("Documentation for \(tunable.label)") + } + } + // MARK: - reset to defaults /// Restores every tunable to its shipped default via `config reset --all`, @@ -508,6 +629,7 @@ struct SettingsView: View { fields = SettingsFields() state.refreshServiceState() refreshPresets() + refreshSchema() // `path` is the same resolution ConfigApply.seed already did for the // `config get` calls — reusing it here means configPath never needs its // own second background resolve, so there's nothing to race. @@ -531,10 +653,17 @@ struct SettingsView: View { // MARK: - apply (staged fields) private func apply() { - for (label, value) in fields.durationFieldsForValidation { - guard DurationText.looksLikeGoDuration(value) else { - ConfigApply.invalidDurationAlert(label, value) - return + // Which fields are durations, and what they are called, both come from + // the daemon's schema. With no schema there is nothing to pre-validate + // against, and the daemon still rejects a malformed duration and says + // so — better than guessing the field set or inventing labels that + // disagree with the ones on screen. + if let schema { + for (label, value) in fields.durationFieldsForValidation(schema: schema) { + guard DurationText.looksLikeGoDuration(value) else { + ConfigApply.invalidDurationAlert(label, value) + return + } } } diff --git a/gui/macos/Tests/DezhbanCoreTests/DurationChoicesTests.swift b/gui/macos/Tests/DezhbanCoreTests/DurationChoicesTests.swift new file mode 100644 index 0000000..bf66342 --- /dev/null +++ b/gui/macos/Tests/DezhbanCoreTests/DurationChoicesTests.swift @@ -0,0 +1,119 @@ +import Testing +@testable import DezhbanCore + +struct DurationChoicesTests { + // MARK: - parsing + + @Test(arguments: [ + ("30s", 30.0), ("5m", 300.0), ("1h", 3600.0), ("1h30m", 5400.0), + ("720h", 2_592_000.0), ("1m0s", 60.0), ("30m0s", 1800.0), ("0s", 0.0), + ]) + func parsesGoDurations(_ c: (String, Double)) { + #expect(DurationChoices.seconds(c.0) == c.1) + } + + @Test(arguments: ["", "off", "15x", "abc", "m30", "15", "1h30"]) + func rejectsNonDurations(_ text: String) { + #expect(DurationChoices.seconds(text) == nil) + } + + /// A value the app writes must read back identically from `config get`, + /// which reports Go's own Duration.String rendering. + @Test(arguments: [(0, "0s"), (45, "45s"), (60, "1m0s"), (90, "1m30s"), + (1800, "30m0s"), (3600, "1h0m0s"), (5400, "1h30m0s")]) + func rendersLikeGo(_ c: (Int, String)) { + #expect(DurationChoices.goDuration(c.0) == c.1) + } + + @Test func goDurationRoundTripsThroughTheParser() { + for secs in [1, 45, 60, 90, 300, 1800, 3600, 5400, 2_592_000] { + let rendered = DurationChoices.goDuration(secs) + #expect(DurationChoices.seconds(rendered) == Double(secs), + "\(secs)s rendered as \(rendered) did not parse back") + } + } + + // MARK: - the off sentinel + + /// The round trip spells "off" three ways: the app writes "0", + /// `config get` reports "0s", and KeyValues renders "off". A control that + /// recognised only one would show a disabled setting as enabled. + @Test(arguments: ["0", "0s", "off", " 0s "]) + func recognisesEverySpellingOfOff(_ value: String) { + #expect(DurationChoices.isOff(value)) + } + + @Test(arguments: ["30s", "1m0s", ""]) + func doesNotMistakeADurationForOff(_ value: String) { + #expect(!DurationChoices.isOff(value)) + } + + // MARK: - choices + + /// Off is offered only where the schema says the sentinel survives. Offering + /// it elsewhere would present a security choice that silently does nothing. + @Test func offIsOfferedOnlyForDisablableKeys() { + let disablable = DurationChoices.build(defaultValue: "30s", cap: "10m0s", disablable: true) + #expect(disablable.contains { $0.isOff }) + + let notDisablable = DurationChoices.build(defaultValue: "1m0s", cap: nil, disablable: false) + #expect(!notDisablable.contains { $0.isOff }) + } + + /// Nothing above the cap is offered: this menu lists what will be accepted, + /// so an option the daemon would reject is noise. + @Test func nothingAboveTheCapIsOffered() { + let choices = DurationChoices.build(defaultValue: "30s", cap: "1m0s", disablable: true) + for c in choices where !c.isOff { + let secs = try! #require(DurationChoices.seconds(c.value)) + #expect(secs <= 60, "\(c.label) is above the 1m cap") + } + } + + /// The cap itself is always reachable — it is the most relaxed setting the + /// operator has allowed, and hitting it by doubling upward is luck. + @Test func theCapItselfIsAlwaysOffered() { + let choices = DurationChoices.build(defaultValue: "30s", cap: "10m0s", disablable: true) + #expect(choices.contains { $0.value == "10m0s" }) + } + + /// Lowering the cap must move the choices, which is the point of deriving + /// them from the schema rather than listing them. + @Test func loweringTheCapNarrowsTheChoices() { + let wide = DurationChoices.build(defaultValue: "5s", cap: "3m0s", disablable: true) + let narrow = DurationChoices.build(defaultValue: "5s", cap: "10s", disablable: true) + #expect(narrow.count < wide.count) + #expect(!narrow.contains { $0.value == "3m0s" }) + } + + @Test func theShippedDefaultIsMarked() { + let choices = DurationChoices.build(defaultValue: "30s", cap: "10m0s", disablable: false) + let defaults = choices.filter(\.isDefault) + #expect(defaults.count == 1) + #expect(defaults.first?.value == "30s") + } + + @Test func choicesAreUnique() { + let choices = DurationChoices.build(defaultValue: "1s", cap: "8s", disablable: true) + #expect(Set(choices.map(\.value)).count == choices.count) + } + + /// An unusable default must not produce a bogus menu — Off (where real) and + /// nothing else, leaving Custom as the way in. + @Test func anUnparsableDefaultYieldsNoInventedChoices() { + let choices = DurationChoices.build(defaultValue: "", cap: "1m0s", disablable: true) + #expect(choices.count == 1) + #expect(choices.first?.isOff == true) + #expect(DurationChoices.build(defaultValue: "nonsense", cap: nil, disablable: false).isEmpty) + } + + @Test func humanizesForReading() { + #expect(DurationChoices.humanize(0) == "Off") + #expect(DurationChoices.humanize(1) == "1 second") + #expect(DurationChoices.humanize(30) == "30 seconds") + #expect(DurationChoices.humanize(60) == "1 minute") + #expect(DurationChoices.humanize(1800) == "30 minutes") + #expect(DurationChoices.humanize(3600) == "1 hour") + #expect(DurationChoices.humanize(2_592_000) == "720 hours") + } +} diff --git a/gui/macos/Tests/DezhbanCoreTests/HelpIndexTests.swift b/gui/macos/Tests/DezhbanCoreTests/HelpIndexTests.swift new file mode 100644 index 0000000..2380667 --- /dev/null +++ b/gui/macos/Tests/DezhbanCoreTests/HelpIndexTests.swift @@ -0,0 +1,157 @@ +import Foundation +import Testing +@testable import DezhbanCore + +/// The producer side — that help-index.json is written with these keys, and +/// that every page in it has headings and text — is pinned by Go's +/// internal/help TestIndexDecodes. This is the consumer side: that the app +/// decodes that exact shape, and that the sidebar and search built on it behave. +struct HelpIndexTests { + /// The shape `help.Build` writes, including `tutorial` absent on a + /// reference page (Go's `omitempty`) — the field most likely to be missing, + /// and the one whose absence would break decoding hardest. + static let indexJSON = """ + [ + { + "file": "usage-getting-started.html", + "source": "usage/getting-started.md", + "title": "Quick start", + "summary": "Install it, set it up, and arm it.", + "tutorial": 1, + "headings": [ + {"level": 1, "text": "Getting started", "anchor": "getting-started"}, + {"level": 2, "text": "Install", "anchor": "install"} + ], + "text": "Getting started\\nInstall dezhban and check it will not cut your traffic by surprise.\\n" + }, + { + "file": "usage-config.html", + "source": "usage/config.md", + "title": "Configuration reference", + "summary": "Every setting: what it does and what it costs.", + "headings": [ + {"level": 2, "text": "Fields", "anchor": "fields"}, + {"level": 2, "text": "vpn block", "anchor": "vpn-block"} + ], + "text": "Fields\\nvpn.pauseMax bounds a pause, and how long traffic stays uncut.\\n" + }, + { + "file": "concepts-modes.html", + "source": "concepts/modes.md", + "title": "Postures", + "summary": "Every posture the guard can be in.", + "tutorial": 2, + "headings": [{"level": 2, "text": "FULL BLOCK", "anchor": "full-block"}], + "text": "FULL BLOCK cuts everything but the geo lookup.\\n" + } + ] + """ + + /// Writes the fixture where `HelpBundle.load` expects it, the same way the + /// app reads it out of its own bundle. + static func fixture() throws -> HelpBundle { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("help-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try Data(indexJSON.utf8).write(to: dir.appendingPathComponent("help-index.json")) + return try #require(HelpBundle.load(directory: dir)) + } + + @Test func decodesTheShapeHelpgenWrites() throws { + let bundle = try Self.fixture() + #expect(bundle.pages.count == 3) + let config = try #require(bundle.page(source: "usage/config.md")) + // The omitted `tutorial` reads as reference material, not as a decode + // failure that would drop the page from the sidebar entirely. + #expect(config.tutorial == 0) + #expect(config.file == "usage-config.html") + #expect(config.hasAnchor("fields")) + #expect(!config.hasAnchor("no-such-heading")) + } + + /// A missing index has to be nil rather than an empty bundle, so the pane + /// can say this build shipped without its docs instead of showing an empty + /// sidebar and looking broken. + @Test func aMissingIndexIsNotAnEmptyBundle() { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("help-missing-\(UUID().uuidString)") + #expect(HelpBundle.load(directory: dir) == nil) + } + + @Test func tutorialComesFirstAndInOrder() throws { + let bundle = try Self.fixture() + #expect(bundle.tutorial.map(\.title) == ["Quick start", "Postures"]) + #expect(bundle.reference.map(\.title) == ["Configuration reference"]) + } + + // MARK: - Deep links + + @Test func docAnchorParses() { + #expect(HelpTarget(docAnchor: "usage/config.md#fields")?.source == "usage/config.md") + #expect(HelpTarget(docAnchor: "usage/config.md#fields")?.anchor == "fields") + // A page with no fragment is a whole-page link, not a broken one. + #expect(HelpTarget(docAnchor: "usage/cli.md")?.anchor == nil) + #expect(HelpTarget(docAnchor: "usage/cli.md#")?.anchor == nil) + #expect(HelpTarget(docAnchor: "#fields") == nil) + } + + /// A stale anchor must still open the right page. Landing at the top of the + /// configuration reference is a bad link; landing nowhere is a dead control. + @Test func aStaleAnchorStillOpensThePage() throws { + let bundle = try Self.fixture() + let good = try #require(bundle.resolve(HelpTarget(source: "usage/config.md", anchor: "fields"))) + #expect(good.anchor == "fields") + + let stale = try #require(bundle.resolve(HelpTarget(source: "usage/config.md", anchor: "gone"))) + #expect(stale.page.title == "Configuration reference") + #expect(stale.anchor == nil) + + #expect(bundle.resolve(HelpTarget(source: "docs/nope.md", anchor: nil)) == nil) + } + + // MARK: - Search + + @Test func searchPrefersTitleThenHeadingThenBody() throws { + let bundle = try Self.fixture() + + // A title match wins outright. + #expect(bundle.search("postures").first?.page.title == "Postures") + + // A heading match carries the anchor, so the result lands on the + // section rather than the top of a long reference page. + let heading = try #require(bundle.search("full block").first) + #expect(heading.page.title == "Postures") + #expect(heading.anchor == "full-block") + + // A body-only match still finds the page, and reports the line it + // matched so the result says what it found, not just where. + let body = try #require(bundle.search("pauseMax").first) + #expect(body.page.title == "Configuration reference") + #expect(body.anchor == nil) + #expect(body.context.contains("pauseMax")) + } + + @Test func searchIsCaseInsensitiveAndTrimmed() throws { + let bundle = try Self.fixture() + #expect(bundle.search(" QUICK ").first?.page.title == "Quick start") + #expect(bundle.search(" ").isEmpty) + #expect(bundle.search("wireguard").isEmpty) + } + + /// Within one rank the guided track comes first: a reader who does not know + /// the vocabulary yet is better served by the tutorial than the reference. + /// "traffic" is in the body of one of each and in no title, summary, or + /// heading — so only the tutorial ordering separates them. + @Test func tiedResultsPutTheTutorialFirst() throws { + let bundle = try Self.fixture() + #expect(bundle.search("traffic").map(\.page.title) == ["Quick start", "Configuration reference"]) + } + + @Test func snippetIsOneLineAndBounded() throws { + let long = String(repeating: "pause ", count: 60) + let snippet = try #require(HelpBundle.snippet(in: "unrelated\n\(long)\nmore", around: "pause")) + #expect(snippet.count <= 141) + #expect(!snippet.contains("\n")) + #expect(HelpBundle.snippet(in: "nothing here", around: "pause") == nil) + } +} diff --git a/gui/macos/Tests/DezhbanCoreTests/PauseOptionsTests.swift b/gui/macos/Tests/DezhbanCoreTests/PauseOptionsTests.swift new file mode 100644 index 0000000..8e86f94 --- /dev/null +++ b/gui/macos/Tests/DezhbanCoreTests/PauseOptionsTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import DezhbanCore + +struct PauseOptionsTests { + /// Decodes what `dezhban pause --list --json` actually emits. + @Test func decodesTheDaemonsList() { + let json = """ + [ + {"label":"15 minutes","value":"15m0s","why":"a bank transfer or a delivery tracker"}, + {"label":"2 hours","value":"2h0m0s","why":"an evening of something the VPN can't reach", + "unavailable":"longer than your 30m0s cap (vpn.pauseMax)"} + ] + """.data(using: .utf8)! + let opts = try! #require(PauseOption.decodeList(json)) + #expect(opts.count == 2) + #expect(opts[0].isAvailable) + #expect(opts[0].value == "15m0s") + #expect(!opts[1].isAvailable) + } + + /// An over-cap length is still an option — shown, disabled, explained. The + /// alternative teaches the user their cap is something other than it is. + @Test func overCapOptionsAreKeptWithTheirReason() { + let json = """ + [{"label":"2 hours","value":"2h0m0s","why":"a long evening", + "unavailable":"longer than your 30m0s cap (vpn.pauseMax)"}] + """.data(using: .utf8)! + let opts = try! #require(PauseOption.decodeList(json)) + #expect(opts.count == 1, "an unavailable option must not be dropped at decode time") + #expect(opts[0].unavailable?.contains("vpn.pauseMax") == true, + "the reason must name the setting to change") + } + + /// Absent `unavailable` means available — the JSON omits it rather than + /// sending an empty string. + @Test func absentUnavailableMeansAvailable() { + let json = #"[{"label":"5 minutes","value":"5m0s","why":"one stubborn site"}]"#.data(using: .utf8)! + let opts = try! #require(PauseOption.decodeList(json)) + #expect(opts[0].isAvailable) + #expect(opts[0].unavailable == nil) + } + + @Test func corruptDataFailsToDecode() { + #expect(PauseOption.decodeList(Data("not json".utf8)) == nil) + } +} diff --git a/gui/macos/Tests/DezhbanCoreTests/SettingsFieldsTests.swift b/gui/macos/Tests/DezhbanCoreTests/SettingsFieldsTests.swift index ba097e2..4f57ce0 100644 --- a/gui/macos/Tests/DezhbanCoreTests/SettingsFieldsTests.swift +++ b/gui/macos/Tests/DezhbanCoreTests/SettingsFieldsTests.swift @@ -1,14 +1,74 @@ +import Foundation import Testing @testable import DezhbanCore +/// Builds a schema good enough to exercise the pane's use of it, without +/// shelling out to the CLI. Kinds and labels match what `config schema --json` +/// reports for these keys. +private func testSchema() -> ConfigSchema { + func tunable(_ key: String, _ label: String, _ kind: String, + defaultValue: String = "", capKey: String? = nil, + disablable: Bool = false) -> ConfigTunable { + let json = """ + {"key":"\(key)","label":"\(label)","kind":"\(kind)","default":"\(defaultValue)", + \(capKey.map { "\"capKey\":\"\($0)\"," } ?? "") + "disablable":\(disablable),"advanced":false,"preset":false, + "help":"help for \(key)","docAnchor":"usage/config.md#fields"} + """ + return try! JSONDecoder().decode(ConfigTunable.self, from: Data(json.utf8)) + } + + return ConfigSchema([ + tunable("vpn.tunnelInterfaces", "Your VPN tunnel", "list"), + tunable("vpn.endpoints", "VPN server addresses", "list"), + tunable("vpn.autoDetect", "Find my VPN tunnel automatically", "bool", defaultValue: "true"), + tunable("vpn.autoDiscoverEndpoints", "Find the VPN server address automatically", "bool", + defaultValue: "true"), + tunable("vpn.autoArm", "Arm the guard when a VPN connects", "bool", defaultValue: "true"), + tunable("vpn.allowLocalNetwork", "Keep local devices reachable", "bool", defaultValue: "true"), + tunable("blockedCountries", "Blocked countries", "list", defaultValue: "IR,RU,KP"), + tunable("pollInterval", "Exit country check interval", "duration", defaultValue: "15s"), + tunable("vpn.switchWindow", "Switch window", "duration", defaultValue: "5s", + capKey: "vpn.advanced.switchWindowMax", disablable: true), + tunable("vpn.redialWindow", "Redial window", "duration", defaultValue: "30s", + capKey: "vpn.advanced.redialWindowMax", disablable: true), + tunable("vpn.pauseMax", "Longest pause", "duration", defaultValue: "30m0s", disablable: true), + tunable("vpn.endpointGrace", "VPN server address grace", "duration", defaultValue: "15m0s"), + tunable("vpn.endpointRefresh", "VPN server address refresh", "duration", defaultValue: "1m0s"), + tunable("vpn.tunnelWatch", "Tunnel check interval", "duration", defaultValue: "1s"), + tunable("vpn.advanced.switchWindowMax", "Switch window cap", "duration", defaultValue: "3m0s"), + tunable("vpn.advanced.redialWindowMax", "Redial window cap", "duration", defaultValue: "10m0s"), + tunable("vpn.advanced.redialMinUptime", "Redial anti-flap uptime", "duration", + defaultValue: "15s", disablable: true), + tunable("vpn.advanced.commandFreshness", "Command freshness", "duration", defaultValue: "30s"), + tunable("vpn.advanced.windowDiscoveryInterval", "Window discovery interval", "duration", + defaultValue: "1s"), + tunable("vpn.advanced.tunnelPruneAfter", "Tunnel prune delay", "duration", defaultValue: "1m0s"), + tunable("vpn.advanced.learnedEndpointTTL", "Learned address lifetime", "duration", + defaultValue: "720h0m0s"), + tunable("vpn.advanced.learnedMaxPerProfile", "Learned addresses per profile", "int", + defaultValue: "16"), + tunable("vpn.advanced.promoteAfterRefreshes", "Sightings before an address is learned", "int", + defaultValue: "3"), + tunable("vpn.advanced.endpointWarnThreshold", "Address-bloat warning threshold", "int", + defaultValue: "256"), + tunable("vpn.advanced.windowProtocols", "Window protocols", "list"), + tunable("vpn.advanced.windowPorts", "Window ports", "list"), + ]) +} + struct SettingsFieldsTests { - @Test func keysAndPairsHaveTheSameCount() { - #expect(SettingsFields.keys.count == SettingsFields().pairs().count) + @Test func pairsCoverEveryStagedKey() { + let pairs = SettingsFields().pairs() + #expect(pairs.count == SettingsFields.keys.count) + for (key, pair) in zip(SettingsFields.keys, pairs) { + #expect(pair.hasPrefix("\(key)="), "expected pair \"\(pair)\" to start with \"\(key)=\"") + } } - @Test func everyPairsEntryIsKeyedByTheCorrespondingKey() { - // Ordering matters: init(seeded:) and pairs() must agree on which index - // is which key, or a value gets staged under the WRONG key silently. + /// The bug this storage change exists to make unrepresentable: a value must + /// come back out under the key it went in under, whatever the key order is. + @Test func everyValueIsStagedUnderItsOwnKey() { var f = SettingsFields() f.tunnelInterfaces = "utun9" f.endpoints = "203.0.113.5" @@ -20,6 +80,7 @@ struct SettingsFieldsTests { f.pollInterval = "20s" f.switchWindow = "10s" f.redialWindow = "40s" + f.pauseMax = "45m" f.endpointGrace = "5m" f.endpointRefresh = "1m" f.tunnelWatch = "2s" @@ -36,46 +97,151 @@ struct SettingsFieldsTests { f.advWindowProtocols = "udp,tcp" f.advWindowPorts = "51820,443" + // Named accessor and keyed lookup are the same storage, not two copies. + #expect(f.value(for: "vpn.tunnelInterfaces") == "utun9") + #expect(f.value(for: "vpn.switchWindow") == "10s") + #expect(f.value(for: "vpn.pauseMax") == "45m") + #expect(f.value(for: "vpn.advanced.windowPorts") == "51820,443") + #expect(f.value(for: "vpn.autoDetect") == "true") + #expect(f.value(for: "vpn.allowLocalNetwork") == "false") + let pairs = f.pairs() - for (key, pair) in zip(SettingsFields.keys, pairs) { - #expect(pair.hasPrefix("\(key)="), "expected pair \"\(pair)\" to start with \"\(key)=\"") - } + #expect(pairs.contains("vpn.switchWindow=10s")) + #expect(pairs.contains("vpn.pauseMax=45m")) + #expect(pairs.contains("blockedCountries=IR,RU")) } - @Test func seedThenPairsRoundTrips() { - // Values in SettingsFields.keys order. + @Test func seedThenCurrentValuesRoundTrips() { let seeded = [ - "utun4", "203.0.113.9", - "true", "true", "false", - "true", - "IR,RU", "15s", - "5s", "30s", "15m", - "1m", "1s", - "3m", "10m", "15s", - "30s", "1s", "60s", - "720h", "16", "3", - "256", "udp", "51820", + "vpn.tunnelInterfaces": "utun4", + "vpn.endpoints": "203.0.113.9", + "vpn.autoDetect": "true", + "vpn.switchWindow": "5s", + "vpn.pauseMax": "30m0s", + "vpn.advanced.windowPorts": "51820", ] let f = SettingsFields(seeded: seeded) - #expect(f.currentValues == seeded) + for (key, value) in seeded { + #expect(f.value(for: key) == value) + } + #expect(f.tunnelInterfaces == "utun4") + #expect(f.autoDetect) + } + + /// A key the seed did not include must keep its resting value rather than + /// taking a neighbour's — the failure the positional version could not rule out. + @Test func seedingASubsetLeavesOtherKeysAlone() { + let f = SettingsFields(seeded: ["vpn.switchWindow": "12s"]) + #expect(f.switchWindow == "12s") + #expect(f.redialWindow == "") + #expect(f.pollInterval == "") + } + + /// A key this pane does not stage must not create storage by being written. + @Test func unknownKeysAreIgnored() { + var f = SettingsFields(seeded: ["control.group": "wheel"]) + #expect(f.value(for: "control.group") == "") + f.setValue("wheel", for: "control.group") + #expect(f.value(for: "control.group") == "") + #expect(!f.pairs().contains { $0.hasPrefix("control.group=") }) } - @Test func durationFieldsForValidationCoversEveryDurationKey() { + /// Labels now come from the daemon, so the pane and the validation alert + /// cannot call the same field two different things. + @Test func durationValidationUsesSchemaLabelsAndCoversEveryDurationKey() { let f = SettingsFields() - let labels = Set(f.durationFieldsForValidation.map(\.label)) + let labels = Set(f.durationFieldsForValidation(schema: testSchema()).map(\.label)) #expect(labels == [ - "Geo IP lookup interval", "Switch window", "Redial window", - "Endpoint grace", "Endpoint refresh", "Tunnel watch", - "Manual switch window cap", "Redial window cap", "Redial anti-flap uptime", + "Exit country check interval", "Switch window", "Redial window", "Longest pause", + "VPN server address grace", "VPN server address refresh", "Tunnel check interval", + "Switch window cap", "Redial window cap", "Redial anti-flap uptime", "Command freshness", "Window discovery interval", "Tunnel prune delay", - "Learned endpoint TTL", + "Learned address lifetime", ]) } - @Test func durationFieldsForValidationTrimsWhitespace() { + @Test func durationValidationTrimsWhitespace() { var f = SettingsFields() f.pollInterval = " 15s " - let entry = f.durationFieldsForValidation.first { $0.label == "Geo IP lookup interval" } + let entry = f.durationFieldsForValidation(schema: testSchema()) + .first { $0.label == "Exit country check interval" } #expect(entry?.value == "15s") } + + /// Without a schema there is nothing to pre-validate. Returning nothing is + /// deliberate: the daemon still rejects a malformed duration, whereas + /// guessing the field set would miss one or invent a label. + @Test func durationValidationIsEmptyWithoutASchema() { + let f = SettingsFields() + #expect(f.durationFieldsForValidation(schema: ConfigSchema([])).isEmpty) + } +} + +struct ConfigSchemaTests { + @Test func decodesTheDaemonsShape() { + let json = """ + [{"key":"vpn.redialWindow","label":"Redial window","kind":"duration","default":"30s", + "capKey":"vpn.advanced.redialWindowMax","disablable":true,"advanced":false,"preset":true, + "help":"How long the guard relaxes by itself.","docAnchor":"usage/config.md#vpn-block", + "restartReason":""}] + """ + let schema = ConfigSchema.decode(Data(json.utf8)) + let tunable = try! #require(schema?["vpn.redialWindow"]) + #expect(tunable.label == "Redial window") + #expect(tunable.defaultValue == "30s") + #expect(tunable.capKey == "vpn.advanced.redialWindowMax") + #expect(tunable.disablable) + #expect(tunable.preset) + #expect(tunable.appliesLive) + } + + @Test func aRestartReasonMeansItDoesNotApplyLive() { + let json = """ + [{"key":"logLevel","label":"Log level","kind":"string","default":"info", + "disablable":false,"advanced":false,"preset":false,"help":"h", + "docAnchor":"usage/config.md#fields", + "restartReason":"the logger is wired up before the run loop starts"}] + """ + let schema = try! #require(ConfigSchema.decode(Data(json.utf8))) + #expect(!(schema["logLevel"]?.appliesLive ?? true)) + #expect(schema.restartRequired(among: ["logLevel", "unknown"]) == ["logLevel"]) + } + + /// Every control's help link is only as good as the anchor it carries. Go's + /// TestEveryTunableDocAnchorResolves proves the anchors exist in the + /// bundled pages; this proves the app turns them into a page and a heading + /// rather than dropping the fragment and landing at the top. + @Test func docAnchorBecomesADeepLink() { + let schema = testSchema() + let target = try! #require(schema["vpn.endpointRefresh"]?.docTarget) + #expect(target.source == "usage/config.md") + #expect(target.anchor == "fields") + } + + /// The placeholder states the real default, which is the whole point: the + /// pane used to say "e.g. 30s" for a key whose default was 1m. + @Test func placeholderStatesTheRealDefault() { + let schema = testSchema() + #expect(schema["vpn.endpointRefresh"]?.placeholder == "VPN server address refresh (default 1m0s)") + // No default to state (an empty list): just the concept's name. + #expect(schema["vpn.advanced.windowPorts"]?.placeholder == "Window ports") + } + + /// A missing schema must degrade to a plainer label, never to a wrong value. + @Test func placeholderFallsBackWithoutASchema() { + let empty = ConfigSchema([]) + #expect(empty.placeholder(for: "vpn.redialWindow", fallback: "Redial window") == "Redial window") + #expect(empty.help(for: "vpn.redialWindow") == nil) + } + + /// The ceiling is read from the live config, not from the cap's default — + /// lowering a cap by hand must actually lower the control's top. + @Test func capResolvesThroughSeededValues() { + let schema = testSchema() + #expect(schema.cap(for: "vpn.switchWindow", in: ["vpn.advanced.switchWindowMax": "90s"]) == "90s") + // Falls back to the cap's own default when the value is not to hand. + #expect(schema.cap(for: "vpn.switchWindow", in: [:]) == "3m0s") + // An unbounded key has no ceiling. + #expect(schema.cap(for: "vpn.pauseMax", in: [:]) == nil) + } } diff --git a/gui/macos/Tests/DezhbanCoreTests/SetupQuestionsTests.swift b/gui/macos/Tests/DezhbanCoreTests/SetupQuestionsTests.swift new file mode 100644 index 0000000..e01e561 --- /dev/null +++ b/gui/macos/Tests/DezhbanCoreTests/SetupQuestionsTests.swift @@ -0,0 +1,146 @@ +import Foundation +import Testing +@testable import DezhbanCore + +/// The producer side — what the questions ARE, how they are gated, and what +/// they apply to — is pinned by Go's internal/setup tests. This is the consumer +/// side: that the app decodes that JSON, gates the same way, and turns answers +/// into the same config writes. A difference here is two wizards asking the +/// same question and doing different things with the answer. +struct SetupQuestionsTests { + /// Exactly what `dezhban setup --questions --json` emits, `omitempty` and + /// all: `key`, `options`, `selected`, and the gate fields are absent on the + /// questions that do not have them. + static let json = """ + [ + {"id":"pollInterval","key":"pollInterval","kind":"duration","title":"Poll interval", + "description":"How often the exit country is checked, e.g. 30s.","default":"15s","group":1}, + {"id":"blockedCountries","key":"blockedCountries","kind":"multiselect","title":"Blocked countries", + "options":[{"label":"Iran (IR)","value":"IR"},{"label":"Russia (RU)","value":"RU"}], + "selected":["IR"],"group":1}, + {"id":"otherCountries","kind":"list","title":"Other country codes","default":"AQ","group":1}, + {"id":"configureVPN","kind":"bool","title":"Configure your VPN now?","default":"true","group":1}, + {"id":"autoMode","kind":"bool","title":"Use automatic VPN detection? (recommended)", + "default":"true","group":2,"requiresId":"configureVPN","requiresValue":"true"}, + {"id":"tunnels","key":"vpn.tunnelInterfaces","kind":"multiselect","title":"Tunnel interface(s)", + "options":[{"label":"utun4","value":"utun4"},{"label":"utun7","value":"utun7"}], + "selected":["utun4"],"group":3,"requiresId":"autoMode","requiresValue":"false"}, + {"id":"profileFiles","kind":"list","title":"Self-hosted VPN config files","group":4, + "requiresId":"configureVPN","requiresValue":"true"}, + {"id":"endpoints","key":"vpn.endpoints","kind":"list","title":"VPN endpoint(s)", + "default":"203.0.113.7","group":4,"requiresId":"configureVPN","requiresValue":"true"} + ] + """ + + static func questions() throws -> [SetupQuestion] { + try #require(SetupQuestion.decodeList(Data(json.utf8))) + } + + @Test func decodesTheDaemonsQuestions() throws { + let qs = try Self.questions() + #expect(qs.count == 8) + let countries = try #require(qs.first { $0.id == "blockedCountries" }) + #expect(countries.selected == ["IR"]) + #expect(countries.options.map(\.value) == ["IR", "RU"]) + // Absent `key` decodes as "no config key", not as a decode failure. + #expect(try #require(qs.first { $0.id == "otherCountries" }).key.isEmpty) + #expect(try #require(qs.first { $0.id == "autoMode" }).isGated) + #expect(!(try #require(qs.first { $0.id == "pollInterval" }).isGated)) + } + + @Test func answersSeedFromTheQuestions() throws { + let a = SetupAnswers(questions: try Self.questions()) + #expect(a["pollInterval"] == "15s") + #expect(a.list("blockedCountries") == ["IR"]) + #expect(a.bool("configureVPN")) + } + + @Test func gatingMatchesTheCLI() throws { + let qs = try Self.questions() + var a = SetupAnswers(questions: qs) + + a["configureVPN"] = "false" + for q in qs where q.requiresID == "configureVPN" { + #expect(!a.shouldAsk(q), "\(q.id) should be hidden when the VPN branch is declined") + } + + a["configureVPN"] = "true" + a["autoMode"] = "true" + #expect(!a.shouldAsk(try #require(qs.first { $0.id == "tunnels" }))) + a["autoMode"] = "false" + #expect(a.shouldAsk(try #require(qs.first { $0.id == "tunnels" }))) + } + + /// The free-text codes fold into the same key as the checkboxes — they are + /// one setting asked as two questions, and writing them separately would + /// mean the second overwrote the first. + @Test func otherCountriesFoldIntoBlockedCountries() throws { + let qs = try Self.questions() + var a = SetupAnswers(questions: qs) + a["blockedCountries"] = "IR,RU" + a["otherCountries"] = "AQ, KP" + + let pairs = a.configPairs(for: qs) + #expect(pairs.contains("blockedCountries=IR,RU,AQ,KP")) + // And never as a key of its own. + #expect(!pairs.contains { $0.hasPrefix("otherCountries=") }) + } + + /// Choosing automatic detection must CLEAR pinned interfaces, not skip the + /// key: a leftover pin is exactly what stops autodetect from happening. + @Test func automaticDetectionClearsPinnedInterfaces() throws { + let qs = try Self.questions() + var a = SetupAnswers(questions: qs) + a["configureVPN"] = "true" + a["autoMode"] = "true" + + let pairs = a.configPairs(for: qs) + #expect(pairs.contains("vpn.tunnelInterfaces=")) + } + + @Test func pinningWritesTheChosenInterfaces() throws { + let qs = try Self.questions() + var a = SetupAnswers(questions: qs) + a["configureVPN"] = "true" + a["autoMode"] = "false" + a["tunnels"] = "utun4,utun7" + + let pairs = a.configPairs(for: qs) + #expect(pairs.contains("vpn.tunnelInterfaces=utun4,utun7")) + #expect(pairs.filter { $0.hasPrefix("vpn.tunnelInterfaces=") }.count == 1, + "a pinned config must not also be cleared") + } + + /// Declining the VPN branch writes none of its keys, so a VPN somebody + /// already configured is left alone — the same rule as Go's setup.Apply. + @Test func decliningTheVPNBranchWritesNoVPNKey() throws { + let qs = try Self.questions() + var a = SetupAnswers(questions: qs) + a["configureVPN"] = "false" + + let pairs = a.configPairs(for: qs) + #expect(!pairs.contains { $0.hasPrefix("vpn.") }) + // The answers that were given still apply. + #expect(pairs.contains { $0.hasPrefix("pollInterval=") }) + } + + /// Profile files are not a config key: they become profiles through + /// `vpn import`, so they must never reach `config set`. + @Test func profileFilesAreNotAConfigKey() throws { + let qs = try Self.questions() + var a = SetupAnswers(questions: qs) + a["profileFiles"] = "/tmp/home.conf, /tmp/work.ovpn" + + #expect(a.profileFiles == ["/tmp/home.conf", "/tmp/work.ovpn"]) + #expect(!a.configPairs(for: qs).contains { $0.contains("profileFiles") }) + } + + @Test func firstRunIsOfferedOnlyWhenNothingIsKnownYet() { + // Whether the flag is set is UserDefaults' business; whether the wizard + // should be offered given that flag is a rule, and lives in the core. + #expect(FirstRunDecision.offer(isComplete: false, vpnKnown: false)) + #expect(!FirstRunDecision.offer(isComplete: false, vpnKnown: true), + "a VPN configured from the CLI means these questions were already answered") + #expect(!FirstRunDecision.offer(isComplete: true, vpnKnown: false)) + } +} diff --git a/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift b/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift index 7f0114e..52ea9a8 100644 --- a/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift +++ b/gui/macos/Tests/DezhbanCoreTests/SnapshotTests.swift @@ -63,4 +63,61 @@ struct SnapshotTests { let json = "{ \"time\": \"2026-07-25T10:00:00Z\" }".data(using: .utf8)! #expect(StateReader.decode(json) == nil) } + + /// The drop record is what lets the app report a VPN drop at all, so it has + /// to survive decoding — including across a window, which is precisely when + /// the posture alone says nothing about the drop. + @Test func decodesTheDropRecordCarriedThroughAWindow() { + let json = """ + { + "time": "2026-07-25T10:00:05Z", "posture": "switch-window", "blocked": false, + "switch": { "open": true, "until": "2026-07-25T10:00:35Z", "trigger": "auto" }, + "drop": { "at": "2026-07-25T10:00:00Z" } + } + """.data(using: .utf8)! + let s = try! #require(StateReader.decode(json)) + #expect(s.drop != nil) + #expect(s.switch?.isAutoRedial == true) + // The drop happened before the window it opened, and the two timestamps + // must not be confused for one another. + let dropAt = try! #require(s.drop?.at) + let until = try! #require(s.switch?.until) + #expect(dropAt < until) + #expect(dropAt < s.time) + } + + /// A key the app no longer knows about must not fail the decode. The daemon + /// and the app ship in one package but are not guaranteed to be restarted + /// together, so a running daemon can still be emitting a field this build + /// has dropped — `drop.cut` is the first one that happened to. Refusing the + /// whole snapshot over it would blank the menubar until the next restart. + @Test func anUnknownFieldOnTheDropRecordIsIgnored() { + let json = """ + { "time": "2026-07-25T10:00:05Z", "posture": "guard", "blocked": true, + "drop": { "at": "2026-07-25T10:00:00Z", "cut": true } } + """.data(using: .utf8)! + let s = try! #require(StateReader.decode(json)) + #expect(s.drop?.at != nil) + } + + @Test func decodesHoldTheLineArmed() { + let json = """ + { "time": "2026-07-25T10:00:00Z", "posture": "guard", "blocked": false, + "hold": { "armed": true, "at": "2026-07-25T09:59:00Z" } } + """.data(using: .utf8)! + let s = try! #require(StateReader.decode(json)) + #expect(s.holdArmed) + } + + /// Both fields are additive: a daemon that predates them, or one where + /// nothing is armed and nothing has dropped, must still decode. Absent must + /// read as "not armed", never as a decode failure. + @Test func absentDropAndHoldAreNotAFailure() { + let json = """ + { "time": "2026-07-25T10:00:00Z", "posture": "guard", "blocked": false } + """.data(using: .utf8)! + let s = try! #require(StateReader.decode(json)) + #expect(s.drop == nil) + #expect(!s.holdArmed) + } } diff --git a/gui/macos/build-app.sh b/gui/macos/build-app.sh index 0fe1639..ad2578e 100755 --- a/gui/macos/build-app.sh +++ b/gui/macos/build-app.sh @@ -61,6 +61,16 @@ cp "$HERE/Info.plist" "$APP/Contents/Info.plist" # SUDO_ASKPASS helper. Lives in the (code-signed, read-only) bundle so sudo is # never pointed at a path a local process could swap out from under it. install -m 0755 "$HERE/askpass.sh" "$APP/Contents/Resources/askpass.sh" + +# Documentation, rendered from the repo's own markdown into the bundle. Shipping +# it means the help pane works with every byte of egress cut — which is exactly +# when someone needs it — and that the docs always match the version they +# document. tools/helpgen is dev tooling (stdlib only, never installed); a +# missing or renamed page is an error there, not a silently thinner bundle. +if ! (cd "$REPO_ROOT" && go run ./tools/helpgen -docs docs -out "$APP/Contents/Resources/help"); then + echo "build-app.sh: helpgen failed — the app would ship without its documentation" >&2 + exit 1 +fi # Brand artifacts (gui/artifacts/png): full-color menubar + Dock state # icons. Optional — a checkout without gui/artifacts/ still builds, and AppDelegate # falls back to SF Symbols / the static app icon when the PNGs are absent. diff --git a/internal/command/command.go b/internal/command/command.go index f1b7069..1419e79 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -36,6 +36,12 @@ const ( // PauseMax/AllowPauseOps doc comments. OpPause Op = "pause" OpResume Op = "resume" + // OpHoldArm arms "hold the line" so the next tunnel drop stays cut instead + // of opening an automatic redial window; OpHoldCancel disarms it. Mirror + // control.OpHoldArm/OpHoldCancel for the root-owned command-file path, which + // stays available when no daemon answers the socket. + OpHoldArm Op = "hold-arm" + OpHoldCancel Op = "hold-cancel" ) // Command is one control message. Duration/Profile/Name are op-specific. diff --git a/internal/config/config.go b/internal/config/config.go index 983874e..6333ed1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -932,7 +932,7 @@ func Normalize(cfg *Config) { // defaults review). An explicit "blockedCountries": [] is a deliberate // choice to block nothing and must stay that way — see the len>0 branch // below, which never fires for a genuinely empty, non-nil slice. - cfg.BlockedCountries = []string{"IR", "RU", "KP"} + cfg.BlockedCountries = defaultBlockedCountries() case len(cfg.BlockedCountries) > 0: seen := make(map[string]bool, len(cfg.BlockedCountries)) out := make([]string, 0, len(cfg.BlockedCountries)) @@ -963,13 +963,13 @@ func Normalize(cfg *Config) { // VPN guard cadence defaults. Set unconditionally — these are only read in // VPN mode, but defaulting here keeps Validate and the runner simple. if cfg.VPN.EndpointRefresh <= 0 { - cfg.VPN.EndpointRefresh = time.Minute + cfg.VPN.EndpointRefresh = defaultEndpointRefresh } if cfg.VPN.EndpointGrace <= 0 { cfg.VPN.EndpointGrace = defaultEndpointGrace } if cfg.VPN.TunnelWatch <= 0 { - cfg.VPN.TunnelWatch = 1 * time.Second + cfg.VPN.TunnelWatch = defaultTunnelWatch } if cfg.VPN.SwitchWindow == 0 { cfg.VPN.SwitchWindow = defaultSwitchWindow @@ -1024,8 +1024,19 @@ func normalizeAdvanced(a *Advanced) { } } +// defaultBlockedCountries is the recommended starting blocklist, returned fresh +// each call because Normalize hands it straight to the caller's config, which is +// then free to sort, extend, or de-duplicate it in place. +func defaultBlockedCountries() []string { return []string{"IR", "RU", "KP"} } + // Switch-window / learning defaults. These are the recommended values; the // vpn.advanced config block overrides any of them. +// +// This block is the ONLY place a default number is written down. Every surface +// that shows a default — the macOS app's hints and slider bounds, `dezhban config +// schema`, the tables in docs/usage/config.md — derives it from here via +// config.Defaults(), and a test fails the build when the docs or the example +// configs disagree. See schema.go for why. const ( defaultSwitchWindow = 5 * time.Second // 2026-07-22 defaults review: windows exist to be closed fast defaultSwitchWindowMax = 3 * time.Minute @@ -1037,6 +1048,9 @@ const ( defaultPromoteAfterRefreshes = 3 defaultEndpointWarnThreshold = 256 + defaultEndpointRefresh = 1 * time.Minute + defaultTunnelWatch = 1 * time.Second // how fast a tunnel drop is noticed + defaultRedialWindow = 30 * time.Second defaultRedialWindowMax = 10 * time.Minute defaultRedialMinUptime = 15 * time.Second diff --git a/internal/config/docdrift_test.go b/internal/config/docdrift_test.go new file mode 100644 index 0000000..0d2e578 --- /dev/null +++ b/internal/config/docdrift_test.go @@ -0,0 +1,292 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// Defaults are data (see schema.go), but documentation is prose, and prose is +// where the drift lived: the config reference and the example configs restate +// values that nothing forces to agree with the code. These tests are that force. +// +// They run inside `go test ./...`, so both CI legs catch a stale table — this is +// cheaper and earlier than a CI-only check, and it means editing a default +// without editing its documentation fails on the contributor's own machine. + +const ( + docConfigRef = "../../docs/usage/config.md" + exampleDir = "../../configs" +) + +// docDefaultIsProse names keys whose Default column deliberately says something +// other than the literal value, with the reason. Anything NOT listed here must +// state its default verbatim, so a new prose default fails the build until +// somebody justifies it rather than quietly opting out of the check. +var docDefaultIsProse = map[string]string{ + "providers": "eight URLs would not fit a table cell; the Notes column names them in order instead", + "control.socket": "the code default is empty, meaning 'resolve it against the daemon's state dir'; " + + "the table documents the resolved path, which is what a reader needs", + "control.group": "genuinely platform-dependent (admin on macOS, empty elsewhere), so there is no " + + "single literal to state", +} + +// TestDocumentedDefaultsMatchTheCode is the drift check. A default stated in the +// config reference that disagrees with the shipped default is worse than no +// documentation: a reader acts on it. +func TestDocumentedDefaultsMatchTheCode(t *testing.T) { + documented := parseDocDefaults(t, docConfigRef) + + for _, tun := range Tunables() { + cell, ok := documented[tun.Key] + if !ok { + t.Errorf("%s is settable but has no row in %s — every key a user can set must be documented", + tun.Key, docConfigRef) + continue + } + + got, parsed := normalizeDocDefault(cell) + if reason, prose := docDefaultIsProse[tun.Key]; prose { + // A stale exemption is its own kind of rot: if the cell now states + // the value exactly, the exemption is no longer buying anything. + if parsed && got == tun.Default { + t.Errorf("%s: documented default now matches the code exactly, so its "+ + "docDefaultIsProse exemption (%q) should be removed", tun.Key, reason) + } + continue + } + + if !parsed { + t.Errorf("%s: Default column %q is not a literal value. State the default, "+ + "or add the key to docDefaultIsProse with a reason.", tun.Key, cell) + continue + } + if !sameDefault(tun, got) { + t.Errorf("%s: %s says the default is %q, the code says %q", + tun.Key, docConfigRef, got, tun.Default) + } + } + + for key := range docDefaultIsProse { + if _, ok := TunableByKey(key); !ok { + t.Errorf("docDefaultIsProse exempts %q, which is not a settable key", key) + } + } +} + +// TestExampleConfigsUseOnlyKnownKeys keeps the shipped examples honest. They are +// the first config most people ever see, and a copied-out example that sets a +// renamed or retired key silently does nothing — the exact failure the +// reconnect→redial rename was designed to make loud everywhere else. +// +// The check is "every key is real", not "every value is the default": an example +// exists precisely to show non-default values, so equality would be the wrong +// assertion. What must never happen is an example naming a key that no longer +// takes effect. +func TestExampleConfigsUseOnlyKnownKeys(t *testing.T) { + paths, err := filepath.Glob(filepath.Join(exampleDir, "*.json")) + if err != nil { + t.Fatalf("glob %s: %v", exampleDir, err) + } + if len(paths) == 0 { + t.Fatalf("no example configs found under %s", exampleDir) + } + + for _, path := range paths { + t.Run(filepath.Base(path), func(t *testing.T) { + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + for _, u := range unknownKeys(data) { + reason, tookEffect := describeUnknown(u) + if tookEffect { + // A renamed key that still works: worth saying, not failing. + t.Logf("%s: %s", u.Key, reason) + continue + } + t.Errorf("sets %q, which does nothing: %s", u.Key, reason) + } + + // It must also still load and validate — an example that cannot be + // used is not an example. + if _, err := Load(path); err != nil { + t.Errorf("does not load: %v", err) + } + }) + } +} + +// sameDefault compares a documented default to the declared one. Durations are +// compared as durations, not as strings: the tables read "30m" while Go's +// Duration.String renders "30m0s", and those are the same value. Forcing the +// prose to say "30m0s" would make the documentation worse to serve the test, +// which is the wrong way round. +func sameDefault(tun Tunable, documented string) bool { + if tun.Kind != KindDuration { + return documented == tun.Default + } + want, errWant := parseDuration(tun.Default) + got, errGot := parseDuration(documented) + if errWant != nil || errGot != nil { + return documented == tun.Default + } + return want == got +} + +// parseDuration accepts the two forms a default can take: a Go duration, or the +// literal "off" KeyValues renders for the Disabled sentinel. +func parseDuration(v string) (time.Duration, error) { + if v == "off" { + return Disabled, nil + } + return time.ParseDuration(v) +} + +// parseDocDefaults reads every markdown table in the reference and returns the +// Default cell for each documented field, keyed by the dotted config key. +// +// Columns are located by header name rather than by position, because the tables +// genuinely differ: the field tables are Field/Type/Default/Notes while the +// advanced table is Field/Default/What it controls. +func parseDocDefaults(t *testing.T, path string) map[string]string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + + out := map[string]string{} + fieldCol, defaultCol := -1, -1 + prefix := "" + + for rawLine := range strings.SplitSeq(string(data), "\n") { + line := strings.TrimSpace(rawLine) + if !strings.HasPrefix(line, "|") { + // A heading sets the key prefix for the tables under it: the + // advanced table lists bare field names (`switchWindowMax`) because + // its heading already says which block they live in. + if strings.HasPrefix(line, "#") { + prefix = "" + if strings.Contains(line, "vpn.advanced") { + prefix = "vpn.advanced." + } + } + // Any non-table line ends the current table, so a Default column + // index can never leak from one table into the next. + fieldCol, defaultCol = -1, -1 + continue + } + cells := splitRow(line) + + // A separator row (|---|---|) means the row above was the header. + if isSeparator(cells) { + continue + } + if fieldCol == -1 { + fieldCol, defaultCol = findColumns(cells) + continue + } + if defaultCol == -1 || fieldCol >= len(cells) || defaultCol >= len(cells) { + continue + } + + key := strings.Trim(strings.TrimSpace(cells[fieldCol]), "`") + if key == "" { + continue + } + // A bare name under a prefixed heading is that block's field; a name + // that is already dotted is used as written. + if _, known := TunableByKey(key); !known && prefix != "" { + key = prefix + key + } + // First table wins: a key documented twice keeps its primary row, and + // the duplicate is reported rather than silently shadowing it. + if prev, dup := out[key]; dup && prev != cells[defaultCol] { + t.Errorf("%s: %s is documented twice with different defaults (%q and %q)", + path, key, prev, cells[defaultCol]) + continue + } + out[key] = cells[defaultCol] + } + return out +} + +// splitRow splits a markdown table row into its cells, dropping the empty +// leading and trailing fields the surrounding pipes produce. +func splitRow(line string) []string { + parts := strings.Split(strings.Trim(line, "|"), "|") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + return parts +} + +func isSeparator(cells []string) bool { + for _, c := range cells { + if c == "" || strings.Trim(c, "-: ") != "" { + return false + } + } + return len(cells) > 0 +} + +// findColumns locates the Field and Default columns in a header row, returning +// -1 for a table that has neither (so it is skipped entirely). +func findColumns(header []string) (fieldCol, defaultCol int) { + fieldCol, defaultCol = -1, -1 + for i, h := range header { + switch strings.ToLower(strings.TrimSpace(h)) { + case "field", "key": + fieldCol = i + case "default": + defaultCol = i + } + } + if defaultCol == -1 { + return -1, -1 + } + return fieldCol, defaultCol +} + +// normalizeDocDefault turns a documented Default cell into the same rendering +// KeyValues produces, so the two are directly comparable. The bool is false when +// the cell is not a literal value at all (an em dash, prose, or empty). +func normalizeDocDefault(cell string) (string, bool) { + v := strings.TrimSpace(cell) + v = strings.TrimSpace(strings.Trim(v, "`")) + if v == "" || v == "—" || v == "-" { + return "", false + } + + // Lists render comma-joined with no spaces, matching KeyValues/joinInts. + if strings.HasPrefix(v, "[") && strings.HasSuffix(v, "]") { + var strs []string + if err := json.Unmarshal([]byte(v), &strs); err == nil { + return strings.Join(strs, ","), true + } + var nums []int + if err := json.Unmarshal([]byte(v), &nums); err == nil { + parts := make([]string, len(nums)) + for i, n := range nums { + parts[i] = strconv.Itoa(n) + } + return strings.Join(parts, ","), true + } + return "", false + } + + // Durations and strings are quoted in the tables ("15s", "info"); ints and + // bools are bare. + if unquoted, err := strconv.Unquote(v); err == nil { + return unquoted, true + } + if strings.ContainsAny(v, " ") { + return v, false // prose, e.g. "8 geo-IP URLs" + } + return v, true +} diff --git a/internal/config/pause_test.go b/internal/config/pause_test.go new file mode 100644 index 0000000..ed93ff1 --- /dev/null +++ b/internal/config/pause_test.go @@ -0,0 +1,106 @@ +package config + +import ( + "strings" + "testing" + "time" +) + +// The offered lengths must be usable as typed: a surface passes Value straight +// to `dezhban pause`, so a Value that does not parse back to its own Duration +// would silently grant a different pause than the label promised. +func TestPauseOptionValuesRoundTrip(t *testing.T) { + c := Default() + Normalize(&c) + for _, o := range PauseOptions(&c) { + got, err := time.ParseDuration(o.Value) + if err != nil { + t.Errorf("%s: Value %q does not parse: %v", o.Label, o.Value, err) + continue + } + if got != o.Duration { + t.Errorf("%s: Value %q parses to %v, want %v", o.Label, o.Value, got, o.Duration) + } + if o.Why == "" { + t.Errorf("%s: no reason given; a pause length should be chosen on the need, not the number", o.Label) + } + } +} + +// Over-cap options are LISTED and explained, never hidden and never shortened. +// Hiding them teaches the user their cap is something other than it is. +func TestPauseOptionsMarkOverCapWithoutHiding(t *testing.T) { + c := Default() + Normalize(&c) + c.VPN.PauseMax = 20 * time.Minute + + opts := PauseOptions(&c) + if len(opts) != len(pauseOptions) { + t.Fatalf("PauseOptions returned %d of %d — an over-cap option was hidden", len(opts), len(pauseOptions)) + } + for _, o := range opts { + overCap := o.Duration > c.VPN.PauseMax + if overCap && o.Unavailable == "" { + t.Errorf("%s is above the cap but is offered as available", o.Label) + } + if !overCap && o.Unavailable != "" { + t.Errorf("%s is within the cap but is marked unavailable: %s", o.Label, o.Unavailable) + } + if overCap && !strings.Contains(o.Unavailable, "20m") { + t.Errorf("%s: %q does not name the cap, so the user cannot tell what to change", + o.Label, o.Unavailable) + } + } +} + +// With pausing off, every option is unavailable and says why — the reason is +// the disabled setting, not the length. +func TestPauseOptionsWhenPausingIsDisabled(t *testing.T) { + c := Default() + Normalize(&c) + c.VPN.PauseMax = Disabled + + for _, o := range PauseOptions(&c) { + if o.Unavailable == "" { + t.Errorf("%s is offered even though pausing is disabled", o.Label) + } + if !strings.Contains(o.Unavailable, "pauseMax") { + t.Errorf("%s: %q does not name the setting that disabled it", o.Label, o.Unavailable) + } + } +} + +// PauseRefusal is where "clamp nothing silently" lives: an over-cap request is +// refused and explained rather than quietly shortened. +func TestPauseRefusal(t *testing.T) { + c := Default() + Normalize(&c) + c.VPN.PauseMax = 30 * time.Minute + + if got := PauseRefusal(&c, 15*time.Minute); got != "" { + t.Errorf("a within-cap pause was refused: %s", got) + } + if got := PauseRefusal(&c, 30*time.Minute); got != "" { + t.Errorf("a pause exactly at the cap was refused: %s", got) + } + // Unspecified: the daemon picks its own default, which is its business. + if got := PauseRefusal(&c, 0); got != "" { + t.Errorf("an unspecified duration was refused: %s", got) + } + + over := PauseRefusal(&c, time.Hour) + if over == "" { + t.Fatal("an over-cap pause was accepted; it would have been silently shortened") + } + for _, want := range []string{"1h", "30m", "vpn.pauseMax"} { + if !strings.Contains(over, want) { + t.Errorf("refusal %q does not mention %q — it must name what was asked, the cap, and the key", + over, want) + } + } + + c.VPN.PauseMax = Disabled + if got := PauseRefusal(&c, 5*time.Minute); got == "" { + t.Error("a pause was accepted with pausing disabled") + } +} diff --git a/internal/config/preset.go b/internal/config/preset.go index 4aeb067..76b28bd 100644 --- a/internal/config/preset.go +++ b/internal/config/preset.go @@ -253,3 +253,83 @@ func MatchPreset(c *Config) (name string, exact bool) { } return "", false } + +// Pause durations, defined once so the CLI and the app offer the same choices. +// +// A pause is the one relaxation a user asks for on purpose, so the question is +// never "how many seconds" but "how long do I need my real IP for". These are +// the realistic answers to that; anything else is still typeable +// (`dezhban pause 7m`), this is just what gets offered. +var pauseOptions = []PauseOption{ + {Label: "5 minutes", Duration: 5 * time.Minute, Why: "long enough to load one stubborn site"}, + {Label: "15 minutes", Duration: 15 * time.Minute, Why: "a bank transfer or a delivery tracker"}, + {Label: "30 minutes", Duration: 30 * time.Minute, Why: "a form you have to fill in properly"}, + {Label: "1 hour", Duration: time.Hour, Why: "a long call with a domestic-only service"}, + {Label: "2 hours", Duration: 2 * time.Hour, Why: "an evening of something the VPN's exit can't reach"}, +} + +// A PauseOption is one offered pause length. +type PauseOption struct { + Label string `json:"label"` + Duration time.Duration `json:"-"` + // Value is the Duration as `dezhban pause` accepts it, so a surface can + // pass it straight through without formatting a duration itself. + Value string `json:"value"` + // Why says what this length is for, so the choice is made on the need + // rather than on the number. + Why string `json:"why"` + // Unavailable is non-empty when vpn.pauseMax forbids this length, and says + // so in words. The option is still LISTED: silently hiding or shortening a + // choice teaches a user their cap is something other than it is. See the + // "clamp nothing silently" decision in issue #33's second addendum. + Unavailable string `json:"unavailable,omitempty"` +} + +// PauseOptions returns the offered pause lengths for a config, each marked +// available or not against that config's vpn.pauseMax. +// +// Options above the cap are returned, not filtered. A picker must show them as +// unavailable with the reason, because the alternative — quietly dropping them, +// or worse, accepting one and shortening it — leaves the user with a wrong idea +// of what their own setting does. +func PauseOptions(c *Config) []PauseOption { + max := c.VPN.PauseMax + out := make([]PauseOption, 0, len(pauseOptions)) + for _, o := range pauseOptions { + o.Value = durString(o.Duration) + switch { + case max <= 0: + o.Unavailable = "pausing is off (vpn.pauseMax is \"0\")" + case o.Duration > max: + o.Unavailable = fmt.Sprintf("longer than your %s cap (vpn.pauseMax)", durString(max)) + } + out = append(out, o) + } + return out +} + +// PauseRefusal reports why a requested pause duration cannot be granted, or "" +// when it can. +// +// This is where "clamp nothing silently" is enforced. The runner used to shorten +// an over-cap request to the cap, which is the same class of bug as accepting a +// disabled window and restoring the default: the user asked for one thing, got +// another, and was not told. +// +// runner.pauseDuration says the same things to a client that got past this one +// (a raw socket call, or a stale command file). Kept as two sentences rather +// than one helper because internal/runner takes an Options and never a +// *Config — change one, change the other. +func PauseRefusal(c *Config, requested time.Duration) string { + switch { + case c.VPN.PauseMax <= 0: + return "pausing is disabled (vpn.pauseMax is \"0\"). Set it to a duration to enable it." + case requested <= 0: + return "" + case requested > c.VPN.PauseMax: + return fmt.Sprintf("%s is longer than your %s cap (vpn.pauseMax). "+ + "Ask for %s or less, or raise the cap with `dezhban config set vpn.pauseMax=%s`.", + durString(requested), durString(c.VPN.PauseMax), durString(c.VPN.PauseMax), durString(requested)) + } + return "" +} diff --git a/internal/config/schema.go b/internal/config/schema.go new file mode 100644 index 0000000..14f675e --- /dev/null +++ b/internal/config/schema.go @@ -0,0 +1,451 @@ +package config + +import "sort" + +// This file exists because a default stated in more than one place is a default +// that will drift — and this one already had. Before it, every tunable's default +// was written down four times: the constants below, the macOS app's placeholder +// hints, the tables in docs/usage/config.md, and the example configs. The app was +// advertising a 30s endpoint refresh and a 5s tunnel watch while the shipped +// defaults were 1m and 1s, so anyone reading the settings pane was being told the +// wrong thing. +// +// The fix is to make defaults data. Every surface — the GUI's hints and slider +// bounds, `dezhban config schema`, the CLI's help — reads the table below instead +// of restating it, and a test fails the build when the docs or the example configs +// disagree with it. +// +// Crucially, a Tunable does NOT carry a hand-written default value. Defaults are +// derived from a normalized Default() config (see Defaults), so the only place a +// default number is written down stays the const block in config.go. A metadata +// table that restated them would just be a fifth copy. + +// Kind classifies a tunable's value shape, so a surface can pick a control for it +// without knowing the key. It describes the *shape*, not the meaning: two +// durations may mean very different things but both get a duration control. +type Kind string + +const ( + KindDuration Kind = "duration" + KindBool Kind = "bool" + KindInt Kind = "int" + KindList Kind = "list" // comma-separated, order-significant + KindString Kind = "string" // free text with no list semantics +) + +// A Tunable describes one settable config key well enough that a surface can +// render a control for it, bound it, explain it, and link to its documentation +// without hardcoding anything of its own. +type Tunable struct { + // Key is the dotted name `dezhban config set` accepts. It is the identity: + // every other table in the codebase is keyed by the same string. + Key string `json:"key"` + + // Label is the human name for the concept, in the vocabulary of + // docs/concepts/glossary.md. It never contains the config key — "Serialised + // forms are not a UI", and neither are JSON keys in a label. + Label string `json:"label"` + + Kind Kind `json:"kind"` + + // Default is the value this key takes when the config does not set it, + // rendered exactly as KeyValues renders it. Filled by Tunables from a + // normalized Default() — never written by hand. + Default string `json:"default"` + + // CapKey names the tunable that bounds this one, or "" when nothing does. + // It is a key rather than a number because the ceiling is itself settable: + // a slider's top must be read from the live config, not from a constant. + // + // Deliberately NOT transitive and deliberately not shared — the three windows + // have their own caps precisely so that widening one cannot widen another + // (see CLAUDE.md's window-trigger invariant). + CapKey string `json:"capKey,omitempty"` + + // Unit names what an int counts, for surfaces that want to write "16 + // endpoints" rather than "16". Empty for every other kind: a duration's unit + // is carried by its own Go duration syntax, and bools and lists count nothing. + Unit string `json:"unit,omitempty"` + + // Disablable reports whether "0" is an explicit, persisted opt-out for this + // key rather than "reset me to the default". Only the keys whose setters + // install the negative Disabled sentinel may set this — for every other + // duration, Normalize coerces a plain 0 back to the default, so offering an + // "Off" choice would silently discard the user's decision. + Disablable bool `json:"disablable"` + + // Advanced marks the `vpn.advanced.*` knobs, which surfaces put behind a + // disclosure with the "touch only if you know why" warning. + Advanced bool `json:"advanced"` + + // Help is one line explaining what the key does and what it costs. + Help string `json:"help"` + + // DocAnchor points at the section of the bundled documentation that covers + // this key, as "#". Section-level rather than per-key because + // that is the granularity docs/usage/config.md actually has; a test asserts + // every anchor resolves in the generated help bundle. + DocAnchor string `json:"docAnchor"` + + // RestartReason is why a running daemon cannot adopt this key in place, or "" + // when it can. Derived from restartReasonFor — never restated here, so a key + // cannot claim to be live in one table and restart-required in another. + RestartReason string `json:"restartReason,omitempty"` +} + +// LiveAppliable reports whether a running daemon adopts this key in place. +func (t Tunable) LiveAppliable() bool { return t.RestartReason == "" } + +// Doc anchors. Keys are documented by section, so these are the four sections of +// docs/usage/config.md that between them cover every key. +const ( + anchorFields = "usage/config.md#fields" + anchorControl = "usage/config.md#control-block" + anchorVPN = "usage/config.md#vpn-block" + anchorAdvanced = "usage/config.md#advanced-tunables-vpnadvanced" +) + +// tunables is the declared table, in the order surfaces should present it: +// what the guard blocks, then which tunnel it trusts, then how it behaves when +// that tunnel comes and goes, then the control socket, then the advanced knobs. +// +// Default is left empty here on purpose — Tunables fills it. See the file comment. +var tunables = []Tunable{ + { + Key: "blockedCountries", + Label: "Blocked countries", + Kind: KindList, + Help: "Exit countries the guard refuses. If your VPN surfaces in one of these, everything is cut until it moves.", + DocAnchor: anchorFields, + }, + { + Key: "pollInterval", + Label: "Exit country check interval", + Kind: KindDuration, + Help: "How often the current VPN exit's country is checked.", + DocAnchor: anchorFields, + }, + { + Key: "hysteresis", + Label: "Agreeing readings before a flip", + Kind: KindInt, + Unit: "readings", + Help: "How many consecutive agreeing readings it takes to change posture. Higher is slower to react but harder to fool with one bad lookup.", + DocAnchor: anchorFields, + }, + { + Key: "providers", + Label: "Exit country lookup services", + Kind: KindList, + Help: "Tried in order, so the first reachable one absorbs nearly all the traffic.", + DocAnchor: anchorFields, + }, + { + Key: "providerQuorum", + Label: "Require two services to agree", + Kind: KindBool, + Help: "Confirms each reading against a second service before it counts. Safer against one wrong answer; twice the lookups.", + DocAnchor: anchorFields, + }, + { + Key: "logLevel", + Label: "Log level", + Kind: KindString, + Help: "How much the daemon writes to its log: debug, info, warn, or error.", + DocAnchor: anchorFields, + }, + + { + Key: "vpn.tunnelInterfaces", + Label: "Your VPN tunnel", + Kind: KindList, + Help: "Tunnels named here are pinned: the guard trusts them and never prunes them automatically. Leave empty to rely on autodetection.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.endpoints", + Label: "VPN server addresses", + Kind: KindList, + Help: "The addresses your VPN client dials. The guard keeps these reachable on the physical link so a dropped tunnel can redial without any relaxation.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.autoDetect", + Label: "Find my VPN tunnel automatically", + Kind: KindBool, + Help: "Watches for tunnel interfaces appearing and disappearing instead of relying only on the pinned list.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.autoDiscoverEndpoints", + Label: "Find the VPN server address automatically", + Kind: KindBool, + Help: "Learns the address your VPN client is actually talking to, so a redial works without you typing it in.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.autoArm", + Label: "Arm the guard when a VPN connects", + Kind: KindBool, + Help: "With no VPN connected dezhban idles in standby and arms the moment a tunnel appears. It never disarms on a drop — that is the kill switch.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.armAtBoot", + Label: "Arm the guard at boot", + Kind: KindBool, + Help: "On a host where a tunnel has been up at least once, a reboot stays cut until the VPN redials rather than opening for however long that takes.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.allowLocalNetwork", + Label: "Keep local devices reachable", + Kind: KindBool, + Help: "Printers, NAS, your router's admin page, AirPlay and local dev servers keep working. Local destinations only, so nothing on the internet is opened — but on untrusted Wi-Fi it also lets that network reach you.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.allowPhysicalDNS", + Label: "Allow DNS on the physical link", + Kind: KindBool, + Help: "Lets name lookups use your ISP's resolver while the tunnel is down. Convenient, but your ISP sees what you look up.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.switchWindow", + Label: "Switch window", + Kind: KindDuration, + CapKey: "vpn.advanced.switchWindowMax", + Disablable: true, + Help: "How long the guard relaxes when you ask to switch VPN by hand. Off means nothing you type can relax it.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.redialWindow", + Label: "Redial window", + Kind: KindDuration, + CapKey: "vpn.advanced.redialWindowMax", + Disablable: true, + Help: "How long the guard relaxes by itself when a healthy tunnel drops, so your VPN can redial. Your real IP may be exposed for that long. Off is the strict, zero-leak choice.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.pauseMax", + Label: "Longest pause", + Kind: KindDuration, + Disablable: true, + Help: "The most you can pause the guard for in one go, when you deliberately want your real IP. Off removes pausing entirely.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.endpointRefresh", + Label: "VPN server address refresh", + Kind: KindDuration, + Help: "How often a VPN server hostname is re-resolved, so a rotated address is noticed.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.endpointGrace", + Label: "VPN server address grace", + Kind: KindDuration, + Help: "How long a discovered VPN server stays reachable after its connection disappears, so a dropped VPN can redial the same server.", + DocAnchor: anchorVPN, + }, + { + Key: "vpn.tunnelWatch", + Label: "Tunnel check interval", + Kind: KindDuration, + Help: "How often the tunnel is checked for coming up or going down. This is how fast a drop is noticed.", + DocAnchor: anchorVPN, + }, + + { + Key: "control.enabled", + Label: "Control socket", + Kind: KindBool, + Help: "Lets an authorised local client ask the running daemon to act, instead of every command needing root.", + DocAnchor: anchorControl, + }, + { + Key: "control.socket", + Label: "Control socket path", + Kind: KindString, + Help: "Where the control socket is bound. Empty means the daemon picks the path under its own state directory.", + DocAnchor: anchorControl, + }, + { + Key: "control.group", + Label: "Control socket group", + Kind: KindString, + Help: "The group allowed to talk to the socket. Membership in it is the authorisation.", + DocAnchor: anchorControl, + }, + { + Key: "control.allowSwitchOps", + Label: "Allow switch windows over the socket", + Kind: KindBool, + Help: "Lets opening and cancelling a switch window go through the daemon. Off makes those root-only again.", + DocAnchor: anchorControl, + }, + { + Key: "control.allowPauseOps", + Label: "Allow pause over the socket", + Kind: KindBool, + Help: "Lets pause and resume go through the daemon. Independent of switch windows: turning one off leaves the other alone.", + DocAnchor: anchorControl, + }, + { + Key: "control.allowConfigOps", + Label: "Allow settings changes over the socket", + Kind: KindBool, + Help: "Lets an enrolled, token-holding client change settings without root. The token gate still applies.", + DocAnchor: anchorControl, + }, + + { + Key: "vpn.advanced.switchWindowMax", + Label: "Switch window cap", + Kind: KindDuration, + Advanced: true, + Help: "The ceiling on a manual switch window. Its own cap on purpose — raising it must never widen the redial window or a pause.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.redialWindowMax", + Label: "Redial window cap", + Kind: KindDuration, + Advanced: true, + Help: "The ceiling on an automatic redial window, and so the most exposure one dropped tunnel can cause.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.redialMinUptime", + Label: "Redial anti-flap uptime", + Kind: KindDuration, + Advanced: true, + Disablable: true, + Help: "A tunnel that was up for less than this gets no automatic window, so a flapping VPN cannot chain windows into standing exposure. Off removes the gate.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.commandFreshness", + Label: "Command freshness", + Kind: KindDuration, + Advanced: true, + Help: "How recent a control command must be to be acted on — the replay guard.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.windowDiscoveryInterval", + Label: "Window discovery interval", + Kind: KindDuration, + Advanced: true, + Help: "How often a new VPN server is looked for while a window is open.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.tunnelPruneAfter", + Label: "Tunnel prune delay", + Kind: KindDuration, + Advanced: true, + Help: "How long a detected tunnel must be gone before it is dropped from the trusted set. Pinned tunnels are never pruned.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.learnedEndpointTTL", + Label: "Learned address lifetime", + Kind: KindDuration, + Advanced: true, + Help: "How long an unused learned VPN server address is kept.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.learnedMaxPerProfile", + Label: "Learned addresses per profile", + Kind: KindInt, + Unit: "addresses", + Advanced: true, + Help: "How many learned VPN server addresses are kept for each profile before the oldest is forgotten.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.promoteAfterRefreshes", + Label: "Sightings before an address is learned", + Kind: KindInt, + Unit: "sightings", + Advanced: true, + Help: "Consecutive sightings before a discovered VPN server address is learned under a normal guard.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.endpointWarnThreshold", + Label: "Address-bloat warning threshold", + Kind: KindInt, + Unit: "addresses", + Advanced: true, + Help: "How many resolved VPN server addresses it takes before dezhban warns that the set has grown unreasonably.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.windowProtocols", + Label: "Window protocols", + Kind: KindList, + Advanced: true, + Help: "Restricts an open window to these protocols instead of allowing all outbound traffic.", + DocAnchor: anchorAdvanced, + }, + { + Key: "vpn.advanced.windowPorts", + Label: "Window ports", + Kind: KindList, + Advanced: true, + Help: "Restricts an open window to these ports instead of allowing all outbound traffic.", + DocAnchor: anchorAdvanced, + }, +} + +// Defaults renders every key's default exactly as KeyValues renders a live value, +// so the two are directly comparable. It is derived rather than declared: a +// normalized Default() IS the definition of "what you get if you set nothing", +// and asking it beats maintaining a second copy that can disagree with it. +func Defaults() map[string]string { + c := Default() + Normalize(&c) + return KeyValues(&c) +} + +// Tunables returns the declared table with Default and RestartReason filled in +// from the code that actually decides them. Callers get a fresh slice, so a +// surface cannot mutate the table for everyone else. +func Tunables() []Tunable { + defaults := Defaults() + out := make([]Tunable, len(tunables)) + for i, t := range tunables { + t.Default = defaults[t.Key] + t.RestartReason = restartReasonFor(t.Key) + out[i] = t + } + return out +} + +// TunableByKey looks one key up. The bool is false for a key that is not +// settable, which callers should treat as "unknown key", not "no metadata". +func TunableByKey(key string) (Tunable, bool) { + for _, t := range Tunables() { + if t.Key == key { + return t, true + } + } + return Tunable{}, false +} + +// TunableKeys lists every settable key, sorted. Handy for tests and for CLI help +// that wants a stable order rather than the presentation order. +func TunableKeys() []string { + out := make([]string, 0, len(tunables)) + for _, t := range tunables { + out = append(out, t.Key) + } + sort.Strings(out) + return out +} diff --git a/internal/config/schema_test.go b/internal/config/schema_test.go new file mode 100644 index 0000000..e47bfbc --- /dev/null +++ b/internal/config/schema_test.go @@ -0,0 +1,214 @@ +package config + +import ( + "strings" + "testing" + "time" +) + +// The point of these tests is that the declared table cannot quietly fall out of +// step with the code. A metadata table nobody checks is just a fifth copy of the +// defaults with extra steps. + +// TestTunablesCoverEverySettableKey is the load-bearing one: the declared table +// and the settable-key set must be exactly equal, in both directions. A new key +// added to KeyValues without a Tunable would reach the config file with no label, +// no help, and no default for any surface to show; a Tunable for a key KeyValues +// does not carry would describe something nobody can set. +func TestTunablesCoverEverySettableKey(t *testing.T) { + c := Default() + Normalize(&c) + settable := KeyValues(&c) + + declared := map[string]bool{} + for _, tun := range Tunables() { + if declared[tun.Key] { + t.Errorf("tunable %q is declared twice", tun.Key) + } + declared[tun.Key] = true + } + + for key := range settable { + if !declared[key] { + t.Errorf("settable key %q has no Tunable — add one to schema.go", key) + } + } + for key := range declared { + if _, ok := settable[key]; !ok { + t.Errorf("Tunable %q is not a settable key — remove it or add it to KeyValues", key) + } + } +} + +// TestTunableDefaultsMatchANormalizedDefaultConfig pins the derivation itself. +// If Tunables ever grew a hand-written Default, this is what would catch it +// disagreeing with what you actually get by setting nothing. +func TestTunableDefaultsMatchANormalizedDefaultConfig(t *testing.T) { + c := Default() + Normalize(&c) + want := KeyValues(&c) + + for _, tun := range Tunables() { + if got := tun.Default; got != want[tun.Key] { + t.Errorf("%s: declared default %q, but a normalized Default() yields %q", + tun.Key, got, want[tun.Key]) + } + } +} + +// TestTunableRestartClassificationMatchesReload keeps one classification, not two. +// Telling a user in the settings pane that a key applies live while the reload +// path reports it as restart-required is the same failure as claiming a change +// applied when the old value is still being enforced. +func TestTunableRestartClassificationMatchesReload(t *testing.T) { + for _, tun := range Tunables() { + want := restartReasonFor(tun.Key) + if tun.RestartReason != want { + t.Errorf("%s: RestartReason %q, want %q", tun.Key, tun.RestartReason, want) + } + if tun.LiveAppliable() != (want == "") { + t.Errorf("%s: LiveAppliable() disagrees with its own RestartReason", tun.Key) + } + } +} + +// TestTunableCapKeysResolve — a cap is a key rather than a number precisely so a +// surface can read the live ceiling, which only works if the key exists. +func TestTunableCapKeysResolve(t *testing.T) { + for _, tun := range Tunables() { + if tun.CapKey == "" { + continue + } + ceiling, ok := TunableByKey(tun.CapKey) + if !ok { + t.Errorf("%s: CapKey %q is not a settable key", tun.Key, tun.CapKey) + continue + } + if ceiling.Kind != tun.Kind { + t.Errorf("%s is %s but its cap %s is %s — a cap must be comparable to what it bounds", + tun.Key, tun.Kind, ceiling.Key, ceiling.Kind) + } + if ceiling.CapKey != "" { + t.Errorf("%s: cap %s is itself capped by %s; caps are deliberately not transitive", + tun.Key, ceiling.Key, ceiling.CapKey) + } + } +} + +// TestDisablableKeysSurviveNormalize is the one that protects the worst bug this +// tool can have. Disablable promises a surface it may offer an explicit "Off"; +// that promise is only honest if the negative sentinel actually survives +// Normalize instead of being coerced back to the default. +func TestDisablableKeysSurviveNormalize(t *testing.T) { + // Field accessors kept test-local: the production key→field table lives in + // cmd/dezhban's configFields, and internal/config must not depend on it. + fields := map[string]func(*Config) *time.Duration{ + "vpn.switchWindow": func(c *Config) *time.Duration { return &c.VPN.SwitchWindow }, + "vpn.redialWindow": func(c *Config) *time.Duration { return &c.VPN.RedialWindow }, + "vpn.pauseMax": func(c *Config) *time.Duration { return &c.VPN.PauseMax }, + "vpn.advanced.redialMinUptime": func(c *Config) *time.Duration { return &c.VPN.Advanced.RedialMinUptime }, + } + + var disablable []string + for _, tun := range Tunables() { + if tun.Disablable { + disablable = append(disablable, tun.Key) + } + } + if len(disablable) != len(fields) { + t.Fatalf("Disablable keys are %v, but this test knows how to set %d of them — "+ + "a new disablable key needs an entry here", disablable, len(fields)) + } + + for _, key := range disablable { + get, ok := fields[key] + if !ok { + t.Errorf("%s is marked Disablable but this test cannot reach its field", key) + continue + } + c := Default() + *get(&c) = Disabled + Normalize(&c) + if got := *get(&c); got != Disabled { + t.Errorf("%s: Normalize turned the explicit off-sentinel into %v — "+ + "a security setting was accepted and silently discarded", key, got) + } + if got := KeyValues(&c)[key]; got != "off" { + t.Errorf("%s: reads as %q once disabled, want \"off\"", key, got) + } + } +} + +// TestNonDisablableDurationsCoerceZeroToTheirDefault is the mirror image: a key +// NOT marked Disablable must be one where Normalize really does restore the +// default, so no surface offers an "Off" that would silently do nothing. +func TestNonDisablableDurationsCoerceZeroToTheirDefault(t *testing.T) { + for _, tun := range Tunables() { + if tun.Kind != KindDuration || tun.Disablable { + continue + } + if tun.Default == "off" { + t.Errorf("%s is not Disablable yet defaults to off", tun.Key) + } + } +} + +// TestTunableMetadataIsComplete — every field a surface relies on is populated, +// and the ones that are meaningful for only one Kind stay empty elsewhere. +func TestTunableMetadataIsComplete(t *testing.T) { + for _, tun := range Tunables() { + if tun.Label == "" { + t.Errorf("%s: no Label", tun.Key) + } + if tun.Help == "" { + t.Errorf("%s: no Help", tun.Key) + } + if tun.Kind == "" { + t.Errorf("%s: no Kind", tun.Key) + } + if tun.DocAnchor == "" { + t.Errorf("%s: no DocAnchor", tun.Key) + } else if !strings.Contains(tun.DocAnchor, "#") { + t.Errorf("%s: DocAnchor %q is not of the form page#anchor", tun.Key, tun.DocAnchor) + } + if tun.Unit != "" && tun.Kind != KindInt { + t.Errorf("%s: Unit %q on a %s — units name what an int counts", tun.Key, tun.Unit, tun.Kind) + } + if tun.Disablable && tun.Kind != KindDuration { + t.Errorf("%s: only durations carry the off-sentinel", tun.Key) + } + // The label is what a user reads; the key is the machine's name for it. + // Mixing them is the "Serialised forms are not a UI" entry in the glossary. + if strings.Contains(tun.Label, tun.Key) { + t.Errorf("%s: Label %q restates the config key", tun.Key, tun.Label) + } + if wantAdvanced := strings.HasPrefix(tun.Key, "vpn.advanced."); tun.Advanced != wantAdvanced { + t.Errorf("%s: Advanced=%v, want %v (it is set by the key prefix)", + tun.Key, tun.Advanced, wantAdvanced) + } + } +} + +// TestTunableKeysAreSorted — TunableKeys promises a stable order to CLI help and +// tests, which is only useful if it is actually stable. +func TestTunableKeysAreSorted(t *testing.T) { + keys := TunableKeys() + if len(keys) != len(tunables) { + t.Fatalf("TunableKeys returned %d keys, want %d", len(keys), len(tunables)) + } + for i := 1; i < len(keys); i++ { + if keys[i-1] >= keys[i] { + t.Fatalf("TunableKeys is not sorted: %q before %q", keys[i-1], keys[i]) + } + } +} + +// TestTunablesReturnsACopy — a surface that mutates what it is handed must not +// change what the next caller sees. +func TestTunablesReturnsACopy(t *testing.T) { + first := Tunables() + first[0].Label = "mutated" + if Tunables()[0].Label == "mutated" { + t.Fatal("Tunables handed out the shared table; callers can corrupt it for everyone") + } +} diff --git a/internal/control/protocol.go b/internal/control/protocol.go index 4fb9cc7..2b2c383 100644 --- a/internal/control/protocol.go +++ b/internal/control/protocol.go @@ -64,6 +64,19 @@ const ( // additionally governed by control.allowConfigOps. A client that cannot // prove it holds the enrolled token is refused, whatever its group. OpConfigWrite Op = "config-write" + // OpHoldArm arms "hold the line": the next tunnel drop stays cut instead of + // opening an automatic redial window, so a deliberate disconnect does not + // get a relaxation the operator never asked for. + // + // Ungated, and deliberately so. Every other gate exists to withhold an + // authority; this op grants none — it only ever SUPPRESSES a relaxation, so + // the worst an unwanted call can do is leave the host more protected than it + // would otherwise be. Adding a control.allow* flag would imply there is + // something here to protect against, and would give an operator a way to + // switch off the safer behaviour. + OpHoldArm Op = "hold-arm" + // OpHoldCancel disarms it, so the next drop opens a redial window normally. + OpHoldCancel Op = "hold-cancel" // OpReload makes the daemon re-read its own config file and adopt whatever // it can without restarting. Ungated, and deliberately so: the config file // is root-owned, so this op grants no authority its caller did not already diff --git a/internal/control/server.go b/internal/control/server.go index 707e4ed..583e4a3 100644 --- a/internal/control/server.go +++ b/internal/control/server.go @@ -212,7 +212,8 @@ func (s *Server) serve(ctx context.Context, conn net.Conn) { } switch req.Op { - case OpStatus, OpBlock, OpUnblock, OpOpenSwitch, OpCancelSwitch, OpPause, OpResume, OpReload: + case OpStatus, OpBlock, OpUnblock, OpOpenSwitch, OpCancelSwitch, OpPause, OpResume, OpReload, + OpHoldArm, OpHoldCancel: case OpConfigWrite: // Authorised here rather than in the run loop, so a request that cannot // prove itself never reaches the goroutine that touches the firewall. diff --git a/internal/help/bundle.go b/internal/help/bundle.go new file mode 100644 index 0000000..4c34bdb --- /dev/null +++ b/internal/help/bundle.go @@ -0,0 +1,163 @@ +package help + +import ( + "encoding/json" + "fmt" + "html" + "os" + "path/filepath" + "strings" +) + +// IndexEntry is one page in help-index.json, which the app's sidebar and search +// are built from. Keys are lowerCamelCase, the same convention every other +// JSON the app decodes uses. +type IndexEntry struct { + File string `json:"file"` + Source string `json:"source"` + Title string `json:"title"` + Summary string `json:"summary"` + Tutorial int `json:"tutorial,omitempty"` + Headings []Heading `json:"headings"` + // Text is the page stripped to words, so search can run entirely in the app + // with no second pass over the HTML. + Text string `json:"text"` +} + +// Build renders every manifest page from docsDir into outDir, writing the HTML, +// help.css, and help-index.json. It reports the index it wrote. +// +// A missing or renamed source is an error, never a skipped page: a help bundle +// that is quietly missing the troubleshooting page is worse than a build that +// stops. +func Build(docsDir, outDir string) ([]IndexEntry, error) { + if err := os.MkdirAll(outDir, 0o755); err != nil { + return nil, fmt.Errorf("create %s: %w", outDir, err) + } + + index := make([]IndexEntry, 0, len(Pages)) + for _, page := range Pages { + src := filepath.Join(docsDir, filepath.FromSlash(page.Source)) + data, err := os.ReadFile(src) + if err != nil { + return nil, fmt.Errorf("bundled page %s: %w (it is referenced by internal/help; "+ + "moving or renaming a doc means updating the manifest)", page.Source, err) + } + + rendered := Render(page.Source, string(data)) + if len(rendered.Unsupported) > 0 { + return nil, fmt.Errorf("%s uses markdown the help renderer cannot show: %s", + page.Source, strings.Join(rendered.Unsupported, ", ")) + } + + name := OutputName(page.Source) + if err := os.WriteFile(filepath.Join(outDir, name), []byte(document(page, rendered)), 0o644); err != nil { + return nil, fmt.Errorf("write %s: %w", name, err) + } + + index = append(index, IndexEntry{ + File: name, Source: page.Source, Title: page.Title, Summary: page.Summary, + Tutorial: page.Tutorial, Headings: rendered.Headings, Text: rendered.Text, + }) + } + + if err := os.WriteFile(filepath.Join(outDir, "help.css"), []byte(stylesheet), 0o644); err != nil { + return nil, fmt.Errorf("write help.css: %w", err) + } + data, err := json.MarshalIndent(index, "", " ") + if err != nil { + return nil, fmt.Errorf("encode help-index.json: %w", err) + } + if err := os.WriteFile(filepath.Join(outDir, "help-index.json"), data, 0o644); err != nil { + return nil, fmt.Errorf("write help-index.json: %w", err) + } + return index, nil +} + +// document wraps rendered body HTML in a page. The stylesheet is a local file +// and there is nothing else: no script, no font, no image from anywhere but the +// bundle. This has to work with every byte of egress cut, which is exactly when +// it will be opened. +func document(page Page, r Rendered) string { + var b strings.Builder + b.WriteString("\n\n\n") + b.WriteString("\n") + // The pane's navigation delegate only sees NAVIGATIONS, so it is not what + // would stop an or an @import from reaching the network. This is: the + // page itself declares that nothing but its own stylesheet may load. Three + // layers now say the same thing — the renderer refuses a remote src, the + // bundle test greps for one, and the page cannot execute it either — because + // the guarantee ("it never touches the network") is the reason this feature + // exists at all, and the pane is opened when there is no network to check. + // + // `file:` is named alongside 'self' deliberately. A page loaded from a file: + // URL has an opaque origin, so whether 'self' matches its own sibling + // stylesheet is a WebKit implementation detail — and a CSP that quietly + // blocked help.css would ship an unstyled help pane, which is a worse + // regression than the one this prevents. Naming the scheme keeps the part + // that matters (no http, no https, nothing off-disk) independent of that. + b.WriteString("\n") + b.WriteString("" + html.EscapeString(page.Title) + "\n") + b.WriteString("\n") + b.WriteString("\n\n") + b.WriteString(r.HTML) + b.WriteString("\n\n") + return b.String() +} + +// stylesheet follows the system appearance in both directions, since the pane +// sits inside a native window and a light page in a dark app reads as a bug. +const stylesheet = ` +:root { + color-scheme: light dark; + --fg: #1d1d1f; + --muted: #6e6e73; + --bg: #ffffff; + --rule: #d2d2d7; + --code-bg: #f5f5f7; + --link: #0066cc; +} +@media (prefers-color-scheme: dark) { + :root { + --fg: #f5f5f7; --muted: #a1a1a6; --bg: #1e1e1e; + --rule: #3a3a3c; --code-bg: #2a2a2c; --link: #4da3ff; + } +} +* { box-sizing: border-box; } +body { + margin: 0; padding: 24px 28px 64px; + font: 14px/1.6 -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif; + color: var(--fg); background: var(--bg); + overflow-wrap: break-word; +} +h1, h2, h3, h4 { line-height: 1.25; margin: 1.6em 0 0.5em; } +h1 { font-size: 1.9em; margin-top: 0; } +h2 { font-size: 1.4em; border-bottom: 1px solid var(--rule); padding-bottom: 0.25em; } +h3 { font-size: 1.15em; } +h4 { font-size: 1em; color: var(--muted); } +p, li { margin: 0.6em 0; } +a { color: var(--link); text-decoration: none; } +a:hover { text-decoration: underline; } +code { + font: 12.5px/1.5 ui-monospace, "SF Mono", Menlo, monospace; + background: var(--code-bg); padding: 0.15em 0.35em; border-radius: 4px; +} +pre { + background: var(--code-bg); padding: 12px 14px; border-radius: 8px; + overflow-x: auto; +} +pre code { background: none; padding: 0; } +blockquote { + margin: 1em 0; padding: 0.2em 1em; + border-left: 3px solid var(--rule); color: var(--muted); +} +hr { border: none; border-top: 1px solid var(--rule); margin: 2em 0; } +/* Wide tables scroll inside their own box so the page body never does. */ +.table-scroll { overflow-x: auto; margin: 1em 0; } +table { border-collapse: collapse; width: 100%; font-size: 13px; } +th, td { border: 1px solid var(--rule); padding: 6px 10px; text-align: left; vertical-align: top; } +th { background: var(--code-bg); font-weight: 600; } +img { max-width: 100%; } +:target { scroll-margin-top: 12px; background: rgba(255, 214, 10, 0.25); } +` diff --git a/internal/help/help_test.go b/internal/help/help_test.go new file mode 100644 index 0000000..3505944 --- /dev/null +++ b/internal/help/help_test.go @@ -0,0 +1,414 @@ +package help + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/behnam-rk/dezhban/internal/config" +) + +const docsDir = "../../docs" + +// TestEveryBundledPageExists is the load-bearing-doc rule from CLAUDE.md, +// enforced. A page listed here ships inside the app, so moving or renaming a doc +// without updating the manifest would quietly drop it from the bundle — and the +// page most likely to be missed is the one a locked-out user opens. +// +// It runs in `go test ./...` rather than only in CI, so the failure lands on the +// machine that made the change. +func TestEveryBundledPageExists(t *testing.T) { + for _, page := range Pages { + path := filepath.Join(docsDir, filepath.FromSlash(page.Source)) + if _, err := os.Stat(path); err != nil { + t.Errorf("bundled page %q does not exist: %v", page.Source, err) + } + if page.Title == "" || page.Summary == "" { + t.Errorf("%s: a bundled page needs a title and a one-line summary", page.Source) + } + } +} + +// TestBundleBuilds renders the real docs, which is the only way to know the +// subset renderer copes with what is actually written. +func TestBundleBuilds(t *testing.T) { + index := buildInto(t) + + if len(index) != len(Pages) { + t.Fatalf("index has %d entries, want %d", len(index), len(Pages)) + } + for _, e := range index { + if len(e.Headings) == 0 { + t.Errorf("%s rendered with no headings — it probably did not render at all", e.Source) + } + if len(e.Text) < 200 { + t.Errorf("%s produced %d characters of searchable text, which is implausibly little", + e.Source, len(e.Text)) + } + } +} + +// TestEveryTunableDocAnchorResolves ties the settings schema to the bundle. A +// contextual help link is a promise that the section exists; a stale anchor +// silently lands the reader at the top of a long reference page instead. +func TestEveryTunableDocAnchorResolves(t *testing.T) { + index := buildInto(t) + + anchors := map[string]map[string]bool{} + for _, e := range index { + set := map[string]bool{} + for _, h := range e.Headings { + set[h.Anchor] = true + } + anchors[e.Source] = set + } + + for _, tun := range config.Tunables() { + page, frag, found := strings.Cut(tun.DocAnchor, "#") + if !found { + t.Errorf("%s: DocAnchor %q has no fragment", tun.Key, tun.DocAnchor) + continue + } + set, ok := anchors[page] + if !ok { + t.Errorf("%s: DocAnchor names %q, which is not a bundled page", tun.Key, page) + continue + } + if !set[frag] { + t.Errorf("%s: no heading in %s has the anchor %q", tun.Key, page, frag) + } + } +} + +// TestBundleIsSelfContained — the pane opens when the kill switch has cut every +// byte of egress, so a page that reaches for a CDN would render broken at +// exactly the moment it is needed. +func TestBundleIsSelfContained(t *testing.T) { + dir := t.TempDir() + if _, err := Build(docsDir, dir); err != nil { + t.Fatalf("Build: %v", err) + } + entries, err := filepath.Glob(filepath.Join(dir, "*.html")) + if err != nil || len(entries) == 0 { + t.Fatalf("no rendered pages: %v", err) + } + for _, path := range entries { + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + html := string(data) + for _, forbidden := range []string{" a quote\n\n" + + "| Field | Default |\n|---|---|\n| `a` | `1s` |\n\n" + + "```sh\necho hi\n```\n\n" + + "---\n" + r := Render("usage/config.md", md) + + if len(r.Unsupported) != 0 { + t.Fatalf("the subset renderer reported %v on its own subset", r.Unsupported) + } + for _, want := range []string{ + "

Title

", "code", "bold", + "
    ", "
      ", "
      ", "", "", "", + "
      ", "
      ", + } { + if !strings.Contains(r.HTML, want) { + t.Errorf("rendered HTML is missing %q", want) + } + } + // A link to a bundled page is rewritten to the bundled copy; the fragment + // survives, since that is what a contextual deep link depends on. + if !strings.Contains(r.HTML, `href="usage-config.html#fields"`) { + t.Errorf("cross-document link was not rewritten to the bundled page:\n%s", r.HTML) + } +} + +// Content inside a fence is verbatim: a shell example full of pipes and dashes +// must not be read as a table, and markup in it must not be interpreted. +func TestFencedCodeIsVerbatim(t *testing.T) { + r := Render("", "```sh\n| a | b |\n|---|---|\n**not bold**\n```\n") + if strings.Contains(r.HTML, "
      Field1s
      ") { + t.Error("a table inside a code fence was rendered as a table") + } + if strings.Contains(r.HTML, "") { + t.Error("emphasis inside a code fence was interpreted") + } +} + +// TestEveryRealPageRendersCleanly is the test the synthetic subset sample above +// cannot be: it runs the renderer over the pages that actually ship. +// +// Both checks failed before they existed. usage/getting-started.md — the first +// page of the guided track — opened with a raw HTML banner that rendered as +// three paragraphs of visible tag source, and usage/cli.md left literal ** in +// the middle of a sentence. Render reported neither, so the build passed and the +// bundle shipped wrong. Asserting on the rendered HTML, not just on Unsupported, +// is what makes this independent of the reporting it is meant to police. +func TestEveryRealPageRendersCleanly(t *testing.T) { + for _, page := range Pages { + data, err := os.ReadFile(filepath.Join(docsDir, filepath.FromSlash(page.Source))) + if err != nil { + t.Fatalf("%s: %v", page.Source, err) + } + r := Render(page.Source, string(data)) + if len(r.Unsupported) != 0 { + t.Errorf("%s: renderer reported %v", page.Source, r.Unsupported) + } + // A paragraph or list item opening with an escaped tag is markup that + // leaked through as text. Inline code legitimately contains <, hence + // anchoring on the element boundary rather than searching for <. + for _, leak := range []string{"

      <", "

    1. <"} { + if strings.Contains(r.HTML, leak) { + t.Errorf("%s: raw HTML rendered as visible text (%q)", page.Source, leak) + } + } + // Fenced code is verbatim and may legitimately contain **, so only the + // prose outside it is checked. + if strings.Contains(stripFences(r.HTML), "**") { + t.Errorf("%s: literal ** survived into the rendered page", page.Source) + } + } +} + +// stripFences removes
       blocks, whose contents are verbatim by design.
      +func stripFences(html string) string {
      +	var b strings.Builder
      +	for rest := html; ; {
      +		start := strings.Index(rest, "
      ")
      +		if start < 0 {
      +			b.WriteString(rest)
      +			return b.String()
      +		}
      +		b.WriteString(rest[:start])
      +		end := strings.Index(rest[start:], "
      ") + if end < 0 { + return b.String() + } + rest = rest[start+end+len("
      "):] + } +} + +// TestEveryLinkGoesSomewhere — no bundled page may contain a link that resolves +// to nothing. +// +// Thirty did. The docs cross-reference the ADRs and the contributor docs +// constantly, and rewriteLink used to return an unmatched relative link +// unchanged, so "../adr/0008-arm-at-boot.md" shipped verbatim and resolved +// beside the bundle, where no such file exists. Clicking one produced "That link +// points outside the app: file:///…/Contents/Resources/adr/0008-arm-at-boot.md" +// — an internal path, on a Copy button that copied it. Every page had them, and +// the only existing link assertion covered the case that DID resolve. +// +// The rule is checked against the built bundle rather than the renderer, because +// "resolves to nothing" is a fact about the directory, not about the markdown. +func TestEveryLinkGoesSomewhere(t *testing.T) { + dir := t.TempDir() + if _, err := Build(docsDir, dir); err != nil { + t.Fatalf("Build: %v", err) + } + href := regexp.MustCompile(`href="([^"]*)"`) + for _, page := range Pages { + name := OutputName(page.Source) + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatal(err) + } + for _, m := range href.FindAllStringSubmatch(string(data), -1) { + link := m[1] + switch { + case strings.HasPrefix(link, "#"), link == "help.css": + continue + case strings.HasPrefix(link, "https://"), strings.HasPrefix(link, "http://"), + strings.HasPrefix(link, "mailto:"): + // Off-bundle by design: the pane reports it and offers the URL. + continue + } + file, _, _ := strings.Cut(link, "#") + if _, err := os.Stat(filepath.Join(dir, file)); err != nil { + t.Errorf("%s links to %q, which is not in the bundle — it would resolve "+ + "to a file that does not exist", page.Source, link) + } + } + } +} + +// TestRenderReportsWhatItCannotShow pins the reporting itself. Each of these +// rendered silently — and wrongly — before, which is the failure mode the whole +// bundled-docs design depends on not having. +func TestRenderReportsWhatItCannotShow(t *testing.T) { + cases := map[string]string{ + "raw HTML block": "

      \n \n

      \n", + "nested list item": "- parent\n - child\n", + "unpaired bold": "A sentence with **one marker only.\n", + "javascript: link": "A [trap](javascript:alert(1)) link.\n", + "unclosed code fence": "```sh\necho hi\n", + } + for name, md := range cases { + if r := Render("usage/config.md", md); len(r.Unsupported) == 0 { + t.Errorf("%s was rendered without being reported:\n%s", name, r.HTML) + } + } + // A refused scheme must also not reach the href, so the bundle cannot carry + // the link even if someone ships past the build failure. + if r := Render("usage/config.md", "[trap](javascript:alert(1))\n"); strings.Contains(r.HTML, "javascript:") { + t.Errorf("a javascript: href survived into the rendered page:\n%s", r.HTML) + } +} + +// TestRelativeLinkCannotClimbAboveTheRepoRoot pins rewriteLink's bound on ".." +// segments: enough of them turn "docs/
      /" into a path that climbs +// past the repo root, which repoBase would otherwise turn into a URL pointing +// outside the project (or nowhere). Refused the same way a disallowed scheme +// is, rather than shipped. +func TestRelativeLinkCannotClimbAboveTheRepoRoot(t *testing.T) { + md := "[trap](../../../../../../../etc/passwd)\n" + r := Render("usage/config.md", md) + if len(r.Unsupported) == 0 { + t.Fatalf("a link climbing above the repo root was rendered without being reported:\n%s", r.HTML) + } + if strings.Contains(r.HTML, "etc/passwd") { + t.Errorf("the climbing path survived into the rendered href:\n%s", r.HTML) + } +} + +// TestInlineMarkupSpansSoftLineBreaks — these docs are hard-wrapped prose, so +// emphasis routinely straddles a line break. Rendering line by line left the ** +// as literal text in eight of the nine bundled pages. +func TestInlineMarkupSpansSoftLineBreaks(t *testing.T) { + r := Render("", "The guard **fail-closes\nand stays closed** afterwards.\n") + if !strings.Contains(r.HTML, "fail-closes and stays closed") { + t.Errorf("bold did not survive a soft line break:\n%s", r.HTML) + } + if len(r.Unsupported) != 0 { + t.Errorf("a soft-wrapped bold span was reported as unsupported: %v", r.Unsupported) + } +} + +// A list item's continuation belongs INSIDE its
    2. . Emitting it afterwards put +// loose text directly inside the
        , which is invalid. +func TestListItemContinuationStaysInsideTheItem(t *testing.T) { + r := Render("", "- first line\n second line\n- next item\n") + if !strings.Contains(r.HTML, "
      • first line second line
      • ") { + t.Errorf("continuation leaked out of its list item:\n%s", r.HTML) + } +} + +// Emphasis must not reach inside a code span. `vpn.advanced.*` used to pair its +// glob with a later asterisk and open an that closed outside the
        . +func TestCodeSpansAreNotTouchedByEmphasis(t *testing.T) { + r := Render("", "Every `vpn.advanced.*` key, **\"Use Touch ID\"** and more.\n") + if !strings.Contains(r.HTML, "vpn.advanced.*") { + t.Errorf("a code span was rewritten by the emphasis passes:\n%s", r.HTML) + } + if strings.Contains(r.HTML, "") { + t.Errorf("an asterisk inside a code span opened emphasis:\n%s", r.HTML) + } +} + +// Bold containing italics is written throughout the docs. +func TestItalicsInsideBold(t *testing.T) { + r := Render("", "**Endpoints are deliberately *not* required.**\n") + if !strings.Contains(r.HTML, "") || !strings.Contains(r.HTML, "not") { + t.Errorf("nested emphasis was not rendered:\n%s", r.HTML) + } +} + +// The anchors have to match the ones written in the docs (and in DocAnchor), so +// this pins the derivation rather than trusting it. +func TestAnchorMatchesTheDocsConvention(t *testing.T) { + cases := map[string]string{ + "Fields": "fields", + "`control` block": "control-block", + "`vpn` block": "vpn-block", + // Punctuation is dropped and the surviving words are joined by hyphens, + // so the two words either side of the parenthesis stay separated. + "Advanced tunables (`vpn.advanced`)": "advanced-tunables-vpnadvanced", + "Words we do not use": "words-we-do-not-use", + } + for title, want := range cases { + if got := Anchor(title); got != want { + t.Errorf("Anchor(%q) = %q, want %q", title, got, want) + } + } +} + +// A page name is flattened into one directory, so the bundle stays flat and a +// link can be a bare filename. +func TestOutputName(t *testing.T) { + cases := map[string]string{ + "usage/config.md": "usage-config.html", + "concepts/glossary.md": "concepts-glossary.html", + "usage/getting-started.md": "usage-getting-started.html", + } + for src, want := range cases { + if got := OutputName(src); got != want { + t.Errorf("OutputName(%q) = %q, want %q", src, got, want) + } + } +} + +// The index is what the app decodes, so it must be valid JSON with the fields +// the sidebar and search need. +func TestIndexDecodes(t *testing.T) { + dir := t.TempDir() + if _, err := Build(docsDir, dir); err != nil { + t.Fatalf("Build: %v", err) + } + data, err := os.ReadFile(filepath.Join(dir, "help-index.json")) + if err != nil { + t.Fatal(err) + } + var entries []IndexEntry + if err := json.Unmarshal(data, &entries); err != nil { + t.Fatalf("help-index.json does not decode: %v", err) + } + tutorial := 0 + for _, e := range entries { + if e.File == "" || e.Title == "" { + t.Errorf("%s: incomplete index entry", e.Source) + } + if e.Tutorial > 0 { + tutorial++ + } + } + if tutorial == 0 { + t.Error("no page is in the tutorial track, so a first-time reader has no starting point") + } +} diff --git a/internal/help/manifest.go b/internal/help/manifest.go new file mode 100644 index 0000000..aa2f8a8 --- /dev/null +++ b/internal/help/manifest.go @@ -0,0 +1,104 @@ +// Package help turns the repo's documentation into what the macOS app bundles +// and displays. +// +// It exists so the app can answer a question while the kill switch has cut +// traffic — which is exactly when a user most needs to know what is happening +// and cannot reach the web. Pages are rendered at build time and shipped inside +// the app, so the documentation always matches the version that documents it. +// +// Documentation stays single-source: this package RENDERS the repo's markdown, +// it never restates it. A page listed here is load-bearing, the same rule +// CLAUDE.md already applies to doc paths cited from source — a test fails the +// build when one goes missing or is renamed. +// +// Stdlib only, and dev tooling only (invoked by gui/macos/build-app.sh through +// tools/helpgen). Nothing here runs in the daemon. +package help + +// A Page is one bundled document. +type Page struct { + // Source is the path under docs/, which is also its identity: a DocAnchor + // of "usage/config.md#fields" names this page and a heading within it. + Source string + // Title is how the page reads in the browser's sidebar. + Title string + // Summary is one line saying what the page answers, for search results and + // the sidebar. + Summary string + // Tutorial is the position in the first-time reading order, 1-based. Zero + // means the page is reference material — reachable and searchable, but not + // part of the guided track. + Tutorial int +} + +// Pages is what the app ships, in sidebar order. +// +// It is deliberately not "everything under docs/". The ADRs and the contributor +// docs are written for people working ON dezhban and would drown a user looking +// for how to unblock themselves; they stay in the repo. What ships is what +// somebody running dezhban needs, offline, possibly mid-lockout. +var Pages = []Page{ + { + Source: "usage/getting-started.md", Title: "Quick start", Tutorial: 1, + Summary: "Install it, set it up, check it won't lock you out, and arm it.", + }, + { + Source: "concepts/how-it-works.md", Title: "How dezhban works", Tutorial: 2, + Summary: "What the guard does, from launch to teardown.", + }, + { + Source: "concepts/modes.md", Title: "Postures", Tutorial: 3, + Summary: "Every posture the guard can be in, and the exact rules each one installs.", + }, + { + Source: "usage/troubleshooting.md", Title: "Troubleshooting", Tutorial: 4, + Summary: "Locked out, or the guard is not behaving — start here.", + }, + { + Source: "usage/config.md", Title: "Configuration reference", + Summary: "Every setting: what it does, its default, and what it costs.", + }, + { + Source: "usage/cli.md", Title: "Command reference", + Summary: "Every command and flag, and which of them need root.", + }, + { + Source: "concepts/glossary.md", Title: "Glossary", + Summary: "One word per concept — the words this app, the CLI, and the docs all use.", + }, + { + Source: "usage/install.md", Title: "Installing", + Summary: "Every install path, and how to verify a download.", + }, + { + Source: "usage/upgrade.md", Title: "Upgrading", + Summary: "How an upgrade is applied and activated, and how to roll one back.", + }, +} + +// PageBySource finds a page by its docs-relative path. +func PageBySource(source string) (Page, bool) { + for _, p := range Pages { + if p.Source == source { + return p, true + } + } + return Page{}, false +} + +// OutputName is the file a page is rendered to, flattened so the bundle is one +// directory: "usage/config.md" becomes "usage-config.html". +func OutputName(source string) string { + out := make([]rune, 0, len(source)) + for _, r := range source { + if r == '/' { + r = '-' + } + out = append(out, r) + } + name := string(out) + if len(name) > 3 && name[len(name)-3:] == ".md" { + name = name[:len(name)-3] + } + return name + ".html" +} diff --git a/internal/help/markdown.go b/internal/help/markdown.go new file mode 100644 index 0000000..68a359b --- /dev/null +++ b/internal/help/markdown.go @@ -0,0 +1,536 @@ +package help + +import ( + "fmt" + "html" + "path" + "regexp" + "sort" + "strconv" + "strings" +) + +// A deliberately small CommonMark subset: exactly the constructs these docs use +// — headings, paragraphs, fenced code, bullet and numbered lists, tables, block +// quotes, horizontal rules, and inline links/code/emphasis. +// +// Written here rather than pulled in because the deliverable is a +// dependency-light standalone binary (see CLAUDE.md's dependency rule), and a +// markdown library would be a fifth third-party module for a build-time step. +// The subset is enforced rather than assumed: Render reports anything it does +// not understand, and a test fails the build when a bundled page uses it. That +// keeps the renderer honest AND keeps the docs inside a style the renderer can +// actually show — a silently mangled page would be worse than a build failure. +// +// "Reports anything it does not understand" is the load-bearing half, and it has +// to be maintained deliberately: a construct this renderer degrades silently is +// a page that ships wrong while every test passes. Whenever a case is added +// here that cannot be represented, note() it — do not let it fall through to +// the paragraph branch and render as literal text. + +// Heading is one heading in a rendered page, for search and anchor resolution. +type Heading struct { + Level int `json:"level"` + Text string `json:"text"` + Anchor string `json:"anchor"` +} + +// Rendered is one page's HTML plus what a browser needs to index it. +type Rendered struct { + HTML string + Headings []Heading + // Text is the page with markup stripped, for substring search. + Text string + // Unsupported names constructs the renderer met and could not represent. + // Non-empty means the page would display wrongly. + Unsupported []string +} + +var ( + inlineCode = regexp.MustCompile("`([^`]+)`") + inlineLink = regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\)`) + inlineImage = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`) + // Non-greedy and NOT [^*]+, so a bold span may contain italics: the docs + // write **… *not* …** and an inner-asterisk ban left the outer ** as + // literal text. Bold is substituted first and the inner *not* survives into + // the emphasis pass below, which is what turns it into . + inlineBold = regexp.MustCompile(`\*\*(.+?)\*\*`) + inlineItalic = regexp.MustCompile(`(^|[^*])\*([^*]+)\*`) + headingRe = regexp.MustCompile(`^(#{1,6})\s+(.*)$`) + bulletRe = regexp.MustCompile(`^\s*[-*]\s+(.*)$`) + numberedRe = regexp.MustCompile(`^\s*\d+\.\s+(.*)$`) + anchorStrip = regexp.MustCompile(`[^a-z0-9 -]`) + // htmlBlockRe matches a line that opens, closes, or comments raw HTML. The + // renderer has no way to show it, and escaping it prints the tag's source + // at the top of the page — which is exactly what shipped before this check + // existed. + htmlBlockRe = regexp.MustCompile(`^<(/?[a-zA-Z][a-zA-Z0-9-]*|!--)`) + // schemeRe matches a URL scheme prefix, so a link can be checked against the + // allowlist in rewriteLink. + schemeRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*:`) +) + +// linkSchemes is what a documentation link is allowed to use. Anything else — +// javascript:, data:, file: — is refused at render time rather than relied on +// being cancelled by the app's navigation delegate: the bundle should not +// contain the link in the first place. +var linkSchemes = map[string]bool{"http": true, "https": true, "mailto": true} + +// repoBase is where a document that is NOT bundled lives instead. The docs +// cross-reference the ADRs and the contributor docs constantly, and those +// deliberately do not ship (see Pages) — so their links have to become +// somewhere real. Left as written, they resolve to a file: path beside the +// bundle that does not exist, and the pane reports "points outside the app" +// with an internal path nobody can use. +// +// Pinned to main rather than the built tag on purpose: this is the only kind of +// link the pane cannot follow, so it is a URL a reader copies into a browser +// later, and a tag that has since been deleted or rewritten reads as a broken +// project. The bundled pages themselves are always version-matched, which is +// what the offline guarantee is actually about. +const repoBase = "https://github.com/behnam-rk/dezhban/blob/main/" + +// Render converts one markdown document to HTML. +// +// source is the page's docs-relative path ("usage/cli.md"), which is what +// relative links in it are resolved against — "../adr/0001.md" means something +// different depending on the page it is written in. Empty source is for +// rendering a fragment with no place in the manifest (tests): relative links +// are then reported as unresolvable rather than guessed at. +func Render(source, markdown string) Rendered { + var out strings.Builder + var text strings.Builder + r := Rendered{} + + // note collects what could not be represented. A set, because one page + // repeating the same unsupported construct twenty times is still one thing + // to fix, and the message should say what it is rather than how often. + unsupported := map[string]bool{} + note := func(what string) { unsupported[what] = true } + + // renderInline is the only way inline markup reaches the output, so every + // construct it cannot represent is reported from one place. + renderInline := func(s string) string { + rendered := inline(source, s, note) + if strings.Contains(rendered, "**") { + note("unpaired ** (bold that never closed)") + } + return rendered + } + + lines := strings.Split(markdown, "\n") + inCode, inList, inQuote := false, false, false + listTag := "" + + closeList := func() { + if inList { + out.WriteString("\n") + inList = false + } + } + closeQuote := func() { + if inQuote { + out.WriteString("\n") + inQuote = false + } + } + + for i := 0; i < len(lines); i++ { + line := lines[i] + trimmed := strings.TrimSpace(line) + + // Fenced code: taken verbatim, so nothing inside is interpreted. + if strings.HasPrefix(trimmed, "```") { + if inCode { + out.WriteString("\n") + inCode = false + continue + } + closeList() + closeQuote() + lang := strings.TrimPrefix(trimmed, "```") + out.WriteString(`
        `)
        +			inCode = true
        +			continue
        +		}
        +		if inCode {
        +			out.WriteString(html.EscapeString(line) + "\n")
        +			continue
        +		}
        +
        +		if trimmed == "" {
        +			closeList()
        +			closeQuote()
        +			continue
        +		}
        +
        +		// Raw HTML. Checked before everything else that could swallow the line,
        +		// and reported rather than rendered: there is no honest way to show it,
        +		// and the paragraph branch would print the tag source as visible text.
        +		if htmlBlockRe.MatchString(trimmed) {
        +			note("raw HTML (" + firstTag(trimmed) + ") — the renderer has no way to show it")
        +			continue
        +		}
        +
        +		if m := headingRe.FindStringSubmatch(trimmed); m != nil {
        +			closeList()
        +			closeQuote()
        +			level := len(m[1])
        +			title := stripInline(m[2])
        +			anchor := Anchor(title)
        +			r.Headings = append(r.Headings, Heading{Level: level, Text: title, Anchor: anchor})
        +			fmt.Fprintf(&out, "%s\n", level, anchor, renderInline(m[2]), level)
        +			text.WriteString(title + "\n")
        +			continue
        +		}
        +
        +		if trimmed == "---" || trimmed == "***" || trimmed == "___" {
        +			closeList()
        +			closeQuote()
        +			out.WriteString("
        \n") + continue + } + + // Tables: a header row followed by a separator row. Detected by looking + // ahead, since a lone pipe-containing line is just a paragraph. + if strings.HasPrefix(trimmed, "|") && i+1 < len(lines) && isTableSeparator(lines[i+1]) { + closeList() + closeQuote() + consumed := renderTable(&out, &text, lines[i:], renderInline) + i += consumed - 1 + continue + } + + if strings.HasPrefix(trimmed, ">") { + closeList() + if !inQuote { + out.WriteString("
        \n") + inQuote = true + } + body := quoteBody(trimmed) + for i+1 < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i+1]), ">") { + i++ + body += " " + quoteBody(strings.TrimSpace(lines[i])) + } + out.WriteString("

        " + renderInline(body) + "

        \n") + text.WriteString(stripInline(body) + "\n") + continue + } + + if m := bulletRe.FindStringSubmatch(line); m != nil { + noteIfNested(line, note) + if inList && listTag != "ul" { + closeList() + } + if !inList { + out.WriteString("
          \n") + inList, listTag = true, "ul" + } + body, last := gatherItem(lines, i, m[1]) + i = last + out.WriteString("
        • " + renderInline(body) + "
        • \n") + text.WriteString(stripInline(body) + "\n") + continue + } + if m := numberedRe.FindStringSubmatch(line); m != nil { + noteIfNested(line, note) + if inList && listTag != "ol" { + closeList() + } + if !inList { + out.WriteString("
            \n") + inList, listTag = true, "ol" + } + body, last := gatherItem(lines, i, m[1]) + i = last + out.WriteString("
          1. " + renderInline(body) + "
          2. \n") + text.WriteString(stripInline(body) + "\n") + continue + } + + closeList() + closeQuote() + body := trimmed + for i+1 < len(lines) && !startsNewBlock(lines, i+1) { + i++ + body += " " + strings.TrimSpace(lines[i]) + } + out.WriteString("

            " + renderInline(body) + "

            \n") + text.WriteString(stripInline(body) + "\n") + } + + closeList() + closeQuote() + if inCode { + note("an unclosed code fence") + } + + // Sorted, so a build failure names the same things in the same order twice + // running and a diff of two failures is readable. + r.Unsupported = make([]string, 0, len(unsupported)) + for what := range unsupported { + r.Unsupported = append(r.Unsupported, what) + } + sort.Strings(r.Unsupported) + + r.HTML = out.String() + r.Text = text.String() + return r +} + +// A block's soft-wrapped lines are joined before any inline markup is rendered, +// because markdown emphasis spans a line break and this renderer's input is +// hard-wrapped prose. Rendering line by line meant a bold span the author wrapped +// across two lines never paired, and the `**` showed up as literal text in the +// shipped bundle — in eight of the nine pages. Joining is also what puts a list +// item's continuation INSIDE its
          3. rather than after it. + +// startsNewBlock reports whether the line at i begins something that cannot be +// a continuation of the paragraph or list item being gathered. +func startsNewBlock(lines []string, i int) bool { + line := lines[i] + t := strings.TrimSpace(line) + switch { + case t == "": + case strings.HasPrefix(t, "```"): + case htmlBlockRe.MatchString(t): + case headingRe.MatchString(t): + case t == "---" || t == "***" || t == "___": + case strings.HasPrefix(t, ">"): + case bulletRe.MatchString(line) || numberedRe.MatchString(line): + case strings.HasPrefix(t, "|") && i+1 < len(lines) && isTableSeparator(lines[i+1]): + default: + return false + } + return true +} + +// gatherItem joins a list item with its indented continuation lines, reporting +// the index of the last line it consumed. Continuation must be indented: an +// unindented line after an item ends the list, the same boundary the renderer +// drew before blocks were gathered. +func gatherItem(lines []string, i int, first string) (string, int) { + body := first + for i+1 < len(lines) { + next := lines[i+1] + if startsNewBlock(lines, i+1) || strings.TrimLeft(next, " \t") == next { + break + } + i++ + body += " " + strings.TrimSpace(next) + } + return body, i +} + +// quoteBody strips one leading ">" from a block-quote line. +func quoteBody(trimmed string) string { + return strings.TrimSpace(strings.TrimPrefix(trimmed, ">")) +} + +// noteIfNested reports an indented list item. The renderer emits one flat list, +// so a nested item would silently become a sibling of its own parent — the +// hierarchy the author wrote would be gone, and the surrounding text would end +// up directly inside the
              . +func noteIfNested(line string, note func(string)) { + if len(line)-len(strings.TrimLeft(line, " \t")) >= 2 { + note("a nested list item — this renderer emits one flat list") + } +} + +// firstTag names the tag in a raw-HTML line, so the build failure says which +// one to remove rather than only that some HTML exists. +func firstTag(line string) string { + if strings.HasPrefix(line, "" + } + end := strings.IndexAny(line, " \t>") + if end < 0 { + end = len(line) + } + return line[:end] + ">" +} + +// isTableSeparator matches the |---|---| row that turns the line above it into +// a table header. +func isTableSeparator(line string) bool { + s := strings.TrimSpace(line) + if !strings.HasPrefix(s, "|") { + return false + } + for _, cell := range splitRow(s) { + if cell == "" || strings.Trim(cell, "-: ") != "" { + return false + } + } + return true +} + +// renderTable emits one table and reports how many lines it consumed. +func renderTable(out *strings.Builder, text *strings.Builder, lines []string, renderInline func(string) string) int { + header := splitRow(lines[0]) + out.WriteString("
    3. \n") + for _, c := range header { + out.WriteString("") + text.WriteString(stripInline(c) + " ") + } + out.WriteString("\n\n") + text.WriteString("\n") + + used := 2 // header + separator + for _, line := range lines[2:] { + s := strings.TrimSpace(line) + if !strings.HasPrefix(s, "|") { + break + } + out.WriteString("") + for _, c := range splitRow(s) { + out.WriteString("") + text.WriteString(stripInline(c) + " ") + } + out.WriteString("\n") + text.WriteString("\n") + used++ + } + out.WriteString("
      " + renderInline(c) + "
      " + renderInline(c) + "
      \n") + return used +} + +func splitRow(line string) []string { + parts := strings.Split(strings.Trim(strings.TrimSpace(line), "|"), "|") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + return parts +} + +// codeMark delimits a lifted code span. NUL cannot appear in the escaped text — +// html.EscapeString does not produce it and these documents do not contain it — +// so a placeholder can never collide with document content, and it matches none +// of the emphasis or link patterns. +const codeMark = "\x00" + +func codePlaceholder(i int) string { return codeMark + strconv.Itoa(i) + codeMark } + +// inline renders inline markup. Escaping happens FIRST and the markup is +// substituted into the escaped text, so no document content can inject HTML — +// the pages are ours, but the rule costs nothing and removes the question. +func inline(source, s string, note func(string)) string { + out := html.EscapeString(s) + + // Code spans are LIFTED OUT before anything else runs, then put back last. + // Wrapping them in in place is not enough: the emphasis passes scan + // the whole string afterwards, so an asterisk inside a code span (the glob + // in `vpn.advanced.*`) would pair with an unrelated asterisk later in the + // line and open an that closes outside the . That produced + // invalid markup in the shipped bundle. Lifting also keeps link and image + // syntax inside a code span from being turned into a real link. + var spans []string + out = inlineCode.ReplaceAllStringFunc(out, func(m string) string { + spans = append(spans, inlineCode.FindStringSubmatch(m)[1]) + return codePlaceholder(len(spans) - 1) + }) + + // An image is checked, not rewritten. It has no banner to fall back on the + // way a link does — the navigation delegate never sees a subresource load — + // so a remote src would be a silent network request from the one pane whose + // entire purpose is working with egress cut. Refused here rather than left + // to the bundle's self-contained test, so Render is safe on its own terms. + out = inlineImage.ReplaceAllStringFunc(out, func(m string) string { + g := inlineImage.FindStringSubmatch(m) + if schemeRe.MatchString(g[2]) || strings.HasPrefix(g[2], "//") { + note("a remote image (" + g[2] + ") — the pane must load nothing from the network") + return g[1] + } + return fmt.Sprintf("%q", g[2], g[1]) + }) + out = inlineLink.ReplaceAllStringFunc(out, func(m string) string { + g := inlineLink.FindStringSubmatch(m) + return fmt.Sprintf("%s", rewriteLink(source, g[2], note), g[1]) + }) + out = inlineBold.ReplaceAllString(out, "$1") + out = inlineItalic.ReplaceAllString(out, "$1$2") + + for i, span := range spans { + out = strings.ReplaceAll(out, codePlaceholder(i), ""+span+"") + } + return out +} + +// rewriteLink points a cross-document link at the bundled copy of that page. +// A link to something not bundled is left as written; the browser refuses +// anything that is not a local file, so it simply does nothing rather than +// silently leaving the app. +// +// A scheme outside linkSchemes is refused HERE, at build time, rather than left +// for the app's navigation delegate to cancel. Defence in depth is the reason +// the delegate exists, but a javascript: or data: href has no business being in +// the bundle at all, and a build failure is how it gets removed. +func rewriteLink(source, href string, note func(string)) string { + if scheme := schemeRe.FindString(href); scheme != "" { + name := strings.ToLower(strings.TrimSuffix(scheme, ":")) + if !linkSchemes[name] { + note("a " + name + ": link — only http, https, and mailto are allowed") + return "#" + } + return href + } + if strings.HasPrefix(href, "#") { + return href + } + target, frag, _ := strings.Cut(href, "#") + if target == "" { + return href + } + if source == "" { + note("a relative link (" + href + ") in a page with no place in the manifest") + return href + } + // Links are written relative to the page they live in, so resolve against + // that page's own directory: "../adr/0001.md" from usage/config.md is a + // different file than the same text in concepts/modes.md. path.Join cleans + // the "../" segments, and the repo root is the frame of reference because + // the docs also link outside docs/ ("../../configs/dezhban.example.json"). + repoPath := path.Join("docs", path.Dir(source), target) + + // A link with enough "../" to climb past the repo root itself is refused + // rather than turned into a repoBase URL that climbs past it too — that + // would resolve to something outside the project, or nothing at all, + // which is the same class of bug a bad scheme is refused for above. + if strings.HasPrefix(repoPath, "../") { + note("a relative link (" + href + ") that climbs above the repository root") + return "#" + } + + if inDocs, ok := strings.CutPrefix(repoPath, "docs/"); ok { + if _, bundled := PageBySource(inDocs); bundled { + if frag != "" { + return OutputName(inDocs) + "#" + frag + } + return OutputName(inDocs) + } + } + // Not bundled. Point at the repository rather than leaving a path that + // resolves to nothing beside the bundle — see repoBase. The pane still + // cannot follow it, but it reports a URL the reader can actually use. + if frag != "" { + return repoBase + repoPath + "#" + frag + } + return repoBase + repoPath +} + +// stripInline reduces a line to its words, for the search index. +func stripInline(s string) string { + out := inlineImage.ReplaceAllString(s, "$1") + out = inlineLink.ReplaceAllString(out, "$1") + out = strings.NewReplacer("`", "", "**", "", "*", "", "_", "").Replace(out) + return strings.TrimSpace(out) +} + +// Anchor derives the fragment id a heading gets, matching GitHub's rule so an +// anchor written in the docs (and in a Tunable's DocAnchor) resolves in the +// rendered page too. +func Anchor(title string) string { + s := strings.ToLower(stripInline(title)) + s = anchorStrip.ReplaceAllString(s, "") + return strings.ReplaceAll(strings.TrimSpace(s), " ", "-") +} diff --git a/internal/render/render.go b/internal/render/render.go index c7d567a..2fa0904 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -155,10 +155,14 @@ func postureDisplay(s state.Snapshot) Display { func guardDisplay(s state.Snapshot) Display { if GuardHoldsDownedTunnel(s) { + detail := "Guard active, but no tunnel is up — all traffic is cut until your VPN redials." + if at := dropTime(s); at != "" { + detail = "Your VPN dropped at " + at + ". " + detail + } return Display{ Key: KeyBlocked, Headline: "VPN down — traffic cut", - Detail: "Guard active, but no tunnel is up — all traffic is cut until your VPN redials.", + Detail: detail, } } return Display{ @@ -221,24 +225,29 @@ func windowDisplay(s state.Snapshot) Display { until = s.Switch.Until.Format(untilFormat) } } + // The exposure and when it ends LEAD every one of these sentences. A relaxed + // guard is the one state where this tool is not protecting the user, so the + // copy must not open with machinery ("Guard relaxed so…") and leave the + // consequence trailing behind a dash. switch trigger { case state.TriggerPause: return Display{ Key: KeyPaused, Headline: "Paused", - Detail: "Using your real IP at your request. " + rearmSentence(until), + Detail: joinSentences(pausedSentence(), rearmSentence(until)), } case state.TriggerAuto: return Display{ Key: KeyWarning, Headline: "Redial window open", - Detail: "Your VPN dropped. The guard is relaxed while it redials — " + exposedSentence(until), + Detail: joinSentences(exposedSentence(until), redialCause(s)), } default: return Display{ Key: KeyWarning, Headline: "Switch window open", - Detail: "Guard relaxed so a new VPN can connect — " + exposedSentence(until), + Detail: joinSentences(exposedSentence(until), + "The guard is relaxed so a new VPN can connect."), } } } @@ -250,11 +259,60 @@ func rearmSentence(until string) string { return "The guard re-arms automatically at " + until + "." } +// pausedSentence leads with the fact that matters — the real IP is in use — and +// says so as a present condition, not a risk: during a pause the exposure is the +// point, not a side effect. It never states the until time itself: rearmSentence +// states it immediately after, and stating it twice in adjacent sentences reads +// as an unintentional echo rather than emphasis. +func pausedSentence() string { + return "You are using your real IP at your request." +} + func exposedSentence(until string) string { if until == "" { - return "your real IP may be exposed until it closes." + return "Your real IP may be exposed until this window closes." + } + return "Your real IP may be exposed until " + until + "." +} + +// redialCause explains why the guard relaxed, after the exposure has already +// been stated. It names the drop time when the snapshot carries one — a drop is +// the one thing a user can check against their own experience of the network +// dying, and it is what turns "a window is open" into an account of what +// happened. +func redialCause(s state.Snapshot) string { + if at := dropTime(s); at != "" { + return "Your VPN dropped at " + at + " and the guard relaxed so it can redial." } - return "your real IP may be exposed until it closes (" + until + ")." + return "Your VPN dropped and the guard relaxed so it can redial." +} + +// droppedFormat qualifies a drop with the day it happened. Used once the drop is +// no longer on the snapshot's own day. +const droppedFormat = time.Kitchen + " on Jan 2" + +// dropTime renders the carried drop's time, or "" when no drop is being carried. +// +// The record is carried until a tunnel returns, which through an overnight +// outage or a long FULL BLOCK is not the same day. A bare "3:04PM" then reads as +// "a few minutes ago" and quietly misstates how long the host has been cut, so +// the day is named as soon as it is not the snapshot's own. Compared against +// s.Time rather than time.Now() so the sentence a snapshot renders to depends +// only on the snapshot. +func dropTime(s state.Snapshot) string { + if s.Drop == nil || s.Drop.At.IsZero() { + return "" + } + if !s.Time.IsZero() && !sameDay(s.Drop.At, s.Time) { + return s.Drop.At.Format(droppedFormat) + } + return s.Drop.At.Format(untilFormat) +} + +func sameDay(a, b time.Time) bool { + ay, am, ad := a.Date() + by, bm, bd := b.Date() + return ay == by && am == bm && ad == bd } // lookupNote surfaces a genuine exit-country lookup failure. ExitUnknown is diff --git a/internal/render/render_test.go b/internal/render/render_test.go index e8bcace..a3ba627 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -9,6 +9,9 @@ import ( func TestText(t *testing.T) { until := time.Date(2026, 7, 25, 15, 4, 0, 0, time.UTC) + // A minute before the window's deadline, so a case carrying both can tell + // the drop time and the close time apart. + dropAt := time.Date(2026, 7, 25, 15, 3, 0, 0, time.UTC) cases := []struct { name string @@ -54,6 +57,36 @@ func TestText(t *testing.T) { wantHeadline: "VPN down — traffic cut", wantDetail: "Guard active, but no tunnel is up — all traffic is cut until your VPN redials.", }, + { + // The whole point of carrying the drop record: a cut the user can + // check against their own experience of the network dying. + name: "guard holds downed tunnel, drop time known", + snap: state.Snapshot{ + Posture: PostureGuard, + Tunnels: []state.Tunnel{{Name: "utun4", Up: false}}, + Drop: &state.DropRecord{At: until}, + }, + wantKey: KeyBlocked, + wantHeadline: "VPN down — traffic cut", + wantDetail: "Your VPN dropped at 3:04PM. Guard active, but no tunnel is up — " + + "all traffic is cut until your VPN redials.", + }, + { + // The record is carried until a tunnel returns, so it outlives the + // day it happened on. A bare clock time would then read as "a few + // minutes ago" and understate how long the host has been cut. + name: "guard holds downed tunnel, drop was not today", + snap: state.Snapshot{ + Posture: PostureGuard, + Time: until.AddDate(0, 0, 2), + Tunnels: []state.Tunnel{{Name: "utun4", Up: false}}, + Drop: &state.DropRecord{At: until}, + }, + wantKey: KeyBlocked, + wantHeadline: "VPN down — traffic cut", + wantDetail: "Your VPN dropped at 3:04PM on " + until.Format("Jan 2") + + ". Guard active, but no tunnel is up — all traffic is cut until your VPN redials.", + }, { name: "full block with country", snap: state.Snapshot{Posture: PostureFullBlock, CountryCode: "IR"}, @@ -75,7 +108,7 @@ func TestText(t *testing.T) { }}, wantKey: KeyWarning, wantHeadline: "Switch window open", - wantDetail: "Guard relaxed so a new VPN can connect — your real IP may be exposed until it closes (3:04PM).", + wantDetail: "Your real IP may be exposed until 3:04PM. The guard is relaxed so a new VPN can connect.", }, { name: "automatic redial window", @@ -84,7 +117,21 @@ func TestText(t *testing.T) { }}, wantKey: KeyWarning, wantHeadline: "Redial window open", - wantDetail: "Your VPN dropped. The guard is relaxed while it redials — your real IP may be exposed until it closes (3:04PM).", + wantDetail: "Your real IP may be exposed until 3:04PM. Your VPN dropped and the guard relaxed so it can redial.", + }, + { + // A redial window that still knows when the drop happened reports + // both halves: the exposure and its cause, in that order. + name: "automatic redial window, drop time known", + snap: state.Snapshot{ + Posture: PostureSwitchWindow, + Switch: &state.SwitchState{Open: true, Until: until, Trigger: state.TriggerAuto}, + Drop: &state.DropRecord{At: dropAt}, + }, + wantKey: KeyWarning, + wantHeadline: "Redial window open", + wantDetail: "Your real IP may be exposed until 3:04PM. " + + "Your VPN dropped at 3:03PM and the guard relaxed so it can redial.", }, { name: "pause window", @@ -93,14 +140,14 @@ func TestText(t *testing.T) { }}, wantKey: KeyPaused, wantHeadline: "Paused", - wantDetail: "Using your real IP at your request. The guard re-arms automatically at 3:04PM.", + wantDetail: "You are using your real IP at your request. The guard re-arms automatically at 3:04PM.", }, { name: "switch window with no Switch struct falls back to manual wording", snap: state.Snapshot{Posture: PostureSwitchWindow}, wantKey: KeyWarning, wantHeadline: "Switch window open", - wantDetail: "Guard relaxed so a new VPN can connect — your real IP may be exposed until it closes.", + wantDetail: "Your real IP may be exposed until this window closes. The guard is relaxed so a new VPN can connect.", }, { name: "unknown posture", @@ -217,7 +264,7 @@ func TestPostureIgnoresEnforcementErrAndNotes(t *testing.T) { } got := Posture(snap) - want := "Guard relaxed so a new VPN can connect — your real IP may be exposed until it closes (3:04PM)." + want := "Your real IP may be exposed until 3:04PM. The guard is relaxed so a new VPN can connect." if got.Key != KeyWarning || got.Headline != "Switch window open" || got.Detail != want { t.Errorf("Posture() = %+v, want the window sentence alone (detail %q)", got, want) } diff --git a/internal/runner/configwrite_test.go b/internal/runner/configwrite_test.go index 9a97e27..34555a2 100644 --- a/internal/runner/configwrite_test.go +++ b/internal/runner/configwrite_test.go @@ -181,9 +181,13 @@ func TestConfigWriteUnavailableWithoutAWriter(t *testing.T) { } // Lowering vpn.pauseMax has to bind the very next pause. The cap is a security -// setting: a reload that reported it applied while the loop kept clamping to the +// setting: a reload that reported it applied while the loop kept honouring the // old, larger value would be exactly the silent-discard failure the config layer // goes to such lengths to prevent. +// +// The new cap binds by REFUSING an over-cap request, not by shortening it — see +// Options.pauseDuration. A caller who asked for nine minutes and was handed one +// would have no way to know, which is the same failure in a different costume. func TestReloadLowersThePauseCapImmediately(t *testing.T) { be := &fakeBackend{} w := &writeRecorder{} @@ -212,10 +216,23 @@ func TestReloadLowersThePauseCapImmediately(t *testing.T) { t.Fatalf("config-write refused: %+v", resp) } - // Ask for far more than the new cap allows. + // Ask for far more than the new cap allows: it must be refused, and the + // refusal must name the NEW cap, or the reload did not take effect. + resp := do(t, path, control.Request{Op: control.OpPause, Duration: "9m"}) + if resp.OK { + t.Fatal("a 9m pause was accepted after the cap was lowered to 1m — either the reload " + + "did not bind, or the request was silently shortened") + } + if !strings.Contains(resp.Error, "1m") { + t.Errorf("refusal = %q, want it to name the reloaded 1m cap (the old 10m cap may still be in force)", + resp.Error) + } + + // A request within the new cap still works, so the refusal above is the cap + // binding rather than pausing having broken outright. before := time.Now() - if resp := do(t, path, control.Request{Op: control.OpPause, Duration: "9m"}); !resp.OK { - t.Fatalf("pause refused: %+v", resp) + if resp := do(t, path, control.Request{Op: control.OpPause, Duration: "30s"}); !resp.OK { + t.Fatalf("a within-cap pause was refused: %+v", resp) } var until time.Time @@ -233,6 +250,6 @@ func TestReloadLowersThePauseCapImmediately(t *testing.T) { t.Fatal("no snapshot reported an open pause") } if got := until.Sub(before); got > 2*time.Minute { - t.Fatalf("pause runs for %s, want it clamped to the reloaded 1m cap (the old 10m cap was still in force)", got.Round(time.Second)) + t.Fatalf("pause runs for %s, want the 30s that was asked for", got.Round(time.Second)) } } diff --git a/internal/runner/recovery_test.go b/internal/runner/recovery_test.go index 3e8019a..e291175 100644 --- a/internal/runner/recovery_test.go +++ b/internal/runner/recovery_test.go @@ -29,7 +29,7 @@ func TestSnapshotCarriesTheHysteresisStreak(t *testing.T) { Interval: time.Minute, Publish: func(s state.Snapshot) { got = s }, } - o.publish(false, false, monitor.Reading{CountryCode: "IR"}, nil, nil, nil, nil, nil, "") + o.publish(false, false, monitor.Reading{CountryCode: "IR"}, nil, nil, nil, nil, nil, "", nil, nil) if got.Pending == nil { t.Fatal("no pending flip published while a hysteresis streak was running") @@ -47,7 +47,7 @@ func TestPublishingProgressDoesNotDisturbTheStreak(t *testing.T) { o := Options{Decider: d, Interval: time.Minute, Publish: func(state.Snapshot) {}} for range 5 { - o.publish(false, false, monitor.Reading{}, nil, nil, nil, nil, nil, "") + o.publish(false, false, monitor.Reading{}, nil, nil, nil, nil, nil, "", nil, nil) } _, have, _ := d.Pending() if have != 1 { diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 93929a2..edae441 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -331,7 +331,7 @@ func anyTunnelUp(tunnels []state.Tunnel) bool { // only a nil check when observability is off. Each call emits a complete snapshot // (the file is replaced atomically), so callers pass the last-known reading even // on tunnel/endpoint events to avoid blanking IP/country between polls. -func (o Options) publish(blocked bool, standby bool, r monitor.Reading, lookupErr error, enfErr error, tunnels []state.Tunnel, endpoints []netip.Addr, win *state.SwitchState, profile string) { +func (o Options) publish(blocked bool, standby bool, r monitor.Reading, lookupErr error, enfErr error, tunnels []state.Tunnel, endpoints []netip.Addr, win *state.SwitchState, profile string, drop *state.DropRecord, hold *state.HoldState) { if o.Publish == nil { return } @@ -348,6 +348,8 @@ func (o Options) publish(blocked bool, standby bool, r monitor.Reading, lookupEr PID: os.Getpid(), ActiveProfile: profile, Switch: win, + Drop: drop, + Hold: hold, } if r.IP.IsValid() { snap.IP = r.IP.String() @@ -742,8 +744,34 @@ func (o Options) runGuard(ctx context.Context) error { } return &state.SwitchState{Open: true, Until: windowDeadline, Profile: windowProfile, Trigger: windowTrigger} } + // lastDrop is the tunnel drop currently being lived through: set on the + // down edge, carried across whatever follows it (a redial window, a guard + // holding the line), and cleared once a tunnel is up again. + // + // It has to be carried rather than merely published once. The down edge + // opens the redial window in the same loop pass, so a snapshot showing the + // guard holding a downed tunnel is replaced within microseconds, and + // observers poll the state file about once a second — they would never see + // it. Carrying the record is what lets both surfaces say "your VPN dropped + // at 3:04PM" instead of only "a window is open". + var lastDrop *state.DropRecord + // holdArmed suppresses the NEXT automatic redial window, so a deliberate + // disconnect stays cut instead of being handed a relaxation nobody asked + // for. It only ever suppresses — there is still no fourth relaxation + // trigger — and it is one-shot: spent by the drop it covers, cleared by a + // tunnel coming back, and gone on restart. Not persisted on purpose, since + // a forgotten armed flag surviving a reboot would leave a later accidental + // drop cut with no redial help. + holdArmed := false + holdArmedAt := time.Time{} + holdState := func() *state.HoldState { + if !holdArmed { + return nil + } + return &state.HoldState{Armed: true, At: holdArmedAt} + } snapshot := func() { - o.publish(blocked, standby, lastRes.Reading, lastRes.Err, enfErr, lastTun, endpoints, switchState(), activeProfile) + o.publish(blocked, standby, lastRes.Reading, lastRes.Err, enfErr, lastTun, endpoints, switchState(), activeProfile, lastDrop, holdState()) } rebuild := func() { guard, fullBlock = o.vpnPolicies(tunnels, endpoints, providers) } @@ -960,6 +988,22 @@ func (o Options) runGuard(ctx context.Context) error { if o.RedialWindow <= 0 || windowActive || standby || blocked || !sawTunnelUp { return } + // Hold the line, checked before the flap guard: an operator who said + // this drop is deliberate has answered the only question the window + // exists to guess at. One-shot — spent here rather than left armed for + // a later, accidental drop. + // + // The guard clause above returns first when nothing could have opened a + // window anyway (standby, FULL BLOCK, a window already open, a tunnel + // never seen up), so the flag survives those. That is safe because a + // drop cannot follow a drop without an intervening tunnel-up edge, and + // that edge disarms it — see the st.Up branch in the watcher. + if holdArmed { + holdArmed = false + o.Log.Warn("vpn tunnel down — redial window suppressed (hold the line was armed); "+ + "guard holds, traffic stays cut", "detail", detail) + return + } if minUp := o.RedialMinUptime; minUp > 0 && !goodExitThisUp && !tunnelUpSince.IsZero() && now.Sub(tunnelUpSince) < minUp { o.Log.Warn("vpn tunnel down — redial window suppressed (flap guard: tunnel up "+ @@ -1345,7 +1389,13 @@ func (o Options) runGuard(ctx context.Context) error { if windowActive && windowTrigger != state.TriggerPause { return reply(false, "a switch window is open — cancel it first") } - openWindow(time.Now(), o.clampPause(req.Duration), "", state.TriggerPause) + // Refused, not shortened: see pauseDuration. A client that asked for + // an hour must not be handed thirty minutes and told it succeeded. + dur, refusal := o.pauseDuration(req.Duration) + if refusal != "" { + return reply(false, refusal) + } + openWindow(time.Now(), dur, "", state.TriggerPause) if !windowActive { return reply(false, "open pause failed") } @@ -1366,6 +1416,32 @@ func (o Options) runGuard(ctx context.Context) error { return reply(false, "resume failed — pause held open, revert is being retried") } return reply(true, "") + + // Hold the line is ungated on purpose: every control.allow* flag exists + // to withhold an authority, and these two grant none — they only ever + // suppress a relaxation. A gate would imply there is something to + // protect against, and would hand an operator a way to switch off the + // safer behaviour. + case control.OpHoldArm: + if o.RedialWindow <= 0 { + // Nothing to suppress. Saying so beats reporting success for an + // action that cannot have an effect. + return reply(false, "the automatic redial window is already disabled (vpn.redialWindow is \"0\"), so there is nothing to hold") + } + if !holdArmed { + holdArmed = true + holdArmedAt = time.Now() + o.Log.Info("hold the line armed — the next tunnel drop will stay cut") + } + snapshot() + return reply(true, "") + case control.OpHoldCancel: + if holdArmed { + holdArmed = false + o.Log.Info("hold the line disarmed (control socket)") + } + snapshot() + return reply(true, "") } return reply(false, fmt.Sprintf("unsupported op %q", req.Op)) } @@ -1460,7 +1536,7 @@ func (o Options) runGuard(ctx context.Context) error { } pauseWindowMax = ls.PauseMax pauseEnabled = pauseWindowMax > 0 && o.PollCommand != nil - o.PauseMax = ls.PauseMax // clampPause reads this, and so does the log line below + o.PauseMax = ls.PauseMax // pauseDuration reads this, and so does the log line below if ls.EndpointRefresh > 0 { if ls.EndpointRefresh != o.EndpointRefresh { @@ -1522,8 +1598,9 @@ func (o Options) runGuard(ctx context.Context) error { // that started with both disabled must still notice a command once one is // re-enabled — otherwise the reload reports the key as applied while the // root-owned command path stays deaf until a restart. Every command handler - // below re-checks the live value (clampWindow / clampPause return <=0 when the - // trigger is off), so an ungated tick can never act on a disabled trigger. + // below re-checks the live value (clampWindow returns <=0 and pauseDuration + // refuses when the trigger is off), so an ungated tick can never act on a + // disabled trigger. var cmdC <-chan time.Time if o.PollCommand != nil { cmdInterval := o.CommandPoll @@ -1603,8 +1680,28 @@ func (o Options) runGuard(ctx context.Context) error { tryAutoArm(st.Detail) } if wasUp && !st.Up { + // Record the drop, and publish it, BEFORE anything relaxes. + // maybeAutoWindow may open a redial window on this very line, + // which would otherwise make the guard-holding-a-downed-tunnel + // state unreachable on the common path. + lastDrop = &state.DropRecord{At: time.Now()} + snapshot() maybeAutoWindow(time.Now(), st.Detail) } + if st.Up { + // The drop is over the moment a tunnel is back, whether or not + // the exit has been verified yet. Keeping it past that point + // would leave both surfaces narrating an event that has ended. + lastDrop = nil + // A tunnel coming back also ends the intent behind "hold the + // line": the deliberate disconnect it was armed for is over. + // Leaving it armed would silently cut a LATER, accidental drop + // off from the redial help it should have had. + if holdArmed { + holdArmed = false + o.Log.Info("hold the line disarmed — a tunnel is up again") + } + } // A tunnel coming back while blocked is the strongest hint available // that the forbidden exit may be gone — it is exactly the moment the // user has redialed and is waiting. Going down ends any episode: there @@ -1667,12 +1764,19 @@ func (o Options) runGuard(ctx context.Context) error { o.Log.Warn("ignoring pause command — a switch window is already open (cancel it first)") continue } - dur := o.clampPause(cmd.Duration) - if dur <= 0 { - // Reachable, and the live gate: the poll ticks regardless of - // whether pausing is enabled. Never open a pause the operator - // disabled. - o.Log.Warn("ignoring pause command — pausing is disabled (vpn.pauseMax: \"0\")") + // Same refusal the socket path gives, for the same reason: an + // over-cap request is declined and explained, never silently + // shortened. Reporting the refusal verbatim matters because this + // path has no reply channel — the log line is the only account + // the operator gets, so it must say which setting refused and + // why rather than blaming a disabled pause for a length problem. + // + // Reachable, and the live gate: the poll ticks regardless of + // whether pausing is enabled, so a PauseMax <= 0 refusal here is + // what stops a disabled pause from ever opening. + dur, refusal := o.pauseDuration(cmd.Duration) + if refusal != "" { + o.Log.Warn("ignoring pause command — " + refusal) continue } openWindow(now, dur, "", state.TriggerPause) @@ -1681,6 +1785,30 @@ func (o Options) runGuard(ctx context.Context) error { if windowActive && windowTrigger == state.TriggerPause { closeWindowRevert("resumed") } + case command.OpHoldArm: + // Same account the socket path gives, for the same reason as the + // pause refusal above: this path has no reply channel, so a + // command that changed nothing must still say so. `dezhban hold` + // checks the config first, but it skips that check when the file + // cannot be read — and then prints "armed" for something the + // daemon would otherwise discard in silence. + if o.RedialWindow <= 0 { + o.Log.Warn("ignoring hold command — the automatic redial window is already " + + "disabled (vpn.redialWindow: \"0\"), so there is nothing to hold") + continue + } + if !holdArmed { + holdArmed = true + holdArmedAt = time.Now() + o.Log.Info("hold the line armed — the next tunnel drop will stay cut") + } + snapshot() + case command.OpHoldCancel: + if holdArmed { + holdArmed = false + o.Log.Info("hold the line disarmed") + } + snapshot() default: o.Log.Debug("ignoring unsupported command", "op", cmd.Op) } @@ -2098,26 +2226,47 @@ func (o Options) clampWindow(req string) time.Duration { // comes from the caller (CLI flag / GUI preset). const defaultPauseDuration = 15 * time.Minute -// clampPause parses a requested pause duration and caps it at PauseMax (no -// floor). An empty/invalid request falls back to defaultPauseDuration. Returns -// 0 when pausing is disabled (PauseMax <= 0) — callers (control-socket ops, -// command-poll cases) are already gated on pauseEnabled, but failing closed -// here means a future caller that skipped the gate can never turn "disabled" -// into a real relaxation of the guard, whatever value req parses to. -func (o Options) clampPause(req string) time.Duration { +// pauseDuration resolves a requested pause length, or explains why it cannot be +// granted. The refusal string is empty when the duration is usable. +// +// Both callers — the control-socket op and the command-file poll — are already +// gated on pauseEnabled, but this fails closed on PauseMax <= 0 anyway, so a +// future caller that skipped the gate can never turn "disabled" into a real +// relaxation of the guard, whatever value req parses to. +// +// The refusals are worded to match config.PauseRefusal, which says the same +// things to a CLI user before the request is ever sent. They are two sentences +// rather than one shared helper on purpose: internal/runner takes an Options, +// never a *config.Config, and that boundary is worth more than the duplication. +// Change one, change the other. +// +// An explicitly requested length longer than vpn.pauseMax is REFUSED, not +// shortened. Quietly granting 30m to someone who asked for an hour is the same +// class of bug as accepting a disabled window and restoring the default: the +// operator asked for one thing, got another, and was never told. An unspecified +// request is different — nobody asked for a particular length, so the built-in +// default is clamped to the cap rather than refused. +func (o Options) pauseDuration(req string) (time.Duration, string) { if o.PauseMax <= 0 { - return 0 + return 0, "pausing is disabled (vpn.pauseMax is \"0\")" } - dur := defaultPauseDuration - if req != "" { - if d, err := time.ParseDuration(req); err == nil && d > 0 { - dur = d + if req == "" { + dur := defaultPauseDuration + if dur > o.PauseMax { + dur = o.PauseMax } + return dur, "" } - if dur > o.PauseMax { - dur = o.PauseMax + d, err := time.ParseDuration(req) + if err != nil || d <= 0 { + return 0, fmt.Sprintf("invalid pause duration %q", req) } - return dur + if d > o.PauseMax { + return 0, fmt.Sprintf("%s is longer than your %s cap (vpn.pauseMax). "+ + "Ask for %s or less, or raise the cap with `dezhban config set vpn.pauseMax=%s`.", + d, o.PauseMax, o.PauseMax, d) + } + return d, "" } // configKeys is the sorted key list of a config-write request, for logging. diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 6aa86f4..a66ab88 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -1064,6 +1064,291 @@ func TestVPNAutoRedialWindowOpensAndExpires(t *testing.T) { } } +// The cut must be observable. On a tunnel drop the run loop opens the redial +// window in the same pass, so unless the guard-holding-a-downed-tunnel snapshot +// is published FIRST and the drop is then carried through the window, no +// observer could ever report the drop — they poll the state file about once a +// second and would only ever see "a window is open". +func TestTunnelDropPublishesTheCutBeforeRelaxing(t *testing.T) { + be := &fakeBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) + defer cancel() + + var mu sync.Mutex + var snaps []state.Snapshot + o := Options{ + Monitor: steadyMonitor{cc: "US"}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: edgeWatcher(5), + RedialWindow: 50 * time.Millisecond, + Publish: func(s state.Snapshot) { + mu.Lock() + defer mu.Unlock() + snaps = append(snaps, s) + }, + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + + firstDrop, firstWindow := -1, -1 + for i, s := range snaps { + if firstDrop == -1 && s.Drop != nil { + firstDrop = i + } + if firstWindow == -1 && s.Posture == "switch-window" { + firstWindow = i + } + } + if firstDrop == -1 { + t.Fatalf("no snapshot carried a Drop record; postures = %v", postures(snaps)) + } + if firstWindow == -1 { + t.Fatalf("the redial window never opened; postures = %v", postures(snaps)) + } + if firstDrop >= firstWindow { + t.Errorf("the drop was first reported at snapshot %d but the window opened at %d — "+ + "the cut must be published before anything relaxes", firstDrop, firstWindow) + } + if got := snaps[firstDrop].Posture; got != "guard" { + t.Errorf("the cut snapshot's posture = %q, want \"guard\" (the guard holding a downed tunnel)", got) + } + if snaps[firstDrop].Drop.At.IsZero() { + t.Error("the drop record carries no time, which is the one thing it exists to report") + } + + // Carried through the window, or the surface that users actually look at + // still cannot name the drop. + if snaps[firstWindow].Drop == nil { + t.Error("the window snapshot dropped the record; both surfaces would be back to only saying \"window open\"") + } else if !snaps[firstWindow].Drop.At.Equal(snaps[firstDrop].Drop.At) { + t.Error("the window snapshot carries a different drop time than the cut it followed") + } +} + +// The drop record must not outlive the drop: once a tunnel is back, continuing +// to carry it would leave both surfaces narrating an event that has ended. +func TestDropRecordClearsWhenTheTunnelReturns(t *testing.T) { + // Down for the first three samples, then up for the rest. + n := 0 + watcher := &netdetect.Watcher{ + Interval: time.Millisecond, + Sample: func([]string) netdetect.TunnelState { + n++ + if n <= 2 || (n > 4 && n <= 6) { + return netdetect.TunnelState{Up: true, Name: "utun4", Names: []string{"utun4"}} + } + if n > 6 { + return netdetect.TunnelState{Up: true, Name: "utun4", Names: []string{"utun4"}} + } + return netdetect.TunnelState{} + }, + } + + be := &fakeBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) + defer cancel() + + var mu sync.Mutex + var snaps []state.Snapshot + o := Options{ + Monitor: steadyMonitor{cc: "US"}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: watcher, + RedialWindow: 10 * time.Millisecond, + Publish: func(s state.Snapshot) { + mu.Lock() + defer mu.Unlock() + snaps = append(snaps, s) + }, + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + + sawDrop := false + for _, s := range snaps { + if s.Drop != nil { + sawDrop = true + } + } + if !sawDrop { + t.Fatal("the tunnel never dropped in this test; the fixture is wrong") + } + // The last snapshot with a tunnel up must carry no drop. + for i := len(snaps) - 1; i >= 0; i-- { + if !anyTunnelUp(snaps[i].Tunnels) { + continue + } + if snaps[i].Drop != nil { + t.Errorf("snapshot %d has a tunnel up but still carries a drop record", i) + } + break + } +} + +// Hold the line must suppress the automatic redial window for exactly one drop: +// a deliberate disconnect stays cut instead of being handed a relaxation nobody +// asked for. It never opens anything — the three sanctioned triggers are +// unchanged — so the assertion is the absence of a switch window. +func TestHoldTheLineSuppressesTheRedialWindow(t *testing.T) { + be := &fakeBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) + defer cancel() + + // Arm it over the command file before the tunnel drops. + cmds := []command.Command{{Op: command.OpHoldArm, IssuedAt: time.Now(), Nonce: "hold-1"}} + sent := 0 + o := Options{ + Monitor: steadyMonitor{cc: "US"}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: edgeWatcher(8), + RedialWindow: 50 * time.Millisecond, + CommandPoll: time.Millisecond, + PollCommand: func() (command.Command, bool) { + if sent < len(cmds) { + c := cmds[sent] + sent++ + return c, true + } + return command.Command{}, false + }, + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + for _, c := range be.calls { + if c == "apply-switch" { + t.Fatalf("a redial window opened despite hold the line being armed; calls = %v", be.calls) + } + } +} + +// Without the arm, the very same fixture must open a window — otherwise the +// test above would pass for the wrong reason (a fixture that never drops). +func TestWithoutHoldTheSameDropOpensAWindow(t *testing.T) { + be := &fakeBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) + defer cancel() + + o := Options{ + Monitor: steadyMonitor{cc: "US"}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: edgeWatcher(8), + RedialWindow: 50 * time.Millisecond, + CommandPoll: time.Millisecond, + PollCommand: func() (command.Command, bool) { return command.Command{}, false }, + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + opened := false + for _, c := range be.calls { + if c == "apply-switch" { + opened = true + } + } + if !opened { + t.Fatalf("the control fixture never opened a window, so the hold test proves nothing; calls = %v", be.calls) + } +} + +// It is one-shot. Arming must not silently persist and cut a LATER, accidental +// drop off from the redial help it should have had. +func TestHoldTheLineIsSpentByTheDropItCovers(t *testing.T) { + be := &fakeBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond) + defer cancel() + + // up, up, DOWN (hold spends here), up, up, DOWN (must open a window). + n := 0 + watcher := &netdetect.Watcher{ + Interval: 2 * time.Millisecond, + Sample: func([]string) netdetect.TunnelState { + n++ + up := netdetect.TunnelState{Up: true, Name: "utun4", Names: []string{"utun4"}} + switch { + case n <= 3: + return up + case n <= 6: + return netdetect.TunnelState{} + case n <= 10: + return up + default: + return netdetect.TunnelState{} + } + }, + } + + cmds := []command.Command{{Op: command.OpHoldArm, IssuedAt: time.Now(), Nonce: "hold-1"}} + sent := 0 + o := Options{ + Monitor: steadyMonitor{cc: "US"}, + Decider: decision.New([]string{"IR"}, 1), + Backend: be, + Log: discardLog(), + Interval: time.Millisecond, + Tunnels: []string{"utun4"}, + Endpoints: []netip.Addr{netip.MustParseAddr("203.0.113.7")}, + Watcher: watcher, + RedialWindow: 30 * time.Millisecond, + CommandPoll: time.Millisecond, + PollCommand: func() (command.Command, bool) { + if sent < len(cmds) { + c := cmds[sent] + sent++ + return c, true + } + return command.Command{}, false + }, + } + if err := Run(ctx, o); err != nil { + t.Fatal(err) + } + switches := 0 + for _, c := range be.calls { + if c == "apply-switch" { + switches++ + } + } + if switches == 0 { + t.Fatalf("the second drop opened no window — hold the line outlived the drop it covered; calls = %v", be.calls) + } +} + +// postures summarizes a snapshot run for failure messages. +func postures(snaps []state.Snapshot) []string { + out := make([]string, 0, len(snaps)) + for _, s := range snaps { + out = append(out, s.Posture) + } + return out +} + // A manual command taking over an already-open AUTO window must keep the // episode's original (auto) exposure cap, never the manual cap — see // Options.RedialWindowMax's doc comment and the windowMax fork in Run's @@ -1244,7 +1529,7 @@ func TestLookupFailureClassification(t *testing.T) { t.Run(c.name, func(t *testing.T) { var got state.Snapshot o := Options{Publish: func(s state.Snapshot) { got = s }} - o.publish(false, false, monitor.Reading{}, errors.New("all providers failed"), nil, c.tunnels, nil, nil, "") + o.publish(false, false, monitor.Reading{}, errors.New("all providers failed"), nil, c.tunnels, nil, nil, "", nil, nil) if hasErr := got.LookupErr != ""; hasErr != c.wantLookupErr { t.Errorf("LookupErr set = %v, want %v (got %q)", hasErr, c.wantLookupErr, got.LookupErr) @@ -1266,7 +1551,7 @@ func TestSuccessfulLookupSetsNoErrorFields(t *testing.T) { var got state.Snapshot o := Options{Publish: func(s state.Snapshot) { got = s }} o.publish(false, false, monitor.Reading{CountryCode: "NL"}, nil, nil, - []state.Tunnel{{Name: "utun4", Up: true}}, nil, nil, "") + []state.Tunnel{{Name: "utun4", Up: true}}, nil, nil, "", nil, nil) if got.LookupErr != "" || got.ExitUnknown != "" { t.Errorf("a successful lookup set LookupErr=%q ExitUnknown=%q, want both empty", got.LookupErr, got.ExitUnknown) } diff --git a/internal/setup/answers.go b/internal/setup/answers.go new file mode 100644 index 0000000..caaf831 --- /dev/null +++ b/internal/setup/answers.go @@ -0,0 +1,281 @@ +package setup + +import ( + "errors" + "strconv" + "strings" + "time" + + "github.com/behnam-rk/dezhban/internal/config" +) + +// Answers holds one binding per question, seeded with that question's default. +// +// The bindings are pointers because that is what huh writes through: the CLI +// hands `TextPtr`/`BoolPtr`/`ListPtr` straight to a form field, the form fills +// them in, and Input reads the result. A native wizard sets them with Set +// instead. Both then produce the same Input, which is the point. +type Answers struct { + text map[string]*string + flag map[string]*bool + list map[string]*[]string + // asked remembers the question set, so gating can be evaluated against the + // answers actually collected rather than re-derived by each caller. + asked []Question +} + +// NewAnswers allocates a binding per question, seeded from its default. +func NewAnswers(qs []Question) *Answers { + a := &Answers{ + text: map[string]*string{}, flag: map[string]*bool{}, list: map[string]*[]string{}, + asked: qs, + } + for _, q := range qs { + switch q.Kind { + case KindBool: + v := q.Default == "true" + a.flag[q.ID] = &v + case KindMultiSelect: + v := append([]string(nil), q.Selected...) + a.list[q.ID] = &v + default: + v := q.Default + a.text[q.ID] = &v + } + } + return a +} + +// TextPtr, BoolPtr, and ListPtr are the bindings a form writes through. An +// unknown ID gets a throwaway binding rather than a nil dereference: a wizard +// asking for a question that does not exist is a bug, but not one worth +// crashing a user's setup over. +func (a *Answers) TextPtr(id string) *string { + if p, ok := a.text[id]; ok { + return p + } + var v string + a.text[id] = &v + return &v +} + +func (a *Answers) BoolPtr(id string) *bool { + if p, ok := a.flag[id]; ok { + return p + } + var v bool + a.flag[id] = &v + return &v +} + +func (a *Answers) ListPtr(id string) *[]string { + if p, ok := a.list[id]; ok { + return p + } + var v []string + a.list[id] = &v + return &v +} + +// Text, Bool, and List read an answer back. +func (a *Answers) Text(id string) string { + if p, ok := a.text[id]; ok { + return *p + } + return "" +} + +func (a *Answers) Bool(id string) bool { + if p, ok := a.flag[id]; ok { + return *p + } + return false +} + +// OptionalBool distinguishes "answered false" from "never asked". A question the +// wizard did not put on screen — the macOS-only ones, on another platform — has +// no binding at all, and Bool would report false for it, which Apply would then +// write over whatever the user had configured. +func (a *Answers) OptionalBool(id string) *bool { + p, ok := a.flag[id] + if !ok { + return nil + } + v := *p + return &v +} + +func (a *Answers) List(id string) []string { + if p, ok := a.list[id]; ok { + return append([]string(nil), *p...) + } + return nil +} + +// Set writes an answer as a string, which is how a non-Go wizard delivers one: +// "true"/"false" for a bool, comma-separated for a list. +func (a *Answers) Set(id, value string) { + switch { + case a.flag[id] != nil: + v := value == "true" + *a.flag[id] = v + case a.list[id] != nil: + *a.list[id] = SplitList(value) + default: + v := value + a.text[id] = &v + } +} + +// ShouldAsk reports whether a question's gate is satisfied by the answers so +// far. A question whose gate is not met is skipped, and its seeded default is +// what Input sees — which is why gating and application live together here +// rather than in each wizard. +func (a *Answers) ShouldAsk(q Question) bool { + if !q.Gated() { + return true + } + if p, ok := a.flag[q.RequiresID]; ok { + return strconv.FormatBool(*p) == q.RequiresValue + } + return a.Text(q.RequiresID) == q.RequiresValue +} + +// Input is the collected answers, in the shape the config wants them. +type Input struct { + PollInterval, Hysteresis, LogLevel string + Quorum bool + Countries []string + ConfigureVPN bool + // AutoMode is automatic tunnel detection: no pinned interface names. + AutoMode bool + Tunnels, Endpoints []string + Profiles []config.Profile + // AutoDiscover is nil when the wizard never asked — the question is macOS-only, + // so on every other platform there is no answer to apply. Nil rather than + // false because Apply must leave an unasked key ALONE: writing false would + // silently clear a value the user set, which is the same failure that made + // re-running setup delete imported profiles (see mergeProfiles). + AutoDiscover *bool + AllowPhysicalDNS bool +} + +// Input folds the answers into the shape Apply writes. +// +// hysteresis and profiles are not questions: hysteresis is carried through from +// the existing config untouched, and profiles are the result of importing the +// files named by the "profileFiles" answer — which the caller does, because +// reading a file is not this package's job. +func (a *Answers) Input(hysteresis string, profiles []config.Profile) Input { + countries := append(a.List("blockedCountries"), SplitList(a.Text("otherCountries"))...) + tunnels := a.List("tunnels") + if len(tunnels) == 0 { + // The no-tunnels-detected form of the question is free text. + tunnels = SplitList(a.Text("tunnels")) + } + return Input{ + PollInterval: a.Text("pollInterval"), + Hysteresis: hysteresis, + LogLevel: a.Text("logLevel"), + Quorum: a.Bool("providerQuorum"), + Countries: countries, + ConfigureVPN: a.Bool("configureVPN"), + AutoMode: a.Bool("autoMode"), + Tunnels: tunnels, + Endpoints: SplitList(a.Text("endpoints")), + Profiles: profiles, + // Absent on every platform but macOS, where the question is not asked at + // all. Nil there, so Apply leaves the configured value untouched. + AutoDiscover: a.OptionalBool("autoDiscover"), + AllowPhysicalDNS: a.Bool("allowPhysicalDNS"), + } +} + +// Apply writes collected answers onto cfg. Validation happens after, by the +// caller: this only assembles. +// +// A question the user never reached leaves its part of the config alone. That +// is why the VPN keys are written only when ConfigureVPN is true — answering +// "no" must not blank out a tunnel someone configured earlier. +func Apply(cfg *config.Config, in Input) { + if d, err := time.ParseDuration(strings.TrimSpace(in.PollInterval)); err == nil { + cfg.PollInterval = d + } + if n, err := strconv.Atoi(strings.TrimSpace(in.Hysteresis)); err == nil { + cfg.Hysteresis = n + } + cfg.LogLevel = in.LogLevel + cfg.ProviderQuorum = in.Quorum + cfg.BlockedCountries = in.Countries // config.Normalize upper-cases and de-dupes on save + + if !in.ConfigureVPN { + return + } + if in.AutoMode { + // Automatic detection: no pinned interface names (Normalize implies + // autodetect), plus live discovery where supported. + cfg.VPN.TunnelInterfaces = nil + } else { + cfg.VPN.TunnelInterfaces = in.Tunnels + } + cfg.VPN.Endpoints = in.Endpoints + cfg.VPN.Profiles = mergeProfiles(cfg.VPN.Profiles, in.Profiles) + // Only when the wizard asked. An unasked question leaves its key alone — + // the same rule ConfigureVPN applies to the whole VPN branch. + if in.AutoDiscover != nil { + cfg.VPN.AutoDiscoverEndpoints = *in.AutoDiscover + } + cfg.VPN.AllowPhysicalDNS = in.AllowPhysicalDNS +} + +// mergeProfiles adds newly imported profiles to the ones already configured, +// replacing by name. +// +// It merges rather than assigns because imported profiles are the only ones the +// wizard collects: assigning would mean that re-running setup and not naming a +// file again silently deleted every saved profile — losing the endpoints a user +// imported months ago, in a wizard they ran to change their log level. +func mergeProfiles(existing, imported []config.Profile) []config.Profile { + if len(imported) == 0 { + return existing + } + out := append([]config.Profile(nil), existing...) + for _, p := range imported { + replaced := false + for i := range out { + if out[i].Name == p.Name { + out[i] = p + replaced = true + break + } + } + if !replaced { + out = append(out, p) + } + } + return out +} + +// ValidDuration validates an answer holding a positive Go duration. +func ValidDuration(s string) error { + d, err := time.ParseDuration(strings.TrimSpace(s)) + if err != nil { + return errors.New("enter a duration like 30s or 5m") + } + if d <= 0 { + return errors.New("must be greater than zero") + } + return nil +} + +// SplitList parses comma-separated input, dropping blanks. +func SplitList(v string) []string { + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/setup/lockout.go b/internal/setup/lockout.go new file mode 100644 index 0000000..9640909 --- /dev/null +++ b/internal/setup/lockout.go @@ -0,0 +1,37 @@ +package setup + +import ( + "fmt" + "net/netip" + "strings" + + "github.com/behnam-rk/dezhban/internal/config" + "github.com/behnam-rk/dezhban/internal/netdetect" +) + +// EndpointLockoutWarning returns a non-empty message when an IP endpoint sits +// inside a tunnel's own subnet — the single most common way to lock yourself +// out, because the pass that is supposed to let the VPN redial points at an +// address only reachable through the tunnel that is down. +func EndpointLockoutWarning(cfg *config.Config) string { + var addrs []netip.Addr + for _, ep := range config.EffectiveEndpoints(cfg, nil) { + if a, err := netip.ParseAddr(ep); err == nil { + addrs = append(addrs, a) + } + } + if len(addrs) == 0 { + return "" + } + bad, err := netdetect.CheckEndpointRouting(addrs, cfg.VPN.TunnelInterfaces) + if err != nil || len(bad) == 0 { + return "" + } + var b strings.Builder + b.WriteString("\n⚠ WARNING: endpoint(s) sit inside a tunnel subnet — this will likely lock you out:\n") + for _, r := range bad { + fmt.Fprintf(&b, " %s is within %s (%s)\n", r.Endpoint, r.Subnet, r.Iface) + } + b.WriteString(" Set the VPN server's PHYSICAL (public) address instead — see 'dezhban doctor --discover'.") + return b.String() +} diff --git a/internal/setup/questions.go b/internal/setup/questions.go new file mode 100644 index 0000000..c4b5e1d --- /dev/null +++ b/internal/setup/questions.go @@ -0,0 +1,269 @@ +// Package setup holds the first-run wizard's decisions: what it asks, in what +// order, which answers unlock which follow-ups, and how the answers become a +// config. +// +// It is deliberately free of any presentation. The CLI renders these questions +// with huh; the macOS app renders the same list natively from +// `dezhban setup --questions --json`. Neither owns the question set, so the two +// wizards cannot ask different things or apply the same answer differently — +// the same reason internal/config owns the tunable schema rather than each +// surface holding its own copy. +// +// Nothing here touches the firewall, the daemon, or the filesystem. It reads a +// config, and reports questions and the config the answers imply. +package setup + +import ( + "sort" + "strconv" + "strings" + + "github.com/behnam-rk/dezhban/internal/config" +) + +// Question kinds. The set is small on purpose: every kind must be renderable +// both as a huh field and as a native control, so adding one is a change in +// two places and should be rare. +const ( + // KindDuration is a Go duration string, validated by ValidDuration. + KindDuration = "duration" + // KindText is free text. + KindText = "text" + // KindList is comma-separated free text that becomes a list. + KindList = "list" + // KindSelect is one value from Options. + KindSelect = "select" + // KindMultiSelect is any number of values from Options. + KindMultiSelect = "multiselect" + // KindBool is a yes/no. + KindBool = "bool" +) + +// An Option is one choice in a select or multi-select question. +type Option struct { + Label string `json:"label"` + Value string `json:"value"` +} + +// A Question is one decision the wizard asks the user to make. +type Question struct { + // ID is the stable identifier answers are keyed by. Both wizards and the + // JSON use it, so it is a compatibility surface: do not rename one. + ID string `json:"id"` + // Key is the dotted config key this answer writes, empty when the question + // only steers the flow (ConfigureVPN, AutoMode) or is folded into another + // key's value (OtherCountries). + Key string `json:"key,omitempty"` + Kind string `json:"kind"` + Title string `json:"title"` + Description string `json:"description"` + // Options is present for select and multiselect. + Options []Option `json:"options,omitempty"` + // Default is the seeded answer for every kind but multiselect, rendered the + // way the config renders it. + Default string `json:"default,omitempty"` + // Selected is the seeded answer for multiselect. + Selected []string `json:"selected,omitempty"` + // Group is the screenful this question belongs to. Questions with the same + // Group are asked together; the numbers are ordering only. + Group int `json:"group"` + // RequiresID and RequiresValue gate this question on an earlier answer — + // "tunnels" is only asked when "autoMode" answered "false". Empty means + // always asked. + RequiresID string `json:"requiresId,omitempty"` + RequiresValue string `json:"requiresValue,omitempty"` +} + +// Gated reports whether this question depends on an earlier answer. +func (q Question) Gated() bool { return q.RequiresID != "" } + +// CommonBlocked are the countries offered as checkboxes. Any other ISO code can +// be typed into the free-text question that follows. +var CommonBlocked = []Option{ + {Label: "Iran (IR)", Value: "IR"}, + {Label: "Russia (RU)", Value: "RU"}, + {Label: "China (CN)", Value: "CN"}, + {Label: "North Korea (KP)", Value: "KP"}, + {Label: "Syria (SY)", Value: "SY"}, + {Label: "Cuba (CU)", Value: "CU"}, + {Label: "Belarus (BY)", Value: "BY"}, +} + +// Options is what the question set is seeded from. +type Options struct { + // Config is what the answers start at, so re-running the wizard edits + // rather than clobbers. Nil means the shipped defaults. + Config *config.Config + // ConfigExisted distinguishes "the user has a config" from "we fell back to + // defaults". Only a brand-new macOS config defaults endpoint discovery on: + // re-running setup must never silently flip an explicit `false` back. + ConfigExisted bool + // GOOS is the platform the config is FOR, not necessarily the one asking — + // live endpoint discovery is macOS-only. + GOOS string + // DetectedTunnels turns the tunnel question from free text into a pick + // list. Supplied by the caller (netdetect in the CLI, `detect-vpn --json` + // in the app) so this package stays free of platform probes. + DetectedTunnels []string +} + +// Questions returns the wizard's questions in the order they are asked, seeded +// from the given config. +func Questions(opts Options) []Question { + cfg := opts.Config + if cfg == nil { + d := config.Default() + cfg = &d + } + macOS := opts.GOOS == "darwin" + + blocked := map[string]bool{} + for _, c := range cfg.BlockedCountries { + blocked[strings.ToUpper(strings.TrimSpace(c))] = true + } + var selected []string + for _, opt := range CommonBlocked { + if blocked[opt.Value] { + selected = append(selected, opt.Value) + delete(blocked, opt.Value) + } + } + // Whatever is configured but not on the common list seeds the free-text + // question, so a code the wizard does not offer survives a re-run. + var extra []string + for code := range blocked { + extra = append(extra, code) + } + // Stable order, so a re-run does not reshuffle a field nobody touched. + sort.Strings(extra) + + // Endpoint discovery is macOS-only and defaults on ONLY for a brand-new + // config there, for the reason in Options.ConfigExisted. + autoDiscover := cfg.VPN.AutoDiscoverEndpoints + if macOS && !opts.ConfigExisted { + autoDiscover = true + } + + endpointDesc := "Server IP(s)/hostname(s), comma-separated. Optional on macOS (auto-discovered); needed elsewhere." + if !macOS { + endpointDesc = "Server IP(s)/hostname(s), comma-separated. Required on this platform (no live discovery)." + } + + qs := []Question{ + { + ID: "pollInterval", Key: "pollInterval", Kind: KindDuration, Group: 1, + Title: "Poll interval", + Description: "How often the exit country is checked, e.g. 30s.", + Default: cfg.PollInterval.String(), + }, + { + ID: "blockedCountries", Key: "blockedCountries", Kind: KindMultiSelect, Group: 1, + Title: "Blocked countries", + Description: "Traffic is cut when the VPN's exit lands in one of these.", + Options: CommonBlocked, + Selected: selected, + }, + { + ID: "otherCountries", Kind: KindList, Group: 1, + Title: "Other country codes", + Description: "Comma-separated ISO codes not listed above (optional).", + Default: strings.Join(extra, ","), + }, + { + ID: "logLevel", Key: "logLevel", Kind: KindSelect, Group: 1, + Title: "Log level", + Options: []Option{{"Debug", "debug"}, {"Info", "info"}, {"Warn", "warn"}, {"Error", "error"}}, + Default: cfg.LogLevel, + }, + { + ID: "providerQuorum", Key: "providerQuorum", Kind: KindBool, Group: 1, + Title: "Require provider quorum?", + Description: "Only act when a majority of the geo providers agree.", + Default: strconv.FormatBool(cfg.ProviderQuorum), + }, + { + ID: "configureVPN", Kind: KindBool, Group: 1, + Title: "Configure your VPN now?", + Description: "dezhban only enforces once it knows your VPN's tunnel and server. " + + "Say no and it starts in standby — fully open, nothing blocked — until you " + + "run 'dezhban setup' again or edit the config.", + Default: "true", + }, + { + ID: "autoMode", Kind: KindBool, Group: 2, + Title: "Use automatic VPN detection? (recommended)", + Description: "dezhban finds your tunnel and, on macOS, learns the server address " + + "itself — works with any VPN and survives redials.", + Default: "true", + RequiresID: "configureVPN", + RequiresValue: "true", + }, + tunnelQuestion(opts.DetectedTunnels, cfg.VPN.TunnelInterfaces), + { + ID: "profileFiles", Kind: KindList, Group: 4, + Title: "Self-hosted VPN config files", + Description: "Comma-separated paths to WireGuard/.conf, OpenVPN/.ovpn, or V2Ray " + + "JSON to import as profiles (optional).", + RequiresID: "configureVPN", RequiresValue: "true", + }, + { + ID: "endpoints", Key: "vpn.endpoints", Kind: KindList, Group: 4, + Title: "VPN endpoint(s)", + Description: endpointDesc, + Default: strings.Join(cfg.VPN.Endpoints, ","), + RequiresID: "configureVPN", RequiresValue: "true", + }, + { + ID: "allowPhysicalDNS", Key: "vpn.allowPhysicalDNS", Kind: KindBool, Group: 4, + Title: "Allow DNS on the physical link while the tunnel is down?", + Description: "Lets a VPN client re-resolve its server hostname to redial. Leaks " + + "only DNS-query metadata; your traffic stays blocked. Recommended if any " + + "endpoint is a hostname.", + Default: strconv.FormatBool(cfg.VPN.AllowPhysicalDNS), + RequiresID: "configureVPN", RequiresValue: "true", + }, + } + + // Live endpoint discovery is macOS-only, so elsewhere the question is not + // asked at all rather than asked and ignored. + if macOS { + qs = append(qs, Question{ + ID: "autoDiscover", Key: "vpn.autoDiscoverEndpoints", Kind: KindBool, Group: 5, + Title: "Auto-discover the VPN server address? (recommended)", + Description: "dezhban watches the live tunnel socket to learn the server IP, so " + + "you don't pin one that changes. macOS only.", + Default: strconv.FormatBool(autoDiscover), + RequiresID: "configureVPN", RequiresValue: "true", + }) + } + return qs +} + +// tunnelQuestion is a pick list when tunnels were detected and free text when +// none were — the same split the CLI's tunnelSelector used to make on its own. +func tunnelQuestion(detected, configured []string) Question { + q := Question{ + ID: "tunnels", Key: "vpn.tunnelInterfaces", Group: 3, + Title: "Tunnel interface(s)", + RequiresID: "autoMode", RequiresValue: "false", + } + if len(detected) == 0 { + q.Kind = KindList + q.Description = "None detected. Enter comma-separated names (e.g. utun4)." + q.Default = strings.Join(configured, ",") + return q + } + cfgSet := map[string]bool{} + for _, t := range configured { + cfgSet[t] = true + } + q.Kind = KindMultiSelect + q.Description = "Detected tunnels — pick the VPN's." + for _, t := range detected { + q.Options = append(q.Options, Option{Label: t, Value: t}) + if cfgSet[t] { + q.Selected = append(q.Selected, t) + } + } + return q +} diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go new file mode 100644 index 0000000..ccdee13 --- /dev/null +++ b/internal/setup/setup_test.go @@ -0,0 +1,302 @@ +package setup + +import ( + "reflect" + "strconv" + "testing" + + "github.com/behnam-rk/dezhban/internal/config" +) + +// Apply in auto mode must produce a "connect any VPN" config: no pinned +// interfaces (autodetect implied on Normalize), profiles carried, and +// allowPhysicalDNS honored. +func TestApplyAutoMode(t *testing.T) { + cfg := config.Default() + Apply(&cfg, Input{ + PollInterval: "30s", Hysteresis: "3", LogLevel: "info", + ConfigureVPN: true, AutoMode: true, + Tunnels: []string{"utun9"}, // must be ignored in auto mode + Endpoints: []string{"vpn.example.com"}, + Profiles: []config.Profile{{Name: "home", Endpoints: []string{"203.0.113.7"}}}, + AutoDiscover: boolPtr(true), + AllowPhysicalDNS: true, + }) + if !cfg.VPN.AutoDiscoverEndpoints { + t.Error("an answered autoDiscover should be applied") + } + if len(cfg.VPN.TunnelInterfaces) != 0 { + t.Errorf("auto mode must not pin interfaces, got %v", cfg.VPN.TunnelInterfaces) + } + if !cfg.VPN.AllowPhysicalDNS { + t.Error("allowPhysicalDNS should be set") + } + if len(cfg.VPN.Profiles) != 1 || cfg.VPN.Profiles[0].Name != "home" { + t.Errorf("profiles not carried: %+v", cfg.VPN.Profiles) + } + // Normalize (run on save) implies autodetect when no interfaces are pinned. + config.Normalize(&cfg) + if !cfg.VPN.AutoDetect { + t.Error("autodetect should be implied for an auto-mode config") + } + if err := cfg.Validate(); err != nil { + t.Errorf("auto-mode config should validate: %v", err) + } +} + +func boolPtr(v bool) *bool { return &v } + +// A question the wizard never put on screen must not have its key rewritten. +// The autoDiscover question is macOS-only, so off macOS there is no binding for +// it — and reading a missing binding as "false" meant re-running setup on Linux +// silently cleared vpn.autoDiscoverEndpoints. Same failure as the one that used +// to delete imported profiles: a value the user never touched, overwritten. +func TestAnUnaskedQuestionLeavesItsKeyAlone(t *testing.T) { + cfg := config.Default() + cfg.VPN.AutoDiscoverEndpoints = true + + qs := Questions(Options{Config: &cfg, GOOS: "linux", ConfigExisted: true}) + for _, q := range qs { + if q.ID == "autoDiscover" { + t.Fatal("the autoDiscover question is macOS-only; this test assumes it is absent") + } + } + + answers := NewAnswers(qs) + if answers.OptionalBool("autoDiscover") != nil { + t.Fatal("an unasked question should have no binding") + } + Apply(&cfg, answers.Input(strconv.Itoa(cfg.Hysteresis), nil)) + + if !cfg.VPN.AutoDiscoverEndpoints { + t.Error("vpn.autoDiscoverEndpoints was cleared by a wizard that never asked about it") + } +} + +// Advanced mode pins the chosen interfaces. +func TestApplyAdvancedPin(t *testing.T) { + cfg := config.Default() + Apply(&cfg, Input{ + PollInterval: "30s", Hysteresis: "3", LogLevel: "info", + ConfigureVPN: true, AutoMode: false, + Tunnels: []string{"utun4"}, + Endpoints: []string{"203.0.113.7"}, + }) + if len(cfg.VPN.TunnelInterfaces) != 1 || cfg.VPN.TunnelInterfaces[0] != "utun4" { + t.Errorf("advanced mode should pin utun4, got %v", cfg.VPN.TunnelInterfaces) + } +} + +// Answering "no" to "configure your VPN now?" must leave a VPN somebody already +// set up completely alone — the wizard is also how people change their log +// level. +func TestDecliningTheVPNBranchTouchesNoVPNKey(t *testing.T) { + cfg := config.Default() + cfg.VPN.TunnelInterfaces = []string{"utun4"} + cfg.VPN.Endpoints = []string{"203.0.113.7"} + cfg.VPN.AllowPhysicalDNS = true + + Apply(&cfg, Input{PollInterval: "45s", LogLevel: "warn", ConfigureVPN: false}) + + if !reflect.DeepEqual(cfg.VPN.TunnelInterfaces, []string{"utun4"}) { + t.Errorf("tunnels changed: %v", cfg.VPN.TunnelInterfaces) + } + if !reflect.DeepEqual(cfg.VPN.Endpoints, []string{"203.0.113.7"}) { + t.Errorf("endpoints changed: %v", cfg.VPN.Endpoints) + } + if !cfg.VPN.AllowPhysicalDNS { + t.Error("allowPhysicalDNS was cleared") + } + if cfg.LogLevel != "warn" { + t.Errorf("the answers that WERE given should still apply, got logLevel %q", cfg.LogLevel) + } +} + +// Profiles are imported from files named in the wizard, and nowhere else. If +// applying them ASSIGNED rather than merged, re-running setup without naming a +// file again would silently delete every saved profile. +func TestImportedProfilesAddToTheSavedOnes(t *testing.T) { + cfg := config.Default() + cfg.VPN.Profiles = []config.Profile{ + {Name: "home", Endpoints: []string{"203.0.113.7"}}, + {Name: "work", Endpoints: []string{"198.51.100.9"}}, + } + + // A run that imported nothing keeps both. + Apply(&cfg, Input{ConfigureVPN: true, AutoMode: true}) + if len(cfg.VPN.Profiles) != 2 { + t.Fatalf("a run importing nothing must keep saved profiles, got %+v", cfg.VPN.Profiles) + } + + // A run that re-imports one replaces that one and keeps the other. + Apply(&cfg, Input{ConfigureVPN: true, AutoMode: true, + Profiles: []config.Profile{{Name: "work", Endpoints: []string{"192.0.2.5"}}}}) + if len(cfg.VPN.Profiles) != 2 { + t.Fatalf("re-importing a profile must not drop the others, got %+v", cfg.VPN.Profiles) + } + byName := map[string][]string{} + for _, p := range cfg.VPN.Profiles { + byName[p.Name] = p.Endpoints + } + if !reflect.DeepEqual(byName["work"], []string{"192.0.2.5"}) { + t.Errorf("re-imported profile should win, got %v", byName["work"]) + } + if !reflect.DeepEqual(byName["home"], []string{"203.0.113.7"}) { + t.Errorf("untouched profile should survive, got %v", byName["home"]) + } +} + +// --- questions --- + +// The wizard edits rather than clobbers, so every question must start at what +// the config already says. +func TestQuestionsSeedFromTheConfig(t *testing.T) { + cfg := config.Default() + cfg.BlockedCountries = []string{"IR", "AQ", "CN"} + cfg.VPN.Endpoints = []string{"203.0.113.7", "vpn.example.com"} + cfg.LogLevel = "debug" + + qs := byID(Questions(Options{Config: &cfg, ConfigExisted: true, GOOS: "darwin"})) + + if got := qs["blockedCountries"].Selected; !reflect.DeepEqual(got, []string{"IR", "CN"}) { + t.Errorf("checkbox seed = %v, want the codes that ARE on the common list", got) + } + // A configured code the wizard does not offer must survive a re-run, which + // means it has to land in the free-text question. + if got := qs["otherCountries"].Default; got != "AQ" { + t.Errorf("other-countries seed = %q, want the codes not on the list", got) + } + if got := qs["endpoints"].Default; got != "203.0.113.7,vpn.example.com" { + t.Errorf("endpoints seed = %q", got) + } + if got := qs["logLevel"].Default; got != "debug" { + t.Errorf("logLevel seed = %q", got) + } +} + +// Endpoint discovery is macOS-only, and defaults on only for a brand-new config +// there: re-running setup must never silently flip an explicit false back on. +func TestAutoDiscoverDefaultsOnlyForANewMacConfig(t *testing.T) { + off := config.Default() + off.VPN.AutoDiscoverEndpoints = false + + fresh := byID(Questions(Options{Config: &off, ConfigExisted: false, GOOS: "darwin"})) + if fresh["autoDiscover"].Default != "true" { + t.Error("a brand-new macOS config should be offered discovery on") + } + + existing := byID(Questions(Options{Config: &off, ConfigExisted: true, GOOS: "darwin"})) + if existing["autoDiscover"].Default != "false" { + t.Error("an existing config's explicit false must be preserved") + } + + linux := byID(Questions(Options{Config: &off, ConfigExisted: false, GOOS: "linux"})) + if _, asked := linux["autoDiscover"]; asked { + t.Error("discovery is macOS-only, so elsewhere it must not be asked at all") + } +} + +// The tunnel question is a pick list when tunnels were detected and free text +// when none were — the same split the CLI used to make on its own. +func TestTunnelQuestionFollowsDetection(t *testing.T) { + cfg := config.Default() + cfg.VPN.TunnelInterfaces = []string{"utun4"} + + detected := byID(Questions(Options{Config: &cfg, GOOS: "darwin", DetectedTunnels: []string{"utun4", "utun7"}}))["tunnels"] + if detected.Kind != KindMultiSelect { + t.Errorf("kind = %q, want a pick list when tunnels were detected", detected.Kind) + } + if !reflect.DeepEqual(detected.Selected, []string{"utun4"}) { + t.Errorf("configured tunnel should be preselected, got %v", detected.Selected) + } + + none := byID(Questions(Options{Config: &cfg, GOOS: "darwin"}))["tunnels"] + if none.Kind != KindList { + t.Errorf("kind = %q, want free text when nothing was detected", none.Kind) + } + if none.Default != "utun4" { + t.Errorf("free-text seed = %q", none.Default) + } +} + +// --- gating --- + +func TestGatingHidesTheWholeVPNBranch(t *testing.T) { + qs := Questions(Options{GOOS: "darwin"}) + a := NewAnswers(qs) + a.Set("configureVPN", "false") + + for _, q := range qs { + if q.RequiresID == "configureVPN" && a.ShouldAsk(q) { + t.Errorf("%s should not be asked when the VPN branch was declined", q.ID) + } + } + + a.Set("configureVPN", "true") + a.Set("autoMode", "true") + for _, q := range qs { + if q.ID == "tunnels" && a.ShouldAsk(q) { + t.Error("automatic detection must not ask which interface to pin") + } + } + a.Set("autoMode", "false") + for _, q := range qs { + if q.ID == "tunnels" && !a.ShouldAsk(q) { + t.Error("declining automatic detection must ask which interface to pin") + } + } +} + +// Walking the wizard and pressing Enter on every question must land on the +// config you started with. Anything else means a default is stated in one place +// and applied differently in another — the drift Phase M exists to prevent, +// arriving through the wizard instead. +func TestAnsweringNothingChangesNothing(t *testing.T) { + cfg := config.Default() + cfg.BlockedCountries = []string{"IR", "AQ"} + cfg.VPN.Endpoints = []string{"203.0.113.7"} + cfg.VPN.TunnelInterfaces = []string{"utun4"} + cfg.VPN.AllowPhysicalDNS = true + config.Normalize(&cfg) + before := config.KeyValues(&cfg) + + qs := Questions(Options{Config: &cfg, ConfigExisted: true, GOOS: "darwin", DetectedTunnels: []string{"utun4"}}) + a := NewAnswers(qs) + // The one answer with no config to seed it: the VPN branch is offered, and + // its own sub-answers are seeded, so accepting them must be a no-op too. + a.Set("configureVPN", "true") + a.Set("autoMode", "false") + + after := cfg + // Hysteresis has no question; the wizard carries the current value through, + // which is exactly what the caller passes here. + Apply(&after, a.Input(strconv.Itoa(cfg.Hysteresis), nil)) + config.Normalize(&after) + + for key, want := range before { + if got := config.KeyValues(&after)[key]; got != want { + t.Errorf("%s changed by answering nothing: %q → %q", key, want, got) + } + } +} + +func TestValidDuration(t *testing.T) { + for _, ok := range []string{"30s", "5m", " 1h "} { + if err := ValidDuration(ok); err != nil { + t.Errorf("ValidDuration(%q) = %v", ok, err) + } + } + for _, bad := range []string{"", "soon", "0s", "-5m"} { + if err := ValidDuration(bad); err == nil { + t.Errorf("ValidDuration(%q) accepted", bad) + } + } +} + +func byID(qs []Question) map[string]Question { + out := make(map[string]Question, len(qs)) + for _, q := range qs { + out[q.ID] = q + } + return out +} diff --git a/internal/state/state.go b/internal/state/state.go index b424d97..6d70c73 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -84,6 +84,16 @@ type Snapshot struct { // Pending describes a posture change the daemon is counting toward but has // not committed, present only while a hysteresis streak is running. Pending *PendingFlip `json:"pending,omitempty"` + // Drop records the most recent tunnel drop, from the moment it happened + // until a tunnel is up again — including across the redial window that + // follows, which is the only reason any surface can report the drop at all. + // Additive field: absent from older snapshots, so nil means "no drop is + // being carried", never "no drop has ever happened". + Drop *DropRecord `json:"drop,omitempty"` + // Hold reports that the next tunnel drop will stay cut instead of opening + // an automatic redial window. Present only while armed. Additive field: + // absent from older snapshots, so nil means "not armed". + Hold *HoldState `json:"hold,omitempty"` // Display is the rendered posture sentence — see internal/render, the // package that composes it from this same Snapshot. Carried here for the // one consumer that cannot call Go directly: the macOS menubar app reads @@ -138,6 +148,49 @@ type SwitchState struct { Trigger string `json:"trigger,omitempty"` } +// DropRecord is the moment a healthy tunnel went down, carried forward until a +// tunnel is up again. +// +// It exists because the cut is otherwise unobservable on the common path. The +// run loop opens the automatic redial window on the same tunnel-down edge, so a +// snapshot showing the guard holding a downed tunnel is superseded microseconds +// later, and observers read the state file about once a second — they would +// never see it. Without this, both surfaces could only say "a window is open" +// and never "your VPN dropped at 3:04PM, and here is what happened next". +// +// Carrying it does NOT make the window look like a cut. An open window is a +// relaxation: traffic is flowing and the real IP may be exposed, so it stays +// amber. The record says what happened, not what is happening. +// It carries the moment and nothing else. A companion "was traffic cut at that +// instant" flag was tried and removed: no surface could truthfully render it, +// because a window may have opened and expired in between, so neither "cut +// since" nor "not cut" describes what followed. What a reader needs from a drop +// is WHEN — the posture fields already say what is happening now. +type DropRecord struct { + // At is when the tunnel was observed down. + At time.Time `json:"at"` +} + +// HoldState reports that "hold the line" is armed: the next tunnel drop will +// NOT open an automatic redial window, so a deliberate disconnect stays cut. +// +// dezhban cannot tell an intentional disconnect from an accidental drop, and +// that ambiguity is the whole reason this exists — disconnecting by hand +// otherwise opens a redial window the user never wanted. Arming says which kind +// of drop the next one will be. +// +// It only ever SUPPRESSES. It removes an automatic relaxation for one drop and +// grants nothing, so the three sanctioned relaxation triggers are unchanged and +// there is no fourth. Being strictly more restrictive is also why it needs no +// config gate of its own: there is no setting to protect. +type HoldState struct { + // Armed is true from the moment it is armed until the drop it covers, an + // explicit cancel, or a tunnel coming back up. + Armed bool `json:"armed"` + // At is when it was armed. + At time.Time `json:"at"` +} + // Trigger values for SwitchState.Trigger. Stable identifiers — status --json // consumers match on them. const ( diff --git a/tools/helpgen/main.go b/tools/helpgen/main.go new file mode 100644 index 0000000..2faa10b --- /dev/null +++ b/tools/helpgen/main.go @@ -0,0 +1,29 @@ +// Command helpgen renders the documentation the macOS app bundles. +// +// Dev tooling only — the same standing as tools/taskmenu. It is invoked by +// gui/macos/build-app.sh at package time and is never installed, never shipped, +// and never on the enforcement path. +// +// go run ./tools/helpgen -docs docs -out dist/help +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/behnam-rk/dezhban/internal/help" +) + +func main() { + docs := flag.String("docs", "docs", "path to the docs/ directory") + out := flag.String("out", "dist/help", "directory to write the rendered bundle into") + flag.Parse() + + index, err := help.Build(*docs, *out) + if err != nil { + fmt.Fprintln(os.Stderr, "helpgen:", err) + os.Exit(1) + } + fmt.Printf("helpgen: %d pages → %s\n", len(index), *out) +}