From 659ad6f9500a95cb307e93f48e1b93a4a5b10cda Mon Sep 17 00:00:00 2001 From: Hermes Date: Fri, 24 Jul 2026 09:52:11 +0000 Subject: [PATCH] feat(watch): treat ModemManager journal as redial standard Use MM registration/packet-service events as the recovery trigger instead of public egress probes, so re-register heals with zero cellular traffic. Wait for wwan IPv4 after settle, coalesce bursts, and keep ip-monitor + poll as fallbacks. --- README.md | 3 +- config/settings.conf.example | 13 +- docs/ops.md | 11 ++ scripts/lib.sh | 6 + scripts/watch-cellular.sh | 300 +++++++++++++++++++++++++++++++---- 5 files changed, 304 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index c493952..9827057 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,8 @@ cpxy watch once # 手动跑一轮检查 | 层级 | 做法 | |------|------| | **绑定(推荐)** | 默认 `BIND_SOURCE_IP=false`:只 `bind_interface=wwanX`,**不 pin 私网源 IP**,重拨换地址通常不用 regenerate | -| **监控** | `cellular-proxy-watch`:`ip monitor` 事件 + 60s 轮询;网卡改名/掉线/仍 pin 了 stale IP 时自动 `generate` | +| **监控** | `cellular-proxy-watch`:`ip monitor` + **ModemManager 重拨/重注册日志** + 60s 轮询;网卡改名/掉线/stale pin 或 MM 抖动后自动等数据面再 `generate` | +| **MM 标准(省流量)** | 默认 `WATCH_MM_EVENTS=true`:只读本机 `journalctl -u ModemManager`,**不做公网探针**;命中 `registration/packet service` 等状态变化后 settle→等 IPv4→restart | | **兜底 timer** | 每 2 分钟 oneshot 再检查一次 | | **手动** | `cpxy rebind && cpxy verify` | diff --git a/config/settings.conf.example b/config/settings.conf.example index 27141cd..3a58d3b 100644 --- a/config/settings.conf.example +++ b/config/settings.conf.example @@ -29,8 +29,19 @@ REQUIRE_CELLULAR_IFACE=true CELLULAR_IFACE_PATTERNS=wwan,wwp,usb,enx,ppp,cdc # 后台监控(推荐开):网卡改名/掉线/无 IPv4 时自动 rebind;默认开启 long-running watch ENABLE_CELLULAR_WATCH=true -# watch 轮询兜底间隔秒(事件驱动 ip monitor 为主) +# watch 轮询兜底间隔秒(事件驱动 ip monitor + ModemManager 日志为主) WATCH_INTERVAL_SEC=60 +# 以 ModemManager 日志为「重拨/重注册」标准(零公网流量,只读本机 journal) +# 命中 home→searching / attached→detached / bearer 断连等后:settle → 等 IPv4 → generate/restart +WATCH_MM_EVENTS=true +WATCH_MM_UNIT=ModemManager +# 事件后先等几秒再查数据面(避免刚 detached 就 generate) +WATCH_MM_SETTLE_SEC=5 +# 等待 wwan IPv4 / MM home 的最长时间 +WATCH_MM_READY_TIMEOUT_SEC=90 +WATCH_MM_READY_POLL_SEC=2 +# 同一轮抖动合并窗口(searching→registering→home 只恢复一次) +WATCH_MM_COALESCE_SEC=20 # ---- 代理监听(局域网其它设备用 0.0.0.0)---- # mixed = 同一端口同时支持 HTTP 代理 与 SOCKS5 diff --git a/docs/ops.md b/docs/ops.md index f9c4044..bc92c71 100644 --- a/docs/ops.md +++ b/docs/ops.md @@ -6,6 +6,17 @@ 2. 需要「用流量出口」时:把应用的 HTTP/SOCKS 指到 `LAN:7890` 3. `cpxy verify` 确认代理 IP ≠ 直连 IP +## 重拨检测(ModemManager 日志标准) + +watch 默认以 **本机 ModemManager journal** 作为「数据网络重拨/重注册」信号,**不消耗蜂窝公网流量**: + +- 典型命中:`registration state changed (home -> searching)`、`packet service state changed (attached -> detached)`、bearer 断连、access tech 抖动 +- 动作:`WATCH_MM_SETTLE_SEC` 后等待 `wwan` IPv4(可选 mmcli home),再 `generate/restart` 清半开连接 +- 同一轮抖动用 `WATCH_MM_COALESCE_SEC` 合并,避免连刷三次 +- 首次启动写 `mm.cursor`,**不回放历史日志**,避免升级后误 rebind +- 相关日志:`/var/log/cellular-proxy/watch.log`、`mm.last_event`、`watch.state` +- 关闭:`WATCH_MM_EVENTS=false` 后 `systemctl restart cellular-proxy-watch` + ## 内存 ```bash diff --git a/scripts/lib.sh b/scripts/lib.sh index d364866..89ecb1d 100755 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -36,6 +36,12 @@ load_config() { 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}" + 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:-}" diff --git a/scripts/watch-cellular.sh b/scripts/watch-cellular.sh index ba4cc95..703137a 100644 --- a/scripts/watch-cellular.sh +++ b/scripts/watch-cellular.sh @@ -1,8 +1,11 @@ #!/usr/bin/env bash -# 监控数据网卡:IP/链路变化时自动 regenerate(避免 stale bind 或网卡改名导致 rep=0x01) +# 监控数据网卡 + ModemManager 重拨/重注册事件,自动等待附着并 regenerate。 # 策略: -# 1) 默认配置只 bind_interface(不写死源 IP)—— 多数场景无需本脚本也能自愈 -# 2) 本脚本仍负责:iface 丢失→重探、可选 BIND_SOURCE_IP 时同步源 IP、链路 down/up 后拉起 +# 1) 默认只 bind_interface(不写死源 IP)—— 多数 IP 漂移无需 pin +# 2) iface 丢失→重探;可选 BIND_SOURCE_IP 时同步源 IP +# 3) **优先以 ModemManager 日志为重拨标准**(零公网流量): +# home→searching / attached→detached / bearer 断连 等 → 等 IPv4 回来 → generate/restart +# 4) 不默认做公网出口探针(省流量);仅本地 iface/MM 状态 set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -20,13 +23,31 @@ load_config INTERVAL="${WATCH_INTERVAL_SEC:-60}" DEBOUNCE_SEC="${WATCH_DEBOUNCE_SEC:-3}" +# ModemManager 事件驱动(默认开;只读本机 journal,不耗蜂窝公网流量) +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}" +# 同一轮 MM 抖动合并窗口(秒):连续 searching/detached 只恢复一次 +WATCH_MM_COALESCE_SEC="${WATCH_MM_COALESCE_SEC:-20}" + LOCK_DIR="${LOG_DIR:-/var/log/cellular-proxy}" mkdir -p "$LOCK_DIR" 2>/dev/null || true STATE_FILE="${LOCK_DIR}/watch.state" +MM_CURSOR_FILE="${LOCK_DIR}/mm.cursor" +MM_LAST_EVENT_FILE="${LOCK_DIR}/mm.last_event" LOG_TAG="cellular-proxy-watch" logw() { printf '[%s] %s %s\n' "$(date '+%F %T')" "$LOG_TAG" "$*" >&2; } +truthy() { + case "${1,,}" in + 1|true|yes|on) return 0 ;; + *) return 1 ;; + esac +} + current_cfg_iface() { python3 - <<'PY' 2>/dev/null || true import json @@ -60,6 +81,168 @@ print("") PY } +# 将 ModemManager 日志行判定为「重拨 / 脱网 / 重注册」类事件 +is_mm_redial_line() { + local line="${1,,}" + # 忽略纯噪声 + [[ -z "$line" ]] && return 1 + [[ "$line" == *"-- no entries --"* ]] && return 1 + + # 核心:注册/分组附着状态机(本次 16:48 日志即此类) + if [[ "$line" == *"registration state changed"* ]]; then + return 0 + fi + if [[ "$line" == *"packet service state changed"* ]]; then + return 0 + fi + if [[ "$line" == *"consolidated registration state"* && ( "$line" == *"searching"* || "$line" == *"denied"* || "$line" == *"unknown"* ) ]]; then + return 0 + fi + # bearer / 连接态 + if [[ "$line" == *"bearer"* && ( "$line" == *"disconnect"* || "$line" == *"connect"* || "$line" == *"failed"* ) ]]; then + return 0 + fi + if [[ "$line" == *"state changed"* && ( "$line" == *"connected"* || "$line" == *"disconnected"* || "$line" == *"searching"* || "$line" == *"registered"* ) ]]; then + # 避免把完全无关的 service 状态刷进来:需 modem 语境 + if [[ "$line" == *"[modem"* || "$line" == *"modem"* || "$line" == *"3gpp"* ]]; then + return 0 + fi + fi + if [[ "$line" == *"access technology changed"* ]]; then + return 0 + fi + if [[ "$line" == *"unexpected lte system info"* ]]; then + return 0 + fi + return 1 +} + +init_mm_cursor_now() { + # 升级/首次启动:锚到「现在」,不回放历史,避免一启动就误 rebind + [[ -f "$MM_CURSOR_FILE" ]] && return 0 + if ! command -v journalctl >/dev/null 2>&1; then + return 1 + fi + local cur + cur="$(journalctl -u "$WATCH_MM_UNIT" -n 0 --show-cursor --no-pager 2>/dev/null | sed -n 's/^-- cursor: //p' | tail -1 || true)" + if [[ -n "$cur" ]]; then + printf '%s\n' "$cur" >"$MM_CURSOR_FILE" + logw "mm cursor initialized (no backlog replay)" + return 0 + fi + # 无 journal 时写空标记 + : >"$MM_CURSOR_FILE" + return 0 +} + +# 扫描 cursor 之后的 MM 日志;命中则打印摘要并 return 0 +scan_mm_events_since_cursor() { + truthy "$WATCH_MM_EVENTS" || return 1 + command -v journalctl >/dev/null 2>&1 || return 1 + init_mm_cursor_now || true + + local out hits=0 sample="" + # --after-cursor 需要已有 cursor;首次空文件则用 -n 0 建锚后返回无事件 + if [[ ! -s "$MM_CURSOR_FILE" ]]; then + init_mm_cursor_now || true + return 1 + fi + + # 用临时 cursor 复制,避免 journalctl 在无新条目时搞乱;成功读后再写回 + local cur + cur="$(tr -d '\r\n' <"$MM_CURSOR_FILE" 2>/dev/null || true)" + if [[ -z "$cur" ]]; then + init_mm_cursor_now || true + return 1 + fi + + out="$(journalctl -u "$WATCH_MM_UNIT" --after-cursor="$cur" --no-pager -o short-iso 2>/dev/null || true)" + # 推进 cursor 到最新(即使本轮无匹配事件也推进,避免重复扫) + local newcur + newcur="$(journalctl -u "$WATCH_MM_UNIT" -n 0 --show-cursor --no-pager 2>/dev/null | sed -n 's/^-- cursor: //p' | tail -1 || true)" + if [[ -n "$newcur" ]]; then + printf '%s\n' "$newcur" >"$MM_CURSOR_FILE" + fi + + [[ -z "$out" ]] && return 1 + while IFS= read -r line; do + [[ -z "$line" || "$line" == --* ]] && continue + if is_mm_redial_line "$line"; then + hits=$((hits + 1)) + sample="$line" + fi + done <<<"$out" + + if (( hits > 0 )); then + printf '%s\n' "$sample" >"$MM_LAST_EVENT_FILE" 2>/dev/null || true + logw "mm redial/re-register events=${hits} last=${sample}" + return 0 + fi + return 1 +} + +mm_line_looks_recovered() { + local line="${1,,}" + [[ "$line" == *"registration state changed"*"home"* ]] && return 0 + [[ "$line" == *"packet service state changed"*"attached"* ]] && return 0 + [[ "$line" == *"consolidated registration state"* && "$line" == *"'home'"* ]] && return 0 + return 1 +} + +# 等待蜂窝数据面可用:iface 存在 + IPv4;可选 mmcli connected +wait_cellular_ready() { + local cell="${1:-}" + local deadline now src + deadline=$(( $(date +%s) + WATCH_MM_READY_TIMEOUT_SEC )) + logw "wait cellular ready iface=${cell:-?} timeout=${WATCH_MM_READY_TIMEOUT_SEC}s" + + while true; do + now="$(date +%s)" + if (( now >= deadline )); then + logw "wait cellular ready: timeout" + return 1 + fi + + load_config + cell="${CELLULAR_IFACE:-$cell}" + if [[ -z "$cell" ]] || ! iface_exists "$cell"; then + cell="$(detect_cellular_iface "" || true)" + fi + if [[ -n "$cell" ]] && iface_exists "$cell"; then + src="$(detect_source_ip "$cell" || true)" + if [[ -n "$src" ]]; then + # 若 mmcli 可用,尽量确认 modem 已 connected/attached(失败不阻断,有 IPv4 即可) + if command -v mmcli >/dev/null 2>&1; then + if mmcli -L 2>/dev/null | grep -qi modem; then + if mmcli -m any 2>/dev/null | grep -qiE 'state:[[:space:]]*connected|packet service state:[[:space:]]*attached|registration:[[:space:]]*home'; then + logw "cellular ready: iface=$cell src=$src (mmcli ok)" + printf '%s\n' "$cell" + return 0 + fi + # 有 IPv4 但 mmcli 尚未 home:再等一小会儿 + sleep "$WATCH_MM_READY_POLL_SEC" + src2="$(detect_source_ip "$cell" || true)" + if [[ -n "$src2" ]]; then + logw "cellular ready: iface=$cell src=$src2 (ipv4 present; mmcli not yet home)" + printf '%s\n' "$cell" + return 0 + fi + else + logw "cellular ready: iface=$cell src=$src" + printf '%s\n' "$cell" + return 0 + fi + else + logw "cellular ready: iface=$cell src=$src" + printf '%s\n' "$cell" + return 0 + fi + fi + fi + sleep "$WATCH_MM_READY_POLL_SEC" + done +} + need_refresh() { load_config local cell="${CELLULAR_IFACE:-}" @@ -74,9 +257,8 @@ need_refresh() { live_src="$(detect_source_ip "$cell" || true)" if [[ -z "$live_src" ]]; then - # 网卡在但无 IPv4:可能刚重拨;不立刻 die,等下一轮 + # 网卡在但无 IPv4:可能刚重拨;不立刻 die,等下一轮或 MM 恢复路径 logw "wwan up but no IPv4 yet on $cell" - # 若配置里仍绑旧源 IP,清掉/重生更安全 if [[ -n "$cfg_src" ]]; then return 0 fi @@ -106,21 +288,22 @@ do_refresh() { load_config local cell="${CELLULAR_IFACE:-}" local src="" + local reason="${1:-manual}" if [[ -z "$cell" ]] || ! iface_exists "$cell"; then cell="$(detect_cellular_iface "" || true)" if [[ -z "$cell" ]]; then - logw "detect failed; skip" + logw "detect failed; skip reason=$reason" return 1 fi fi src="$(detect_source_ip "$cell" || true)" if [[ -z "$src" ]]; then - logw "no IPv4 on $cell yet; skip generate" + logw "no IPv4 on $cell yet; skip generate reason=$reason" return 1 fi persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src" || true - logw "refresh: iface=$cell src=$src" + logw "refresh: reason=$reason iface=$cell src=$src" # 走 cpxy generate(含 check + restart);失败则直接 scripts if command -v cpxy >/dev/null 2>&1; then cpxy generate >/dev/null 2>&1 || { @@ -137,57 +320,120 @@ do_refresh() { 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" + printf '%s reason=%s iface=%s src=%s\n' "$(date -Iseconds)" "$reason" "$cell" "$src" >"$STATE_FILE" 2>/dev/null || true + logw "refresh done reason=$reason" } -# oneshot mode (systemd timer) -if [[ "${1:-}" == "--once" ]]; then - if need_refresh; then - do_refresh || true +# MM 事件后:settle → 等数据面 → refresh(即使未 pin 源 IP,也重启清半开连接) +handle_mm_recover() { + local now last=0 + now="$(date +%s)" + if [[ -f "$STATE_FILE" ]]; then + # 合并窗口:避免 searching→registering→home 连打三次 + last="$(stat -c %Y "$STATE_FILE" 2>/dev/null || echo 0)" + if (( now - last < WATCH_MM_COALESCE_SEC )); then + # 若 state 很新且是 mm 恢复,跳过 + if grep -q 'reason=mm' "$STATE_FILE" 2>/dev/null; then + logw "mm recover coalesced (<${WATCH_MM_COALESCE_SEC}s)" + return 0 + fi + fi fi - exit 0 -fi -# long-running: periodic + optional ip monitor events -logw "start interval=${INTERVAL}s (debounce=${DEBOUNCE_SEC}s)" + sleep "${WATCH_MM_SETTLE_SEC}" + local cell="" + cell="$(wait_cellular_ready "${CELLULAR_IFACE:-}" || true)" + if [[ -z "$cell" ]]; then + # 超时仍尝试一轮 need_refresh / refresh(可能仅有短暂 IPv4) + logw "mm recover: ready wait failed; try best-effort refresh" + fi + do_refresh "mm" || true +} + last_run=0 trigger() { - local now + local now reason="${1:-poll}" now="$(date +%s)" if (( now - last_run < DEBOUNCE_SEC )); then return 0 fi last_run=$now + + # 1) ModemManager 重拨标准(零公网流量) + if scan_mm_events_since_cursor; then + handle_mm_recover + return 0 + fi + + # 2) 原有 iface / stale pin 逻辑 if need_refresh; then - do_refresh || true + do_refresh "$reason" || true fi } +# oneshot mode (systemd timer) +if [[ "${1:-}" == "--once" ]]; then + trigger "once" + exit 0 +fi + +# long-running: periodic + ip monitor + MM journal follow +logw "start interval=${INTERVAL}s debounce=${DEBOUNCE_SEC}s mm_events=${WATCH_MM_EVENTS} mm_unit=${WATCH_MM_UNIT}" +init_mm_cursor_now || true + # initial -trigger +trigger "start" + +child_pids=() +cleanup() { + local p + for p in "${child_pids[@]:-}"; do + kill "$p" 2>/dev/null || true + done + exit 0 +} +trap cleanup TERM INT # 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 + trigger "ip-monitor" fi done ) & - mon_pid=$! - trap 'kill $mon_pid 2>/dev/null || true; exit 0' TERM INT + child_pids+=($!) +fi + +# ModemManager 实时日志(标准:重拨/重注册) +if truthy "$WATCH_MM_EVENTS" && command -v journalctl >/dev/null 2>&1; then + ( + # -n 0:不回放历史;-f:跟随 + journalctl -u "$WATCH_MM_UNIT" -f -n 0 --no-pager 2>/dev/null | while IFS= read -r line; do + if is_mm_redial_line "$line"; then + logw "mm live: $line" + printf '%s\n' "$line" >"$MM_LAST_EVENT_FILE" 2>/dev/null || true + # 推进 cursor,避免 poll 再扫到同一批 + newcur="$(journalctl -u "$WATCH_MM_UNIT" -n 0 --show-cursor --no-pager 2>/dev/null | sed -n 's/^-- cursor: //p' | tail -1 || true)" + [[ -n "$newcur" ]] && printf '%s\n' "$newcur" >"$MM_CURSOR_FILE" + sleep "$DEBOUNCE_SEC" + handle_mm_recover + fi + done + ) & + child_pids+=($!) + logw "mm journal follow enabled unit=$WATCH_MM_UNIT" +else + logw "mm journal follow disabled or journalctl missing" fi while true; do sleep "$INTERVAL" - trigger + trigger "poll" done