#!/usr/bin/env bash # 公共库 set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CONFIG_FILE="${CONFIG_FILE:-$ROOT_DIR/config/settings.conf}" log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2; } info() { log "INFO $*"; } warn() { log "WARN $*"; } err() { log "ERROR $*"; } die() { err "$*"; exit 1; } need_root() { if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then die "请用 root 运行(sudo)" fi } load_config() { if [[ ! -f "$CONFIG_FILE" ]]; then die "缺少配置: $CONFIG_FILE 请先: cp $ROOT_DIR/config/settings.conf.example $ROOT_DIR/config/settings.conf" fi set -a # shellcheck disable=SC1090 source "$CONFIG_FILE" set +a INSTALL_DIR="${INSTALL_DIR:-/opt/cellular-proxy}" LOG_DIR="${LOG_DIR:-/var/log/cellular-proxy}" CELLULAR_IFACE="${CELLULAR_IFACE:-}" 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,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}" WATCH_MM_UNIT="${WATCH_MM_UNIT:-ModemManager}" WATCH_MM_SETTLE_SEC="${WATCH_MM_SETTLE_SEC:-5}" WATCH_MM_READY_TIMEOUT_SEC="${WATCH_MM_READY_TIMEOUT_SEC:-90}" WATCH_MM_READY_POLL_SEC="${WATCH_MM_READY_POLL_SEC:-2}" WATCH_MM_COALESCE_SEC="${WATCH_MM_COALESCE_SEC:-20}" PROXY_LISTEN_HOST="${PROXY_LISTEN_HOST:-0.0.0.0}" PROXY_MIXED_PORT="${PROXY_MIXED_PORT:-7890}" PROXY_USER="${PROXY_USER:-}" PROXY_PASS="${PROXY_PASS:-}" PANEL_LISTEN_HOST="${PANEL_LISTEN_HOST:-0.0.0.0}" PANEL_PORT="${PANEL_PORT:-9090}" PANEL_SECRET="${PANEL_SECRET:-please-change-me}" ENABLE_DNS="${ENABLE_DNS:-true}" LOG_LEVEL="${LOG_LEVEL:-warn}" SING_BOX_SOURCE="${SING_BOX_SOURCE:-auto}" SING_BOX_VERSION="${SING_BOX_VERSION:-1.11.7}" SING_BOX_BIN="${SING_BOX_BIN:-}" GITEA_BASE="${GITEA_BASE:-https://gitea.chickliu.fun}" GITEA_OWNER="${GITEA_OWNER:-Hermes}" GITEA_REPO="${GITEA_REPO:-cellular-proxy}" SING_BOX_RELEASE_TAG="${SING_BOX_RELEASE_TAG:-bin-v${SING_BOX_VERSION}}" GITHUB_PROXY="${GITHUB_PROXY:-https://git.86482425.xyz}" MEMORY_MAX_MB="${MEMORY_MAX_MB:-96}" EGRESS_CHECK_URL="${EGRESS_CHECK_URL:-https://ifconfig.me}" EXPECTED_CELLULAR_PUBLIC_IP="${EXPECTED_CELLULAR_PUBLIC_IP:-}" for v in REQUIRE_CELLULAR_IFACE ENABLE_DNS; do val="${!v}" case "${val,,}" in 1|true|yes|on) printf -v "$v" '%s' true ;; *) printf -v "$v" '%s' false ;; esac done } iface_exists() { local ifc="$1" [[ -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 lo|docker*|br-*|veth*|virbr*|cni*|flannel*|tun*|tap*|wg*|zt*|tailscale*|easy*|et*|nlmon*|dummy*|ifb*|bond*|team*|macvlan*|ipvlan*) return 0 ;; esac # bridge that is not a physical device 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 } list_ifaces() { local name state ip for name in $(ls /sys/class/net 2>/dev/null | sort); do [[ "$name" == "lo" ]] && continue state="$(cat "/sys/class/net/$name/operstate" 2>/dev/null || echo unknown)" ip="$(ip -4 -o addr show dev "$name" 2>/dev/null | awk '{print $4}' | head -1)" printf '%s|%s|%s\n' "$name" "$state" "${ip:-}" done } detect_default_iface() { ip route show default 2>/dev/null | awk '/default/ {print $5; exit}' } iface_has_carrier() { local ifc="$1" c c="$(cat "/sys/class/net/$ifc/carrier" 2>/dev/null || echo 0)" [[ "$c" == "1" ]] } iface_driver() { local ifc="$1" link link="$(readlink -f "/sys/class/net/$ifc/device/driver" 2>/dev/null || true)" if [[ -n "$link" ]]; then basename "$link" return fi # some USB eth put driver under device if [[ -f "/sys/class/net/$ifc/device/uevent" ]]; then awk -F= '/^DRIVER=/{print $2; exit}' "/sys/class/net/$ifc/device/uevent" 2>/dev/null || true fi } 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|bam-dmux|bam_dmux|qcom-ipa|ipa_wan) return 0 ;; esac return 1 } iface_matches_patterns() { local name="$1" p # 默认不含 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// /}" [[ -z "$p" ]] && continue if [[ "$name" == *"$p"* ]]; then return 0 fi done 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 primary="$(detect_default_iface || true)" secondary="$( ip route show default 2>/dev/null | awk -v p="$primary" ' /default/ { iface=""; metric=0 for (i=1;i<=NF;i++) { if ($i=="dev") iface=$(i+1) if ($i=="metric") metric=$(i+1)+0 } if (iface!="" && iface!=p) { print metric, iface } } ' | sort -n | awk 'END {print $2}' )" if [[ -n "$secondary" && "$secondary" != "$primary" ]]; then echo "$secondary" return 0 fi return 1 } # 打分选数据网卡(stdout 仅输出网卡名) # 更高分优先 score_iface_as_cellular() { local name="$1" local default_if="$2" local state ip score=0 drv is_virtual_or_skip_iface "$name" && { echo 0; return; } state="$(cat "/sys/class/net/$name/operstate" 2>/dev/null || echo unknown)" ip="$(ip -4 -o addr show dev "$name" 2>/dev/null | awk '{print $4}' | head -1 | cut -d/ -f1)" drv="$(iface_driver "$name" || true)" # 名称(仅启发式,权重必须低于驱动判定,避免 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 # 有 IPv4 if [[ -n "$ip" ]]; then score=$((score + 30)); fi # up/carrier if [[ "$state" == "up" || "$state" == "unknown" ]]; then score=$((score + 15)); fi if iface_has_carrier "$name"; then score=$((score + 10)); fi # 默认网卡通常是 WiFi/有线,大幅降权 if [[ -n "$default_if" && "$name" == "$default_if" ]]; then score=$((score - 60)); fi # docker 等已在 skip;再防 enp/eth 当默认时 case "$name" in eth*|enp*|eno*|ens*|wlan*|wlp*|wl*) if [[ "$name" == "$default_if" ]]; then score=$((score - 20)); fi ;; esac echo "$score" } # 自动探测数据网卡:ModemManager bearer > 配置 > 名称/驱动打分 > 次默认路由 > 非默认有 IP 物理口 # 任何一步都不允许返回 USB gadget 网卡 detect_cellular_iface() { local configured="${1:-}" local default_if name state ip best_name="" best_score=0 score 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 else warn "配置的 CELLULAR_IFACE=$configured 不存在,将尝试自动探测" if [[ "${REQUIRE_CELLULAR_IFACE:-true}" == "true" && -n "${FORCE_CONFIGURED_ONLY:-}" ]]; then return 1 fi fi fi 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 is_virtual_or_skip_iface "$name" && continue score="$(score_iface_as_cellular "$name" "$default_if")" if [[ "$score" -gt "$best_score" ]]; then best_score="$score" best_name="$name" fi done < <(list_ifaces) # 需要足够置信度(避免把唯一的 WiFi 当数据) if [[ -n "$best_name" && "$best_score" -ge 70 ]]; then info "自动探测数据网卡: $best_name (score=$best_score, default=$default_if, driver=$(iface_driver "$best_name" || true))" echo "$best_name" return 0 fi # 2) 多默认路由的次要口 if name="$(detect_secondary_default_iface 2>/dev/null || true)"; then if [[ -n "$name" ]] && iface_exists "$name" && ! iface_is_usb_gadget "$name"; then info "根据次要默认路由探测数据网卡: $name" echo "$name" return 0 fi fi # 3) 任意非默认、有 IPv4、非虚拟 while IFS='|' read -r name state ip; do [[ -z "$name" ]] && continue is_virtual_or_skip_iface "$name" && continue [[ -z "$ip" ]] && continue if [[ -n "$default_if" && "$name" == "$default_if" ]]; then continue fi if [[ "$state" == "up" || "$state" == "unknown" ]]; then info "回退选择非默认网卡: $name" echo "$name" return 0 fi done < <(list_ifaces) if [[ -n "$best_name" && "$best_score" -gt 0 ]]; then warn "置信度较低,选用: $best_name (score=$best_score)" echo "$best_name" return 0 fi return 1 } detect_source_ip() { local ifc="$1" ip -4 -o addr show dev "$ifc" 2>/dev/null | awk '{print $4}' | head -1 | cut -d/ -f1 } # 把探测结果写回 settings.conf persist_cellular_to_settings() { local conf="${1:-$CONFIG_FILE}" local cell="$2" local src_ip="${3:-}" [[ -f "$conf" ]] || return 1 local tmp tmp="$(mktemp)" awk -v k="CELLULAR_IFACE" -v v="$cell" ' BEGIN { done=0 } index($0, k "=")==1 { print k "=" v; done=1; next } { print } END { if (!done) print k "=" v } ' "$conf" > "$tmp" mv "$tmp" "$conf" if [[ -n "$src_ip" ]]; then tmp="$(mktemp)" awk -v k="CELLULAR_SOURCE_IP" -v v="$src_ip" ' BEGIN { done=0 } index($0, k "=")==1 { print k "=" v; done=1; next } { print } END { if (!done) print k "=" v } ' "$conf" > "$tmp" mv "$tmp" "$conf" fi } arch_go() { case "$(uname -m)" in x86_64|amd64) echo amd64 ;; aarch64|arm64) echo arm64 ;; armv7l|armhf) echo armv7 ;; *) die "不支持的架构: $(uname -m)" ;; esac } ensure_dirs() { mkdir -p "$INSTALL_DIR"/{bin,etc,ui,generated} "$LOG_DIR" } resolve_cellular() { local cell if ! cell="$(detect_cellular_iface "$CELLULAR_IFACE")"; then if [[ "$REQUIRE_CELLULAR_IFACE" == "true" ]]; then die "无法确定数据网卡。请设置 CELLULAR_IFACE= 或运行 scripts/detect.sh / cpxy detect" fi 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" }