From d73aab2e6106c81f47f0ab56fd3a714694653f55 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 2 Apr 2026 13:34:01 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20VPN=20compatibility=20=E2=80=94=20a?= =?UTF-8?q?uto-detect=20local=20VPN=20and=20guide=20DIRECT=20rule=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- build.sh | 1 + cac | 474 ++++++++++++++++++++++++++++++++++++++++++++++ src/cmd_env.sh | 4 + src/cmd_self.sh | 1 + src/vpn_compat.sh | 467 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 947 insertions(+) create mode 100644 src/vpn_compat.sh diff --git a/build.sh b/build.sh index acf78e0..7810870 100644 --- a/build.sh +++ b/build.sh @@ -9,6 +9,7 @@ OUT="$PROJ_DIR/cac" # 拼接顺序 SOURCES=( utils.sh + vpn_compat.sh dns_block.sh mtls.sh templates.sh diff --git a/cac b/cac index 3a76aad..11ccfa3 100755 --- a/cac +++ b/cac @@ -429,6 +429,475 @@ PYEOF [[ $? -eq 0 ]] || echo "warning: failed to update claude.json userID" >&2 } +# ━━━ vpn_compat.sh ━━━ +# ── vpn_compat: VPN detection and split-routing ────────────── + +_vpn_is_ipv4() { + local ip="$1" + [[ "$ip" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || return 1 + local i + for i in "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" "${BASH_REMATCH[4]}"; do + (( i > 255 )) && return 1 + done + return 0 +} + +_vpn_is_loopback() { + local ip="$1" + [[ "$ip" == "127."* || "$ip" == "localhost" || "$ip" == "::1" ]] +} + +_vpn_resolve_ipv4() { + local host="$1" + _vpn_is_ipv4 "$host" && { echo "$host"; return 0; } + + local resolved + resolved=$(python3 - "$host" <<'PY' 2>/dev/null +import socket, sys + +host = sys.argv[1] +try: + for item in socket.getaddrinfo(host, None, socket.AF_INET, socket.SOCK_STREAM): + ip = item[4][0] + if ip: + print(ip) + raise SystemExit(0) +except Exception: + pass +sys.exit(1) +PY +) + [[ -n "$resolved" ]] && { echo "$resolved"; return 0; } + return 1 +} + +_vpn_has_process() { + local pattern="$1" + # Use pgrep on macOS/Linux for reliable process matching + if command -v pgrep &>/dev/null; then + pgrep -if "$pattern" >/dev/null 2>&1 + else + # Fallback: ps + grep, exclude only exact 'grep' and 'apply_patch' + ps ax -o command= 2>/dev/null | grep -iE "$pattern" | grep -ivE '^grep |apply_patch' >/dev/null 2>&1 + fi +} + +_vpn_http_status() { + local url="$1" secret="${2:-}" code + if [[ -n "$secret" ]]; then + code=$(curl -sS --connect-timeout 3 --max-time 5 -H "Authorization: Bearer $secret" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null) || code="000" + else + code=$(curl -sS --connect-timeout 3 --max-time 5 -o /dev/null -w '%{http_code}' "$url" 2>/dev/null) || code="000" + fi + echo "$code" +} + +_vpn_port_from_controller() { + local controller="$1" port + port=$(printf '%s' "$controller" | sed -E 's#^[a-zA-Z]+://##; s#/.*$##; s#.*:([0-9]+)$#\1#') + [[ "$port" =~ ^[0-9]+$ ]] && echo "$port" +} + +_vpn_yaml_value() { + local file="$1" key="$2" + [[ -f "$file" ]] || return 1 + # Compatible with macOS default awk (no capture arrays) + grep -E "^[[:space:]]*${key}:" "$file" 2>/dev/null | head -1 | sed -E "s/^[[:space:]]*${key}:[[:space:]]*//" | sed -E "s/[[:space:]]*#.*$//" | tr -d "\"'" | tr -d '[:space:]' +} + +_vpn_clash_known_configs() { + local os; os=$(_detect_os) + # macOS paths first (preferred on macOS) + if [[ "$os" == "macos" ]]; then + printf '%s\n' \ + "$HOME/Library/Application Support/io.github.niceneasy.ClashX/config.yaml" \ + "$HOME/Library/Application Support/clash-verge-rev/clash/config.yaml" \ + "$HOME/Library/Application Support/clash-verge/clash/config.yaml" \ + "$HOME/Library/Application Support/mihomo/config.yaml" \ + "$HOME/Library/Application Support/clash/config.yaml" + fi + # XDG paths (Linux primary, macOS fallback) + printf '%s\n' \ + "$HOME/.config/mihomo/config.yaml" \ + "$HOME/.config/clash/config.yaml" \ + "$HOME/.config/clash-verge-rev/clash/config.yaml" \ + "$HOME/.config/clash-verge/clash/config.yaml" +} + +_vpn_clash_detect_port() { + local controller port file status + + controller="${CLASH_EXTERNAL_CONTROLLER:-}" + if [[ -n "$controller" ]]; then + port=$(_vpn_port_from_controller "$controller") + [[ -n "$port" ]] && { echo "$port"; return 0; } + fi + + while IFS= read -r file; do + [[ -f "$file" ]] || continue + controller=$(_vpn_yaml_value "$file" 'external-controller') + if [[ -n "$controller" ]]; then + port=$(_vpn_port_from_controller "$controller") + [[ -n "$port" ]] && { echo "$port"; return 0; } + fi + done < <(_vpn_clash_known_configs) + + for port in 9090 9097; do + status=$(_vpn_http_status "http://127.0.0.1:$port/configs") + [[ "$status" != "000" ]] && { echo "$port"; return 0; } + done + + return 1 +} + +_vpn_clash_secret() { + local file secret="${CLASH_API_SECRET:-${CLASH_SECRET:-}}" + [[ -n "$secret" ]] && { echo "$secret"; return 0; } + + while IFS= read -r file; do + [[ -f "$file" ]] || continue + secret=$(_vpn_yaml_value "$file" 'secret') + [[ -n "$secret" ]] && { echo "$secret"; return 0; } + done < <(_vpn_clash_known_configs) + + return 1 +} + +_vpn_sing_box_detect_port() { + local port status + local config_paths=( + "$HOME/.config/sing-box/config.json" + "/usr/local/etc/sing-box/config.json" + "/etc/sing-box/config.json" + ) + if [[ "$(_detect_os)" == "macos" ]]; then + config_paths=("$HOME/Library/Application Support/sing-box/config.json" "${config_paths[@]}") + fi + + local config + for config in "${config_paths[@]}"; do + [[ -f "$config" ]] || continue + port=$(python3 - "$config" <<'PY' 2>/dev/null || true +import json, sys + +path = sys.argv[1] +try: + data = json.load(open(path, 'r')) +except Exception: + raise SystemExit(0) + +controller = '' +experimental = data.get('experimental') or {} +clash_api = experimental.get('clash_api') or {} +controller = clash_api.get('external_controller') or clash_api.get('external-controller') or '' + +if isinstance(controller, str) and controller: + value = controller.split('://', 1)[-1].split('/', 1)[0] + if ':' in value: + print(value.rsplit(':', 1)[1]) +PY +) + [[ -n "$port" ]] && { echo "$port"; return 0; } + done + + status=$(_vpn_http_status "http://127.0.0.1:9090") + [[ "$status" != "000" ]] && { echo "9090"; return 0; } + return 1 +} + +_vpn_detect_surge_port() { + local status + status=$(_vpn_http_status "http://127.0.0.1:6171") + [[ "$status" != "000" ]] && { echo "6171"; return 0; } + return 1 +} + +_detect_vpn() { + local port + + # Shadowrocket (macOS) — check first since it's common on macOS + if _vpn_has_process 'Shadowrocket'; then + echo "shadowrocket:" + return 0 + fi + + # Clash family: mihomo, clash-meta, ClashX, Clash Verge, Stash + if _vpn_has_process '(mihomo|clash-meta|clash-verge|ClashX|Clash\.Meta)([ /]|$)' || _vpn_has_process '(^|[ /])Stash([ .]|$)'; then + port=$(_vpn_clash_detect_port 2>/dev/null || true) + echo "clash:${port:-}" + return 0 + fi + # Plain 'clash' checked separately to avoid false positives + if _vpn_has_process '(^|[ /])clash([[:space:]]|$)'; then + port=$(_vpn_clash_detect_port 2>/dev/null || true) + echo "clash:${port:-}" + return 0 + fi + + # sing-box + if _vpn_has_process 'sing-box'; then + port=$(_vpn_sing_box_detect_port 2>/dev/null || true) + echo "sing-box:${port:-}" + return 0 + fi + + # V2Ray / Xray + if _vpn_has_process '(^|[ /])(v2ray|xray)([[:space:]]|$)'; then + echo "v2ray:" + return 0 + fi + + # Surge + if _vpn_has_process '(^|[ /])(Surge|surge-cli)([ .]|$)'; then + port=$(_vpn_detect_surge_port 2>/dev/null || true) + echo "surge:${port:-6171}" + return 0 + fi + + # tun2socks + if _vpn_has_process 'tun2socks'; then + echo "tun2socks:" + return 0 + fi + + return 1 +} + +_vpn_generate_rule() { + local proxy_ip="$1" vpn_type="$2" + case "$vpn_type" in + clash) + printf '%s\n' "- IP-CIDR,${proxy_ip}/32,DIRECT,no-resolve" + ;; + shadowrocket|surge) + printf '%s\n' "IP-CIDR,${proxy_ip}/32,DIRECT" + ;; + sing-box) + printf '%s\n' "{\"ip_cidr\":[\"${proxy_ip}/32\"],\"outbound\":\"direct\"}" + ;; + v2ray) + printf '%s\n' "{\"type\":\"field\",\"ip\":[\"${proxy_ip}/32\"],\"outboundTag\":\"direct\"}" + ;; + *) + return 1 + ;; + esac +} + +_vpn_clash_api_get_configs() { + local port="$1" secret="${2:-}" + if [[ -n "$secret" ]]; then + curl -fsS --connect-timeout 3 --max-time 5 -H "Authorization: Bearer $secret" "http://127.0.0.1:$port/configs" 2>/dev/null || true + else + curl -fsS --connect-timeout 3 --max-time 5 "http://127.0.0.1:$port/configs" 2>/dev/null || true + fi +} + +_vpn_clash_api_config_path() { + local port="$1" secret="${2:-}" body path + body=$(_vpn_clash_api_get_configs "$port" "$secret") + [[ -n "$body" ]] || return 1 + + path=$(printf '%s' "$body" | python3 -c 'import json,sys; data=json.load(sys.stdin); print(data.get("path", ""))' 2>/dev/null || true) + [[ -n "$path" && -f "$path" ]] && echo "$path" +} + +_vpn_clash_insert_rule() { + local file="$1" proxy_ip="$2" rule tmp + [[ -f "$file" ]] || return 1 + + # Already exists + if grep -F "IP-CIDR,${proxy_ip}/32,DIRECT" "$file" >/dev/null 2>&1; then + return 0 + fi + + rule=$(_vpn_generate_rule "$proxy_ip" clash) || return 1 + tmp=$(mktemp "${file}.cac.XXXXXX") || return 1 + + python3 - "$file" "$tmp" "$rule" <<'PY' || { rm -f "$tmp"; return 1; } +import sys + +src, dst, rule = sys.argv[1:4] + +with open(src, 'r') as f: + text = f.read() + +newline = '\r\n' if '\r\n' in text else '\n' +lines = text.splitlines() +inserted = False +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: + if out and out[-1] != '': + out.append('') + out.append('rules:') + out.append(' ' + rule) + +with open(dst, 'w') as f: + f.write(newline.join(out) + newline) +PY + + # Backup with timestamp + cp "$file" "${file}.cac.bak.$(date +%s)" 2>/dev/null || true + cat "$tmp" > "$file" && rm -f "$tmp" +} + +_vpn_clash_reload() { + local port="$1" config_path="$2" secret="${3:-}" payload + payload=$(python3 - "$config_path" <<'PY' +import json, sys +print(json.dumps({"path": sys.argv[1]})) +PY +) + + if [[ -n "$secret" ]]; then + curl -fsS --connect-timeout 3 --max-time 5 -X PUT \ + -H "Authorization: Bearer $secret" \ + -H 'Content-Type: application/json' \ + --data "$payload" \ + "http://127.0.0.1:$port/configs?force=true" >/dev/null 2>&1 + else + curl -fsS --connect-timeout 3 --max-time 5 -X PUT \ + -H 'Content-Type: application/json' \ + --data "$payload" \ + "http://127.0.0.1:$port/configs?force=true" >/dev/null 2>&1 + fi +} + +_vpn_try_auto_inject_clash() { + local proxy_ip="$1" api_port="$2" secret config_path file + [[ -n "$api_port" ]] || api_port=$(_vpn_clash_detect_port 2>/dev/null || true) + [[ -n "$api_port" ]] || return 1 + + secret=$(_vpn_clash_secret 2>/dev/null || true) + config_path=$(_vpn_clash_api_config_path "$api_port" "" 2>/dev/null || true) + [[ -z "$config_path" && -n "$secret" ]] && config_path=$(_vpn_clash_api_config_path "$api_port" "$secret" 2>/dev/null || true) + + if [[ -z "$config_path" ]]; then + while IFS= read -r file; do + [[ -f "$file" ]] || continue + config_path="$file" + break + done < <(_vpn_clash_known_configs) + fi + + [[ -n "$config_path" && -f "$config_path" ]] || return 1 + _vpn_clash_insert_rule "$config_path" "$proxy_ip" || return 1 + _vpn_clash_reload "$api_port" "$config_path" "$secret" || return 1 +} + +_vpn_try_auto_inject() { + local proxy_ip="$1" vpn_type="$2" api_port="${3:-}" + + case "$vpn_type" in + clash) _vpn_try_auto_inject_clash "$proxy_ip" "$api_port" ;; + *) return 1 ;; + esac +} + +_vpn_show_manual_guide() { + local proxy_ip="$1" vpn_type="$2" + + echo " $(_yellow "!") VPN/TUN detected. Add a direct rule for proxy IP $(_cyan "$proxy_ip") to avoid traffic being hijacked by local VPN software." + + case "$vpn_type" in + shadowrocket) + echo " $(_dim "Shadowrocket:") open the app and add a rule manually:" + echo + echo " $(_bold "Config") → $(_bold "Rules") → $(_bold "Add Rule")" + echo " Type: $(_cyan "IP-CIDR")" + echo " IP: $(_cyan "${proxy_ip}/32")" + echo " Policy: $(_cyan "DIRECT")" + echo + echo " $(_dim "Make sure the rule is near the top of your rule list.")" + ;; + clash) + echo " $(_dim "Clash / mihomo:") add this near the top of the $(_bold "rules:") section:" + echo " $(_cyan "$(_vpn_generate_rule "$proxy_ip" clash)")" + local os; os=$(_detect_os) + if [[ "$os" == "macos" ]]; then + echo " $(_dim "Common config paths:")" + echo " $(_dim "ClashX: ~/Library/Application Support/io.github.niceneasy.ClashX/config.yaml")" + echo " $(_dim "Clash Verge: ~/Library/Application Support/clash-verge-rev/clash/config.yaml")" + echo " $(_dim "mihomo: ~/.config/mihomo/config.yaml")" + else + echo " $(_dim "Common config paths: ~/.config/clash/config.yaml, ~/.config/mihomo/config.yaml")" + fi + ;; + sing-box) + echo " $(_dim "sing-box:") add this object to $(_bold "route.rules") with outbound $(_bold "direct"):" + echo " $(_cyan "$(_vpn_generate_rule "$proxy_ip" sing-box)")" + echo " $(_dim "Common config path: ~/.config/sing-box/config.json")" + ;; + v2ray) + echo " $(_dim "V2Ray / Xray:") add this object to $(_bold "routing.rules") with outbound tag $(_bold "direct"):" + echo " $(_cyan "$(_vpn_generate_rule "$proxy_ip" v2ray)")" + ;; + surge) + echo " $(_dim "Surge:") add this line under the $(_bold "[Rule]") section:" + echo " $(_cyan "$(_vpn_generate_rule "$proxy_ip" surge)")" + ;; + *) + echo " $(_dim "If your VPN client uses Clash-compatible rules, add:")" + echo " $(_cyan "$(_vpn_generate_rule "$proxy_ip" clash)")" + echo " $(_dim "If it uses Surge rules, add:")" + echo " $(_cyan "$(_vpn_generate_rule "$proxy_ip" surge)")" + ;; + esac + + echo +} + +# Main entry: ensure VPN won't hijack cac proxy traffic +# Always returns 0 — VPN compat is best-effort, never fatal +_vpn_ensure_compatible() { + local proxy_url="$1" hp host proxy_ip detected vpn_type api_port + [[ -n "$proxy_url" ]] || return 0 + + hp=$(_proxy_host_port "$proxy_url") + host="${hp%%:*}" + [[ -n "$host" ]] || return 0 + + # Skip loopback — no VPN bypass needed + if _vpn_is_loopback "$host"; then + return 0 + fi + + # Resolve to IPv4 + proxy_ip=$(_vpn_resolve_ipv4 "$host") || true + if [[ -z "$proxy_ip" ]]; then + echo " $(_dim "skipped VPN check: could not resolve $host")" + return 0 + fi + + # Skip loopback IPs + if _vpn_is_loopback "$proxy_ip"; then + return 0 + fi + + # Detect VPN + detected=$(_detect_vpn 2>/dev/null || true) + [[ -n "$detected" ]] || return 0 + + vpn_type="${detected%%:*}" + api_port="${detected#*:}" + [[ "$api_port" == "$detected" ]] && api_port="" + + echo " $(_yellow "!") detected local VPN/TUN: $(_cyan "$vpn_type")" + if _vpn_try_auto_inject "$proxy_ip" "$vpn_type" "$api_port" 2>/dev/null; then + echo " $(_green "+") added DIRECT rule for $(_cyan "$proxy_ip") in $(_cyan "$vpn_type")" + else + _vpn_show_manual_guide "$proxy_ip" "$vpn_type" + fi + return 0 +} + # ━━━ dns_block.sh ━━━ # ── DNS interception & telemetry domain blocking ───────────────────────────────────── @@ -1832,6 +2301,9 @@ MERGE_EOF fi fi + # VPN compatibility check (auto-add DIRECT rule for proxy IP) + [[ -n "$proxy_url" ]] && _vpn_ensure_compatible "$proxy_url" + _generate_client_cert "$name" >/dev/null 2>&1 || true # Auto-activate @@ -2014,6 +2486,7 @@ _env_cmd_set() { proxy_url=$(_parse_proxy "$value") fi echo "$proxy_url" > "$env_dir/proxy" + _vpn_ensure_compatible "$proxy_url" echo "$(_green_bold "Set") proxy for $(_bold "$name") → $proxy_url" fi ;; @@ -2874,6 +3347,7 @@ cmd_self() { case "${1:-help}" in update) _self_cmd_update ;; delete|remove) cmd_delete ;; + vpn-ensure) _vpn_ensure_compatible "${2:-}" ;; help|-h|--help) echo "$(_bold "cac self") — cac self-management" echo diff --git a/src/cmd_env.sh b/src/cmd_env.sh index 36993c1..d786a06 100644 --- a/src/cmd_env.sh +++ b/src/cmd_env.sh @@ -180,6 +180,9 @@ MERGE_EOF fi fi + # VPN compatibility check (auto-add DIRECT rule for proxy IP) + [[ -n "$proxy_url" ]] && _vpn_ensure_compatible "$proxy_url" + _generate_client_cert "$name" >/dev/null 2>&1 || true # Auto-activate @@ -362,6 +365,7 @@ _env_cmd_set() { proxy_url=$(_parse_proxy "$value") fi echo "$proxy_url" > "$env_dir/proxy" + _vpn_ensure_compatible "$proxy_url" echo "$(_green_bold "Set") proxy for $(_bold "$name") → $proxy_url" fi ;; diff --git a/src/cmd_self.sh b/src/cmd_self.sh index 3c3fcaf..be87e0b 100644 --- a/src/cmd_self.sh +++ b/src/cmd_self.sh @@ -41,6 +41,7 @@ cmd_self() { case "${1:-help}" in update) _self_cmd_update ;; delete|remove) cmd_delete ;; + vpn-ensure) _vpn_ensure_compatible "${2:-}" ;; help|-h|--help) echo "$(_bold "cac self") — cac self-management" echo diff --git a/src/vpn_compat.sh b/src/vpn_compat.sh new file mode 100644 index 0000000..af9af8c --- /dev/null +++ b/src/vpn_compat.sh @@ -0,0 +1,467 @@ +# ── vpn_compat: VPN detection and split-routing ────────────── + +_vpn_is_ipv4() { + local ip="$1" + [[ "$ip" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || return 1 + local i + for i in "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" "${BASH_REMATCH[4]}"; do + (( i > 255 )) && return 1 + done + return 0 +} + +_vpn_is_loopback() { + local ip="$1" + [[ "$ip" == "127."* || "$ip" == "localhost" || "$ip" == "::1" ]] +} + +_vpn_resolve_ipv4() { + local host="$1" + _vpn_is_ipv4 "$host" && { echo "$host"; return 0; } + + local resolved + resolved=$(python3 - "$host" <<'PY' 2>/dev/null +import socket, sys + +host = sys.argv[1] +try: + for item in socket.getaddrinfo(host, None, socket.AF_INET, socket.SOCK_STREAM): + ip = item[4][0] + if ip: + print(ip) + raise SystemExit(0) +except Exception: + pass +sys.exit(1) +PY +) + [[ -n "$resolved" ]] && { echo "$resolved"; return 0; } + return 1 +} + +_vpn_has_process() { + local pattern="$1" + # Use pgrep on macOS/Linux for reliable process matching + if command -v pgrep &>/dev/null; then + pgrep -if "$pattern" >/dev/null 2>&1 + else + # Fallback: ps + grep, exclude only exact 'grep' and 'apply_patch' + ps ax -o command= 2>/dev/null | grep -iE "$pattern" | grep -ivE '^grep |apply_patch' >/dev/null 2>&1 + fi +} + +_vpn_http_status() { + local url="$1" secret="${2:-}" code + if [[ -n "$secret" ]]; then + code=$(curl -sS --connect-timeout 3 --max-time 5 -H "Authorization: Bearer $secret" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null) || code="000" + else + code=$(curl -sS --connect-timeout 3 --max-time 5 -o /dev/null -w '%{http_code}' "$url" 2>/dev/null) || code="000" + fi + echo "$code" +} + +_vpn_port_from_controller() { + local controller="$1" port + port=$(printf '%s' "$controller" | sed -E 's#^[a-zA-Z]+://##; s#/.*$##; s#.*:([0-9]+)$#\1#') + [[ "$port" =~ ^[0-9]+$ ]] && echo "$port" +} + +_vpn_yaml_value() { + local file="$1" key="$2" + [[ -f "$file" ]] || return 1 + # Compatible with macOS default awk (no capture arrays) + grep -E "^[[:space:]]*${key}:" "$file" 2>/dev/null | head -1 | sed -E "s/^[[:space:]]*${key}:[[:space:]]*//" | sed -E "s/[[:space:]]*#.*$//" | tr -d "\"'" | tr -d '[:space:]' +} + +_vpn_clash_known_configs() { + local os; os=$(_detect_os) + # macOS paths first (preferred on macOS) + if [[ "$os" == "macos" ]]; then + printf '%s\n' \ + "$HOME/Library/Application Support/io.github.niceneasy.ClashX/config.yaml" \ + "$HOME/Library/Application Support/clash-verge-rev/clash/config.yaml" \ + "$HOME/Library/Application Support/clash-verge/clash/config.yaml" \ + "$HOME/Library/Application Support/mihomo/config.yaml" \ + "$HOME/Library/Application Support/clash/config.yaml" + fi + # XDG paths (Linux primary, macOS fallback) + printf '%s\n' \ + "$HOME/.config/mihomo/config.yaml" \ + "$HOME/.config/clash/config.yaml" \ + "$HOME/.config/clash-verge-rev/clash/config.yaml" \ + "$HOME/.config/clash-verge/clash/config.yaml" +} + +_vpn_clash_detect_port() { + local controller port file status + + controller="${CLASH_EXTERNAL_CONTROLLER:-}" + if [[ -n "$controller" ]]; then + port=$(_vpn_port_from_controller "$controller") + [[ -n "$port" ]] && { echo "$port"; return 0; } + fi + + while IFS= read -r file; do + [[ -f "$file" ]] || continue + controller=$(_vpn_yaml_value "$file" 'external-controller') + if [[ -n "$controller" ]]; then + port=$(_vpn_port_from_controller "$controller") + [[ -n "$port" ]] && { echo "$port"; return 0; } + fi + done < <(_vpn_clash_known_configs) + + for port in 9090 9097; do + status=$(_vpn_http_status "http://127.0.0.1:$port/configs") + [[ "$status" != "000" ]] && { echo "$port"; return 0; } + done + + return 1 +} + +_vpn_clash_secret() { + local file secret="${CLASH_API_SECRET:-${CLASH_SECRET:-}}" + [[ -n "$secret" ]] && { echo "$secret"; return 0; } + + while IFS= read -r file; do + [[ -f "$file" ]] || continue + secret=$(_vpn_yaml_value "$file" 'secret') + [[ -n "$secret" ]] && { echo "$secret"; return 0; } + done < <(_vpn_clash_known_configs) + + return 1 +} + +_vpn_sing_box_detect_port() { + local port status + local config_paths=( + "$HOME/.config/sing-box/config.json" + "/usr/local/etc/sing-box/config.json" + "/etc/sing-box/config.json" + ) + if [[ "$(_detect_os)" == "macos" ]]; then + config_paths=("$HOME/Library/Application Support/sing-box/config.json" "${config_paths[@]}") + fi + + local config + for config in "${config_paths[@]}"; do + [[ -f "$config" ]] || continue + port=$(python3 - "$config" <<'PY' 2>/dev/null || true +import json, sys + +path = sys.argv[1] +try: + data = json.load(open(path, 'r')) +except Exception: + raise SystemExit(0) + +controller = '' +experimental = data.get('experimental') or {} +clash_api = experimental.get('clash_api') or {} +controller = clash_api.get('external_controller') or clash_api.get('external-controller') or '' + +if isinstance(controller, str) and controller: + value = controller.split('://', 1)[-1].split('/', 1)[0] + if ':' in value: + print(value.rsplit(':', 1)[1]) +PY +) + [[ -n "$port" ]] && { echo "$port"; return 0; } + done + + status=$(_vpn_http_status "http://127.0.0.1:9090") + [[ "$status" != "000" ]] && { echo "9090"; return 0; } + return 1 +} + +_vpn_detect_surge_port() { + local status + status=$(_vpn_http_status "http://127.0.0.1:6171") + [[ "$status" != "000" ]] && { echo "6171"; return 0; } + return 1 +} + +_detect_vpn() { + local port + + # Shadowrocket (macOS) — check first since it's common on macOS + if _vpn_has_process 'Shadowrocket'; then + echo "shadowrocket:" + return 0 + fi + + # Clash family: mihomo, clash-meta, ClashX, Clash Verge, Stash + if _vpn_has_process '(mihomo|clash-meta|clash-verge|ClashX|Clash\.Meta)([ /]|$)' || _vpn_has_process '(^|[ /])Stash([ .]|$)'; then + port=$(_vpn_clash_detect_port 2>/dev/null || true) + echo "clash:${port:-}" + return 0 + fi + # Plain 'clash' checked separately to avoid false positives + if _vpn_has_process '(^|[ /])clash([[:space:]]|$)'; then + port=$(_vpn_clash_detect_port 2>/dev/null || true) + echo "clash:${port:-}" + return 0 + fi + + # sing-box + if _vpn_has_process 'sing-box'; then + port=$(_vpn_sing_box_detect_port 2>/dev/null || true) + echo "sing-box:${port:-}" + return 0 + fi + + # V2Ray / Xray + if _vpn_has_process '(^|[ /])(v2ray|xray)([[:space:]]|$)'; then + echo "v2ray:" + return 0 + fi + + # Surge + if _vpn_has_process '(^|[ /])(Surge|surge-cli)([ .]|$)'; then + port=$(_vpn_detect_surge_port 2>/dev/null || true) + echo "surge:${port:-6171}" + return 0 + fi + + # tun2socks + if _vpn_has_process 'tun2socks'; then + echo "tun2socks:" + return 0 + fi + + return 1 +} + +_vpn_generate_rule() { + local proxy_ip="$1" vpn_type="$2" + case "$vpn_type" in + clash) + printf '%s\n' "- IP-CIDR,${proxy_ip}/32,DIRECT,no-resolve" + ;; + shadowrocket|surge) + printf '%s\n' "IP-CIDR,${proxy_ip}/32,DIRECT" + ;; + sing-box) + printf '%s\n' "{\"ip_cidr\":[\"${proxy_ip}/32\"],\"outbound\":\"direct\"}" + ;; + v2ray) + printf '%s\n' "{\"type\":\"field\",\"ip\":[\"${proxy_ip}/32\"],\"outboundTag\":\"direct\"}" + ;; + *) + return 1 + ;; + esac +} + +_vpn_clash_api_get_configs() { + local port="$1" secret="${2:-}" + if [[ -n "$secret" ]]; then + curl -fsS --connect-timeout 3 --max-time 5 -H "Authorization: Bearer $secret" "http://127.0.0.1:$port/configs" 2>/dev/null || true + else + curl -fsS --connect-timeout 3 --max-time 5 "http://127.0.0.1:$port/configs" 2>/dev/null || true + fi +} + +_vpn_clash_api_config_path() { + local port="$1" secret="${2:-}" body path + body=$(_vpn_clash_api_get_configs "$port" "$secret") + [[ -n "$body" ]] || return 1 + + path=$(printf '%s' "$body" | python3 -c 'import json,sys; data=json.load(sys.stdin); print(data.get("path", ""))' 2>/dev/null || true) + [[ -n "$path" && -f "$path" ]] && echo "$path" +} + +_vpn_clash_insert_rule() { + local file="$1" proxy_ip="$2" rule tmp + [[ -f "$file" ]] || return 1 + + # Already exists + if grep -F "IP-CIDR,${proxy_ip}/32,DIRECT" "$file" >/dev/null 2>&1; then + return 0 + fi + + rule=$(_vpn_generate_rule "$proxy_ip" clash) || return 1 + tmp=$(mktemp "${file}.cac.XXXXXX") || return 1 + + python3 - "$file" "$tmp" "$rule" <<'PY' || { rm -f "$tmp"; return 1; } +import sys + +src, dst, rule = sys.argv[1:4] + +with open(src, 'r') as f: + text = f.read() + +newline = '\r\n' if '\r\n' in text else '\n' +lines = text.splitlines() +inserted = False +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: + if out and out[-1] != '': + out.append('') + out.append('rules:') + out.append(' ' + rule) + +with open(dst, 'w') as f: + f.write(newline.join(out) + newline) +PY + + # Backup with timestamp + cp "$file" "${file}.cac.bak.$(date +%s)" 2>/dev/null || true + cat "$tmp" > "$file" && rm -f "$tmp" +} + +_vpn_clash_reload() { + local port="$1" config_path="$2" secret="${3:-}" payload + payload=$(python3 - "$config_path" <<'PY' +import json, sys +print(json.dumps({"path": sys.argv[1]})) +PY +) + + if [[ -n "$secret" ]]; then + curl -fsS --connect-timeout 3 --max-time 5 -X PUT \ + -H "Authorization: Bearer $secret" \ + -H 'Content-Type: application/json' \ + --data "$payload" \ + "http://127.0.0.1:$port/configs?force=true" >/dev/null 2>&1 + else + curl -fsS --connect-timeout 3 --max-time 5 -X PUT \ + -H 'Content-Type: application/json' \ + --data "$payload" \ + "http://127.0.0.1:$port/configs?force=true" >/dev/null 2>&1 + fi +} + +_vpn_try_auto_inject_clash() { + local proxy_ip="$1" api_port="$2" secret config_path file + [[ -n "$api_port" ]] || api_port=$(_vpn_clash_detect_port 2>/dev/null || true) + [[ -n "$api_port" ]] || return 1 + + secret=$(_vpn_clash_secret 2>/dev/null || true) + config_path=$(_vpn_clash_api_config_path "$api_port" "" 2>/dev/null || true) + [[ -z "$config_path" && -n "$secret" ]] && config_path=$(_vpn_clash_api_config_path "$api_port" "$secret" 2>/dev/null || true) + + if [[ -z "$config_path" ]]; then + while IFS= read -r file; do + [[ -f "$file" ]] || continue + config_path="$file" + break + done < <(_vpn_clash_known_configs) + fi + + [[ -n "$config_path" && -f "$config_path" ]] || return 1 + _vpn_clash_insert_rule "$config_path" "$proxy_ip" || return 1 + _vpn_clash_reload "$api_port" "$config_path" "$secret" || return 1 +} + +_vpn_try_auto_inject() { + local proxy_ip="$1" vpn_type="$2" api_port="${3:-}" + + case "$vpn_type" in + clash) _vpn_try_auto_inject_clash "$proxy_ip" "$api_port" ;; + *) return 1 ;; + esac +} + +_vpn_show_manual_guide() { + local proxy_ip="$1" vpn_type="$2" + + echo " $(_yellow "!") VPN/TUN detected. Add a direct rule for proxy IP $(_cyan "$proxy_ip") to avoid traffic being hijacked by local VPN software." + + case "$vpn_type" in + shadowrocket) + echo " $(_dim "Shadowrocket:") open the app and add a rule manually:" + echo + echo " $(_bold "Config") → $(_bold "Rules") → $(_bold "Add Rule")" + echo " Type: $(_cyan "IP-CIDR")" + echo " IP: $(_cyan "${proxy_ip}/32")" + echo " Policy: $(_cyan "DIRECT")" + echo + echo " $(_dim "Make sure the rule is near the top of your rule list.")" + ;; + clash) + echo " $(_dim "Clash / mihomo:") add this near the top of the $(_bold "rules:") section:" + echo " $(_cyan "$(_vpn_generate_rule "$proxy_ip" clash)")" + local os; os=$(_detect_os) + if [[ "$os" == "macos" ]]; then + echo " $(_dim "Common config paths:")" + echo " $(_dim "ClashX: ~/Library/Application Support/io.github.niceneasy.ClashX/config.yaml")" + echo " $(_dim "Clash Verge: ~/Library/Application Support/clash-verge-rev/clash/config.yaml")" + echo " $(_dim "mihomo: ~/.config/mihomo/config.yaml")" + else + echo " $(_dim "Common config paths: ~/.config/clash/config.yaml, ~/.config/mihomo/config.yaml")" + fi + ;; + sing-box) + echo " $(_dim "sing-box:") add this object to $(_bold "route.rules") with outbound $(_bold "direct"):" + echo " $(_cyan "$(_vpn_generate_rule "$proxy_ip" sing-box)")" + echo " $(_dim "Common config path: ~/.config/sing-box/config.json")" + ;; + v2ray) + echo " $(_dim "V2Ray / Xray:") add this object to $(_bold "routing.rules") with outbound tag $(_bold "direct"):" + echo " $(_cyan "$(_vpn_generate_rule "$proxy_ip" v2ray)")" + ;; + surge) + echo " $(_dim "Surge:") add this line under the $(_bold "[Rule]") section:" + echo " $(_cyan "$(_vpn_generate_rule "$proxy_ip" surge)")" + ;; + *) + echo " $(_dim "If your VPN client uses Clash-compatible rules, add:")" + echo " $(_cyan "$(_vpn_generate_rule "$proxy_ip" clash)")" + echo " $(_dim "If it uses Surge rules, add:")" + echo " $(_cyan "$(_vpn_generate_rule "$proxy_ip" surge)")" + ;; + esac + + echo +} + +# Main entry: ensure VPN won't hijack cac proxy traffic +# Always returns 0 — VPN compat is best-effort, never fatal +_vpn_ensure_compatible() { + local proxy_url="$1" hp host proxy_ip detected vpn_type api_port + [[ -n "$proxy_url" ]] || return 0 + + hp=$(_proxy_host_port "$proxy_url") + host="${hp%%:*}" + [[ -n "$host" ]] || return 0 + + # Skip loopback — no VPN bypass needed + if _vpn_is_loopback "$host"; then + return 0 + fi + + # Resolve to IPv4 + proxy_ip=$(_vpn_resolve_ipv4 "$host") || true + if [[ -z "$proxy_ip" ]]; then + echo " $(_dim "skipped VPN check: could not resolve $host")" + return 0 + fi + + # Skip loopback IPs + if _vpn_is_loopback "$proxy_ip"; then + return 0 + fi + + # Detect VPN + detected=$(_detect_vpn 2>/dev/null || true) + [[ -n "$detected" ]] || return 0 + + vpn_type="${detected%%:*}" + api_port="${detected#*:}" + [[ "$api_port" == "$detected" ]] && api_port="" + + echo " $(_yellow "!") detected local VPN/TUN: $(_cyan "$vpn_type")" + if _vpn_try_auto_inject "$proxy_ip" "$vpn_type" "$api_port" 2>/dev/null; then + echo " $(_green "+") added DIRECT rule for $(_cyan "$proxy_ip") in $(_cyan "$vpn_type")" + else + _vpn_show_manual_guide "$proxy_ip" "$vpn_type" + fi + return 0 +} From 42c3f03035544bd9bfbf59ba3c5047561538bedc Mon Sep 17 00:00:00 2001 From: James Date: Thu, 2 Apr 2026 13:45:48 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20VPN=20compat=20improvements=20?= =?UTF-8?q?=E2=80=94=20wrapper=20hint,=20path=20validation,=20check=20inte?= =?UTF-8?q?gration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- cac | 33 ++++++++++++++++++++++++++++++++- src/cmd_check.sh | 8 ++++++++ src/cmd_help.sh | 1 + src/cmd_self.sh | 1 + src/templates.sh | 13 +++++++++++++ src/vpn_compat.sh | 10 +++++++++- 6 files changed, 64 insertions(+), 2 deletions(-) diff --git a/cac b/cac index 11ccfa3..4b82eaf 100755 --- a/cac +++ b/cac @@ -699,7 +699,15 @@ _vpn_clash_api_config_path() { [[ -n "$body" ]] || return 1 path=$(printf '%s' "$body" | python3 -c 'import json,sys; data=json.load(sys.stdin); print(data.get("path", ""))' 2>/dev/null || true) - [[ -n "$path" && -f "$path" ]] && echo "$path" + [[ -n "$path" ]] || return 1 + [[ "$path" == /* ]] || return 1 + [[ "$path" == *..* ]] && return 1 + [[ "$path" == *.yaml || "$path" == *.yml ]] || return 1 + case "$path" in + "$HOME"/*|/etc/*|/usr/local/*|/opt/*) ;; + *) return 1 ;; + esac + [[ -f "$path" ]] && echo "$path" } _vpn_clash_insert_rule() { @@ -1624,6 +1632,19 @@ if [[ -n "$PROXY" ]]; then fi fi +# VPN compatibility hint +if [[ -n "$PROXY" ]]; then + _vpn_name="" + if pgrep -if "Shadowrocket" >/dev/null 2>&1; then _vpn_name="Shadowrocket" + elif pgrep -if "(mihomo|clash-meta|clash-verge|ClashX)" >/dev/null 2>&1; then _vpn_name="Clash" + elif pgrep -if "sing-box" >/dev/null 2>&1; then _vpn_name="sing-box" + 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 + fi +fi + # inject env vars — proxy (only when proxy is configured) if [[ -n "$PROXY" ]]; then export _CAC_PROXY="$PROXY" @@ -2986,6 +3007,14 @@ cmd_check() { # ── network check (slow — streaming output) ── local proxy_ip="" if [[ -n "$proxy" ]]; then + # VPN compatibility + local _vpn_detected + _vpn_detected=$(_detect_vpn 2>/dev/null || true) + if [[ -n "$_vpn_detected" ]]; then + local _vpn_type="${_vpn_detected%%:*}" + echo " $(_yellow "!") vpn $_vpn_type detected -- check DIRECT rule for proxy IP" + fi + if ! _proxy_reachable "$proxy"; then echo " $(_red "✗") proxy unreachable" problems+=("proxy unreachable: $proxy") @@ -3353,6 +3382,7 @@ cmd_self() { echo echo " $(_bold "update") Update cac to the latest version" echo " $(_bold "delete") Uninstall cac completely" + echo " $(_green "cac self vpn-ensure") Check VPN compatibility" ;; *) _die "unknown: cac self $1" ;; esac @@ -3968,6 +3998,7 @@ cmd_help() { echo " $(_bold "Self")" echo " $(_green "cac self update") Update cac" echo " $(_green "cac self delete") Uninstall cac completely" + echo " $(_green "cac self vpn-ensure") Check VPN compatibility" echo echo " $(_bold "Docker")" diff --git a/src/cmd_check.sh b/src/cmd_check.sh index cd991b7..83478bf 100644 --- a/src/cmd_check.sh +++ b/src/cmd_check.sh @@ -189,6 +189,14 @@ cmd_check() { # ── network check (slow — streaming output) ── local proxy_ip="" if [[ -n "$proxy" ]]; then + # VPN compatibility + local _vpn_detected + _vpn_detected=$(_detect_vpn 2>/dev/null || true) + if [[ -n "$_vpn_detected" ]]; then + local _vpn_type="${_vpn_detected%%:*}" + echo " $(_yellow "!") vpn $_vpn_type detected -- check DIRECT rule for proxy IP" + fi + if ! _proxy_reachable "$proxy"; then echo " $(_red "✗") proxy unreachable" problems+=("proxy unreachable: $proxy") diff --git a/src/cmd_help.sh b/src/cmd_help.sh index 106b54c..13b5142 100644 --- a/src/cmd_help.sh +++ b/src/cmd_help.sh @@ -24,6 +24,7 @@ cmd_help() { echo " $(_bold "Self")" echo " $(_green "cac self update") Update cac" echo " $(_green "cac self delete") Uninstall cac completely" + echo " $(_green "cac self vpn-ensure") Check VPN compatibility" echo echo " $(_bold "Docker")" diff --git a/src/cmd_self.sh b/src/cmd_self.sh index be87e0b..080b045 100644 --- a/src/cmd_self.sh +++ b/src/cmd_self.sh @@ -47,6 +47,7 @@ cmd_self() { echo echo " $(_bold "update") Update cac to the latest version" echo " $(_bold "delete") Uninstall cac completely" + echo " $(_green "cac self vpn-ensure") Check VPN compatibility" ;; *) _die "unknown: cac self $1" ;; esac diff --git a/src/templates.sh b/src/templates.sh index e58bbba..7d41517 100644 --- a/src/templates.sh +++ b/src/templates.sh @@ -216,6 +216,19 @@ if [[ -n "$PROXY" ]]; then fi fi +# VPN compatibility hint +if [[ -n "$PROXY" ]]; then + _vpn_name="" + if pgrep -if "Shadowrocket" >/dev/null 2>&1; then _vpn_name="Shadowrocket" + elif pgrep -if "(mihomo|clash-meta|clash-verge|ClashX)" >/dev/null 2>&1; then _vpn_name="Clash" + elif pgrep -if "sing-box" >/dev/null 2>&1; then _vpn_name="sing-box" + 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 + fi +fi + # inject env vars — proxy (only when proxy is configured) if [[ -n "$PROXY" ]]; then export _CAC_PROXY="$PROXY" diff --git a/src/vpn_compat.sh b/src/vpn_compat.sh index af9af8c..c36cf1c 100644 --- a/src/vpn_compat.sh +++ b/src/vpn_compat.sh @@ -267,7 +267,15 @@ _vpn_clash_api_config_path() { [[ -n "$body" ]] || return 1 path=$(printf '%s' "$body" | python3 -c 'import json,sys; data=json.load(sys.stdin); print(data.get("path", ""))' 2>/dev/null || true) - [[ -n "$path" && -f "$path" ]] && echo "$path" + [[ -n "$path" ]] || return 1 + [[ "$path" == /* ]] || return 1 + [[ "$path" == *..* ]] && return 1 + [[ "$path" == *.yaml || "$path" == *.yml ]] || return 1 + case "$path" in + "$HOME"/*|/etc/*|/usr/local/*|/opt/*) ;; + *) return 1 ;; + esac + [[ -f "$path" ]] && echo "$path" } _vpn_clash_insert_rule() { From 1524bf2ab2fbeac16d678e22b08093c2c65595be Mon Sep 17 00:00:00 2001 From: James Date: Thu, 2 Apr 2026 15:40:54 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20Windows=20VPN=20support=20=E2=80=94?= =?UTF-8?q?=20Clash=20Verge,=20v2rayN,=20PowerShell=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Claude Opus 4.6 (1M context) --- cac | 40 +++++++-- cac.ps1 | 214 ++++++++++++++++++++++++++++++++++++++++++++++ src/vpn_compat.sh | 40 +++++++-- 3 files changed, 276 insertions(+), 18 deletions(-) diff --git a/cac b/cac index 4b82eaf..e0df306 100755 --- a/cac +++ b/cac @@ -476,6 +476,9 @@ _vpn_has_process() { # Use pgrep on macOS/Linux for reliable process matching if command -v pgrep &>/dev/null; then pgrep -if "$pattern" >/dev/null 2>&1 + # Windows fallback (Git Bash / MSYS2) + elif [[ "$(_detect_os)" == "windows" ]]; then + tasklist.exe /FO CSV /NH 2>/dev/null | grep -iE "$pattern" >/dev/null 2>&1 else # Fallback: ps + grep, exclude only exact 'grep' and 'apply_patch' ps ax -o command= 2>/dev/null | grep -iE "$pattern" | grep -ivE '^grep |apply_patch' >/dev/null 2>&1 @@ -516,6 +519,15 @@ _vpn_clash_known_configs() { "$HOME/Library/Application Support/mihomo/config.yaml" \ "$HOME/Library/Application Support/clash/config.yaml" fi + if [[ "$os" == "windows" ]]; then + local appdata="${APPDATA:-$HOME/AppData/Roaming}" + printf '%s\n' \ + "$appdata/clash-verge-rev/clash/config.yaml" \ + "$appdata/clash-verge/clash/config.yaml" \ + "$appdata/Clash for Windows/profiles/" \ + "$HOME/.config/mihomo/config.yaml" \ + "$HOME/.config/clash/config.yaml" + fi # XDG paths (Linux primary, macOS fallback) printf '%s\n' \ "$HOME/.config/mihomo/config.yaml" \ @@ -641,12 +653,6 @@ _detect_vpn() { return 0 fi - # V2Ray / Xray - if _vpn_has_process '(^|[ /])(v2ray|xray)([[:space:]]|$)'; then - echo "v2ray:" - return 0 - fi - # Surge if _vpn_has_process '(^|[ /])(Surge|surge-cli)([ .]|$)'; then port=$(_vpn_detect_surge_port 2>/dev/null || true) @@ -654,6 +660,18 @@ _detect_vpn() { return 0 fi + # v2rayN (Windows) + if _vpn_has_process '(^|[ /])v2rayN([ .]|$)'; then + echo "v2rayN:" + return 0 + fi + + # V2Ray / Xray + if _vpn_has_process '(^|[ /])(v2ray|xray|v2rayN)([[:space:]]|$)'; then + echo "v2ray:" + return 0 + fi + # tun2socks if _vpn_has_process 'tun2socks'; then echo "tun2socks:" @@ -675,7 +693,7 @@ _vpn_generate_rule() { sing-box) printf '%s\n' "{\"ip_cidr\":[\"${proxy_ip}/32\"],\"outbound\":\"direct\"}" ;; - v2ray) + v2ray|v2rayN) printf '%s\n' "{\"type\":\"field\",\"ip\":[\"${proxy_ip}/32\"],\"outboundTag\":\"direct\"}" ;; *) @@ -843,9 +861,13 @@ _vpn_show_manual_guide() { echo " $(_cyan "$(_vpn_generate_rule "$proxy_ip" sing-box)")" echo " $(_dim "Common config path: ~/.config/sing-box/config.json")" ;; - v2ray) - echo " $(_dim "V2Ray / Xray:") add this object to $(_bold "routing.rules") with outbound tag $(_bold "direct"):" + v2ray|v2rayN) + echo " $(_dim "V2Ray / Xray / v2rayN:") add this object to $(_bold "routing.rules") with outbound tag $(_bold "direct"):" echo " $(_cyan "$(_vpn_generate_rule "$proxy_ip" v2ray)")" + if [[ "$(_detect_os)" == "windows" ]]; then + local appdata="${APPDATA:-$HOME/AppData/Roaming}" + echo " $(_dim "v2rayN config: ${appdata}/v2rayN/guiNConfig.json")" + fi ;; surge) echo " $(_dim "Surge:") add this line under the $(_bold "[Rule]") section:" diff --git a/cac.ps1 b/cac.ps1 index 9651d78..1e04c36 100644 --- a/cac.ps1 +++ b/cac.ps1 @@ -160,6 +160,19 @@ set "HTTP_PROXY=!PROXY!" set "ALL_PROXY=!PROXY!" set "NO_PROXY=localhost,127.0.0.1" +REM derive proxy host for VPN hint +set "_hostport=!PROXY!" +if not "!_hostport:*@=!"=="!_hostport!" set "_hostport=!_hostport:*@=!" +if not "!_hostport:*://=!"=="!_hostport!" set "_hostport=!_hostport:*://=!" +for /f "tokens=1 delims=:" %%i in ("!_hostport!") do set "_host=%%~i" + +REM VPN compatibility hint +where tasklist >nul 2>&1 && ( + tasklist /FO CSV /NH 2>nul | findstr /I "mihomo clash-verge v2rayN sing-box" >nul 2>&1 && ( + echo [cac] hint: VPN detected. Ensure !_host! has a DIRECT rule to avoid hijacking >&2 + ) +) + REM telemetry kill switches set "CLAUDE_CODE_SKIP_AUTO_UPDATE=1" set "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1" @@ -226,6 +239,196 @@ exit /b !ERRORLEVEL! Write-Host " wrapper -> $wrapperPath" } +# -- VPN compatibility ------------------------------------------------ + +function Detect-VPN { + # Returns "vpn_name:api_port" or empty string + $processes = Get-Process -ErrorAction SilentlyContinue | Select-Object -ExpandProperty ProcessName + + # Clash family (mihomo, clash-verge, ClashX, etc.) + $clashNames = @("mihomo", "clash-meta", "clash-verge", "clash-verge-service", "Clash for Windows", "ClashX", "Clash.Meta", "FlClash", "clash", "Stash") + foreach ($name in $clashNames) { + if ($processes -match [regex]::Escape($name)) { + $port = Find-ClashPort + return "clash:$port" + } + } + + # v2rayN + if ($processes -contains "v2rayN") { + return "v2rayN:" + } + + # sing-box + if ($processes -contains "sing-box") { + return "sing-box:" + } + + # V2Ray / Xray + foreach ($name in @("v2ray", "xray")) { + if ($processes -contains $name) { + return "v2ray:" + } + } + + return "" +} + +function Find-ClashPort { + # Check common config paths for external-controller + $configPaths = @( + "$env:APPDATA\clash-verge-rev\clash\config.yaml", + "$env:APPDATA\clash-verge\clash\config.yaml", + "$env:USERPROFILE\.config\mihomo\config.yaml", + "$env:USERPROFILE\.config\clash\config.yaml" + ) + foreach ($path in $configPaths) { + if (Test-Path $path) { + $content = Get-Content $path -ErrorAction SilentlyContinue + $line = $content | Where-Object { $_ -match "^\s*external-controller:" } | Select-Object -First 1 + if ($line -match ":(\d+)") { + return $matches[1] + } + } + } + # Probe common ports + foreach ($port in @(9090, 9097)) { + 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 {} + } + return "" +} + +function Get-ClashSecret { + $configPaths = @( + "$env:APPDATA\clash-verge-rev\clash\config.yaml", + "$env:APPDATA\clash-verge\clash\config.yaml", + "$env:USERPROFILE\.config\mihomo\config.yaml", + "$env:USERPROFILE\.config\clash\config.yaml" + ) + foreach ($path in $configPaths) { + if (Test-Path $path) { + $content = Get-Content $path -ErrorAction SilentlyContinue + $line = $content | Where-Object { $_ -match "^\s*secret:" } | Select-Object -First 1 + if ($line -match 'secret:\s*[''"]?([^''"#\s]+)') { + return $matches[1] + } + } + } + return "" +} + +function Try-ClashAutoInject { + param([string]$ProxyIP, [string]$ApiPort) + if (-not $ApiPort) { return $false } + + $rule = "- IP-CIDR,${ProxyIP}/32,DIRECT,no-resolve" + $secret = Get-ClashSecret + $headers = @{ "Content-Type" = "application/json" } + if ($secret) { $headers["Authorization"] = "Bearer $secret" } + + # Get config path from API + try { + $configResp = Invoke-RestMethod -Uri "http://127.0.0.1:${ApiPort}/configs" -Headers $headers -TimeoutSec 5 -ErrorAction Stop + $configPath = $configResp.path + } catch { return $false } + + if (-not $configPath -or -not (Test-Path $configPath)) { return $false } + + # Validate path + if ($configPath -match "\.\.") { return $false } + if ($configPath -notmatch "\.(yaml|yml)$") { return $false } + + # Check if rule already exists + $content = Get-Content $configPath -Raw + if ($content -match [regex]::Escape("IP-CIDR,${ProxyIP}/32,DIRECT")) { return $true } + + # Inject rule after "rules:" line + $lines = Get-Content $configPath + $newLines = @() + $injected = $false + foreach ($line in $lines) { + $newLines += $line + if (-not $injected -and $line.Trim() -eq "rules:") { + $newLines += " $rule" + $injected = $true + } + } + if (-not $injected) { + $newLines += "" + $newLines += "rules:" + $newLines += " $rule" + } + + # Backup and write + Copy-Item $configPath "${configPath}.cac.bak" -Force -ErrorAction SilentlyContinue + $newLines | Set-Content $configPath -Encoding UTF8 + + # Reload via API + try { + $body = @{ path = $configPath } | ConvertTo-Json + Invoke-RestMethod -Uri "http://127.0.0.1:${ApiPort}/configs?force=true" -Method Put -Body $body -Headers $headers -TimeoutSec 5 -ErrorAction Stop + } catch {} + + return $true +} + +function Show-VPNManualGuide { + param([string]$ProxyIP, [string]$VPNType) + Write-Yellow " ! VPN detected. Add a DIRECT rule for proxy IP $ProxyIP to avoid VPN hijacking." + switch ($VPNType) { + "clash" { + Write-Host " Clash / mihomo: add this near the top of the rules: section:" + Write-Host " - IP-CIDR,${ProxyIP}/32,DIRECT,no-resolve" -ForegroundColor Cyan + Write-Host " Common paths: %APPDATA%\clash-verge-rev\clash\config.yaml" -ForegroundColor DarkGray + } + "v2rayN" { + Write-Host " v2rayN: Settings -> Routing -> Add rule:" -ForegroundColor DarkGray + Write-Host " IP: ${ProxyIP}/32 OutboundTag: direct" -ForegroundColor Cyan + Write-Host " Or edit: %APPDATA%\v2rayN\guiNConfig.json" -ForegroundColor DarkGray + } + "sing-box" { + Write-Host " sing-box: add to route.rules:" -ForegroundColor DarkGray + Write-Host " {`"ip_cidr`": [`"${ProxyIP}/32`"], `"outbound`": `"direct`"}" -ForegroundColor Cyan + } + default { + Write-Host " Add a DIRECT/bypass rule for $ProxyIP in your VPN config." -ForegroundColor DarkGray + } + } + Write-Host "" +} + +function Ensure-VPNCompatible { + param([string]$ProxyUrl) + if (-not $ProxyUrl) { return } + + $hp = Get-ProxyHostPort $ProxyUrl + $host_ = ($hp -split ":")[0] + if (-not $host_ -or $host_ -eq "127.0.0.1" -or $host_ -eq "localhost") { return } + + $detected = Detect-VPN + if (-not $detected) { return } + + $vpnType = ($detected -split ":")[0] + $apiPort = ($detected -split ":")[1] + + Write-Yellow " ! Detected local VPN: $vpnType" + + if ($vpnType -eq "clash" -and $apiPort) { + if (Try-ClashAutoInject $host_ $apiPort) { + Write-Green " + Added DIRECT rule for $host_ in $vpnType" + return + } + } + + Show-VPNManualGuide $host_ $vpnType +} + # ── cmd: setup ──────────────────────────────────────────── function Cmd-Setup { @@ -303,6 +506,8 @@ function Cmd-Add { Write-Host " Warning: proxy currently unreachable" } + Ensure-VPNCompatible $proxy + # detect timezone Write-Host -NoNewline " Detecting timezone ... " $tz = "America/New_York" @@ -433,6 +638,13 @@ function Cmd-Check { Write-Host " TZ : $(Read-FileValue (Join-Path $envDir 'tz') '(not set)')" Write-Host "" + # VPN compatibility + $vpnDetected = Detect-VPN + if ($vpnDetected) { + $vpnType = ($vpnDetected -split ":")[0] + Write-Yellow " ! vpn: $vpnType detected -- check DIRECT rule for proxy IP" + } + Write-Host -NoNewline " TCP test ... " if (-not (Test-ProxyReachable $proxy)) { Write-Red "FAIL" @@ -486,6 +698,7 @@ function Cmd-Help { Write-Host " cac Switch to env" Write-Host " cac ls List all envs" Write-Host " cac check Check current env" + Write-Host " cac vpn-ensure Check VPN compatibility" Write-Host " cac stop Temporarily disable" Write-Host " cac -c Resume from stop" Write-Host "" @@ -519,6 +732,7 @@ switch ($args[0]) { "ls" { Cmd-Ls } "list" { Cmd-Ls } "check" { Cmd-Check } + "vpn-ensure" { if ($args[1]) { Ensure-VPNCompatible $args[1] } else { Write-Host "Usage: cac vpn-ensure " } } "stop" { Cmd-Stop } "-c" { Cmd-Continue } "help" { Cmd-Help } diff --git a/src/vpn_compat.sh b/src/vpn_compat.sh index c36cf1c..f08e1fe 100644 --- a/src/vpn_compat.sh +++ b/src/vpn_compat.sh @@ -44,6 +44,9 @@ _vpn_has_process() { # Use pgrep on macOS/Linux for reliable process matching if command -v pgrep &>/dev/null; then pgrep -if "$pattern" >/dev/null 2>&1 + # Windows fallback (Git Bash / MSYS2) + elif [[ "$(_detect_os)" == "windows" ]]; then + tasklist.exe /FO CSV /NH 2>/dev/null | grep -iE "$pattern" >/dev/null 2>&1 else # Fallback: ps + grep, exclude only exact 'grep' and 'apply_patch' ps ax -o command= 2>/dev/null | grep -iE "$pattern" | grep -ivE '^grep |apply_patch' >/dev/null 2>&1 @@ -84,6 +87,15 @@ _vpn_clash_known_configs() { "$HOME/Library/Application Support/mihomo/config.yaml" \ "$HOME/Library/Application Support/clash/config.yaml" fi + if [[ "$os" == "windows" ]]; then + local appdata="${APPDATA:-$HOME/AppData/Roaming}" + printf '%s\n' \ + "$appdata/clash-verge-rev/clash/config.yaml" \ + "$appdata/clash-verge/clash/config.yaml" \ + "$appdata/Clash for Windows/profiles/" \ + "$HOME/.config/mihomo/config.yaml" \ + "$HOME/.config/clash/config.yaml" + fi # XDG paths (Linux primary, macOS fallback) printf '%s\n' \ "$HOME/.config/mihomo/config.yaml" \ @@ -209,12 +221,6 @@ _detect_vpn() { return 0 fi - # V2Ray / Xray - if _vpn_has_process '(^|[ /])(v2ray|xray)([[:space:]]|$)'; then - echo "v2ray:" - return 0 - fi - # Surge if _vpn_has_process '(^|[ /])(Surge|surge-cli)([ .]|$)'; then port=$(_vpn_detect_surge_port 2>/dev/null || true) @@ -222,6 +228,18 @@ _detect_vpn() { return 0 fi + # v2rayN (Windows) + if _vpn_has_process '(^|[ /])v2rayN([ .]|$)'; then + echo "v2rayN:" + return 0 + fi + + # V2Ray / Xray + if _vpn_has_process '(^|[ /])(v2ray|xray|v2rayN)([[:space:]]|$)'; then + echo "v2ray:" + return 0 + fi + # tun2socks if _vpn_has_process 'tun2socks'; then echo "tun2socks:" @@ -243,7 +261,7 @@ _vpn_generate_rule() { sing-box) printf '%s\n' "{\"ip_cidr\":[\"${proxy_ip}/32\"],\"outbound\":\"direct\"}" ;; - v2ray) + v2ray|v2rayN) printf '%s\n' "{\"type\":\"field\",\"ip\":[\"${proxy_ip}/32\"],\"outboundTag\":\"direct\"}" ;; *) @@ -411,9 +429,13 @@ _vpn_show_manual_guide() { echo " $(_cyan "$(_vpn_generate_rule "$proxy_ip" sing-box)")" echo " $(_dim "Common config path: ~/.config/sing-box/config.json")" ;; - v2ray) - echo " $(_dim "V2Ray / Xray:") add this object to $(_bold "routing.rules") with outbound tag $(_bold "direct"):" + v2ray|v2rayN) + echo " $(_dim "V2Ray / Xray / v2rayN:") add this object to $(_bold "routing.rules") with outbound tag $(_bold "direct"):" echo " $(_cyan "$(_vpn_generate_rule "$proxy_ip" v2ray)")" + if [[ "$(_detect_os)" == "windows" ]]; then + local appdata="${APPDATA:-$HOME/AppData/Roaming}" + echo " $(_dim "v2rayN config: ${appdata}/v2rayN/guiNConfig.json")" + fi ;; surge) echo " $(_dim "Surge:") add this line under the $(_bold "[Rule]") section:" From 6f154dd7590ec35afed3bd998eb21b689ecac1b5 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 2 Apr 2026 15:53:47 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20address=20pre-PR=20review=20?= =?UTF-8?q?=E2=80=94=20Windows=20path=20validation,=20PS1=20security=20har?= =?UTF-8?q?dening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cac | 23 +++++++++++++++-------- cac.ps1 | 25 +++++++++++++++++++++---- src/vpn_compat.sh | 23 +++++++++++++++-------- 3 files changed, 51 insertions(+), 20 deletions(-) diff --git a/cac b/cac index e0df306..87be7a0 100755 --- a/cac +++ b/cac @@ -476,9 +476,9 @@ _vpn_has_process() { # Use pgrep on macOS/Linux for reliable process matching if command -v pgrep &>/dev/null; then pgrep -if "$pattern" >/dev/null 2>&1 - # Windows fallback (Git Bash / MSYS2) + # Windows fallback (Git Bash / MSYS2) — strip CSV quotes before matching elif [[ "$(_detect_os)" == "windows" ]]; then - tasklist.exe /FO CSV /NH 2>/dev/null | grep -iE "$pattern" >/dev/null 2>&1 + tasklist.exe /FO CSV /NH 2>/dev/null | tr -d '"' | grep -iE "$pattern" >/dev/null 2>&1 else # Fallback: ps + grep, exclude only exact 'grep' and 'apply_patch' ps ax -o command= 2>/dev/null | grep -iE "$pattern" | grep -ivE '^grep |apply_patch' >/dev/null 2>&1 @@ -524,7 +524,7 @@ _vpn_clash_known_configs() { printf '%s\n' \ "$appdata/clash-verge-rev/clash/config.yaml" \ "$appdata/clash-verge/clash/config.yaml" \ - "$appdata/Clash for Windows/profiles/" \ + "$appdata/Clash for Windows/config.yaml" \ "$HOME/.config/mihomo/config.yaml" \ "$HOME/.config/clash/config.yaml" fi @@ -718,13 +718,20 @@ _vpn_clash_api_config_path() { path=$(printf '%s' "$body" | python3 -c 'import json,sys; data=json.load(sys.stdin); print(data.get("path", ""))' 2>/dev/null || true) [[ -n "$path" ]] || return 1 - [[ "$path" == /* ]] || return 1 + # Validate: no traversal, yaml extension [[ "$path" == *..* ]] && return 1 [[ "$path" == *.yaml || "$path" == *.yml ]] || return 1 - case "$path" in - "$HOME"/*|/etc/*|/usr/local/*|/opt/*) ;; - *) return 1 ;; - esac + # Must be absolute: Unix (/) or Windows (C:/ D:\) + if [[ "$(_detect_os)" == "windows" ]]; then + [[ "$path" =~ ^[A-Za-z]:[/\\] ]] || return 1 + else + [[ "$path" == /* ]] || return 1 + # Unix: restrict to safe directories + case "$path" in + "$HOME"/*|/etc/*|/usr/local/*|/opt/*) ;; + *) return 1 ;; + esac + fi [[ -f "$path" ]] && echo "$path" } diff --git a/cac.ps1 b/cac.ps1 index 1e04c36..a3a1697 100644 --- a/cac.ps1 +++ b/cac.ps1 @@ -340,9 +340,17 @@ function Try-ClashAutoInject { if (-not $configPath -or -not (Test-Path $configPath)) { return $false } - # Validate path + # Validate path: no traversal, yaml extension, must be absolute if ($configPath -match "\.\.") { return $false } if ($configPath -notmatch "\.(yaml|yml)$") { return $false } + if ($configPath -notmatch "^[A-Za-z]:[/\\]" -and $configPath -notmatch "^/") { return $false } + # Must be under user profile or system config dirs + $allowedPrefixes = @($env:USERPROFILE, $env:APPDATA, $env:LOCALAPPDATA, "$env:ProgramFiles", "${env:ProgramFiles(x86)}") + $pathAllowed = $false + foreach ($prefix in $allowedPrefixes) { + if ($prefix -and $configPath.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { $pathAllowed = $true; break } + } + if (-not $pathAllowed) { return $false } # Check if rule already exists $content = Get-Content $configPath -Raw @@ -365,8 +373,9 @@ function Try-ClashAutoInject { $newLines += " $rule" } - # Backup and write - Copy-Item $configPath "${configPath}.cac.bak" -Force -ErrorAction SilentlyContinue + # Backup with timestamp and write + $timestamp = Get-Date -Format "yyyyMMddHHmmss" + Copy-Item $configPath "${configPath}.cac.bak.${timestamp}" -Force -ErrorAction SilentlyContinue $newLines | Set-Content $configPath -Encoding UTF8 # Reload via API @@ -409,7 +418,15 @@ function Ensure-VPNCompatible { $hp = Get-ProxyHostPort $ProxyUrl $host_ = ($hp -split ":")[0] - if (-not $host_ -or $host_ -eq "127.0.0.1" -or $host_ -eq "localhost") { return } + if (-not $host_ -or $host_ -eq "127.0.0.1" -or $host_ -eq "localhost" -or $host_ -eq "::1") { return } + + # Resolve hostname to IPv4 if needed + if ($host_ -notmatch '^\d+\.\d+\.\d+\.\d+$') { + try { + $resolved = [System.Net.Dns]::GetHostAddresses($host_) | Where-Object { $_.AddressFamily -eq 'InterNetwork' } | Select-Object -First 1 + if ($resolved) { $host_ = $resolved.ToString() } else { return } + } catch { return } + } $detected = Detect-VPN if (-not $detected) { return } diff --git a/src/vpn_compat.sh b/src/vpn_compat.sh index f08e1fe..83edaaf 100644 --- a/src/vpn_compat.sh +++ b/src/vpn_compat.sh @@ -44,9 +44,9 @@ _vpn_has_process() { # Use pgrep on macOS/Linux for reliable process matching if command -v pgrep &>/dev/null; then pgrep -if "$pattern" >/dev/null 2>&1 - # Windows fallback (Git Bash / MSYS2) + # Windows fallback (Git Bash / MSYS2) — strip CSV quotes before matching elif [[ "$(_detect_os)" == "windows" ]]; then - tasklist.exe /FO CSV /NH 2>/dev/null | grep -iE "$pattern" >/dev/null 2>&1 + tasklist.exe /FO CSV /NH 2>/dev/null | tr -d '"' | grep -iE "$pattern" >/dev/null 2>&1 else # Fallback: ps + grep, exclude only exact 'grep' and 'apply_patch' ps ax -o command= 2>/dev/null | grep -iE "$pattern" | grep -ivE '^grep |apply_patch' >/dev/null 2>&1 @@ -92,7 +92,7 @@ _vpn_clash_known_configs() { printf '%s\n' \ "$appdata/clash-verge-rev/clash/config.yaml" \ "$appdata/clash-verge/clash/config.yaml" \ - "$appdata/Clash for Windows/profiles/" \ + "$appdata/Clash for Windows/config.yaml" \ "$HOME/.config/mihomo/config.yaml" \ "$HOME/.config/clash/config.yaml" fi @@ -286,13 +286,20 @@ _vpn_clash_api_config_path() { path=$(printf '%s' "$body" | python3 -c 'import json,sys; data=json.load(sys.stdin); print(data.get("path", ""))' 2>/dev/null || true) [[ -n "$path" ]] || return 1 - [[ "$path" == /* ]] || return 1 + # Validate: no traversal, yaml extension [[ "$path" == *..* ]] && return 1 [[ "$path" == *.yaml || "$path" == *.yml ]] || return 1 - case "$path" in - "$HOME"/*|/etc/*|/usr/local/*|/opt/*) ;; - *) return 1 ;; - esac + # Must be absolute: Unix (/) or Windows (C:/ D:\) + if [[ "$(_detect_os)" == "windows" ]]; then + [[ "$path" =~ ^[A-Za-z]:[/\\] ]] || return 1 + else + [[ "$path" == /* ]] || return 1 + # Unix: restrict to safe directories + case "$path" in + "$HOME"/*|/etc/*|/usr/local/*|/opt/*) ;; + *) return 1 ;; + esac + fi [[ -f "$path" ]] && echo "$path" } From 49f5156150b34d5c796f82eb6e2a2d739bf94203 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 3 Apr 2026 15:05:57 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20IP=20watchdog=20=E2=80=94=20continu?= =?UTF-8?q?ous=20exit-IP=20monitoring=20with=20statusline=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- cac | 115 +++++++++++++++++++++++++++++++++++++++++++++++ src/cmd_check.sh | 23 ++++++++++ src/templates.sh | 92 +++++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+) diff --git a/cac b/cac index 87be7a0..c0254e4 100755 --- a/cac +++ b/cac @@ -1529,6 +1529,16 @@ if [ -n "$worktree_name" ]; then parts+=("$(printf "${cyan}%s${reset}" "$wt")") fi +# IP watchdog status +_cac_dir="$HOME/.cac" +if [ -f "$_cac_dir/ip-alert" ]; then + _alert_ip=$(cat "$_cac_dir/ip-alert" 2>/dev/null | tr -d '[:space:]') + parts+=("$(printf "${red}${bold}IP:${_alert_ip}!${reset}")") +elif [ -f "$_cac_dir/envs/$(cat "$_cac_dir/current" 2>/dev/null | tr -d '[:space:]')/expected_ip" ]; then + _exp_ip=$(cat "$_cac_dir/envs/$(cat "$_cac_dir/current" 2>/dev/null | tr -d '[:space:]')/expected_ip" 2>/dev/null | tr -d '[:space:]') + [ -n "$_exp_ip" ] && parts+=("$(printf "${green}IP:%s${reset}" "$_exp_ip")") +fi + if [ "${#parts[@]}" -eq 0 ]; then printf "${dim}claude${reset}\n"; exit 0; fi sep="$(printf " ${dim}|${reset} ")" result="${parts[0]}" @@ -1953,6 +1963,88 @@ if [[ -n "$PROXY" ]] && [[ -f "$CAC_DIR/relay.js" ]]; then fi fi +# ── IP watchdog: continuous exit-IP monitoring ── +# Detects if VPN/route changes cause proxy traffic to exit from wrong IP +if [[ -n "$PROXY" ]]; then + _ip_watchdog_file="$CAC_DIR/ip-watchdog.pid" + _ip_wd_running=false + if [[ -f "$_ip_watchdog_file" ]]; then + _ip_wpid=$(tr -d '[:space:]' < "$_ip_watchdog_file") + [[ -n "$_ip_wpid" ]] && kill -0 "$_ip_wpid" 2>/dev/null && _ip_wd_running=true + fi + if [[ "$_ip_wd_running" != "true" ]]; then + ( + trap 'rm -f "$CAC_DIR/ip-watchdog.pid" "$CAC_DIR/ip-alert"' EXIT + set +e + + _ip_urls="https://api.ipify.org https://api.ip.sb/ip https://ip.3322.net https://ipinfo.io/ip" + _check_interval=60 # seconds between checks + _expected_ip_file="$_env_dir/expected_ip" + _alert_file="$CAC_DIR/ip-alert" + _ip_log="$CAC_DIR/ip-watchdog.log" + _proxy_for_check="$PROXY" + # If relay is active, check through relay + [[ -f "$CAC_DIR/relay.port" ]] && _proxy_for_check="http://127.0.0.1:$(tr -d '[:space:]' < "$CAC_DIR/relay.port")" + + _get_exit_ip() { + local _url _ip="" + for _url in $_ip_urls; do + _ip=$(curl -s --proxy "$_proxy_for_check" --connect-timeout 5 --max-time 8 "$_url" 2>/dev/null || true) + [[ "$_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] && { echo "$_ip"; return 0; } + done + return 1 + } + + # First check: establish expected IP + sleep 5 # wait for relay/proxy to stabilize + _expected="" + if [[ -f "$_expected_ip_file" ]]; then + _expected=$(tr -d '[:space:]' < "$_expected_ip_file") + fi + if [[ -z "$_expected" ]]; then + _expected=$(_get_exit_ip) || true + if [[ -n "$_expected" ]]; then + echo "$_expected" > "$_expected_ip_file" + echo "[$(date '+%H:%M:%S')] baseline IP: $_expected" >> "$_ip_log" + fi + fi + + # Monitoring loop + while true; do + sleep "$_check_interval" + # Exit if proxy config removed (env switch / cac stop) + [[ -f "$_env_dir/proxy" ]] || exit 0 + [[ -f "$CAC_DIR/current" ]] || exit 0 + _cur_name=$(tr -d '[:space:]' < "$CAC_DIR/current") + [[ "$_cur_name" == "$_name" ]] || exit 0 + + _current_ip=$(_get_exit_ip) || continue # network error, retry next cycle + [[ -n "$_current_ip" ]] || continue + + if [[ -n "$_expected" ]] && [[ "$_current_ip" != "$_expected" ]]; then + # IP changed! + echo "[$(date '+%H:%M:%S')] IP CHANGED: $_expected → $_current_ip" >> "$_ip_log" + echo "$_current_ip" > "$_alert_file" + + # macOS notification + if command -v osascript &>/dev/null; then + osascript -e "display notification \"Exit IP changed: $_expected → $_current_ip\" with title \"⚠️ cac IP Alert\" subtitle \"Proxy traffic may be hijacked\"" 2>/dev/null || true + fi + + # Terminal alert (visible in cac statusline) + echo "[cac] ⚠ EXIT IP CHANGED: $_expected → $_current_ip — proxy may be hijacked" >&2 2>/dev/null || true + else + # IP stable, clear any previous alert + rm -f "$_alert_file" + fi + done + ) & + _ip_new_wpid=$! + echo "$_ip_new_wpid" > "$_ip_watchdog_file" + disown "$_ip_new_wpid" + fi +fi + # ── Concurrent session check ── _max_sessions=10 [[ -f "$CAC_DIR/max_sessions" ]] && _ms=$(tr -d '[:space:]' < "$CAC_DIR/max_sessions") && [[ -n "$_ms" ]] && _max_sessions="$_ms" @@ -3061,6 +3153,29 @@ cmd_check() { # Overwrite the "detecting..." line if [[ -n "$proxy_ip" ]]; then printf "\r $(_green "✓") exit IP $(_cyan "$proxy_ip")\033[K\n" + # IP watchdog: compare with expected IP + local expected_ip; expected_ip=$(_read "$env_dir/expected_ip" "") + if [[ -n "$expected_ip" ]]; then + if [[ "$proxy_ip" != "$expected_ip" ]]; then + echo " $(_red "✗") watchdog IP changed! expected $(_cyan "$expected_ip") got $(_red "$proxy_ip")" + problems+=("exit IP mismatch: expected=$expected_ip actual=$proxy_ip") + else + echo " $(_green "✓") watchdog IP stable $(_dim "(monitoring active)")" + fi + else + # First time: save as baseline + echo "$proxy_ip" > "$env_dir/expected_ip" + echo " $(_green "✓") watchdog baseline saved: $(_cyan "$proxy_ip")" + fi + # IP watchdog process status + if [[ -f "$CAC_DIR/ip-watchdog.pid" ]]; then + local _wd_pid; _wd_pid=$(tr -d '[:space:]' < "$CAC_DIR/ip-watchdog.pid") + if [[ -n "$_wd_pid" ]] && kill -0 "$_wd_pid" 2>/dev/null; then + echo " $(_green "✓") ip-monitor running $(_dim "(pid $_wd_pid, every 60s)")" + else + echo " $(_yellow "!") ip-monitor not running $(_dim "(will start on next claude launch)")" + fi + fi # TZ vs exit IP consistency check local env_tz; env_tz=$(_read "$env_dir/tz" "") if [[ -n "$env_tz" ]] && [[ -n "$proxy_ip" ]]; then diff --git a/src/cmd_check.sh b/src/cmd_check.sh index 83478bf..2cdc778 100644 --- a/src/cmd_check.sh +++ b/src/cmd_check.sh @@ -214,6 +214,29 @@ cmd_check() { # Overwrite the "detecting..." line if [[ -n "$proxy_ip" ]]; then printf "\r $(_green "✓") exit IP $(_cyan "$proxy_ip")\033[K\n" + # IP watchdog: compare with expected IP + local expected_ip; expected_ip=$(_read "$env_dir/expected_ip" "") + if [[ -n "$expected_ip" ]]; then + if [[ "$proxy_ip" != "$expected_ip" ]]; then + echo " $(_red "✗") watchdog IP changed! expected $(_cyan "$expected_ip") got $(_red "$proxy_ip")" + problems+=("exit IP mismatch: expected=$expected_ip actual=$proxy_ip") + else + echo " $(_green "✓") watchdog IP stable $(_dim "(monitoring active)")" + fi + else + # First time: save as baseline + echo "$proxy_ip" > "$env_dir/expected_ip" + echo " $(_green "✓") watchdog baseline saved: $(_cyan "$proxy_ip")" + fi + # IP watchdog process status + if [[ -f "$CAC_DIR/ip-watchdog.pid" ]]; then + local _wd_pid; _wd_pid=$(tr -d '[:space:]' < "$CAC_DIR/ip-watchdog.pid") + if [[ -n "$_wd_pid" ]] && kill -0 "$_wd_pid" 2>/dev/null; then + echo " $(_green "✓") ip-monitor running $(_dim "(pid $_wd_pid, every 60s)")" + else + echo " $(_yellow "!") ip-monitor not running $(_dim "(will start on next claude launch)")" + fi + fi # TZ vs exit IP consistency check local env_tz; env_tz=$(_read "$env_dir/tz" "") if [[ -n "$env_tz" ]] && [[ -n "$proxy_ip" ]]; then diff --git a/src/templates.sh b/src/templates.sh index 7d41517..9105334 100644 --- a/src/templates.sh +++ b/src/templates.sh @@ -84,6 +84,16 @@ if [ -n "$worktree_name" ]; then parts+=("$(printf "${cyan}%s${reset}" "$wt")") fi +# IP watchdog status +_cac_dir="$HOME/.cac" +if [ -f "$_cac_dir/ip-alert" ]; then + _alert_ip=$(cat "$_cac_dir/ip-alert" 2>/dev/null | tr -d '[:space:]') + parts+=("$(printf "${red}${bold}IP:${_alert_ip}!${reset}")") +elif [ -f "$_cac_dir/envs/$(cat "$_cac_dir/current" 2>/dev/null | tr -d '[:space:]')/expected_ip" ]; then + _exp_ip=$(cat "$_cac_dir/envs/$(cat "$_cac_dir/current" 2>/dev/null | tr -d '[:space:]')/expected_ip" 2>/dev/null | tr -d '[:space:]') + [ -n "$_exp_ip" ] && parts+=("$(printf "${green}IP:%s${reset}" "$_exp_ip")") +fi + if [ "${#parts[@]}" -eq 0 ]; then printf "${dim}claude${reset}\n"; exit 0; fi sep="$(printf " ${dim}|${reset} ")" result="${parts[0]}" @@ -508,6 +518,88 @@ if [[ -n "$PROXY" ]] && [[ -f "$CAC_DIR/relay.js" ]]; then fi fi +# ── IP watchdog: continuous exit-IP monitoring ── +# Detects if VPN/route changes cause proxy traffic to exit from wrong IP +if [[ -n "$PROXY" ]]; then + _ip_watchdog_file="$CAC_DIR/ip-watchdog.pid" + _ip_wd_running=false + if [[ -f "$_ip_watchdog_file" ]]; then + _ip_wpid=$(tr -d '[:space:]' < "$_ip_watchdog_file") + [[ -n "$_ip_wpid" ]] && kill -0 "$_ip_wpid" 2>/dev/null && _ip_wd_running=true + fi + if [[ "$_ip_wd_running" != "true" ]]; then + ( + trap 'rm -f "$CAC_DIR/ip-watchdog.pid" "$CAC_DIR/ip-alert"' EXIT + set +e + + _ip_urls="https://api.ipify.org https://api.ip.sb/ip https://ip.3322.net https://ipinfo.io/ip" + _check_interval=60 # seconds between checks + _expected_ip_file="$_env_dir/expected_ip" + _alert_file="$CAC_DIR/ip-alert" + _ip_log="$CAC_DIR/ip-watchdog.log" + _proxy_for_check="$PROXY" + # If relay is active, check through relay + [[ -f "$CAC_DIR/relay.port" ]] && _proxy_for_check="http://127.0.0.1:$(tr -d '[:space:]' < "$CAC_DIR/relay.port")" + + _get_exit_ip() { + local _url _ip="" + for _url in $_ip_urls; do + _ip=$(curl -s --proxy "$_proxy_for_check" --connect-timeout 5 --max-time 8 "$_url" 2>/dev/null || true) + [[ "$_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] && { echo "$_ip"; return 0; } + done + return 1 + } + + # First check: establish expected IP + sleep 5 # wait for relay/proxy to stabilize + _expected="" + if [[ -f "$_expected_ip_file" ]]; then + _expected=$(tr -d '[:space:]' < "$_expected_ip_file") + fi + if [[ -z "$_expected" ]]; then + _expected=$(_get_exit_ip) || true + if [[ -n "$_expected" ]]; then + echo "$_expected" > "$_expected_ip_file" + echo "[$(date '+%H:%M:%S')] baseline IP: $_expected" >> "$_ip_log" + fi + fi + + # Monitoring loop + while true; do + sleep "$_check_interval" + # Exit if proxy config removed (env switch / cac stop) + [[ -f "$_env_dir/proxy" ]] || exit 0 + [[ -f "$CAC_DIR/current" ]] || exit 0 + _cur_name=$(tr -d '[:space:]' < "$CAC_DIR/current") + [[ "$_cur_name" == "$_name" ]] || exit 0 + + _current_ip=$(_get_exit_ip) || continue # network error, retry next cycle + [[ -n "$_current_ip" ]] || continue + + if [[ -n "$_expected" ]] && [[ "$_current_ip" != "$_expected" ]]; then + # IP changed! + echo "[$(date '+%H:%M:%S')] IP CHANGED: $_expected → $_current_ip" >> "$_ip_log" + echo "$_current_ip" > "$_alert_file" + + # macOS notification + if command -v osascript &>/dev/null; then + osascript -e "display notification \"Exit IP changed: $_expected → $_current_ip\" with title \"⚠️ cac IP Alert\" subtitle \"Proxy traffic may be hijacked\"" 2>/dev/null || true + fi + + # Terminal alert (visible in cac statusline) + echo "[cac] ⚠ EXIT IP CHANGED: $_expected → $_current_ip — proxy may be hijacked" >&2 2>/dev/null || true + else + # IP stable, clear any previous alert + rm -f "$_alert_file" + fi + done + ) & + _ip_new_wpid=$! + echo "$_ip_new_wpid" > "$_ip_watchdog_file" + disown "$_ip_new_wpid" + fi +fi + # ── Concurrent session check ── _max_sessions=10 [[ -f "$CAC_DIR/max_sessions" ]] && _ms=$(tr -d '[:space:]' < "$CAC_DIR/max_sessions") && [[ -n "$_ms" ]] && _max_sessions="$_ms"