Files
cellular-proxy/scripts/lib.sh
T
Hermes Agent 06f941c519 feat: bind_interface-only by default + WWAN watch auto-rebind
- Default BIND_SOURCE_IP=false: do not pin inet4_bind_address (stale
  source IP after WWAN re-dial was the main rep=0x01 cause).
- Add scripts/watch-cellular.sh: ip monitor events + poll, regenerate
  when iface missing or pinned source IP drifts.
- systemd: cellular-proxy-watch.service + 2min timer oneshot fallback.
- cpxy watch {status|once|start|stop|logs}
- Install/upgrade enable watch when ENABLE_CELLULAR_WATCH=true.
2026-07-23 09:02:46 +00:00

334 lines
9.9 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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,usb,enx,ppp,cdc,rmnet,ccmni,mbim,qmi}"
ENABLE_CELLULAR_WATCH="${ENABLE_CELLULAR_WATCH:-true}"
WATCH_INTERVAL_SEC="${WATCH_INTERVAL_SEC:-60}"
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" ]]
}
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
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)"
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)
return 0
;;
esac
return 1
}
iface_matches_patterns() {
local name="$1" p
local patterns="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,usb,enx,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
}
# 多默认路由时,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)"
# 名称
if iface_matches_patterns "$name"; then score=$((score + 80)); 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"
}
# 自动探测数据网卡:配置 > 名称/驱动 > 次默认路由 > 非默认有 IP 物理口
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_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)"
# 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"; 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
echo "$cell"
}