diff --git a/README.md b/README.md index 08680c1..c493952 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,22 @@ cpxy rebind cpxy status | logs cpxy auth --user u --pass p cpxy auth --show | --clear +cpxy watch status # WWAN 自动监控 +cpxy watch once # 手动跑一轮检查 ``` +### 重拨 / `rep=0x01` 自愈 + +| 层级 | 做法 | +|------|------| +| **绑定(推荐)** | 默认 `BIND_SOURCE_IP=false`:只 `bind_interface=wwanX`,**不 pin 私网源 IP**,重拨换地址通常不用 regenerate | +| **监控** | `cellular-proxy-watch`:`ip monitor` 事件 + 60s 轮询;网卡改名/掉线/仍 pin 了 stale IP 时自动 `generate` | +| **兜底 timer** | 每 2 分钟 oneshot 再检查一次 | +| **手动** | `cpxy rebind && cpxy verify` | + +关监控:`ENABLE_CELLULAR_WATCH=false` 后 `cpxy upgrade`,或 `cpxy watch stop`。 +若必须 pin 源 IP:`BIND_SOURCE_IP=true`(依赖 watch 同步,否则会再次 stale)。 + - 代理:`LAN-IP:7890`(HTTP + SOCKS5) - 面板:`http://LAN-IP:9090/ui/`(密钥在安装结束输出 / `settings.conf`) - **UI 可直接配置代理账号密码**(管理 API 默认 `9091`,与面板 secret 相同) diff --git a/config/settings.conf.example b/config/settings.conf.example index 507dc78..27141cd 100644 --- a/config/settings.conf.example +++ b/config/settings.conf.example @@ -17,12 +17,20 @@ LOG_DIR=/var/log/cellular-proxy # ---- 数据网卡(代理出口强制绑定)---- # 留空则自动探测 wwan/usb/enx 等;建议手动填准 CELLULAR_IFACE= -# 可选:数据侧源 IPv4(一般留空) +# 元数据:数据侧当前源 IPv4(generate/watch 会更新;默认不写入 sing-box) CELLULAR_SOURCE_IP= +# 是否把源 IP pin 进 sing-box inet4_bind_address +# false(默认,推荐):只 bind_interface=网卡名 → WWAN 重拨换私网 IP 时通常无需 regenerate +# true:额外写 inet4_bind_address(需 watch/rebind 同步,否则易 stale → SOCKS rep=0x01) +BIND_SOURCE_IP=false # 数据网卡不存在则不启动(推荐 true,避免代理静默从 WiFi 出) 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_INTERVAL_SEC=60 # ---- 代理监听(局域网其它设备用 0.0.0.0)---- # mixed = 同一端口同时支持 HTTP 代理 与 SOCKS5 diff --git a/scripts/generate.sh b/scripts/generate.sh index 61dd282..d6396c7 100755 --- a/scripts/generate.sh +++ b/scripts/generate.sh @@ -14,22 +14,33 @@ if [[ -z "$cell" ]]; then resolve_cellular >/dev/null || true fi -# 始终以当前网卡实 IP 为准:WWAN 重拨后 iface 名常不变,但 CELLULAR_SOURCE_IP 会过期。 -# 过期的 inet4_bind_address 会导致 sing-box 出站失败 → 客户端 SOCKS rep=0x01。 +# 绑定策略(优先更好的实现,而不是只靠定时器修 stale IP): +# 默认 BIND_SOURCE_IP=false → 只写 bind_interface,不写 inet4_bind_address。 +# sing-box 1.11+ 在 wwan 重拨后仍能按网卡出口,无需锁定私网源 IP。 +# 实测:去掉 inet4_bind_address 后 SOCKS 仍从数据出口出(公网 IP ≠ WiFi)。 +# BIND_SOURCE_IP=true 时才 pin 源 IP(兼容特殊内核/多地址场景),并始终用 live IP。 live_src="" if [[ -n "$cell" ]]; then live_src="$(detect_source_ip "$cell" || true)" fi src_ip="${live_src:-${CELLULAR_SOURCE_IP:-}}" +BIND_SOURCE_IP="$(echo "${BIND_SOURCE_IP:-false}" | tr '[:upper:]' '[:lower:]')" if [[ -n "$cell" && -n "$live_src" && "${CELLULAR_SOURCE_IP:-}" != "$live_src" ]]; then - info "源 IP 已漂移: ${CELLULAR_SOURCE_IP:-<空>} → $live_src,写回 settings" + info "源 IP 记录更新: ${CELLULAR_SOURCE_IP:-<空>} → $live_src(settings 仅作元数据)" persist_cellular_to_settings "${CONFIG_FILE:-$ROOT_DIR/etc/settings.conf}" "$cell" "$live_src" || true - # 兼容 live 安装布局:settings 在 etc/ if [[ -f /opt/cellular-proxy/etc/settings.conf ]]; then persist_cellular_to_settings /opt/cellular-proxy/etc/settings.conf "$cell" "$live_src" || true fi CELLULAR_SOURCE_IP="$live_src" fi +# 默认不把源 IP 写进 sing-box,避免下次重拨 stale bind +GEN_SRC_FOR_CFG="" +if [[ "$BIND_SOURCE_IP" == "true" || "$BIND_SOURCE_IP" == "1" || "$BIND_SOURCE_IP" == "yes" ]]; then + GEN_SRC_FOR_CFG="$src_ip" + info "BIND_SOURCE_IP=true → pin inet4_bind_address=${GEN_SRC_FOR_CFG:-<空>}" +else + info "绑定模式: bind_interface only(不 pin 源 IP,重拨更稳)" +fi if [[ -z "$cell" ]]; then warn "CELLULAR_IFACE 为空,临时用 lo 生成配置(启动前务必修正)" @@ -47,7 +58,7 @@ fi export GEN_OUT="$OUT_DIR/config.json" export GEN_CELL="$cell_for_cfg" -export GEN_SRC="$src_ip" +export GEN_SRC="${GEN_SRC_FOR_CFG:-}" export GEN_PROXY_HOST="$PROXY_LISTEN_HOST" export GEN_PROXY_PORT="$PROXY_MIXED_PORT" export GEN_PROXY_USER="$PROXY_USER" @@ -83,12 +94,13 @@ inbound = { if users: inbound["users"] = users -# 唯一业务出口:强制绑定数据网卡 +# 唯一业务出口:强制绑定数据网卡(默认仅 bind_interface) out_cell = { "type": "direct", "tag": "cellular", "bind_interface": cell, } +# 仅当 GEN_SRC 非空(BIND_SOURCE_IP=true)时 pin 源 IP if src: out_cell["inet4_bind_address"] = src diff --git a/scripts/install.sh b/scripts/install.sh index 7f3f2f2..6b45e2c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -160,6 +160,23 @@ case "$cmd" in auth) exec "$BASE/scripts/apply-proxy-auth.sh" "$@" ;; + watch) + case "${1:-status}" in + once) exec "$BASE/scripts/watch-cellular.sh" --once ;; + status) + systemctl status cellular-proxy-watch.service --no-pager 2>/dev/null || true + systemctl status cellular-proxy-watch.timer --no-pager 2>/dev/null || true + ;; + start|enable) + systemctl enable --now cellular-proxy-watch.service cellular-proxy-watch.timer 2>/dev/null || true + ;; + stop|disable) + systemctl disable --now cellular-proxy-watch.service cellular-proxy-watch.timer 2>/dev/null || true + ;; + logs) journalctl -u cellular-proxy-watch -n "${2:-80}" --no-pager ;; + *) exec "$BASE/scripts/watch-cellular.sh" "$@" ;; + esac + ;; upgrade) # 一条命令在线增量升级(保留密钥/网卡) if [[ -x "$BASE/scripts/online-upgrade.sh" ]]; then @@ -178,6 +195,7 @@ cpxy — cellular-proxy cpxy verify | logs [N] cpxy auth --user U --pass P # 设置 7890 代理账号密码 cpxy auth --clear | --show + cpxy watch [status|once|start|stop|logs] # WWAN 自动监控 cpxy upgrade # 一条命令在线增量升级 cpxy upgrade --ui-only # 只更新面板 cpxy upgrade --force-binary # 强制重下 sing-box @@ -204,6 +222,19 @@ if [[ -f "$ROOT_DIR/systemd/cellular-proxy-admin.service" ]]; then install -m 0644 "$ROOT_DIR/systemd/cellular-proxy-admin.service" /etc/systemd/system/cellular-proxy-admin.service sed -i "s|@INSTALL_DIR@|$INSTALL_DIR|g" /etc/systemd/system/cellular-proxy-admin.service fi +# WWAN 自动监控:事件驱动 + 定时兜底 +install_watch_units() { + local u + for u in cellular-proxy-watch.service cellular-proxy-watch-once.service cellular-proxy-watch.timer; do + if [[ -f "$ROOT_DIR/systemd/$u" ]]; then + install -m 0644 "$ROOT_DIR/systemd/$u" "/etc/systemd/system/$u" + sed -i "s|@INSTALL_DIR@|$INSTALL_DIR|g" "/etc/systemd/system/$u" + sed -i "s|@LOG_DIR@|${LOG_DIR}|g" "/etc/systemd/system/$u" + fi + done + chmod +x "$INSTALL_DIR/scripts/watch-cellular.sh" 2>/dev/null || true +} +install_watch_units systemctl daemon-reload info "5/6 启动服务" @@ -239,6 +270,21 @@ else fi fi +# 默认开启 watch(ENABLE_CELLULAR_WATCH!=false) +watch_flag="$(echo "${ENABLE_CELLULAR_WATCH:-true}" | tr '[:upper:]' '[:lower:]')" +if [[ "$watch_flag" == "false" || "$watch_flag" == "0" || "$watch_flag" == "no" ]]; then + systemctl disable --now cellular-proxy-watch.service cellular-proxy-watch.timer 2>/dev/null || true + info "ENABLE_CELLULAR_WATCH=false,未启用自动监控" +else + systemctl enable cellular-proxy-watch.service 2>/dev/null || true + systemctl enable cellular-proxy-watch.timer 2>/dev/null || true + if [[ "$SKIP_START" != "true" ]]; then + systemctl restart cellular-proxy-watch.service 2>/dev/null || true + systemctl restart cellular-proxy-watch.timer 2>/dev/null || true + fi + info "已启用 cellular-proxy-watch(网卡/源 IP 变化自动 rebind)" +fi + info "6/6 自动验证出口(短超时,失败不阻塞安装结束)" if [[ "$SKIP_START" != "true" && "$AUTO_VERIFY" == "true" ]]; then set +e diff --git a/scripts/lib.sh b/scripts/lib.sh index aea8d62..d364866 100755 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -31,8 +31,11 @@ load_config() { LOG_DIR="${LOG_DIR:-/var/log/cellular-proxy}" CELLULAR_IFACE="${CELLULAR_IFACE:-}" CELLULAR_SOURCE_IP="${CELLULAR_SOURCE_IP:-}" + BIND_SOURCE_IP="${BIND_SOURCE_IP:-false}" REQUIRE_CELLULAR_IFACE="${REQUIRE_CELLULAR_IFACE:-true}" 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}" 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/upgrade.sh b/scripts/upgrade.sh index 914b7bd..3008c89 100755 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -273,6 +273,23 @@ case "$cmd" in auth) exec "$BASE/scripts/apply-proxy-auth.sh" "$@" ;; + watch) + case "${1:-status}" in + once) exec "$BASE/scripts/watch-cellular.sh" --once ;; + status) + systemctl status cellular-proxy-watch.service --no-pager 2>/dev/null || true + systemctl status cellular-proxy-watch.timer --no-pager 2>/dev/null || true + ;; + start|enable) + systemctl enable --now cellular-proxy-watch.service cellular-proxy-watch.timer 2>/dev/null || true + ;; + stop|disable) + systemctl disable --now cellular-proxy-watch.service cellular-proxy-watch.timer 2>/dev/null || true + ;; + logs) journalctl -u cellular-proxy-watch -n "${2:-80}" --no-pager ;; + *) exec "$BASE/scripts/watch-cellular.sh" "$@" ;; + esac + ;; logs) journalctl -u cellular-proxy -n "${1:-80}" -f ;; help|*) cat </dev/null || true systemctl daemon-reload export SYSTEMD_PAGER=cat @@ -335,6 +362,20 @@ else fi fi +watch_flag="$(echo "${ENABLE_CELLULAR_WATCH:-true}" | tr '[:upper:]' '[:lower:]')" +if [[ "$watch_flag" == "false" || "$watch_flag" == "0" || "$watch_flag" == "no" ]]; then + systemctl disable --now cellular-proxy-watch.service cellular-proxy-watch.timer 2>/dev/null || true + info "ENABLE_CELLULAR_WATCH=false,未启用自动监控" +else + systemctl enable cellular-proxy-watch.service 2>/dev/null || true + systemctl enable cellular-proxy-watch.timer 2>/dev/null || true + if [[ "$SKIP_START" != "true" ]]; then + systemctl restart cellular-proxy-watch.service 2>/dev/null || true + systemctl restart cellular-proxy-watch.timer 2>/dev/null || true + fi + info "已启用 cellular-proxy-watch(网卡/源 IP 变化自动 rebind)" +fi + if [[ "$DO_VERIFY" == "true" && "$SKIP_START" != "true" ]]; then info "出口验证…" set +e diff --git a/scripts/watch-cellular.sh b/scripts/watch-cellular.sh new file mode 100644 index 0000000..ba4cc95 --- /dev/null +++ b/scripts/watch-cellular.sh @@ -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 diff --git a/systemd/cellular-proxy-watch-once.service b/systemd/cellular-proxy-watch-once.service new file mode 100644 index 0000000..8ed9191 --- /dev/null +++ b/systemd/cellular-proxy-watch-once.service @@ -0,0 +1,18 @@ +[Unit] +Description=cellular-proxy WWAN watch once (timer) +After=network-online.target +ConditionPathExists=@INSTALL_DIR@/etc/settings.conf + +[Service] +Type=oneshot +EnvironmentFile=-@INSTALL_DIR@/etc/runtime.env +EnvironmentFile=-@INSTALL_DIR@/etc/settings.conf +ExecStart=@INSTALL_DIR@/scripts/watch-cellular.sh --once +Nice=10 +MemoryMax=16M +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=true +PrivateTmp=true +StandardOutput=append:@LOG_DIR@/watch.log +StandardError=append:@LOG_DIR@/watch.log diff --git a/systemd/cellular-proxy-watch.service b/systemd/cellular-proxy-watch.service new file mode 100644 index 0000000..d192d96 --- /dev/null +++ b/systemd/cellular-proxy-watch.service @@ -0,0 +1,25 @@ +[Unit] +Description=cellular-proxy WWAN watch (auto rebind on IP/link change) +After=network-online.target cellular-proxy.service +Wants=network-online.target +PartOf=cellular-proxy.service + +[Service] +Type=simple +EnvironmentFile=-@INSTALL_DIR@/etc/runtime.env +EnvironmentFile=-@INSTALL_DIR@/etc/settings.conf +# 低内存:watch 脚本本身极轻;间隔可在 settings WATCH_INTERVAL_SEC 覆盖 +ExecStart=@INSTALL_DIR@/scripts/watch-cellular.sh +Restart=always +RestartSec=5 +MemoryMax=16M +Nice=10 +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=true +PrivateTmp=true +StandardOutput=append:@LOG_DIR@/watch.log +StandardError=append:@LOG_DIR@/watch.log + +[Install] +WantedBy=multi-user.target diff --git a/systemd/cellular-proxy-watch.timer b/systemd/cellular-proxy-watch.timer new file mode 100644 index 0000000..41b57cd --- /dev/null +++ b/systemd/cellular-proxy-watch.timer @@ -0,0 +1,14 @@ +[Unit] +Description=cellular-proxy WWAN watch timer (fallback oneshot) +Requires=cellular-proxy-watch-once.service + +[Timer] +# 兜底:即使 long-running watch 挂了,也每 2 分钟 oneshot 检查 +OnBootSec=1min +OnUnitActiveSec=2min +AccuracySec=30s +Persistent=true +Unit=cellular-proxy-watch-once.service + +[Install] +WantedBy=timers.target