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.
This commit is contained in:
Hermes Agent
2026-07-23 09:02:46 +00:00
parent 062a3eee90
commit 06f941c519
10 changed files with 381 additions and 7 deletions
+18 -6
View File
@@ -14,22 +14,33 @@ if [[ -z "$cell" ]]; then
resolve_cellular >/dev/null || true
fi
# 始终以当前网卡实 IP 为准:WWAN 重拨后 iface 名常不变,但 CELLULAR_SOURCE_IP 会过期。
# 过期的 inet4_bind_address 会导致 sing-box 出站失败 → 客户端 SOCKS rep=0x01
# 绑定策略(优先更好的实现,而不是只靠定时器修 stale IP):
# 默认 BIND_SOURCE_IP=false → 只写 bind_interface,不写 inet4_bind_address
# sing-box 1.11+ 在 wwan 重拨后仍能按网卡出口,无需锁定私网源 IP。
# 实测:去掉 inet4_bind_address 后 SOCKS 仍从数据出口出(公网 IP ≠ WiFi)。
# BIND_SOURCE_IP=true 时才 pin 源 IP(兼容特殊内核/多地址场景),并始终用 live IP。
live_src=""
if [[ -n "$cell" ]]; then
live_src="$(detect_source_ip "$cell" || true)"
fi
src_ip="${live_src:-${CELLULAR_SOURCE_IP:-}}"
BIND_SOURCE_IP="$(echo "${BIND_SOURCE_IP:-false}" | tr '[:upper:]' '[:lower:]')"
if [[ -n "$cell" && -n "$live_src" && "${CELLULAR_SOURCE_IP:-}" != "$live_src" ]]; then
info "源 IP 已漂移: ${CELLULAR_SOURCE_IP:-<空>}$live_src,写回 settings"
info "源 IP 记录更新: ${CELLULAR_SOURCE_IP:-<空>}$live_srcsettings 仅作元数据)"
persist_cellular_to_settings "${CONFIG_FILE:-$ROOT_DIR/etc/settings.conf}" "$cell" "$live_src" || true
# 兼容 live 安装布局:settings 在 etc/
if [[ -f /opt/cellular-proxy/etc/settings.conf ]]; then
persist_cellular_to_settings /opt/cellular-proxy/etc/settings.conf "$cell" "$live_src" || true
fi
CELLULAR_SOURCE_IP="$live_src"
fi
# 默认不把源 IP 写进 sing-box,避免下次重拨 stale bind
GEN_SRC_FOR_CFG=""
if [[ "$BIND_SOURCE_IP" == "true" || "$BIND_SOURCE_IP" == "1" || "$BIND_SOURCE_IP" == "yes" ]]; then
GEN_SRC_FOR_CFG="$src_ip"
info "BIND_SOURCE_IP=true → pin inet4_bind_address=${GEN_SRC_FOR_CFG:-<空>}"
else
info "绑定模式: bind_interface only(不 pin 源 IP,重拨更稳)"
fi
if [[ -z "$cell" ]]; then
warn "CELLULAR_IFACE 为空,临时用 lo 生成配置(启动前务必修正)"
@@ -47,7 +58,7 @@ fi
export GEN_OUT="$OUT_DIR/config.json"
export GEN_CELL="$cell_for_cfg"
export GEN_SRC="$src_ip"
export GEN_SRC="${GEN_SRC_FOR_CFG:-}"
export GEN_PROXY_HOST="$PROXY_LISTEN_HOST"
export GEN_PROXY_PORT="$PROXY_MIXED_PORT"
export GEN_PROXY_USER="$PROXY_USER"
@@ -83,12 +94,13 @@ inbound = {
if users:
inbound["users"] = users
# 唯一业务出口:强制绑定数据网卡
# 唯一业务出口:强制绑定数据网卡(默认仅 bind_interface
out_cell = {
"type": "direct",
"tag": "cellular",
"bind_interface": cell,
}
# 仅当 GEN_SRC 非空(BIND_SOURCE_IP=true)时 pin 源 IP
if src:
out_cell["inet4_bind_address"] = src
+46
View File
@@ -160,6 +160,23 @@ case "$cmd" in
auth)
exec "$BASE/scripts/apply-proxy-auth.sh" "$@"
;;
watch)
case "${1:-status}" in
once) exec "$BASE/scripts/watch-cellular.sh" --once ;;
status)
systemctl status cellular-proxy-watch.service --no-pager 2>/dev/null || true
systemctl status cellular-proxy-watch.timer --no-pager 2>/dev/null || true
;;
start|enable)
systemctl enable --now cellular-proxy-watch.service cellular-proxy-watch.timer 2>/dev/null || true
;;
stop|disable)
systemctl disable --now cellular-proxy-watch.service cellular-proxy-watch.timer 2>/dev/null || true
;;
logs) journalctl -u cellular-proxy-watch -n "${2:-80}" --no-pager ;;
*) exec "$BASE/scripts/watch-cellular.sh" "$@" ;;
esac
;;
upgrade)
# 一条命令在线增量升级(保留密钥/网卡)
if [[ -x "$BASE/scripts/online-upgrade.sh" ]]; then
@@ -178,6 +195,7 @@ cpxy — cellular-proxy
cpxy verify | logs [N]
cpxy auth --user U --pass P # 设置 7890 代理账号密码
cpxy auth --clear | --show
cpxy watch [status|once|start|stop|logs] # WWAN 自动监控
cpxy upgrade # 一条命令在线增量升级
cpxy upgrade --ui-only # 只更新面板
cpxy upgrade --force-binary # 强制重下 sing-box
@@ -204,6 +222,19 @@ if [[ -f "$ROOT_DIR/systemd/cellular-proxy-admin.service" ]]; then
install -m 0644 "$ROOT_DIR/systemd/cellular-proxy-admin.service" /etc/systemd/system/cellular-proxy-admin.service
sed -i "s|@INSTALL_DIR@|$INSTALL_DIR|g" /etc/systemd/system/cellular-proxy-admin.service
fi
# WWAN 自动监控:事件驱动 + 定时兜底
install_watch_units() {
local u
for u in cellular-proxy-watch.service cellular-proxy-watch-once.service cellular-proxy-watch.timer; do
if [[ -f "$ROOT_DIR/systemd/$u" ]]; then
install -m 0644 "$ROOT_DIR/systemd/$u" "/etc/systemd/system/$u"
sed -i "s|@INSTALL_DIR@|$INSTALL_DIR|g" "/etc/systemd/system/$u"
sed -i "s|@LOG_DIR@|${LOG_DIR}|g" "/etc/systemd/system/$u"
fi
done
chmod +x "$INSTALL_DIR/scripts/watch-cellular.sh" 2>/dev/null || true
}
install_watch_units
systemctl daemon-reload
info "5/6 启动服务"
@@ -239,6 +270,21 @@ else
fi
fi
# 默认开启 watchENABLE_CELLULAR_WATCH!=false
watch_flag="$(echo "${ENABLE_CELLULAR_WATCH:-true}" | tr '[:upper:]' '[:lower:]')"
if [[ "$watch_flag" == "false" || "$watch_flag" == "0" || "$watch_flag" == "no" ]]; then
systemctl disable --now cellular-proxy-watch.service cellular-proxy-watch.timer 2>/dev/null || true
info "ENABLE_CELLULAR_WATCH=false,未启用自动监控"
else
systemctl enable cellular-proxy-watch.service 2>/dev/null || true
systemctl enable cellular-proxy-watch.timer 2>/dev/null || true
if [[ "$SKIP_START" != "true" ]]; then
systemctl restart cellular-proxy-watch.service 2>/dev/null || true
systemctl restart cellular-proxy-watch.timer 2>/dev/null || true
fi
info "已启用 cellular-proxy-watch(网卡/源 IP 变化自动 rebind"
fi
info "6/6 自动验证出口(短超时,失败不阻塞安装结束)"
if [[ "$SKIP_START" != "true" && "$AUTO_VERIFY" == "true" ]]; then
set +e
+3
View File
@@ -31,8 +31,11 @@ load_config() {
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:-}"
+41
View File
@@ -273,6 +273,23 @@ case "$cmd" in
auth)
exec "$BASE/scripts/apply-proxy-auth.sh" "$@"
;;
watch)
case "${1:-status}" in
once) exec "$BASE/scripts/watch-cellular.sh" --once ;;
status)
systemctl status cellular-proxy-watch.service --no-pager 2>/dev/null || true
systemctl status cellular-proxy-watch.timer --no-pager 2>/dev/null || true
;;
start|enable)
systemctl enable --now cellular-proxy-watch.service cellular-proxy-watch.timer 2>/dev/null || true
;;
stop|disable)
systemctl disable --now cellular-proxy-watch.service cellular-proxy-watch.timer 2>/dev/null || true
;;
logs) journalctl -u cellular-proxy-watch -n "${2:-80}" --no-pager ;;
*) exec "$BASE/scripts/watch-cellular.sh" "$@" ;;
esac
;;
logs) journalctl -u cellular-proxy -n "${1:-80}" -f ;;
help|*)
cat <<H
@@ -281,6 +298,7 @@ cpxy — cellular-proxy
cpxy detect | rebind | generate | start | stop | restart | status
cpxy verify | logs [N]
cpxy auth --user U --pass P | --clear | --show
cpxy watch [status|once|start|stop|logs] # WWAN 自动监控
cpxy upgrade # 一条命令在线增量升级
cpxy upgrade --ui-only | --force-binary | --verify
H
@@ -305,6 +323,15 @@ if [[ -f "$ROOT_DIR/systemd/cellular-proxy-admin.service" ]]; then
install -m 0644 "$ROOT_DIR/systemd/cellular-proxy-admin.service" /etc/systemd/system/cellular-proxy-admin.service
sed -i "s|@INSTALL_DIR@|$INSTALL_DIR|g" /etc/systemd/system/cellular-proxy-admin.service
fi
# WWAN 自动监控
for u in cellular-proxy-watch.service cellular-proxy-watch-once.service cellular-proxy-watch.timer; do
if [[ -f "$ROOT_DIR/systemd/$u" ]]; then
install -m 0644 "$ROOT_DIR/systemd/$u" "/etc/systemd/system/$u"
sed -i "s|@INSTALL_DIR@|$INSTALL_DIR|g" "/etc/systemd/system/$u"
sed -i "s|@LOG_DIR@|${LOG_DIR}|g" "/etc/systemd/system/$u"
fi
done
chmod +x "$INSTALL_DIR/scripts/watch-cellular.sh" 2>/dev/null || true
systemctl daemon-reload
export SYSTEMD_PAGER=cat
@@ -335,6 +362,20 @@ else
fi
fi
watch_flag="$(echo "${ENABLE_CELLULAR_WATCH:-true}" | tr '[:upper:]' '[:lower:]')"
if [[ "$watch_flag" == "false" || "$watch_flag" == "0" || "$watch_flag" == "no" ]]; then
systemctl disable --now cellular-proxy-watch.service cellular-proxy-watch.timer 2>/dev/null || true
info "ENABLE_CELLULAR_WATCH=false,未启用自动监控"
else
systemctl enable cellular-proxy-watch.service 2>/dev/null || true
systemctl enable cellular-proxy-watch.timer 2>/dev/null || true
if [[ "$SKIP_START" != "true" ]]; then
systemctl restart cellular-proxy-watch.service 2>/dev/null || true
systemctl restart cellular-proxy-watch.timer 2>/dev/null || true
fi
info "已启用 cellular-proxy-watch(网卡/源 IP 变化自动 rebind"
fi
if [[ "$DO_VERIFY" == "true" && "$SKIP_START" != "true" ]]; then
info "出口验证…"
set +e
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env bash
# 监控数据网卡:IP/链路变化时自动 regenerate(避免 stale bind 或网卡改名导致 rep=0x01
# 策略:
# 1) 默认配置只 bind_interface(不写死源 IP)—— 多数场景无需本脚本也能自愈
# 2) 本脚本仍负责:iface 丢失→重探、可选 BIND_SOURCE_IP 时同步源 IP、链路 down/up 后拉起
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=lib.sh
source "$ROOT_DIR/scripts/lib.sh"
CONFIG_FILE="${CONFIG_FILE:-$ROOT_DIR/etc/settings.conf}"
if [[ ! -f "$CONFIG_FILE" && -f /opt/cellular-proxy/etc/settings.conf ]]; then
CONFIG_FILE=/opt/cellular-proxy/etc/settings.conf
ROOT_DIR=/opt/cellular-proxy
fi
export CONFIG_FILE
load_config
INTERVAL="${WATCH_INTERVAL_SEC:-60}"
DEBOUNCE_SEC="${WATCH_DEBOUNCE_SEC:-3}"
LOCK_DIR="${LOG_DIR:-/var/log/cellular-proxy}"
mkdir -p "$LOCK_DIR" 2>/dev/null || true
STATE_FILE="${LOCK_DIR}/watch.state"
LOG_TAG="cellular-proxy-watch"
logw() { printf '[%s] %s %s\n' "$(date '+%F %T')" "$LOG_TAG" "$*" >&2; }
current_cfg_iface() {
python3 - <<'PY' 2>/dev/null || true
import json
from pathlib import Path
for p in ("/opt/cellular-proxy/etc/config.json",):
try:
c=json.loads(Path(p).read_text())
for o in c.get("outbounds") or []:
if o.get("tag")=="cellular":
print(o.get("bind_interface") or "")
raise SystemExit
except Exception:
pass
print("")
PY
}
current_cfg_src() {
python3 - <<'PY' 2>/dev/null || true
import json
from pathlib import Path
try:
c=json.loads(Path("/opt/cellular-proxy/etc/config.json").read_text())
for o in c.get("outbounds") or []:
if o.get("tag")=="cellular":
print(o.get("inet4_bind_address") or "")
raise SystemExit
except Exception:
pass
print("")
PY
}
need_refresh() {
load_config
local cell="${CELLULAR_IFACE:-}"
local live_src="" cfg_iface cfg_src
cfg_iface="$(current_cfg_iface)"
cfg_src="$(current_cfg_src)"
if [[ -z "$cell" ]] || ! iface_exists "$cell"; then
logw "iface missing or empty (CELLULAR_IFACE=${cell:-<空>}) → rebind"
return 0
fi
live_src="$(detect_source_ip "$cell" || true)"
if [[ -z "$live_src" ]]; then
# 网卡在但无 IPv4:可能刚重拨;不立刻 die,等下一轮
logw "wwan up but no IPv4 yet on $cell"
# 若配置里仍绑旧源 IP,清掉/重生更安全
if [[ -n "$cfg_src" ]]; then
return 0
fi
return 1
fi
if [[ -n "$cfg_iface" && "$cfg_iface" != "$cell" ]]; then
logw "cfg iface $cfg_iface != settings $cell"
return 0
fi
# 仅当配置写了 inet4_bind_address 且与 live 不一致时才需要
if [[ -n "$cfg_src" && "$cfg_src" != "$live_src" ]]; then
logw "stale source IP cfg=$cfg_src live=$live_src"
return 0
fi
# settings 里的 SOURCE_IP 过期也同步(即使 config 未 pin)
if [[ -n "${CELLULAR_SOURCE_IP:-}" && "${CELLULAR_SOURCE_IP}" != "$live_src" ]]; then
persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$live_src" || true
fi
return 1
}
do_refresh() {
load_config
local cell="${CELLULAR_IFACE:-}"
local src=""
if [[ -z "$cell" ]] || ! iface_exists "$cell"; then
cell="$(detect_cellular_iface "" || true)"
if [[ -z "$cell" ]]; then
logw "detect failed; skip"
return 1
fi
fi
src="$(detect_source_ip "$cell" || true)"
if [[ -z "$src" ]]; then
logw "no IPv4 on $cell yet; skip generate"
return 1
fi
persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src" || true
logw "refresh: iface=$cell src=$src"
# 走 cpxy generate(含 check + restart);失败则直接 scripts
if command -v cpxy >/dev/null 2>&1; then
cpxy generate >/dev/null 2>&1 || {
# shellcheck source=/dev/null
source "$ROOT_DIR/scripts/lib.sh"
load_config
"$ROOT_DIR/scripts/generate.sh" "$ROOT_DIR/generated"
install -m 0644 "$ROOT_DIR/generated/config.json" "$ROOT_DIR/etc/config.json"
install -m 0644 "$ROOT_DIR/generated/runtime.env" "$ROOT_DIR/etc/runtime.env" 2>/dev/null || true
systemctl restart cellular-proxy 2>/dev/null || true
}
else
"$ROOT_DIR/scripts/generate.sh" "$ROOT_DIR/generated"
install -m 0644 "$ROOT_DIR/generated/config.json" "$ROOT_DIR/etc/config.json"
systemctl restart cellular-proxy 2>/dev/null || true
fi
printf '%s iface=%s src=%s\n' "$(date -Iseconds)" "$cell" "$src" >"$STATE_FILE" 2>/dev/null || true
logw "refresh done"
}
# oneshot mode (systemd timer)
if [[ "${1:-}" == "--once" ]]; then
if need_refresh; then
do_refresh || true
fi
exit 0
fi
# long-running: periodic + optional ip monitor events
logw "start interval=${INTERVAL}s (debounce=${DEBOUNCE_SEC}s)"
last_run=0
trigger() {
local now
now="$(date +%s)"
if (( now - last_run < DEBOUNCE_SEC )); then
return 0
fi
last_run=$now
if need_refresh; then
do_refresh || true
fi
}
# initial
trigger
# Prefer event-driven if available; always keep poll as safety net
if command -v ip >/dev/null 2>&1; then
(
# address/link changes on any iface — cheap filter in shell
ip -o monitor address link 2>/dev/null | while read -r line; do
cell="${CELLULAR_IFACE:-}"
# reload cell from settings occasionally
if [[ -f "$CONFIG_FILE" ]]; then
cell="$(awk -F= '/^CELLULAR_IFACE=/{print $2; exit}' "$CONFIG_FILE" 2>/dev/null || true)"
fi
if [[ -n "$cell" ]] && [[ "$line" == *"$cell"* || "$line" == *Deleted* || "$line" == *wwan* || "$line" == *usb* || "$line" == *enx* ]]; then
sleep "$DEBOUNCE_SEC"
trigger
fi
done
) &
mon_pid=$!
trap 'kill $mon_pid 2>/dev/null || true; exit 0' TERM INT
fi
while true; do
sleep "$INTERVAL"
trigger
done