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.
This commit is contained in:
Hermes
2026-07-24 09:53:03 +00:00
parent 6a3015aecc
commit 659ad6f950
5 changed files with 304 additions and 29 deletions
+273 -27
View File
@@ -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