fix(detect): never bind USB gadget iface as proxy egress

设备把自己当 USB 网卡挂给上游主机时会出现 usb0(驱动 configfs-gadget.g1,
IP 192.168.68.1)。该链路只通向 PC,被当成出口后所有出站都 context
deadline exceeded,客户端看到 socks connect failed rep=0x01。

误选原因:score_iface_as_cellular 给 usb0 与真出口 wwan0 都打 175 分。
usb0 靠名称命中 patterns 里的 usb(+80)与非默认路由(+40);wwan0 的
bam-dmux 驱动不在白名单里,白丢 +100。detect_cellular_iface 用 -gt 比较,
list_ifaces 按名排序让 usb0 先入选,平分下无法被顶替。

改动:
- lib.sh 新增 iface_is_usb_gadget(),按驱动名与 gadget 总线路径识别,
  并并入 is_virtual_or_skip_iface(usb0 评分 175 -> 0)
- 驱动白名单补 bam-dmux/bam_dmux/qcom-ipa/ipa_wan;名称权重 80 -> 40,
  确保驱动证据始终压过名称猜测
- 新增 detect_cellular_iface_via_mm(),把 ModemManager bearer 的
  interface: 作为第 0 步权威来源
- detect_cellular_iface / resolve_cellular 全链路拒绝 gadget 网卡,
  取不到真出口时按 REQUIRE_CELLULAR_IFACE 直接失败而不是绑错
- generate/install/upgrade/watch/detect/verify 各入口独立设闸,
  verify.sh 发现出口是 gadget 时直接 exit 4 并给出修复命令
- 默认 CELLULAR_IFACE_PATTERNS 去掉 usb/enx;upgrade.sh 与根 install.sh
  就地迁移已有 settings.conf,旧机器升级即修复
- 新增 tests/detect-gadget.sh:18 条离线断言,无需真机
This commit is contained in:
Hermes
2026-08-23 18:39:34 +08:00
parent 659ad6f950
commit 88f2ff44aa
11 changed files with 447 additions and 30 deletions
+187
View File
@@ -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 <ifaces> <drivers> <ips> <default_if> <mm_iface> <have_mmcli> <shell代码>
# 在子 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 Xip 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" <<EOF
INSTALL_DIR=$sandbox
CELLULAR_IFACE=$1
REQUIRE_CELLULAR_IFACE=false
EOF
# 让 usb0 在这个沙箱里被判定为 gadget,且不真的去打网络
cat >>"$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 ]]