Effortless multi-VPN switching: profiles, dynamic tunnels, switch window (phase 9)#17
Merged
Merged
Conversation
…ical DNS Two changes that break the reconnect livelock where a warming-up tunnel (reports "up" before it routes) drove failing geo lookups into FULL BLOCK country="", cutting the tunnel's own egress and preventing the reconnect it was waiting for. Fix 1 — vpnGeoStep holds the current posture on a lookup error (early return before Decider.Evaluate). Only a successful blocked-country reading escalates GUARD→FULL BLOCK; failClosed becomes a no-op in VPN guard mode, where the standing guard is itself the fail-closed block for physical leaks. Decider is untouched, so fallback/legacy fail-closed behavior is unchanged. Fix 2 — opt-in vpn.allowPhysicalDNS (default false) adds a plain-DNS (port 53) egress pass to GUARD and VPN FULL BLOCK across pf/nft/WFP, so a VPN client with a hostname endpoint can re-resolve its server while the tunnel is down. Plumbed config → Policy → renderers → policyForMode. Tests: TestVPNHoldsGuardOnLookupError, TestVPNHoldsFullBlockOnProbeError, and per-backend allowPhysicalDNS render tests. Docs + sample config updated; CLAUDE.md fail-closed invariant scoped to fallback mode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the phase-9 config surface, backward-compatible (no migration; legacy
configs load and round-trip unchanged):
- vpn.profiles [{name, endpoints, ifaceHint}] — named VPNs whose endpoints stay
reachable (the guard passes the union). ifaceHint is advisory/display-only.
- vpn.switchWindow (default 2m, validated [10s, advanced.switchWindowMax]).
- vpn.advanced.* — configurability for design-decision constants that were
otherwise baked in: switchWindowMax, commandFreshness, windowDiscoveryInterval,
tunnelPruneAfter, learnedEndpointTTL, learnedMaxPerProfile, promoteAfterRefreshes,
endpointWarnThreshold, and windowProtocols/windowPorts (optional window
restriction). Absent block = recommended defaults; toFileConfig emits only
non-default fields so legacy configs stay byte-stable.
- Autodetect is now the effective default: Normalize implies it when the guard is
enabled with no explicit tunnelInterfaces (compat-safe — previously-rejected
configs now validate). The old "requires tunnelInterfaces or autodetect" error
is removed.
- Endpoint requirement widened to the union (flat OR profiles OR autoDiscover).
- config.EffectiveEndpoints(cfg, extra) — the shared dedup union used by the
runner, print-rules, doctor, and setup's lockout check.
Tests: profiles/switchWindow load, advanced defaults+override, EffectiveEndpoints
union, profiles-only valid, and new validation error cases (dup/empty/bad-name
profiles, switchWindow range, advanced proto/port/duration).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nnel internal/learned: versioned learned.json store for endpoints the daemon discovers at runtime (switch window / live discovery). Atomic 0644 writes, missing-file → empty, corrupt-file → empty + non-fatal error, per-entry LRU cap (by last-seen) and TTL prune, dedup/sorted Addrs() for the resolution union. Single writer is the daemon; the CLI reads it unprivileged. internal/command: minimal one-shot control channel — a root-owned command.json the CLI writes and the daemon consumes on a tick. Consume validates (regular file, injectable root-owner check, ±freshness window) and deletes before acting (consume-once, replay-safe); Discard clears a stale file at startup. Owner check is build-tagged: uid-0 + non-world-writable on unix, ACL-based no-op on Windows. No new dependencies. Tests: 15 covering round-trips, corrupt/missing tolerance, cap/TTL eviction, and every command rejection path (stale, future stamp, bad owner, missing op). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng posture
- ModeSwitchWindow: a bounded relaxation for connecting a brand-new VPN. Default
passes all outbound (safety comes from the daemon's short timer + early-close,
not a port filter that would readmit phone-home on 443); optional
WindowProtos/WindowPorts render a restricted variant (tunnel + endpoints + DNS
+ the given proto/port set). WFP flips the profile default to Allow for the
unrestricted window, keeping a marker rule for surgical teardown.
- Policy.TunnelGroups: interface-class passes (pf `on { utun }`, nft
`oifname "utun*"`) so a newly-appeared tunnel is covered with no rule reload;
Windows ignores it (exact alias only). Guard Apply now accepts a group-only
policy.
- Zero-tunnel VPN full-block shape: ModeFullBlock now keys the VPN vs legacy
shape on isVPNPolicy() (endpoints/ifaces/DNS present) rather than "has tunnel
ifaces", so the daemon-before-VPN standing posture (endpoints open, no ifaces)
renders correctly instead of falling through to the legacy allowlist.
Render tests for all three behaviors across pf/nft/WFP.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t source - TunnelState.Names: liveSample now collects ALL up tunnel-like interfaces (sorted), not just the first; Name stays as Names[0] for compatible logging. The watcher emits on set change (a new tunnel appearing while another is up), with growth/up emitted immediately and shrink/down debounced — biasing toward guarding a new tunnel at once and not churning rules on a flapping reconnect. - EndpointSource.ResolveWith(ctx, tunnels): resolves with an explicit live tunnel set for the tunnel-internal drop filter, so the runner's dynamic set keeps the filter tracking reality. Resolve delegates with the configured set. - EndpointSource.Learned: a fourth input tier (between hostnames and discovery) feeding daemon-persisted learned endpoints into the union. Tests: set-growth immediate emission, learned tier + source label, ResolveWith delegation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The runner's tunnel set is now mutable, mirroring how endpoints already are:
- reconcileTunnels grows the guard set immediately when the watcher reports a new
tunnel-like interface and prunes non-pinned ones the watcher drops (the shrink
is already debounced); explicit config interfaces are pinned and never pruned;
the set never narrows to empty. A change re-applies the guard from the run-loop
goroutine (the single-Apply invariant holds).
- Zero-tunnel standing posture: with no tunnels up, vpnPolicies returns the
endpoints-open FULL BLOCK shape (never a backend-rejected empty-iface guard),
and the geo step is suppressed — so the daemon can run before any VPN connects.
Startup gates relax accordingly when autodetect or a switch window is available.
- Switch window: a control command (internal/command via PollCommand) opens a
bounded ModeSwitchWindow; a fast discovery tick learns the new server; the
window closes early to GUARD only when a bounded geo lookup confirms a
non-blocked exit (never fed into the hysteresis streak), persisting the
discovered endpoint via Learn; otherwise it reverts to the prior posture on
cancel or expiry, keeping session-discovered endpoints so a mid-flight handshake
can still complete under GUARD.
state.Snapshot gains ActiveProfile + Switch{Open,Until,Profile} and the
"switch-window" posture. New Options: Autodetect, TunnelGroups, SwitchWindow(+Max),
WindowProtos/Ports, WindowDiscoveryInterval, PollCommand(+CommandPoll), Learn,
ResolveEndpointsWith.
Tests: reconcileTunnels table, new-tunnel-reapplies-guard, zero-tunnel standing
posture, switch-window cancel-revert, and early-close-learns-endpoint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- assembleOptions now wires the full phase-9 runtime: the endpoint source unions flat + profile endpoints (config.EffectiveEndpoints) and feeds the learned store; a learn hook persists switch-window discoveries; the command file is polled (root-owned check + freshness) when a switch window is configured; and autodetect / switch-window / window-restriction knobs are passed through. - `dezhban switch [--for --name --cancel --status --no-wait]`: writes the root-owned control command and narrates the window via the state file. - `dezhban vpn list|add|remove|import|promote|forget`: manage profiles and learned endpoints; add/import go through validate+atomic-save; promote moves a learned entry into a saved profile. Flags parse interspersed with positionals. - internal/vpnimport: stdlib endpoint extraction from WireGuard .conf, OpenVPN .ovpn, and V2Ray/Xray/sing-box JSON (endpoints only — never keys; ports stripped; private/loopback filtered). - detect-vpn now recommends the autodetect config; status shows profiles, switch window, and active profile; `config set` gains vpn.allowPhysicalDNS and vpn.switchWindow. New command/learned file path helpers. Tests: vpnimport format fixtures + private/garbage rejection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reworks the VPN branch of `dezhban setup` to produce a "connect any VPN" config by default: - "Use automatic VPN detection? (recommended)" — writes an autodetect config with no pinned interface names (which go stale across reconnects) and, on macOS, live endpoint discovery. The old pin-specific-interfaces flow survives behind the opt-out. - Profiles step: import self-hosted VPN config files (WireGuard/OpenVPN/V2Ray via internal/vpnimport) and/or type endpoints; required path on Linux/Windows. - allowPhysicalDNS confirm (recommended when endpoints are hostnames), with the leak trade-off stated plainly; fail-closed question reworded for guard mode. - Lockout guard now checks the endpoint union (flat + profiles). - New closing step: offer to install + start the service, closing the one-time-setup loop; points at `dezhban switch` for brand-new VPNs. Tests: applyWizard auto-mode (no pinned ifaces, profiles carried, validates) and advanced-pin mode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Snapshot decodes the new activeProfile and switch{open,until,profile} fields
and the "switch-window" posture.
- Menu (VPN mode, when running): "Switching VPN…" opens a window via
`dezhban switch --no-wait` through the existing admin-prompt path; while a
window is open it becomes "Cancel VPN switch (mm:ss left)" → `switch --cancel`.
- Header shows the active profile ("VPN: <name>") and an open switch window with
its deadline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s --mode switch - config.md: vpn.profiles, vpn.switchWindow, the vpn.advanced tunables table, a "switching between many VPNs" section, and the learned.json location. - modes.md: "Switching between VPNs" + the switch-window posture, with the honest all-outbound rationale and the real-IP-exposure trade-off. - usage.md: switch + vpn command reference and a "Connect & switch VPNs" section. - state.md: activeProfile + switch snapshot fields, switch-window posture. - CLAUDE.md: switch-window and dynamic-tunnel/learned-store invariants. - New docs/plans/phase-9-vpn-profiles-switching.md + readme entry. - configs/dezhban.profiles.json sample (autodetect + profiles + switch window). - policyForMode + print-rules/completion learn `--mode switch` so the window ruleset can be previewed without applying it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Implements “phase 9” VPN-guard UX improvements: VPN profiles + dynamic tunnel detection + a bounded “switch window” to safely connect brand-new VPNs, with daemon-side endpoint learning and status surfaced to CLI/docs and the macOS menubar.
Changes:
- Add VPN profiles, switch-window control path, learned endpoint persistence, and dynamic tunnel re-detection (runner + netdetect + config + new leaf pkgs).
- Extend firewall backends (pf/nft/WFP) with
ModeSwitchWindow, tunnel groups/wildcards, and a zero-tunnel standing posture. - Update CLI/setup/docs/macOS GUI to expose switching and profile/window status.
Reviewed changes
Copilot reviewed 43 out of 43 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| macos-gui/Sources/DezhbanMenu/Snapshot.swift | Decode new snapshot fields (switch, activeProfile, new posture value). |
| macos-gui/Sources/DezhbanMenu/AppDelegate.swift | Menubar actions for opening/canceling switch window; display profile/window status. |
| internal/vpnimport/vpnimport.go | New stdlib-only config importer to extract VPN endpoints (WG/OpenVPN/V2Ray). |
| internal/vpnimport/vpnimport_test.go | Unit tests for importer sniffing/extraction/filtering. |
| internal/state/state.go | Snapshot schema extended with switch window and activeProfile. |
| internal/runner/runner.go | Dynamic tunnels + switch-window state machine + standing posture + DNS opt-in + learned endpoints wiring hooks. |
| internal/runner/runner_test.go | Tests for new posture mapping, lookup-error hold semantics, dynamic tunnels, switch window, and learning. |
| internal/netdetect/watch.go | Multi-tunnel watcher emits Names[] set changes (grow/prune semantics). |
| internal/netdetect/watch_test.go | Test for immediate emission on tunnel-set growth. |
| internal/netdetect/resolve.go | Add learned endpoints tier + ResolveWith(tunnels) for live tunnel sets. |
| internal/netdetect/resolve_test.go | Tests for learned inclusion and ResolveWith delegation behavior. |
| internal/learned/learned.go | New daemon-owned learned endpoint store (atomic save, TTL/cap pruning, corrupt-tolerant load). |
| internal/learned/learned_test.go | Unit tests for learned store load/save/record/prune/forget semantics. |
| internal/firewall/backend.go | Add ModeSwitchWindow, tunnel groups, physical DNS allow flag, and isVPNPolicy helper. |
| internal/firewall/pf_darwin.go | Render switch-window rules, tunnel groups, physical DNS allow, and standing posture behavior. |
| internal/firewall/pf_darwin_test.go | Tests for switch window, tunnel groups, physical DNS, and standing posture on pf. |
| internal/firewall/nft_linux.go | Render switch-window rules, wildcard tunnel groups, physical DNS allow, and standing posture behavior. |
| internal/firewall/nft_linux_test.go | Tests for switch window, tunnel groups, physical DNS, and standing posture on nftables. |
| internal/firewall/wfp_windows.go | Render switch-window rules and physical DNS allow; accept tunnel groups in guard seam check. |
| internal/firewall/wfp_windows_test.go | Tests for switch window, physical DNS allow, and standing posture on WFP. |
| internal/config/config.go | Add VPN profiles, switchWindow, advanced tunables, effective endpoint union, and validation/defaulting updates. |
| internal/config/config_test.go | Round-trip + validation + defaults tests for profiles/switchWindow/advanced/effective endpoints. |
| internal/command/owner_windows.go | Windows owner-check implementation (no-op; relies on ACL). |
| internal/command/owner_unix.go | Unix root-ownership/world-writable checks for command file. |
| internal/command/command.go | Root-owned command file IPC (write/consume/delete, freshness, owner checks). |
| internal/command/command_test.go | Unit tests for command write/consume/discard and validation rules. |
| cmd/dezhban/vpn_cmd.go | New switch command + vpn subcommands (profiles + learned management + import/promote). |
| cmd/dezhban/setup.go | Wizard updates: autodetect-first flow, optional profile import, allowPhysicalDNS prompt, service install/start offer. |
| cmd/dezhban/setup_test.go | Tests for wizard “auto mode” behavior and pinned-interface advanced mode. |
| cmd/dezhban/main.go | Wire up learned store, command polling, switch-window options, effective endpoint union, and print-rules switch mode. |
| cmd/dezhban/config_cmd.go | Add vpn.allowPhysicalDNS and vpn.switchWindow to config set/get; note profiles managed via vpn subcommand. |
| cmd/dezhban/completion.go | Shell completions updated to include switch mode in print-rules. |
| docs/usage.md | Document new switch and vpn CLI workflows. |
| docs/troubleshooting.md | Add reconnect-livelock explanation and the new hold-on-unknown + allowPhysicalDNS guidance. |
| docs/state.md | Document new snapshot posture and fields (activeProfile, switch). |
| docs/plans/readme.md | Mark phase 9 plan as completed. |
| docs/plans/phase-9-vpn-profiles-switching.md | Phase 9 plan/acceptance/verification document added. |
| docs/modes.md | Document profiles switching + switch-window posture and rationale; clarify guard-mode fail-closed semantics. |
| docs/config.md | Document profiles, switchWindow, allowPhysicalDNS, advanced tunables, and effective endpoint union. |
| configs/dezhban.vpn-guard.json | Sample config updated with allowPhysicalDNS flag. |
| configs/dezhban.profiles.json | New sample config demonstrating profiles + switch window. |
| CLAUDE.md | Update invariants/identifiers for switch window and guard-mode semantics. |
| .claude/plans/vpn-guard-reconnect-livelock.md | Add/track reconnect-livelock fix plan and implementation notes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- runner: populate Snapshot.ActiveProfile — a verified switch-window close now attributes the connected profile (sticky) instead of always publishing an empty string, so status/GUI can show the active profile. - vpn remove --learned: require root before mutating the daemon-owned learned store, consistent with vpn forget/promote. - vpn promote: match learned entries case-insensitively (EqualFold), matching how profiles are compared everywhere else; make learned.Store.Forget case-insensitive too so promote's follow-up forget can't silently miss. - vpn help: document the `import` subcommand and its --name/--dry-run flags. - macOS menubar: cache the DateFormatter used by shortTime() instead of allocating one per menu refresh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- learned.Store.Record: match entry names case-insensitively so "Proton" and "proton" merge into one entry instead of duplicating (consistent with Forget and config profile handling). Add regression test. - runner switch window: anchor the hard cap to the first open (windowStart) so repeated "open" commands can't stack deadlines past SwitchWindowMax — the documented bound on real-IP exposure now holds across re-opens. - runner tryCloseWindowVerified: gate the early "exit verified" close on live socket-discovery evidence (a real VPN connection this window), not a stale static endpoint, and document the residual physical-route caveat (the geo probe uses the OS default route; an early close still lands on GUARD with the endpoint open, and the next GUARD-posture tick re-checks via Decider). - netdetect watcher: an interface-enumeration failure now returns an Unknown sample the watcher ignores, instead of an empty Names set that read as a spurious shrink and churned the runner's dynamic tunnel set. Add test. - command.Consume: correct the misleading "delete first" comment (removal is deferred; the file is always deleted on return, before the caller acts). - learned package doc: acknowledge that root `vpn forget`/`promote` edit the file directly, and document the benign lost-update caveat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- command: report future-dated command files with a positive duration and a distinct message instead of a confusing negative "issued -10m0s ago". - vpn add: correct --yes help text (suppresses the endpoint preview; nothing prompts). - print-rules: populate the physical Allowlist only for non-VPN configs in guard/fullblock, mirroring runner.vpnPolicies — an autoDiscover-only VPN config no longer renders phantom physical egress via the legacy fallback. - netdetect: rewrite TestResolveWithLiveTunnels to actually verify the live tunnel set drives the internal-drop filter (stub ifaceAddrs; in-tunnel endpoint dropped with the set, kept without). - docs/state: activeProfile reflects the last completed switch window, not a general "matched profile"; IfaceHint is vpn-list display-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- watcher: in autodetect mode, sample ALL tunnel-like interfaces instead of
pinning to the start-time set. utunN renumbers across reconnects, and
liveSample treats a non-empty list as an allowlist, so a pinned watcher would
never see a new/renumbered tunnel and the runner couldn't grow/prune its set.
The runner still starts from the currently-known tunnels; explicit pins keep
allowlist semantics when autodetect is off.
- vpn list: read the daemon snapshot best-effort and print open switch-window /
last-verified-profile state, so the command matches its advertised "active
state" help text (silent when no daemon is running).
- runner: log the actual applied posture at startup ("vpn posture active",
mode=...) instead of an unconditional "vpn guard active" — the zero-tunnel
standing posture is a ModeFullBlock shape, not ModeGuard. Adds Mode.String().
- macos-gui: mmss rounds the switch-window countdown up (and clamps at 0) so it
never briefly shows more time remaining than is actually left.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- runner: ignore tunnel samples with Unknown=true (interface-enumeration
hiccup) instead of treating them as an up/down edge. The watcher only ever
forwards Unknown as its initial sample; handling it here prevents a misreport
and keeps the tunnel-down geo-skip correct, matching the watch.go contract.
- config: normalize vpn.advanced.windowProtocols (trim + lowercase) in Normalize
so validation and the pf/nft/WFP renderers — which emit the strings verbatim —
can't disagree (" UDP"/"Tcp" no longer leak into a ruleset). Adds a test.
- setup: stop force-enabling autoDiscoverEndpoints on macOS. Preserve an
existing config's value and only default ON for a brand-new macOS config;
a macOS-only confirm makes it an explicit, seeded choice so re-running never
silently flips false->true.
- macos-gui: add a switch-window case to iconFor/humanPosture — the window
allows ALL outbound, so show a yellow warning shield, never a green "safe" one.
- macos-gui: mmss rounds the countdown DOWN (never over-states remaining time)
and the comment now matches the behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- runner: make the regular endpoint-refresh tick grow-only while a switch
window is open (growOnly = blocked || windowActive). A guard-time refresh
otherwise replaces the set on any new address, dropping the session/in-window
endpoints the window must keep open until it closes or reverts.
- vpnimport: strip surrounding brackets from an IPv6 remote with no :port suffix
(OpenVPN `remote [2001:db8::1] 443`), so the endpoint round-trips through netip
and is valid for later consumers. Adds a test.
- config: validate vpn.advanced before bounding vpn.switchWindow against
advanced.switchWindowMax, so an invalid max surfaces as its own direct error
instead of a confusing derived range ("out of range [10s, 5s]"). Adds a test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- learned: reload the store from disk on every read and before every save instead of caching it in memory at startup. External maintenance edits (`dezhban vpn forget`, hand edits to learned.json) are now respected and no longer clobbered/reintroduced by a stale in-memory copy. Both closures run on the single run-loop goroutine, so there is no concurrent-access race. - print-rules: `--mode guard` now mirrors the runner's zero-tunnel standing posture — when no tunnels are detected it renders the endpoints-open FULL BLOCK shape rather than an invalid empty-tunnel ModeGuard, matching what the daemon would actually apply and the backend Apply validation. - vpnimport: raise the bufio.Scanner token limit (8 MiB) for both the WireGuard and OpenVPN extractors, so a config with a long inline blob no longer fails import with ErrTooLong when the endpoint line itself is short. Adds a test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- runner: re-apply the switch-window policy on tunnel/endpoint changes during an open RESTRICTED window (WindowProtos/WindowPorts). reapplyStanding no-ops during a window, so previously a new tunnel/endpoint stayed blocked under a restricted window and the verified early-close could never succeed. An unrestricted window already passes all outbound, so it needs no re-apply (guarded by windowRestricted()). Wired into the tunnel-changed, in-window discovery, and endpoint-refresh branches. - runner: the SWITCH WINDOW OPEN log no longer claims "all outbound allowed" unconditionally — it reflects the restricted proto/port subset when configured. - command: RootOwned now rejects group-writable command files (perm & 0o022), not just world-writable — a group member could otherwise modify a root-owned file for injection/replay even with a locked-down parent dir. - macos-gui: switch-window icon comment and humanPosture text no longer assert "all traffic allowed"; phrased as relaxed egress with possible IP exposure so they stay accurate for restricted windows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- vpnimport: require a numeric port sibling in walkJSON before treating a JSON object as a server entry. Previously the mere presence of a port/server_port key (even a string like "auto" or an object) qualified it, misclassifying non-endpoint fields and extracting bogus hosts. isNumericPort accepts a JSON number or numeric string in 1-65535. Adds a test. - vpn forget: don't treat a learned.Load error as fatal. Load returns a usable empty store on a corrupt/unreadable learned.json, so warn and continue — letting `vpn forget --all` overwrite and recover the file instead of being wedged by the corruption. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- netdetect: liveSample no longer says "no configured tunnel is up" in autodetect mode (empty Tunnels), where nothing is configured — the detail now reads "no tunnel interface is up" unless pinned tunnels were given. - vpn list / vpn promote: surface the learned.Load error instead of discarding it, so a corrupt/unreadable learned.json isn't silently reported as "no learned endpoints" / "no learned entry". - vpn: drop the unused `profile` parameter from waitForSwitch. - vpnimport: read WireGuard/OpenVPN configs via bytes.NewReader(data) instead of strings.NewReader(string(data)), avoiding a full-file copy (configs can carry large inline blobs). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI previously ran on every pull_request event, so each review push triggered a full matrix run. Add a per-job gate: jobs run on any push to main, or on a PR that carries the `run-ci` label. `labeled` is added to the pull_request trigger types so applying the label starts a run; removing it stops further runs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switch-window state must not be committed before the firewall Apply succeeds, and a repeated open must not shorten the window: - openWindow (extend path): only ever push the deadline OUT (a repeated `switch` with a shorter --for no longer shortens an open window), and keep the prior profile attribution when the new command names no profile. - closeWindowRevert: apply the reverted posture FIRST; only then flip windowActive=false / unsuppress geo. If Apply fails, hold the window open and re-arm a short retry timer (windowRevertRetry) so the daemon keeps trying to enforce the deadline instead of falsely reporting the window closed while egress may still be relaxed. - tryCloseWindowVerified: apply GUARD first; only close state, set ActiveProfile, and Learn on success. On failure, keep the window active with timers running so the in-window discovery ticker retries. Adds a test with a phase-selective failing backend. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- netdetect: clarify TunnelState.Unknown doc — Unknown is skipped by the
watcher's edge/set-change emission logic, but the FIRST sample is always
delivered even when Unknown, so consumers must treat it as "no change" and
hold their last state. (No behavior change; the runner already does this.)
- runner: reapplyStanding now logs the actual applied mode ("vpn standing
posture updated", mode=...) instead of always claiming "guard" — the standing
posture is ModeFullBlock in the zero-tunnel/autodetect case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Delivers the target UX for dezhban's core user — someone inside a sanctioned country who juggles many VPNs (commercial + self-hosted, TUN-mode): one-time
setup, run the daemon, then connect to any VPN and switch freely with zero geo-IP-leak worry.Before this, switching VPNs broke twice: the tunnel interface set was frozen at startup (macOS renumbers
utunNacross reconnects), and the guard only passed egress to the old VPN's endpoint IPs — so a new VPN's handshake to its unknown server was dropped (a chicken-and-egg, since socket-based discovery can only observe sockets that were allowed to connect).How
Two mechanisms, both backend-agnostic (macOS/pf first; Linux/nft + Windows/WFP keep working and gain the hooks):
dezhban vpn add/vpn import(WireGuard/OpenVPN/V2Ray configs; endpoints only, never keys).dezhban switchopens a bounded, root-triggered, auto-reverting window to connect a brand-new VPN; the daemon learns and pins its server, then snaps back to GUARD. All-outbound by default (a port filter is false safety when the VPNs blend on 443); safety is the bound + early-close + explicit trigger. Seedocs/modes.md.Plus: runtime tunnel re-detection (grow immediate / prune debounced / pinned kept), a zero-tunnel standing posture so the daemon can run before any VPN connects, endpoint learning into a daemon-owned
learned.json, avpn.advancedtunables block, a reworked setup wizard (defaults to autodetect + optional service install), and a menubar switch-window control.Commits (each independently buildable + tested)
fix(vpn-guard)— prerequisite reconnect-livelock fix: hold GUARD on an undeterminable country; opt-invpn.allowPhysicalDNS. (From a separately-approved plan; folded in as the base this work builds on.)feat(config)— profiles, switchWindow,vpn.advancedtunables,EffectiveEndpoints; fully backward-compatible.feat(learned,command)— daemon-owned endpoint store + root-owned control-file channel.feat(firewall)—ModeSwitchWindow,TunnelGroups, zero-tunnel standing shape.feat(netdetect)— multi-tunnel watcher,ResolveWith, learned source tier.feat(runner)— dynamic tunnels + switch-window state machine.feat(cmd)—switch+vpnsubcommands, endpoint-union wiring,internal/vpnimport.feat(setup)— wizard defaults to autodetect + profiles + service install.feat(gui)— menubar switch-window control + profile/window status.docs(phase-9)— config/modes/usage/state/CLAUDE docs, sample config, phase-9 plan,print-rules --mode switch.Notable decisions
docs/modes.md.Verification
go build ./... && go vet ./... && go test ./...— 145 tests pass;GOOS=linux/windowsbuild clean;go.modunchanged (stdlib only;huhstays wizard-only). Swift menubar app builds.make rules MODE=guard|switch CONFIG=configs/dezhban.profiles.jsonpreviews the union / window rulesets.Full acceptance checklist in
docs/plans/phase-9-vpn-profiles-switching.md.🤖 Generated with Claude Code