Skip to content

Effortless multi-VPN switching: profiles, dynamic tunnels, switch window (phase 9)#17

Merged
Behnam-RK merged 24 commits into
developfrom
feat/vpn-profiles-switch-window
Jul 8, 2026
Merged

Effortless multi-VPN switching: profiles, dynamic tunnels, switch window (phase 9)#17
Behnam-RK merged 24 commits into
developfrom
feat/vpn-profiles-switch-window

Conversation

@Behnam-RK

Copy link
Copy Markdown
Owner

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 utunN across 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):

  • VPN profiles — the guard passes the union of every known VPN's endpoints, so switching between known VPNs needs no reconfiguration. Add via dezhban vpn add / vpn import (WireGuard/OpenVPN/V2Ray configs; endpoints only, never keys).
  • Switch windowdezhban switch opens 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. See docs/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, a vpn.advanced tunables block, a reworked setup wizard (defaults to autodetect + optional service install), and a menubar switch-window control.

Commits (each independently buildable + tested)

  1. fix(vpn-guard)prerequisite reconnect-livelock fix: hold GUARD on an undeterminable country; opt-in vpn.allowPhysicalDNS. (From a separately-approved plan; folded in as the base this work builds on.)
  2. feat(config) — profiles, switchWindow, vpn.advanced tunables, EffectiveEndpoints; fully backward-compatible.
  3. feat(learned,command) — daemon-owned endpoint store + root-owned control-file channel.
  4. feat(firewall)ModeSwitchWindow, TunnelGroups, zero-tunnel standing shape.
  5. feat(netdetect) — multi-tunnel watcher, ResolveWith, learned source tier.
  6. feat(runner) — dynamic tunnels + switch-window state machine.
  7. feat(cmd)switch + vpn subcommands, endpoint-union wiring, internal/vpnimport.
  8. feat(setup) — wizard defaults to autodetect + profiles + service install.
  9. feat(gui) — menubar switch-window control + profile/window status.
  10. docs(phase-9) — config/modes/usage/state/CLAUDE docs, sample config, phase-9 plan, print-rules --mode switch.

Notable decisions

  • Switch window = all-outbound, honestly (opt-in restriction knob exists for all-UDP setups). Rationale in docs/modes.md.
  • eBPF deferred — Linux-only, can't serve the macOS-first story; noted as an optional future Linux backend (nftables already covers most of it).
  • Proxy-mode V2Ray (SOCKS, no TUN), Linux/Windows live discovery, per-port pinning — documented follow-ups (candidate phase 10).

Verification

  • go build ./... && go vet ./... && go test ./... — 145 tests pass; GOOS=linux/windows build clean; go.mod unchanged (stdlib only; huh stays wizard-only). Swift menubar app builds.
  • Backward-compat: legacy configs load, validate, and render identical rules (table test).
  • make rules MODE=guard|switch CONFIG=configs/dezhban.profiles.json previews the union / window rulesets.
  • Importer fixtures (wg/ovpn/v2ray) + private/garbage rejection.

Full acceptance checklist in docs/plans/phase-9-vpn-profiles-switching.md.

🤖 Generated with Claude Code

Behnam-RK and others added 10 commits July 7, 2026 23:22
…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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/runner/runner.go
Comment thread cmd/dezhban/vpn_cmd.go Outdated
Comment thread cmd/dezhban/vpn_cmd.go
Comment thread cmd/dezhban/vpn_cmd.go Outdated
Comment thread macos-gui/Sources/DezhbanMenu/AppDelegate.swift Outdated
- 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 43 out of 43 changed files in this pull request and generated 6 comments.

Comment thread internal/learned/learned.go
Comment thread internal/runner/runner.go Outdated
Comment thread internal/runner/runner.go
Comment thread internal/netdetect/watch.go
Comment thread internal/command/command.go Outdated
Comment thread internal/learned/learned.go Outdated
- 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 43 out of 43 changed files in this pull request and generated 6 comments.

Comment thread internal/command/command.go
Comment thread cmd/dezhban/vpn_cmd.go Outdated
Comment thread cmd/dezhban/main.go
Comment thread internal/netdetect/resolve_test.go Outdated
Comment thread internal/runner/runner.go
Comment thread internal/config/config.go
- 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 43 out of 43 changed files in this pull request and generated 4 comments.

Comment thread cmd/dezhban/main.go
Comment thread cmd/dezhban/vpn_cmd.go
Comment thread internal/runner/runner.go Outdated
Comment thread macos-gui/Sources/DezhbanMenu/AppDelegate.swift
- 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 43 out of 43 changed files in this pull request and generated 5 comments.

Comment thread internal/runner/runner.go
Comment thread internal/config/config.go
Comment thread macos-gui/Sources/DezhbanMenu/AppDelegate.swift Outdated
Comment thread macos-gui/Sources/DezhbanMenu/AppDelegate.swift
Comment thread cmd/dezhban/setup.go
- 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 43 out of 43 changed files in this pull request and generated 4 comments.

Comment thread cmd/dezhban/main.go
Comment thread cmd/dezhban/main.go
Comment thread internal/vpnimport/vpnimport.go
Comment thread internal/vpnimport/vpnimport.go
- 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 43 out of 43 changed files in this pull request and generated 5 comments.

Comment thread internal/runner/runner.go
Comment thread internal/runner/runner.go Outdated
Comment thread internal/command/owner_unix.go
Comment thread macos-gui/Sources/DezhbanMenu/AppDelegate.swift
Comment thread macos-gui/Sources/DezhbanMenu/AppDelegate.swift
- 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 43 out of 43 changed files in this pull request and generated 2 comments.

Comment thread internal/vpnimport/vpnimport.go
Comment thread cmd/dezhban/vpn_cmd.go
- 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 43 out of 43 changed files in this pull request and generated 5 comments.

Comment thread internal/netdetect/watch.go
Comment thread cmd/dezhban/vpn_cmd.go Outdated
Comment thread cmd/dezhban/vpn_cmd.go Outdated
Comment thread cmd/dezhban/vpn_cmd.go Outdated
Comment thread internal/vpnimport/vpnimport.go
Behnam-RK and others added 2 commits July 8, 2026 18:07
- 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 44 out of 44 changed files in this pull request and generated 3 comments.

Comment thread internal/runner/runner.go
Comment thread internal/runner/runner.go
Comment thread internal/runner/runner.go Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 44 out of 44 changed files in this pull request and generated 2 comments.

Comment thread internal/netdetect/watch.go
Comment thread internal/runner/runner.go
- 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>
@Behnam-RK Behnam-RK added the run-ci Run CI on this PR (gates .github/workflows/ci.yml) label Jul 8, 2026
@Behnam-RK Behnam-RK merged commit 30dfed8 into develop Jul 8, 2026
8 checks passed
@Behnam-RK Behnam-RK deleted the feat/vpn-profiles-switch-window branch July 8, 2026 15:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-ci Run CI on this PR (gates .github/workflows/ci.yml)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants