feat: VPN compatibility — auto-detect and guide split-routing#61
feat: VPN compatibility — auto-detect and guide split-routing#61Tinghecui wants to merge 5 commits intonmhjklnm:masterfrom
Conversation
… setup When users run cac with a proxy behind local VPN software (Clash, Shadowrocket, sing-box, V2Ray, Surge), the VPN's TUN mode can hijack proxy traffic, routing it through the VPN's dirty exit IP instead of the configured clean proxy. This adds automatic VPN detection and split-routing guidance: - Detect running VPN processes (Shadowrocket, Clash/mihomo, sing-box, V2Ray, Surge) - For Clash: auto-inject IP-CIDR DIRECT rule via RESTful API + config reload - For others: show step-by-step manual guide with correct rule format - macOS-first: Shadowrocket app guide, ClashX/Verge config paths - Best-effort: never fatal, always returns 0 under set -e Integrated into `cac env create -p` and `cac env set proxy`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…integration - Wrapper startup: show VPN hint when Shadowrocket/Clash/sing-box detected (lightweight inline check, no dependency on vpn_compat.sh functions) - Clash API path validation: reject traversal, non-yaml, and paths outside home/etc/usr/local/opt directories - `cac env check`: show VPN detection status before proxy reachability test - `cac self vpn-ensure`: documented in help text Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bash (vpn_compat.sh): - Windows process detection via tasklist.exe fallback - Windows Clash config paths (%APPDATA%/clash-verge-rev, etc.) - v2rayN detection, rule generation, and manual guide - v2rayN config path hint on Windows PowerShell (cac.ps1): - Full VPN compatibility module: Detect-VPN, Find-ClashPort, Get-ClashSecret, Try-ClashAutoInject, Ensure-VPNCompatible - Clash auto-inject via RESTful API (same logic as bash version) - v2rayN, sing-box manual guides - Integrated into Cmd-Add, Cmd-Check, wrapper (claude.cmd) - New command: cac vpn-ensure <proxy> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rdening Bash (vpn_compat.sh): - Fix tasklist.exe CSV output: strip quotes before regex matching - Support Windows absolute paths (C:/) in Clash API config validation - Fix "Clash for Windows/profiles/" → "config.yaml" (was directory) PowerShell (cac.ps1): - Resolve hostname proxy to IPv4 before generating IP-CIDR rules - Add ::1 to loopback skip list - Harden Clash config path validation: absolute path, allowed prefixes - Backup files with timestamp (no overwrite) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds VPN/TUN compatibility detection and split-routing guidance to prevent local VPN software (e.g., Clash/mihomo, Shadowrocket, sing-box, v2rayN) from hijacking traffic intended for the configured proxy.
Changes:
- Introduces a Bash VPN detection + Clash auto-inject (DIRECT rule) module and integrates it into env create/set and env check flows.
- Adds Windows PowerShell VPN detection + Clash auto-inject support and a new
vpn-ensurecommand entrypoint. - Adds lightweight wrapper startup hints when a VPN client is detected.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/vpn_compat.sh | New Bash module for VPN detection, rule generation, and Clash auto-inject/reload. |
| src/templates.sh | Adds wrapper-time VPN detection hint messaging. |
| src/cmd_env.sh | Hooks VPN compatibility check into env create and proxy set. |
| src/cmd_check.sh | Surfaces VPN detection status in cac check. |
| src/cmd_self.sh | Adds cac self vpn-ensure <proxy> subcommand. |
| src/cmd_help.sh | Documents the new self subcommand in help output. |
| cac.ps1 | Adds Windows VPN detection/auto-inject functions, vpn-ensure command, and wrapper hint changes. |
| build.sh | Ensures vpn_compat.sh is included in the concatenation order for the built cac script. |
| cac | Regenerated single-file script including the new VPN compatibility logic and wrapper hint. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| out = [] | ||
|
|
||
| for line in lines: | ||
| out.append(line) | ||
| if not inserted and line.rstrip() in ('rules:', 'rules: '): | ||
| out.append(' ' + rule) | ||
| inserted = True | ||
|
|
||
| if not inserted: |
There was a problem hiding this comment.
In the YAML injection Python, insertion only triggers when the line is exactly 'rules:'; if the config has 'rules: # comment' or other trailing content, the rule won't be inserted and the fallback appends a second top-level 'rules:' key. Duplicate YAML keys can cause Clash to ignore the earlier rules or fail to load the config. Consider matching 'rules:' more flexibly (e.g., allow trailing whitespace/comments) and avoid adding a new 'rules:' section when one already exists but wasn’t matched.
| out = [] | |
| for line in lines: | |
| out.append(line) | |
| if not inserted and line.rstrip() in ('rules:', 'rules: '): | |
| out.append(' ' + rule) | |
| inserted = True | |
| if not inserted: | |
| has_rules_section = False | |
| out = [] | |
| for line in lines: | |
| out.append(line) | |
| # Consider lines like "rules:", "rules: ", or "rules: # comment" | |
| header = line.split('#', 1)[0].strip() | |
| if header == 'rules:': | |
| has_rules_section = True | |
| if not inserted: | |
| out.append(' ' + rule) | |
| inserted = True | |
| if not inserted and not has_rules_section: |
| fi | ||
| if [[ -n "$_vpn_name" ]]; then | ||
| echo "[cac] hint: $_vpn_name detected. Ensure $_host has a DIRECT rule to avoid VPN hijacking" >&2 | ||
| echo "[cac] hint: run 'cac self vpn-ensure' for guidance" >&2 |
There was a problem hiding this comment.
The wrapper hint suggests running 'cac self vpn-ensure' without arguments, but the command implementation requires a proxy URL argument and otherwise prints nothing. Either include the current proxy in the suggested command (e.g., pass $PROXY), or update the vpn-ensure subcommand to default to the active env’s proxy when is omitted.
| echo "[cac] hint: run 'cac self vpn-ensure' for guidance" >&2 | |
| echo "[cac] hint: run 'cac self vpn-ensure \"$PROXY\"' for guidance" >&2 |
| case "${1:-help}" in | ||
| update) _self_cmd_update ;; | ||
| delete|remove) cmd_delete ;; | ||
| vpn-ensure) _vpn_ensure_compatible "${2:-}" ;; |
There was a problem hiding this comment.
vpn-ensure currently calls _vpn_ensure_compatible with only the second argument. If the user runs 'cac self vpn-ensure' (as suggested by the wrapper hint), ${2:-} is empty and the command becomes a no-op. Consider defaulting to the active environment’s configured proxy when no argument is provided, and/or print a usage message when is missing.
| vpn-ensure) _vpn_ensure_compatible "${2:-}" ;; | |
| vpn-ensure) | |
| if [ -z "${2:-}" ]; then | |
| _die "usage: cac self vpn-ensure <proxy>" | |
| fi | |
| _vpn_ensure_compatible "$2" | |
| ;; |
| try { | ||
| $tcp = New-Object System.Net.Sockets.TcpClient | ||
| $result = $tcp.BeginConnect("127.0.0.1", $port, $null, $null) | ||
| $success = $result.AsyncWaitHandle.WaitOne(1000) | ||
| $tcp.Close() | ||
| if ($success) { return $port } | ||
| } catch {} |
There was a problem hiding this comment.
Find-ClashPort's port probe treats any async connect that completes within 1s as success, but BeginConnect can complete quickly on connection refusal as well. Because EndConnect is never called (and $tcp.Connected isn’t checked), this can return 9090/9097 even when nothing is listening, leading to unreliable detection and unnecessary API calls. Consider using ConnectAsync().Wait(timeout) plus $tcp.Connected, or call EndConnect inside the try/catch to confirm the connection actually succeeded.
| try { | |
| $tcp = New-Object System.Net.Sockets.TcpClient | |
| $result = $tcp.BeginConnect("127.0.0.1", $port, $null, $null) | |
| $success = $result.AsyncWaitHandle.WaitOne(1000) | |
| $tcp.Close() | |
| if ($success) { return $port } | |
| } catch {} | |
| $tcp = New-Object System.Net.Sockets.TcpClient | |
| try { | |
| # Use ConnectAsync with timeout and verify actual connection state | |
| $connectTask = $tcp.ConnectAsync("127.0.0.1", $port) | |
| $success = $connectTask.Wait(1000) -and $tcp.Connected | |
| if ($success) { return $port } | |
| } catch { | |
| # Ignore connection errors and continue probing other ports | |
| } finally { | |
| $tcp.Close() | |
| } |
| foreach ($line in $lines) { | ||
| $newLines += $line | ||
| if (-not $injected -and $line.Trim() -eq "rules:") { | ||
| $newLines += " $rule" | ||
| $injected = $true | ||
| } | ||
| } |
There was a problem hiding this comment.
Auto-inject only inserts after a line where Trim() equals 'rules:'. If the config has 'rules: # comment' or other trailing content, injection will fall through to the fallback which appends a second 'rules:' section at the end of the file, potentially breaking the YAML or overriding the original rules. Consider matching 'rules:' with optional trailing whitespace/comments and never adding a new top-level rules key when one already exists.
| if (-not $injected) { | ||
| $newLines += "" | ||
| $newLines += "rules:" | ||
| $newLines += " $rule" | ||
| } |
There was a problem hiding this comment.
The fallback path appends a new top-level 'rules:' section when injection didn't find an existing 'rules:' line. If the file already contains a rules section that wasn't matched (e.g., due to an inline comment), this will create duplicate keys and may cause Clash to ignore earlier rules or fail to load the config. Prefer detecting the existing rules key robustly and inserting into it, rather than creating a second one.
| fi | ||
| if [[ -n "$_vpn_name" ]]; then | ||
| echo "[cac] hint: $_vpn_name detected. Ensure $_host has a DIRECT rule to avoid VPN hijacking" >&2 | ||
| echo "[cac] hint: run 'cac self vpn-ensure' for guidance" >&2 |
There was a problem hiding this comment.
The wrapper hint echoes 'run 'cac self vpn-ensure' for guidance', but vpn-ensure requires a proxy argument and otherwise is a no-op. Either include the active proxy in this hint (so it’s directly runnable), or make vpn-ensure default to the current env’s proxy when no argument is provided.
| echo "[cac] hint: run 'cac self vpn-ensure' for guidance" >&2 | |
| echo "[cac] hint: run 'cac self vpn-ensure \"$PROXY\"' for guidance" >&2 |
…egration - Background watchdog process: checks exit IP every 60s through proxy - Establishes baseline IP on first run, saves to expected_ip - IP change detection: macOS system notification + ip-alert file + log - Statusline: shows green "IP:x.x.x.x" (stable) or red "IP:x.x.x.x!" (changed) - cac check: compares current vs expected IP, shows watchdog process status - Auto-exits on env switch or cac stop Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
VPN Compatibility
IP-CIDR,<proxy_ip>/32,DIRECTrule via RESTful API + config reloadclaudeinvocationIP Watchdog (continuous exit-IP monitoring)
IP:x.x.x.x(green=stable, red=changed)cac checkintegration: compares current vs expected IP, shows watchdog statusProblem
When users run cac with a proxy behind local VPN software (Clash, Shadowrocket, etc.), the VPN's TUN mode hijacks proxy traffic, routing it through the VPN's exit IP instead of the configured clean proxy IP. Users need both prevention (VPN rule guidance) and detection (IP monitoring).
Platform coverage
Files changed
src/vpn_compat.shsrc/templates.shsrc/cmd_check.shcac.ps1src/cmd_env.shsrc/cmd_self.shsrc/cmd_help.shbuild.shSecurity
_vpn_ensure_compatiblealways returns 0, never fatal underset -e--connect-timeout 3 --max-time 5Test plan
cac env create -p <proxy>triggers VPN check999.999.999.999bash build.shpasses🤖 Generated with Claude Code