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
+6 -2
View File
@@ -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
+13
View File
@@ -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。
+15 -4
View File
@@ -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:-}"
+87 -8
View File
@@ -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 exceededSOCKS 回 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_driverrndis_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"
}
+41 -5
View File
@@ -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:-}"
+26
View File
@@ -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=("$@")
+16 -3
View File
@@ -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