diff --git a/README.md b/README.md index 9827057..5307a8f 100644 --- a/README.md +++ b/README.md @@ -95,16 +95,37 @@ cpxy watch once # 手动跑一轮检查 ## 自动探测逻辑(摘要) -按分数选择,优先: +顺序如下,前一步命中即返回: -1. 驱动像蜂窝/USB 拨号:`qmi_wwan`、`cdc_mbim`、`cdc_ether`、`rndis_host` 等 -2. 名称含:`wwan` / `wwp` / `usb` / `enx` / `ppp` / `rmnet` / `ccmni` … +0. **ModemManager bearer**(`mmcli -b` 报的 `interface:`)—— 最权威,优先采用 +1. 驱动像蜂窝/USB 拨号:`qmi_wwan`、`cdc_mbim`、`cdc_ether`、`rndis_host`、`bam-dmux`、`rmnet*` 等(+100) +2. 名称含 `wwan` / `wwp` / `ppp` / `rmnet` / `ccmni` …(+40,弱启发式) 3. **不是** 系统默认路由网卡(默认通常是 WiFi) 4. 多默认路由时取次要默认口 5. 回退:任意非默认且有 IPv4 的物理网卡 探测失败会直接报错并提示用 `--iface`。 +### 绝不绑定 USB gadget 网卡 + +设备把自己当 USB 网卡挂给上游主机时会出现 `usb0` / `rndis0`(驱动 `configfs-gadget`、 +`g_ether` 等)。那是「本机 → PC」的下行链路,一旦被当成代理出口,所有出站都会 +`context deadline exceeded`,SOCKS5 客户端看到的就是 `socks connect failed rep=0x01`。 + +因此: + +- `iface_is_usb_gadget()` 按驱动名与 gadget 总线路径识别这类接口,打分阶段直接跳过; +- 默认探测关键词不再包含 `usb` / `enx`,升级时会自动从已有 `settings.conf` 里剔除; +- 即使手工把 `CELLULAR_IFACE=usb0` 写进配置或用 `--iface usb0`,`detect` / `generate` / + `install` / `upgrade` / `watch` 都会拒绝并改用 ModemManager 的结果; +- 真正的 USB 上行模组(`rndis_host`、`cdc_*`、`qmi_wwan`)是 host 侧驱动,不受影响。 + +回归测试(不需要真机,纯离线,覆盖 18 条断言): + +```bash +bash tests/detect-gadget.sh +``` + ## 手动安装 ```bash diff --git a/config/settings.conf.example b/config/settings.conf.example index 3a58d3b..5f7ca84 100644 --- a/config/settings.conf.example +++ b/config/settings.conf.example @@ -15,7 +15,10 @@ INSTALL_DIR=/opt/cellular-proxy LOG_DIR=/var/log/cellular-proxy # ---- 数据网卡(代理出口强制绑定)---- -# 留空则自动探测 wwan/usb/enx 等;建议手动填准 +# 留空则自动探测(优先 ModemManager bearer,其次驱动/名称打分);建议手动填准 +# 注意:不要填本机对上游主机暴露的 USB gadget 网卡(常见名 usb0/rndis0,驱动 +# configfs-gadget/g_ether)。那是「本机 -> PC」的下行链路,绑上去所有出站都会超时, +# SOCKS5 会返回 rep=0x01。脚本会拒绝这类网卡并自动重新探测。 CELLULAR_IFACE= # 元数据:数据侧当前源 IPv4(generate/watch 会更新;默认不写入 sing-box) CELLULAR_SOURCE_IP= @@ -25,8 +28,9 @@ CELLULAR_SOURCE_IP= BIND_SOURCE_IP=false # 数据网卡不存在则不启动(推荐 true,避免代理静默从 WiFi 出) REQUIRE_CELLULAR_IFACE=true -# 自动探测关键词(逗号分隔) -CELLULAR_IFACE_PATTERNS=wwan,wwp,usb,enx,ppp,cdc +# 自动探测关键词(逗号分隔)。仅作弱启发式,权重低于驱动判定。 +# 刻意不含 usb/enx:会命中 USB gadget 网卡 usb0,导致出口绑到 PC 侧链路。 +CELLULAR_IFACE_PATTERNS=wwan,wwp,ppp,cdc,rmnet,ccmni,mbim,qmi # 后台监控(推荐开):网卡改名/掉线/无 IPv4 时自动 rebind;默认开启 long-running watch ENABLE_CELLULAR_WATCH=true # watch 轮询兜底间隔秒(事件驱动 ip monitor + ModemManager 日志为主) diff --git a/install.sh b/install.sh index fe4c120..fe6d9f4 100755 --- a/install.sh +++ b/install.sh @@ -232,17 +232,36 @@ prepare_settings() { # shellcheck source=/dev/null source "$src/scripts/lib.sh" - CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,usb,enx,ppp,cdc,rmnet,ccmni,mbim,qmi}" + CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,ppp,cdc,rmnet,ccmni,mbim,qmi}" REQUIRE_CELLULAR_IFACE=false + # 迁移旧配置:usb/enx 关键词会命中本机的 USB gadget 网卡(usb0),必须剔除 + local old_patterns cleaned + old_patterns="$(awk -F= '/^CELLULAR_IFACE_PATTERNS=/{print substr($0,index($0,"=")+1); exit}' "$conf" 2>/dev/null || true)" + if [[ -n "$old_patterns" ]]; then + cleaned="$(printf '%s' "$old_patterns" | tr ',' '\n' | sed 's/[[:space:]]//g' \ + | grep -vxE 'usb|enx' | paste -sd, - 2>/dev/null || true)" + if [[ -n "$cleaned" && "$cleaned" != "$old_patterns" ]]; then + set_kv "$conf" CELLULAR_IFACE_PATTERNS "$cleaned" + CELLULAR_IFACE_PATTERNS="$cleaned" + warn "已清理 CELLULAR_IFACE_PATTERNS: $old_patterns -> $cleaned" + fi + fi + local cell="" if [[ -n "$OPT_IFACE" ]]; then cell="$OPT_IFACE" info "使用 --iface $cell" + if iface_is_usb_gadget "$cell"; then + die "--iface $cell 是本机对上游主机暴露的 USB gadget 网卡,不能作为数据出口" + fi elif [[ -f "$live" ]] && grep -qE '^CELLULAR_IFACE=.' "$live" 2>/dev/null; then # shellcheck disable=SC1090 cell="$(awk -F= '/^CELLULAR_IFACE=/{print substr($0,index($0,"=")+1); exit}' "$live")" - if [[ -n "$cell" ]] && iface_exists "$cell"; then + if [[ -n "$cell" ]] && iface_is_usb_gadget "$cell"; then + warn "已有配置里的 $cell 是 USB gadget 网卡(面向上游主机),丢弃并重新探测" + cell="" + elif [[ -n "$cell" ]] && iface_exists "$cell"; then info "沿用已绑定网卡: $cell" else cell="" @@ -268,6 +287,10 @@ prepare_settings() { die "网卡不存在: $cell" fi + if iface_is_usb_gadget "$cell"; then + die "拒绝把 USB gadget 网卡 $cell 写成数据出口,请用 --iface wwanX 指定真实数据网卡" + fi + local src_ip src_ip="$(detect_source_ip "$cell" || true)" set_kv "$conf" CELLULAR_IFACE "$cell" diff --git a/scripts/detect.sh b/scripts/detect.sh index e6bdd79..81c8809 100755 --- a/scripts/detect.sh +++ b/scripts/detect.sh @@ -6,7 +6,7 @@ source "$ROOT_DIR/scripts/lib.sh" if [[ -f "${CONFIG_FILE:-$ROOT_DIR/config/settings.conf}" ]]; then load_config else - CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,usb,enx,ppp,cdc,rmnet,ccmni,mbim,qmi}" + CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,ppp,cdc,rmnet,ccmni,mbim,qmi}" REQUIRE_CELLULAR_IFACE=false CELLULAR_IFACE= fi @@ -24,9 +24,13 @@ printf '%-12s %-10s %-18s %-12s %s\n' "IFACE" "STATE" "IPv4" "DRIVER" "SCORE" default_if="$(detect_default_iface || true)" while IFS='|' read -r name state ip; do [[ -z "$name" ]] && continue + drv="$(iface_driver "$name" || true)" + if iface_is_usb_gadget "$name"; then + printf '%-12s %-10s %-18s %-12s %s\n' "$name" "$state" "${ip:-}" "${drv:-}" "skip(USB gadget: 面向上游主机,不可作出口)" + continue + fi is_virtual_or_skip_iface "$name" && continue sc="$(score_iface_as_cellular "$name" "$default_if")" - drv="$(iface_driver "$name" || true)" printf '%-12s %-10s %-18s %-12s %s\n' "$name" "$state" "${ip:-}" "${drv:-}" "$sc" done < <(list_ifaces) echo diff --git a/scripts/generate.sh b/scripts/generate.sh index d6396c7..8d3b296 100755 --- a/scripts/generate.sh +++ b/scripts/generate.sh @@ -14,6 +14,19 @@ if [[ -z "$cell" ]]; then resolve_cellular >/dev/null || true fi +# 最后一道闸:绝不把 USB gadget 网卡(本机 -> 上游主机的 RNDIS 链路)写成代理出口。 +# 一旦写错,所有出站连接都会绑到只通向 PC 的接口上,SOCKS 返回 rep=0x01。 +if [[ -n "$cell" ]] && iface_is_usb_gadget "$cell"; then + warn "拒绝使用 USB gadget 网卡 $cell 作为数据出口,重新探测" + cell="$(detect_cellular_iface_via_mm 2>/dev/null || true)" + if [[ -z "$cell" ]]; then + die "无法确定数据网卡(唯一候选是 USB gadget 网卡)。请设置 CELLULAR_IFACE=wwanX" + fi + info "改用 $cell" + persist_cellular_to_settings "${CONFIG_FILE:-$ROOT_DIR/etc/settings.conf}" "$cell" "$(detect_source_ip "$cell" || true)" || true + CELLULAR_IFACE="$cell" +fi + # 绑定策略(优先更好的实现,而不是只靠定时器修 stale IP): # 默认 BIND_SOURCE_IP=false → 只写 bind_interface,不写 inet4_bind_address。 # sing-box 1.11+ 在 wwan 重拨后仍能按网卡出口,无需锁定私网源 IP。 diff --git a/scripts/install.sh b/scripts/install.sh index 5c81713..2c2dc14 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -20,12 +20,19 @@ info "1/6 自动探测并绑定数据网卡" "$ROOT_DIR/scripts/detect.sh" || true cell="" -if [[ -n "${CELLULAR_IFACE:-}" ]] && iface_exists "$CELLULAR_IFACE"; then +if [[ -n "${CELLULAR_IFACE:-}" ]] && iface_is_usb_gadget "$CELLULAR_IFACE"; then + warn "配置的 CELLULAR_IFACE=$CELLULAR_IFACE 是 USB gadget 网卡(面向上游主机),忽略并重新探测" + cell="" +elif [[ -n "${CELLULAR_IFACE:-}" ]] && iface_exists "$CELLULAR_IFACE"; then cell="$CELLULAR_IFACE" info "使用配置的数据网卡: $cell" -elif [[ "$AUTO_DETECT" == "true" ]]; then +fi +if [[ -z "$cell" && "$AUTO_DETECT" == "true" ]]; then cell="$(detect_cellular_iface "" || true)" fi +if [[ -n "$cell" ]] && iface_is_usb_gadget "$cell"; then + die "拒绝把 USB gadget 网卡 $cell 写成数据出口,请手动设置 CELLULAR_IFACE=wwanX" +fi if [[ -z "$cell" ]]; then die "未能自动探测数据网卡。 @@ -119,9 +126,12 @@ case "$cmd" in # shellcheck source=/dev/null source "$BASE/scripts/lib.sh" load_config - if [[ -z "${CELLULAR_IFACE:-}" ]] || ! iface_exists "${CELLULAR_IFACE:-}"; then + if [[ -z "${CELLULAR_IFACE:-}" ]] || ! iface_exists "${CELLULAR_IFACE:-}" || iface_is_usb_gadget "${CELLULAR_IFACE:-}"; then + if [[ -n "${CELLULAR_IFACE:-}" ]] && iface_is_usb_gadget "${CELLULAR_IFACE:-}"; then + warn "CELLULAR_IFACE=$CELLULAR_IFACE 是 USB gadget 网卡,重新探测" + fi cell="$(detect_cellular_iface "" || true)" - if [[ -n "$cell" ]]; then + if [[ -n "$cell" ]] && ! iface_is_usb_gadget "$cell"; then src="$(detect_source_ip "$cell" || true)" persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src" CELLULAR_IFACE="$cell" @@ -149,6 +159,7 @@ case "$cmd" in load_config cell="$(detect_cellular_iface "" || true)" [[ -n "$cell" ]] || die "未能探测数据网卡" + ! iface_is_usb_gadget "$cell" || die "探测到的 $cell 是 USB gadget 网卡,不能作为数据出口" src="$(detect_source_ip "$cell" || true)" persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src" info "已重新绑定 $cell src=${src:-}" diff --git a/scripts/lib.sh b/scripts/lib.sh index 89ecb1d..771ba9c 100755 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -33,7 +33,7 @@ load_config() { CELLULAR_SOURCE_IP="${CELLULAR_SOURCE_IP:-}" BIND_SOURCE_IP="${BIND_SOURCE_IP:-false}" REQUIRE_CELLULAR_IFACE="${REQUIRE_CELLULAR_IFACE:-true}" - CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,usb,enx,ppp,cdc,rmnet,ccmni,mbim,qmi}" + CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,ppp,cdc,rmnet,ccmni,mbim,qmi}" ENABLE_CELLULAR_WATCH="${ENABLE_CELLULAR_WATCH:-true}" WATCH_INTERVAL_SEC="${WATCH_INTERVAL_SEC:-60}" WATCH_MM_EVENTS="${WATCH_MM_EVENTS:-true}" @@ -77,6 +77,34 @@ iface_exists() { [[ -n "$ifc" ]] && [[ -d "/sys/class/net/$ifc" ]] } +# 识别「本机当 USB 设备端」的 gadget 网卡(usb0/rndis0 等)。 +# 这类接口是本机 → 上游主机(PC/路由)的下行链路,绑上去所有出站都会失败, +# sing-box 表现为 open outbound connection: context deadline exceeded,SOCKS 回 rep=0x01。 +# 注意与真正的 USB 上行模组区分:qmi_wwan / cdc_* / rndis_host 是 host 侧驱动,可作出口。 +iface_is_usb_gadget() { + local ifc="$1" drv dev + [[ -n "$ifc" ]] || return 1 + drv="$(iface_driver "$ifc" 2>/dev/null || true)" + case "${drv,,}" in + configfs-gadget*|g_ether*|g_ncm*|g_ether|g_multi*|g_cdc*|gadget*|usb_f_*) + return 0 + ;; + esac + # configfs 组合设备的 device 实体挂在 gadget 总线下 + dev="$(readlink -f "/sys/class/net/$ifc/device" 2>/dev/null || true)" + if [[ -n "$dev" && "$dev" == *"/gadget"* ]]; then + return 0 + fi + if [[ -d "/sys/class/net/$ifc/device" ]]; then + local sub + sub="$(readlink -f "/sys/class/net/$ifc/device/subsystem" 2>/dev/null || true)" + if [[ "$(basename "${sub:-}")" == "gadget" ]]; then + return 0 + fi + fi + return 1 +} + is_virtual_or_skip_iface() { local name="$1" case "$name" in @@ -88,6 +116,10 @@ is_virtual_or_skip_iface() { if [[ -d "/sys/class/net/$name/bridge" ]]; then return 0 fi + # 本机作为 USB gadget 暴露给上游主机的链路(RNDIS/NCM/ECM),只通向 PC,不是数据出口 + if iface_is_usb_gadget "$name"; then + return 0 + fi return 1 } @@ -127,8 +159,10 @@ iface_driver() { iface_looks_cellular_by_driver() { local ifc="$1" drv drv="$(iface_driver "$ifc" || true)" + # gadget 网卡即使名字像 usb0 也不算蜂窝 + iface_is_usb_gadget "$ifc" && return 1 case "${drv,,}" in - qmi_wwan|cdc_mbim|cdc_ncm|cdc_ether|cdc_wdm|option|huawei_cdc_ncm|rndis_host|GobiNet|GobiSerial|simcom*|rmnet*|mhi_net|ipa) + qmi_wwan|cdc_mbim|cdc_ncm|cdc_ether|cdc_wdm|option|huawei_cdc_ncm|rndis_host|GobiNet|GobiSerial|simcom*|rmnet*|mhi_net|ipa|bam-dmux|bam_dmux|qcom-ipa|ipa_wan) return 0 ;; esac @@ -137,7 +171,9 @@ iface_looks_cellular_by_driver() { iface_matches_patterns() { local name="$1" p - local patterns="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,usb,enx,ppp,cdc,rmnet,ccmni,mbim,qmi}" + # 默认不含 usb/enx:本机的 USB gadget 链路也叫 usb0,靠名字猜会把出口绑到 PC 侧。 + # 真正的 USB 上行模组由 iface_looks_cellular_by_driver(rndis_host/cdc_*/qmi_wwan)识别。 + local patterns="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,ppp,cdc,rmnet,ccmni,mbim,qmi}" IFS=',' read -r -a arr <<< "$patterns" for p in "${arr[@]}"; do p="${p// /}" @@ -149,6 +185,23 @@ iface_matches_patterns() { return 1 } +# 最权威的来源:ModemManager 自己报的 bearer 网卡名。 +# 比名称/驱动启发式打分更可靠,能彻底避开 usb0 这类同分误选。 +detect_cellular_iface_via_mm() { + local m b ifc + command -v mmcli >/dev/null 2>&1 || return 1 + for m in $(mmcli -L 2>/dev/null | grep -oE 'Modem/[0-9]+' | grep -oE '[0-9]+$'); do + for b in $(mmcli -m "$m" 2>/dev/null | grep -oE 'Bearer/[0-9]+' | grep -oE '[0-9]+$'); do + ifc="$(mmcli -b "$b" 2>/dev/null | sed -n 's/.*interface:[[:space:]]*\([A-Za-z0-9._-]\{1,\}\).*/\1/p' | head -1)" + if [[ -n "$ifc" ]] && iface_exists "$ifc" && ! iface_is_usb_gadget "$ifc"; then + echo "$ifc" + return 0 + fi + done + done + return 1 +} + # 多默认路由时,metric 更大的往往是数据网(WiFi metric 更小优先) detect_secondary_default_iface() { local primary secondary @@ -186,9 +239,9 @@ score_iface_as_cellular() { ip="$(ip -4 -o addr show dev "$name" 2>/dev/null | awk '{print $4}' | head -1 | cut -d/ -f1)" drv="$(iface_driver "$name" || true)" - # 名称 - if iface_matches_patterns "$name"; then score=$((score + 80)); fi - # 驱动 + # 名称(仅启发式,权重必须低于驱动判定,避免 usb0 与 wwan0 打成平手) + if iface_matches_patterns "$name"; then score=$((score + 40)); fi + # 驱动(强证据) if iface_looks_cellular_by_driver "$name"; then score=$((score + 100)); fi # 非默认网卡(关键:系统默认走 WiFi) if [[ -n "$default_if" && "$name" != "$default_if" ]]; then score=$((score + 40)); fi @@ -209,7 +262,8 @@ score_iface_as_cellular() { echo "$score" } -# 自动探测数据网卡:配置 > 名称/驱动 > 次默认路由 > 非默认有 IP 物理口 +# 自动探测数据网卡:ModemManager bearer > 配置 > 名称/驱动打分 > 次默认路由 > 非默认有 IP 物理口 +# 任何一步都不允许返回 USB gadget 网卡 detect_cellular_iface() { local configured="${1:-}" local default_if name state ip best_name="" best_score=0 score @@ -217,6 +271,8 @@ detect_cellular_iface() { if [[ -n "$configured" ]]; then if [[ "$configured" == "lo" ]]; then warn "CELLULAR_IFACE=lo 无效,将尝试自动探测" + elif iface_is_usb_gadget "$configured"; then + warn "CELLULAR_IFACE=$configured 是本机对上游主机暴露的 USB gadget 网卡,不能作为数据出口,将重新探测" elif iface_exists "$configured"; then echo "$configured" return 0 @@ -230,6 +286,15 @@ detect_cellular_iface() { default_if="$(detect_default_iface || true)" + # 0) ModemManager bearer 是权威答案,优先采用 + if name="$(detect_cellular_iface_via_mm 2>/dev/null || true)"; then + if [[ -n "$name" ]]; then + info "按 ModemManager bearer 确定数据网卡: $name" + echo "$name" + return 0 + fi + fi + # 1) 按评分扫描全部接口 while IFS='|' read -r name state ip; do [[ -z "$name" ]] && continue @@ -250,7 +315,7 @@ detect_cellular_iface() { # 2) 多默认路由的次要口 if name="$(detect_secondary_default_iface 2>/dev/null || true)"; then - if [[ -n "$name" ]] && iface_exists "$name"; then + if [[ -n "$name" ]] && iface_exists "$name" && ! iface_is_usb_gadget "$name"; then info "根据次要默认路由探测数据网卡: $name" echo "$name" return 0 @@ -335,5 +400,19 @@ resolve_cellular() { warn "未找到数据网卡" cell="" fi + # 最后一道闸:探测链路上任何环节都不得交出 gadget 网卡 + if [[ -n "$cell" ]] && iface_is_usb_gadget "$cell"; then + warn "拒绝把 USB gadget 网卡 $cell 当作数据出口,改问 ModemManager" + cell="$(detect_cellular_iface_via_mm 2>/dev/null || true)" + if [[ -z "$cell" ]]; then + if [[ "$REQUIRE_CELLULAR_IFACE" == "true" ]]; then + die "唯一候选是 USB gadget 网卡,无法确定数据出口。请设置 CELLULAR_IFACE=wwanX" + fi + warn "未找到可用数据网卡" + cell="" + else + info "改用 $cell" + fi + fi echo "$cell" } diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index 78ae398..dd62b8a 100755 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -82,6 +82,33 @@ info "======== cellular-proxy 增量升级 ========" info "安装目录: $INSTALL_DIR" info "配置文件: $CONFIG_FILE(将保留密钥/网卡/账密)" +# --- 迁移:老配置里的 usb/enx 关键词会把 USB gadget 网卡(usb0)当数据出口 --- +# 升级保留 settings.conf,因此必须在这里就地清理,否则旧机器升级后仍会误绑。 +migrate_iface_patterns() { + local conf="$1" cur cleaned tmp + [[ -f "$conf" ]] || return 0 + cur="$(awk -F= '/^CELLULAR_IFACE_PATTERNS=/{print substr($0,index($0,"=")+1); exit}' "$conf" 2>/dev/null || true)" + [[ -n "$cur" ]] || return 0 + cleaned="$(printf '%s' "$cur" | tr ',' '\n' | sed 's/[[:space:]]//g' \ + | grep -vxE 'usb|enx' | paste -sd, - 2>/dev/null || true)" + [[ -n "$cleaned" && "$cleaned" != "$cur" ]] || return 0 + tmp="$(mktemp)" + awk -v v="$cleaned" ' + /^CELLULAR_IFACE_PATTERNS=/ { print "CELLULAR_IFACE_PATTERNS=" v; next } + { print } + ' "$conf" > "$tmp" + mv "$tmp" "$conf" + warn "已从 CELLULAR_IFACE_PATTERNS 移除 usb/enx(会误命中 USB gadget 网卡): $cur -> $cleaned" + CELLULAR_IFACE_PATTERNS="$cleaned" +} +migrate_iface_patterns "$CONFIG_FILE" + +# 迁移:已写死 gadget 网卡的旧配置,清空后走重新探测 +if [[ -n "${CELLULAR_IFACE:-}" ]] && iface_is_usb_gadget "${CELLULAR_IFACE:-}"; then + warn "旧配置 CELLULAR_IFACE=$CELLULAR_IFACE 是 USB gadget 网卡,将重新探测数据出口" + CELLULAR_IFACE="" +fi + ensure_dirs mkdir -p /var/lib/cellular-proxy "$INSTALL_DIR/bin" "$INSTALL_DIR/etc" "$INSTALL_DIR/ui" "$INSTALL_DIR/scripts" "$INSTALL_DIR/generated" @@ -104,6 +131,7 @@ if [[ "$DO_REBIND" == "true" ]]; then info "重新探测数据网卡…" cell="$(detect_cellular_iface "" || true)" [[ -n "$cell" ]] || die "未能探测数据网卡" + ! iface_is_usb_gadget "$cell" || die "探测到的 $cell 是 USB gadget 网卡(面向上游主机),不能作为数据出口" src="$(detect_source_ip "$cell" || true)" persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src" load_config @@ -113,10 +141,14 @@ if [[ "$DO_REBIND" == "true" ]]; then fi cell="${CELLULAR_IFACE:-}" -if [[ -z "$cell" ]] || ! iface_exists "$cell"; then - warn "配置中的 CELLULAR_IFACE=${cell:-空} 无效,尝试自动探测(仅本次)" +if [[ -z "$cell" ]] || ! iface_exists "$cell" || iface_is_usb_gadget "$cell"; then + if [[ -n "$cell" ]] && iface_is_usb_gadget "$cell"; then + warn "配置中的 CELLULAR_IFACE=$cell 是 USB gadget 网卡,不能作为出口,重新探测" + else + warn "配置中的 CELLULAR_IFACE=${cell:-空} 无效,尝试自动探测(仅本次)" + fi cell="$(detect_cellular_iface "" || true)" - if [[ -n "$cell" ]]; then + if [[ -n "$cell" ]] && ! iface_is_usb_gadget "$cell"; then src="$(detect_source_ip "$cell" || true)" persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src" load_config @@ -223,9 +255,12 @@ case "$cmd" in # shellcheck source=/dev/null source "$BASE/scripts/lib.sh" load_config - if [[ -z "${CELLULAR_IFACE:-}" ]] || ! iface_exists "${CELLULAR_IFACE:-}"; then + if [[ -z "${CELLULAR_IFACE:-}" ]] || ! iface_exists "${CELLULAR_IFACE:-}" || iface_is_usb_gadget "${CELLULAR_IFACE:-}"; then + if [[ -n "${CELLULAR_IFACE:-}" ]] && iface_is_usb_gadget "${CELLULAR_IFACE:-}"; then + warn "CELLULAR_IFACE=$CELLULAR_IFACE 是 USB gadget 网卡,重新探测" + fi cell="$(detect_cellular_iface "" || true)" - if [[ -n "$cell" ]]; then + if [[ -n "$cell" ]] && ! iface_is_usb_gadget "$cell"; then src="$(detect_source_ip "$cell" || true)" persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src" CELLULAR_IFACE="$cell" @@ -254,6 +289,7 @@ case "$cmd" in load_config cell="$(detect_cellular_iface "" || true)" [[ -n "$cell" ]] || die "未能探测数据网卡" + ! iface_is_usb_gadget "$cell" || die "探测到的 $cell 是 USB gadget 网卡,不能作为数据出口" src="$(detect_source_ip "$cell" || true)" persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src" info "已重新绑定 $cell src=${src:-}" diff --git a/scripts/verify.sh b/scripts/verify.sh index 9cae7fa..c819dbc 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -38,6 +38,32 @@ case "${QUICK,,}" in *) QUICK=false ;; esac +# 出口一旦绑成 USB gadget 网卡,所有代理请求都会超时(客户端看到 SOCKS rep=0x01)。 +# 在打网络探测之前先点名,免得只剩一个含糊的 FAIL。 +cfg_json="${INSTALL_DIR:-/opt/cellular-proxy}/etc/config.json" +cfg_bind="" +if command -v python3 >/dev/null 2>&1 && [[ -f "$cfg_json" ]]; then + cfg_bind="$(python3 -c ' +import json, sys +try: + cfg = json.load(open(sys.argv[1])) + for o in cfg.get("outbounds") or []: + if o.get("tag") == "cellular": + print(o.get("bind_interface") or "") + break +except Exception: + pass +' "$cfg_json" 2>/dev/null || true)" +fi +for _ifc in "${CELLULAR_IFACE:-}" "$cfg_bind"; do + [[ -n "$_ifc" ]] || continue + if iface_is_usb_gadget "$_ifc"; then + err "出口绑在 USB gadget 网卡 $_ifc 上(那是本机 -> 上游主机的下行链路),代理必定超时" + err "修复: sudo cpxy rebind && sudo cpxy generate" + exit 4 + fi +done + curl_ip() { # 单次探测,严格超时,绝不无限挂起 local extra=("$@") diff --git a/scripts/watch-cellular.sh b/scripts/watch-cellular.sh index 703137a..8ec79cb 100644 --- a/scripts/watch-cellular.sh +++ b/scripts/watch-cellular.sh @@ -255,6 +255,16 @@ need_refresh() { return 0 fi + if iface_is_usb_gadget "$cell"; then + logw "CELLULAR_IFACE=$cell is a USB gadget link (host-facing), never a data egress → rebind" + return 0 + fi + + if [[ -n "$cfg_iface" ]] && iface_is_usb_gadget "$cfg_iface"; then + logw "config bind_interface=$cfg_iface is a USB gadget link → rebind" + return 0 + fi + live_src="$(detect_source_ip "$cell" || true)" if [[ -z "$live_src" ]]; then # 网卡在但无 IPv4:可能刚重拨;不立刻 die,等下一轮或 MM 恢复路径 @@ -290,10 +300,13 @@ do_refresh() { local src="" local reason="${1:-manual}" - if [[ -z "$cell" ]] || ! iface_exists "$cell"; then + if [[ -z "$cell" ]] || ! iface_exists "$cell" || iface_is_usb_gadget "$cell"; then + if [[ -n "$cell" ]] && iface_is_usb_gadget "$cell"; then + logw "refuse USB gadget iface $cell as egress; re-detecting" + fi cell="$(detect_cellular_iface "" || true)" - if [[ -z "$cell" ]]; then - logw "detect failed; skip reason=$reason" + if [[ -z "$cell" ]] || iface_is_usb_gadget "$cell"; then + logw "detect failed or gadget-only; skip reason=$reason" return 1 fi fi diff --git a/tests/detect-gadget.sh b/tests/detect-gadget.sh new file mode 100755 index 0000000..ee2932a --- /dev/null +++ b/tests/detect-gadget.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# 离线单元测试:验证 cellular-proxy 的探测链路永不选中 USB gadget 网卡。 +# 用法: bash tests/detect-gadget.sh [repo路径] +# 不依赖真实 /sys,靠覆盖探针函数模拟设备现场(usb0=configfs-gadget.g1, wwan0=bam-dmux)。 + +REPO="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +LIB="$REPO/scripts/lib.sh" +[[ -f "$LIB" ]] || { echo "找不到 $LIB"; exit 1; } + +PASS=0 +FAIL=0 + +check_eq() { + local name="$1" want="$2" got="$3" + if [[ "$want" == "$got" ]]; then + PASS=$((PASS+1)); printf 'ok %s\n' "$name" + else + FAIL=$((FAIL+1)); printf 'FAIL %s (want=%s got=%s)\n' "$name" "$want" "$got" + fi +} + +# scenario +# 在子 shell 里 source lib.sh,替换所有触碰真实系统的探针,然后 eval 代码。 +scenario() { + ( + FAKE_IFACES="$1" + FAKE_DRIVERS="$2" + FAKE_IPS="$3" + FAKE_DEFAULT="$4" + FAKE_MM_IFACE="$5" + FAKE_HAVE_MMCLI="$6" + CODE="$7" + + # shellcheck disable=SC1090 + source "$LIB" >/dev/null 2>&1 + set +eu + + lookup() { + local table="$1" key="$2" pair + for pair in $table; do + if [[ "${pair%%=*}" == "$key" ]]; then printf '%s' "${pair#*=}"; return 0; fi + done + return 1 + } + + iface_exists() { + local i + for i in $FAKE_IFACES; do [[ "$i" == "$1" ]] && return 0; done + return 1 + } + iface_driver() { lookup "$FAKE_DRIVERS" "$1" || true; } + iface_has_carrier() { iface_exists "$1"; } + detect_default_iface() { printf '%s' "$FAKE_DEFAULT"; } + list_ifaces() { + local i ip4 + for i in $FAKE_IFACES; do + ip4="$(lookup "$FAKE_IPS" "$i" || true)" + printf '%s|up|%s\n' "$i" "$ip4" + done + } + # 只需支持 ip -4 -o addr show dev X;ip route show default 一律返回空 + ip() { + local a prev="" dev="" v + for a in "$@"; do + [[ "$prev" == "dev" ]] && dev="$a" + prev="$a" + done + [[ -n "$dev" ]] || return 0 + v="$(lookup "$FAKE_IPS" "$dev" || true)" + [[ -n "$v" ]] && printf '1: %s inet %s scope global %s\n' "$dev" "$v" "$dev" + return 0 + } + + if [[ "$FAKE_HAVE_MMCLI" == "yes" ]]; then + mmcli() { + case "$1" in + -L) echo " /org/freedesktop/ModemManager1/Modem/0 [QUECTEL] EC20" ;; + -m) echo " Bearer | paths: /org/freedesktop/ModemManager1/Bearer/0" ;; + -b) printf ' IPv4 configuration | interface: %s\n' "$FAKE_MM_IFACE" ;; + esac + return 0 + } + else + command() { + if [[ "$1" == "-v" && "$2" == "mmcli" ]]; then return 1; fi + builtin command "$@" + } + fi + + eval "$CODE" + ) 2>/dev/null +} + +IF_REAL="usb0 wlan0 wwan0" +DRV_REAL="usb0=configfs-gadget.g1 wwan0=bam-dmux wlan0=brcmfmac" +IPS_REAL="usb0=192.168.68.1/24 wwan0=10.2.8.250/30 wlan0=192.168.1.23/24" + +real() { scenario "$IF_REAL" "$DRV_REAL" "$IPS_REAL" wlan0 "${2:-}" "${3:-no}" "$1"; } + +check_eq "usb0 识别为 USB gadget" gadget \ + "$(real 'iface_is_usb_gadget usb0 && echo gadget || echo no')" +check_eq "wwan0 不被误判为 gadget" no \ + "$(real 'iface_is_usb_gadget wwan0 && echo gadget || echo no')" +check_eq "usb0 进入 skip 列表" skip \ + "$(real 'is_virtual_or_skip_iface usb0 && echo skip || echo keep')" +check_eq "bam-dmux 命中蜂窝驱动白名单" cell \ + "$(real 'iface_looks_cellular_by_driver wwan0 && echo cell || echo no')" +check_eq "gadget 驱动不算蜂窝" no \ + "$(real 'iface_looks_cellular_by_driver usb0 && echo cell || echo no')" +check_eq "usb0 评分为 0" 0 \ + "$(real 'score_iface_as_cellular usb0 wlan0')" +check_eq "wwan0 评分达到置信阈值" high \ + "$(real 's=$(score_iface_as_cellular wwan0 wlan0); [[ $s -ge 70 ]] && echo high || echo low:$s')" +check_eq "无 mmcli 时自动探测得 wwan0" wwan0 \ + "$(real 'detect_cellular_iface ""')" +check_eq "配置写死 usb0 被纠回 wwan0" wwan0 \ + "$(real 'detect_cellular_iface usb0')" +check_eq "旧 patterns 含 usb/enx 仍选 wwan0" wwan0 \ + "$(real 'CELLULAR_IFACE_PATTERNS="wwan,usb,enx,cdc"; detect_cellular_iface ""')" +check_eq "ModemManager bearer 优先采用" wwan0 \ + "$(real 'detect_cellular_iface ""' wwan0 yes)" +check_eq "ModemManager 报 gadget 时被忽略" wwan0 \ + "$(real 'detect_cellular_iface ""' usb0 yes)" +check_eq "resolve_cellular 拦截 usb0" wwan0 \ + "$(real 'CELLULAR_IFACE=usb0; REQUIRE_CELLULAR_IFACE=true; resolve_cellular')" + +# 只有 gadget 可选时必须失败,而不是把 usb0 绑成出口 +check_eq "仅 gadget 时 resolve_cellular 拒绝返回 usb0" die \ + "$(scenario "usb0 wlan0" "usb0=configfs-gadget.g1 wlan0=brcmfmac" \ + "usb0=192.168.68.1/24 wlan0=192.168.1.23/24" wlan0 "" no \ + 'CELLULAR_IFACE=usb0; REQUIRE_CELLULAR_IFACE=true; out=$(resolve_cellular 2>/dev/null); rc=$?; if [[ $rc -ne 0 || -z $out ]]; then echo die; else echo "$out"; fi')" + +# verify.sh 的前置体检:出口绑在 gadget 上时必须 exit 4,而不是跑一堆 curl 再给含糊 FAIL +verify_guard() { + local sandbox conf rc + sandbox="$(mktemp -d)" + cp -r "$REPO"/. "$sandbox/" 2>/dev/null + rm -rf "$sandbox/.git" + mkdir -p "$sandbox/config" "$sandbox/etc" + cat >"$sandbox/config/settings.conf" <>"$sandbox/scripts/lib.sh" <<'EOF' +iface_is_usb_gadget() { [[ "$1" == usb0 || "$1" == rndis0 ]]; } +EOF + ( cd "$sandbox" && CONFIG_FILE="$sandbox/config/settings.conf" \ + EGRESS_CHECK_URL="http://127.0.0.1:1/" EGRESS_TIMEOUT=1 VERIFY_QUICK=true \ + bash scripts/verify.sh >/dev/null 2>&1 ) + rc=$? + rm -rf "$sandbox" + printf '%s' "$rc" +} +check_eq "verify.sh 对 gadget 出口直接 exit 4" 4 "$(verify_guard usb0)" +check_eq "verify.sh 对正常出口不报 exit 4" not4 \ + "$(rc="$(verify_guard wwan0)"; [[ "$rc" == 4 ]] && echo 4 || echo not4)" + +# upgrade.sh 的迁移函数:旧 settings.conf 里的 usb/enx 必须被就地剔除 +migrate_case() { + local conf fn out + conf="$(mktemp)" + fn="$(mktemp)" + printf 'CELLULAR_IFACE=wwan0\nCELLULAR_IFACE_PATTERNS=%s\nPROXY_MIXED_PORT=7890\n' "$1" >"$conf" + # 从 upgrade.sh 里抽出 migrate_iface_patterns 函数体,避免执行整个升级脚本 + awk '/^migrate_iface_patterns\(\) \{/,/^\}/' "$REPO/scripts/upgrade.sh" >"$fn" + out="$( + set +eu + warn() { :; } + # shellcheck disable=SC1090 + source "$fn" + migrate_iface_patterns "$conf" >/dev/null 2>&1 + awk -F= '/^CELLULAR_IFACE_PATTERNS=/{print substr($0,index($0,"=")+1)}' "$conf" + # 顺带确认其它行没被吃掉 + grep -c '^PROXY_MIXED_PORT=7890$' "$conf" + )" + rm -f "$conf" "$fn" + printf '%s' "$(printf '%s' "$out" | tr '\n' '/')" +} +check_eq "升级迁移剔除 usb/enx 且保留其它配置" "wwan,wwp,ppp,cdc/1" \ + "$(migrate_case 'wwan,wwp,usb,enx,ppp,cdc')" +check_eq "已干净的 patterns 不被改动" "wwan,wwp,ppp,cdc/1" \ + "$(migrate_case 'wwan,wwp,ppp,cdc')" + +printf '\n通过 %d / 失败 %d\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]]