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:
@@ -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:-}"
|
||||
|
||||
+273
-27
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user