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
+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