#!/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