1 Commits
Author SHA1 Message Date
Hermes aec388682b fix: 安装启动后不再卡住,保证脚本退出
- 去掉可能阻塞的 systemctl status 全量输出
- verify 使用 connect/max 超时;安装阶段 8s 快速验证
- 验证失败只告警,不阻塞安装结束
2026-07-21 11:26:30 +00:00
23 changed files with 203 additions and 3084 deletions
+28 -94
View File
@@ -9,20 +9,21 @@
| **不走** 本代理 | 系统默认(通常 WiFi) | | **不走** 本代理 | 系统默认(通常 WiFi) |
| **走** 本代理 | **一律数据网卡**(自动探测并 bind | | **走** 本代理 | **一律数据网卡**(自动探测并 bind |
## ★ 一条命令(安装 / 升级通用) ## 全自动一键安装
无参数即可:
1. 下载源码
2. **自动探测数据网卡**(名称 / 驱动 / 次要默认路由 / 非默认物理口)
3. **写入配置并 bind 出口**
4. 安装 sing-box + systemd
5. 启动并尝试验证出口 IP
```bash ```bash
curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash
``` ```
| 机器状态 | 行为 | 可选覆盖:
|----------|------|
| **未安装** | 全量安装:探测网卡 → 下 sing-box → 启动 → 验证 |
| **已安装** | 增量升级:保留密钥/网卡/账密,更新 UI/scripts,默认不重下二进制 |
不需要记 `--upgrade` / `--install`**同一条命令反复执行即可**。
可选覆盖(首次安装常用):
```bash ```bash
curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash -s -- \ curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash -s -- \
@@ -34,98 +35,38 @@ curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/ins
| 选项 | 说明 | | 选项 | 说明 |
|------|------| |------|------|
| (无参数) | **自动**:未装→安装,已装→升级 | | (无参数) | 全自动探测 + 绑定 + 安装 + 启动 |
| `--force-install` | 已装也强制全量安装 |
| `--force-binary` | 升级时强制重下 sing-box |
| `--ui-only` | 只更新 9090 面板 |
| `--rebind` | 升级时重新探测数据网卡 |
| `--verify` | 升级后做出口验证(升级默认跳过) |
| `--iface` | 强制指定数据网卡 | | `--iface` | 强制指定数据网卡 |
| `--secret` | 面板密钥(默认随机;升级不改已有 | | `--secret` | 面板密钥(默认随机) |
| `--user` / `--pass` | 代理鉴权 | | `--user` / `--pass` | 代理鉴权 |
| `--port` / `--panel-port` | 默认 7890 / 9090 | | `--port` / `--panel-port` | 默认 7890 / 9090 |
| `--memory` | MemoryMax MB,默认 96 | | `--memory` | MemoryMax MB,默认 96 |
| `--skip-start` / `--skip-verify` | 跳过启动或验证 | | `--skip-start` / `--skip-verify` | 跳过启动或验证 |
## 已装后的快捷方式 装完:
装过一次后也可用:
```bash ```bash
sudo cpxy upgrade # 等同再跑上面那条 curl(增量) cpxy verify # 直连 IP ≠ 代理 IP
sudo cpxy upgrade --ui-only cpxy rebind # 重插卡后重新自动探测并绑定
sudo cpxy upgrade --force-binary cpxy detect # 查看评分/驱动
sudo cpxy upgrade --verify
```
| 保留 | 默认跳过 | 会更新 |
|------|----------|--------|
| `settings.conf` / `PANEL_SECRET` / 代理账密 | 重下 sing-box(已存在则复用) | UI 面板 |
| `CELLULAR_IFACE` 绑定 | 重新探测网卡 | scripts / `cpxy` / systemd |
| 端口与 MemoryMax | 出口公网验证 | 重生 `config.json` 并 restart |
常用运维:
```bash
cpxy verify
cpxy rebind
cpxy status | logs 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` + **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` |
关监控:`ENABLE_CELLULAR_WATCH=false``cpxy upgrade`,或 `cpxy watch stop`
若必须 pin 源 IP`BIND_SOURCE_IP=true`(依赖 watch 同步,否则会再次 stale)。
- 代理:`LAN-IP:7890`HTTP + SOCKS5 - 代理:`LAN-IP:7890`HTTP + SOCKS5
- 面板:`http://LAN-IP:9090/ui/`(密钥在安装结束输出 / `settings.conf` - 面板:`http://LAN-IP:9090/`(密钥在安装结束输出 / `settings.conf`
- **UI 可直接配置代理账号密码**(管理 API 默认 `9091`,与面板 secret 相同)
## 自动探测逻辑(摘要) ## 自动探测逻辑(摘要)
顺序如下,前一步命中即返回 按分数选择,优先
0. **ModemManager bearer**`mmcli -b` 报的 `interface:`)—— 最权威,优先采用 1. 驱动像蜂窝/USB 拨号:`qmi_wwan``cdc_mbim``cdc_ether``rndis_host`
1. 驱动像蜂窝/USB 拨号:`qmi_wwan``cdc_mbim``cdc_ether``rndis_host``bam-dmux``rmnet*` 等(+100 2. 名称含:`wwan` / `wwp` / `usb` / `enx` / `ppp` / `rmnet` / `ccmni`
2. 名称含 `wwan` / `wwp` / `ppp` / `rmnet` / `ccmni` …(+40,弱启发式)
3. **不是** 系统默认路由网卡(默认通常是 WiFi) 3. **不是** 系统默认路由网卡(默认通常是 WiFi)
4. 多默认路由时取次要默认口 4. 多默认路由时取次要默认口
5. 回退:任意非默认且有 IPv4 的物理网卡 5. 回退:任意非默认且有 IPv4 的物理网卡
探测失败会直接报错并提示用 `--iface` 探测失败会直接报错并提示用 `--iface`
### 绝不绑定 USB gadget 网卡
设备把自己当 USB 网卡挂给上游主机时会出现 `usb0` / `rndis0`(驱动 `configfs-gadget`
`g_ether` 等)。那是「本机 → PC」的下行链路,一旦被当成代理出口,所有出站都会
`context deadline exceeded`SOCKS5 客户端看到的就是 `socks connect failed rep=0x01`
因此:
- `iface_is_usb_gadget()` 按驱动名与 gadget 总线路径识别这类接口,打分阶段直接跳过;
- 默认探测关键词不再包含 `usb` / `enx`,升级时会自动从已有 `settings.conf` 里剔除;
- 即使手工把 `CELLULAR_IFACE=usb0` 写进配置或用 `--iface usb0``detect` / `generate` /
`install` / `upgrade` / `watch` 都会拒绝并改用 ModemManager 的结果;
- 真正的 USB 上行模组(`rndis_host``cdc_*``qmi_wwan`)是 host 侧驱动,不受影响。
回归测试(不需要真机,纯离线,覆盖 18 条断言):
```bash
bash tests/detect-gadget.sh
```
## 手动安装 ## 手动安装
```bash ```bash
@@ -137,27 +78,20 @@ sudo ./scripts/install.sh # 同样会自动探测并绑定
## 资源 ## 资源
- 单进程 sing-box - 单进程 sing-box
- 中文静态面板animal-island-ui 视觉 token,单文件无 React - 中文静态面板 ~20KB
- 默认 `MemoryMax=96MB` - 默认 `MemoryMax=96MB`
## 二进制从哪里下? ## GitHub 下载代理
**优先 Gitea Release 预置包**(一键安装默认走 Gitea,无需访问 GitHub 安装时下载 sing-box release 默认走
```text ```text
https://gitea.chickliu.fun/Hermes/cellular-proxy/releases/download/bin-v1.11.7/sing-box-1.11.7-linux-amd64.tar.gz https://git.86482425.xyz/https://github.com/SagerNet/sing-box/releases/...
https://gitea.chickliu.fun/Hermes/cellular-proxy/releases/download/bin-v1.11.7/sing-box-1.11.7-linux-arm64.tar.gz
https://gitea.chickliu.fun/Hermes/cellular-proxy/releases/download/bin-v1.11.7/sing-box-1.11.7-linux-armv7.tar.gz
``` ```
下载顺序:`Gitea 整包``Gitea 分片合并(兜底)``GitHub 代理(https://git.86482425.xyz)` `直连 GitHub` 可在 `settings.conf` `GITHUB_PROXY=`(置空则直连 GitHub)。代理失败会自动回退直连。
## 代理账号密码 ## 安全
| 方式 | 命令 / 入口 | 局域网监听建议设置 `--user/--pass`;面板 secret 已默认随机生成。
|------|-------------| `REQUIRE_CELLULAR_IFACE=true`:数据口不在则不装/不生成假出口。
| **UI** | `http://LAN-IP:9090/ui/` → 面板 secret → 代理账号密码 |
| **CLI** | `sudo cpxy auth --user u --pass p` |
| 安装时 | `--user` / `--pass` |
管理 API`POST http://LAN-IP:9091/proxy-auth`Header `Authorization: Bearer $PANEL_SECRET`
+8 -38
View File
@@ -15,37 +15,14 @@ INSTALL_DIR=/opt/cellular-proxy
LOG_DIR=/var/log/cellular-proxy LOG_DIR=/var/log/cellular-proxy
# ---- 数据网卡(代理出口强制绑定)---- # ---- 数据网卡(代理出口强制绑定)----
# 留空则自动探测(优先 ModemManager bearer,其次驱动/名称打分);建议手动填准 # 留空则自动探测 wwan/usb/enx 等;建议手动填准
# 注意:不要填本机对上游主机暴露的 USB gadget 网卡(常见名 usb0/rndis0,驱动
# configfs-gadget/g_ether)。那是「本机 -> PC」的下行链路,绑上去所有出站都会超时,
# SOCKS5 会返回 rep=0x01。脚本会拒绝这类网卡并自动重新探测。
CELLULAR_IFACE= CELLULAR_IFACE=
# 元数据:数据侧当前源 IPv4generate/watch 会更新;默认不写入 sing-box # 可选:数据侧源 IPv4一般留空
CELLULAR_SOURCE_IP= 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 出) # 数据网卡不存在则不启动(推荐 true,避免代理静默从 WiFi 出)
REQUIRE_CELLULAR_IFACE=true REQUIRE_CELLULAR_IFACE=true
# 自动探测关键词(逗号分隔)。仅作弱启发式,权重低于驱动判定。 # 自动探测关键词(逗号分隔)
# 刻意不含 usb/enx:会命中 USB gadget 网卡 usb0,导致出口绑到 PC 侧链路。 CELLULAR_IFACE_PATTERNS=wwan,wwp,usb,enx,ppp,cdc
CELLULAR_IFACE_PATTERNS=wwan,wwp,ppp,cdc,rmnet,ccmni,mbim,qmi
# 后台监控(推荐开):网卡改名/掉线/无 IPv4 时自动 rebind;默认开启 long-running watch
ENABLE_CELLULAR_WATCH=true
# 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)---- # ---- 代理监听(局域网其它设备用 0.0.0.0)----
# mixed = 同一端口同时支持 HTTP 代理 与 SOCKS5 # mixed = 同一端口同时支持 HTTP 代理 与 SOCKS5
@@ -53,7 +30,6 @@ PROXY_LISTEN_HOST=0.0.0.0
PROXY_MIXED_PORT=7890 PROXY_MIXED_PORT=7890
# 代理鉴权(局域网强烈建议设置;留空=无鉴权) # 代理鉴权(局域网强烈建议设置;留空=无鉴权)
# 7890 代理鉴权(空=无密码)。也可用: cpxy auth --user u --pass p 或面板 UI
PROXY_USER= PROXY_USER=
PROXY_PASS= PROXY_PASS=
@@ -70,18 +46,12 @@ ENABLE_DNS=true
LOG_LEVEL=warn LOG_LEVEL=warn
# ---- sing-box ---- # ---- sing-box ----
# auto = 优先 Gitea 预置包(整包/分片),失败再走 GitHub 代理 SING_BOX_SOURCE=release
# gitea | github | local | auto
SING_BOX_SOURCE=auto
SING_BOX_VERSION=1.11.7 SING_BOX_VERSION=1.11.7
SING_BOX_BIN= SING_BOX_BIN=
# Gitea 预置包 Releasepublish-binaries-to-gitea.sh 上传 # GitHub 下载代理前缀(下载 sing-box release 时使用
GITEA_BASE=https://gitea.chickliu.fun # 默认 https://git.86482425.xyz → 实际 URL: 代理/https://github.com/...
GITEA_OWNER=Hermes # 置空则直连 GitHub
GITEA_REPO=cellular-proxy
# 默认 tag: bin-v{SING_BOX_VERSION}
SING_BOX_RELEASE_TAG=
# 维护端从 GitHub 拉包、安装端回退 GitHub 时使用;置空则直连
GITHUB_PROXY=https://git.86482425.xyz GITHUB_PROXY=https://git.86482425.xyz
# 进程内存上限(MB,0=不限制)。512MB 设备建议 64~96 # 进程内存上限(MB,0=不限制)。512MB 设备建议 64~96
+1 -9
View File
@@ -7,22 +7,14 @@ case "$cmd" in
detect) exec "$ROOT/scripts/detect.sh" "$@" ;; detect) exec "$ROOT/scripts/detect.sh" "$@" ;;
generate) exec "$ROOT/scripts/generate.sh" "$@" ;; generate) exec "$ROOT/scripts/generate.sh" "$@" ;;
install) exec "$ROOT/scripts/install.sh" "$@" ;; install) exec "$ROOT/scripts/install.sh" "$@" ;;
upgrade) exec "$ROOT/scripts/online-upgrade.sh" "$@" ;;
verify) exec "$ROOT/scripts/verify.sh" "$@" ;; verify) exec "$ROOT/scripts/verify.sh" "$@" ;;
fetch) exec "$ROOT/scripts/fetch-binaries.sh" "$@" ;; fetch) exec "$ROOT/scripts/fetch-binaries.sh" "$@" ;;
auth) exec "$ROOT/scripts/apply-proxy-auth.sh" "$@" ;;
help|*) help|*)
cat <<H cat <<H
./cpxy <cmd> ./cpxy <cmd>
detect | generate | install | upgrade | verify | fetch detect | generate | install | verify | fetch
auth --user U --pass P | auth --clear | auth --show
语义: 走本代理的连接一律从数据网卡出口(系统默认仍可走 WiFi) 语义: 走本代理的连接一律从数据网卡出口(系统默认仍可走 WiFi)
已装机器一条命令升级:
sudo cpxy upgrade
sudo cpxy upgrade --ui-only
sudo cpxy upgrade --force-binary
H H
;; ;;
esac esac
-11
View File
@@ -6,17 +6,6 @@
2. 需要「用流量出口」时:把应用的 HTTP/SOCKS 指到 `LAN:7890` 2. 需要「用流量出口」时:把应用的 HTTP/SOCKS 指到 `LAN:7890`
3. `cpxy verify` 确认代理 IP ≠ 直连 IP 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 ```bash
+29 -162
View File
@@ -1,16 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# cellular-proxy 一条命令:未安装 → 全量安装;已安装 → 增量升级 # cellular-proxy 全自动一键安装
# 语义:走本代理的连接一律从数据网卡(流量)出口;系统默认仍可走 WiFi # 语义:走本代理的连接一律从数据网卡(流量)出口;系统默认仍可走 WiFi
# #
# ★ 唯一推荐(装 / 升 通用: # 最简(自动探测数据网卡 + 自动生成密钥 + 下载 sing-box + 启动 + 验证:
# curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash # curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash
# #
# 已装后也可: sudo cpxy upgrade # 可选参数仍可用:
#
# 可选参数:
# ... | sudo bash -s -- --iface wwan0 --secret xxx --user u --pass p # ... | sudo bash -s -- --iface wwan0 --secret xxx --user u --pass p
# ... | sudo bash -s -- --force-install # 已装也强制全量
# ... | sudo bash -s -- --force-binary # 升级时强制重下 sing-box
# #
set -euo pipefail set -euo pipefail
@@ -34,12 +30,6 @@ OPT_SKIP_START=0
OPT_SKIP_VERIFY=0 OPT_SKIP_VERIFY=0
OPT_LOCAL_DIR="" OPT_LOCAL_DIR=""
OPT_NO_AUTO_DETECT=0 OPT_NO_AUTO_DETECT=0
OPT_UPGRADE=0
OPT_UI_ONLY=0
OPT_FORCE_BINARY=0
OPT_REBIND=0
OPT_FORCE_INSTALL=0
OPT_DO_VERIFY=0
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2; } log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2; }
info() { log "INFO $*"; } info() { log "INFO $*"; }
@@ -49,32 +39,23 @@ die() { err "$*"; exit 1; }
usage() { usage() {
cat <<'H' cat <<'H'
cellular-proxy — 一条命令自动安装或升级 cellular-proxy 全自动一键安装
curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash 无参数即可:自动探测数据网卡 → 绑定出口 → 装 sing-box → 启动 → 验证
行为(无参数时自动判断: 选项(全可选:
· 未检测到安装 → 全量安装(探测网卡 + 下载 sing-box + 启动 --iface NAME 强制指定数据网卡(跳过自动探测
· 已安装 → 增量升级(保留密钥/网卡;默认不重下二进制 --secret STR 面板 PANEL_SECRET(默认随机生成
--user USER 代理账号
强制选项: --pass PASS 代理密码
--upgrade 强制走增量升级(未安装会报错 --port N 代理端口(默认 7890
--force-install 即使已安装也走全量安装 --panel-port N 面板端口(默认 9090)
--ui-only 仅更新面板(隐含升级)
--force-binary 升级时强制重下 sing-box
--rebind 升级时重新探测数据网卡
--verify 升级后跑出口验证(升级默认跳过)
其它选项:
--iface NAME 强制指定数据网卡
--secret STR 面板 PANEL_SECRET(默认随机;升级不改已有)
--user USER / --pass PASS
--port N / --panel-port N
--memory MB MemoryMax(默认 96 --memory MB MemoryMax(默认 96
--install-dir PATH --install-dir PATH 安装目录
--local DIR 本地源码 --local DIR 本地源码,不拉 Gitea
--ref REF --ref REF 分支/tag
--skip-start / --skip-verify --skip-start 不启动服务
--skip-verify 跳过出口验证
--no-auto-detect 禁止自动探测(必须 --iface) --no-auto-detect 禁止自动探测(必须 --iface)
-h, --help -h, --help
H H
@@ -95,12 +76,6 @@ while [[ $# -gt 0 ]]; do
--skip-start) OPT_SKIP_START=1; shift ;; --skip-start) OPT_SKIP_START=1; shift ;;
--skip-verify) OPT_SKIP_VERIFY=1; shift ;; --skip-verify) OPT_SKIP_VERIFY=1; shift ;;
--no-auto-detect) OPT_NO_AUTO_DETECT=1; shift ;; --no-auto-detect) OPT_NO_AUTO_DETECT=1; shift ;;
--upgrade) OPT_UPGRADE=1; shift ;;
--ui-only) OPT_UI_ONLY=1; shift ;;
--force-binary) OPT_FORCE_BINARY=1; shift ;;
--rebind) OPT_REBIND=1; shift ;;
--force-install) OPT_FORCE_INSTALL=1; shift ;;
--verify) OPT_DO_VERIFY=1; shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) die "未知参数: $1--help" ;; *) die "未知参数: $1--help" ;;
esac esac
@@ -116,24 +91,12 @@ need_cmd() {
command -v "$1" >/dev/null 2>&1 || die "缺少命令: $1" command -v "$1" >/dev/null 2>&1 || die "缺少命令: $1"
} }
is_installed() {
local d="${1:-$INSTALL_DIR}"
# settings 存在即视为已装(可增量升级);兼容只有 conf 或 unit 的半装状态
if [[ -f "$d/etc/settings.conf" ]]; then
return 0
fi
if systemctl cat cellular-proxy.service >/dev/null 2>&1; then
# unit 在但目录被删:仍尝试按 conf 探测
[[ -f /opt/cellular-proxy/etc/settings.conf ]] && return 0
fi
return 1
}
ensure_deps() { ensure_deps() {
local missing=() local missing=()
for c in tar awk ip curl; do for c in tar awk ip curl; do
command -v "$c" >/dev/null 2>&1 || missing+=("$c") command -v "$c" >/dev/null 2>&1 || missing+=("$c")
done done
# curl 可用 wget 替代
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
missing+=("curl") missing+=("curl")
fi fi
@@ -219,59 +182,22 @@ fetch_source() {
prepare_settings() { prepare_settings() {
local src="$WORK_DIR/src" local src="$WORK_DIR/src"
local conf="$src/config/settings.conf" local conf="$src/config/settings.conf"
local live="${INSTALL_DIR}/etc/settings.conf"
[[ -f "$src/config/settings.conf.example" ]] || die "缺少 settings.conf.example" [[ -f "$src/config/settings.conf.example" ]] || die "缺少 settings.conf.example"
# 已有安装:以 live settings 为底,不重置密钥/网卡
if [[ -f "$live" ]]; then
info "沿用已有配置: $live"
cp "$live" "$conf"
else
cp "$src/config/settings.conf.example" "$conf" cp "$src/config/settings.conf.example" "$conf"
fi
# 预加载 lib 做自动探测(源码里的 lib)
# shellcheck source=/dev/null # shellcheck source=/dev/null
source "$src/scripts/lib.sh" source "$src/scripts/lib.sh"
CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,ppp,cdc,rmnet,ccmni,mbim,qmi}" CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,usb,enx,ppp,cdc,rmnet,ccmni,mbim,qmi}"
REQUIRE_CELLULAR_IFACE=false REQUIRE_CELLULAR_IFACE=false
# 迁移旧配置:usb/enx 关键词会命中本机的 USB gadget 网卡(usb0),必须剔除
local old_patterns cleaned
old_patterns="$(awk -F= '/^CELLULAR_IFACE_PATTERNS=/{print substr($0,index($0,"=")+1); exit}' "$conf" 2>/dev/null || true)"
if [[ -n "$old_patterns" ]]; then
cleaned="$(printf '%s' "$old_patterns" | tr ',' '\n' | sed 's/[[:space:]]//g' \
| grep -vxE 'usb|enx' | paste -sd, - 2>/dev/null || true)"
if [[ -n "$cleaned" && "$cleaned" != "$old_patterns" ]]; then
set_kv "$conf" CELLULAR_IFACE_PATTERNS "$cleaned"
CELLULAR_IFACE_PATTERNS="$cleaned"
warn "已清理 CELLULAR_IFACE_PATTERNS: $old_patterns -> $cleaned"
fi
fi
local cell="" local cell=""
if [[ -n "$OPT_IFACE" ]]; then if [[ -n "$OPT_IFACE" ]]; then
cell="$OPT_IFACE" cell="$OPT_IFACE"
info "使用 --iface $cell" info "使用 --iface $cell"
if iface_is_usb_gadget "$cell"; then elif [[ "$OPT_NO_AUTO_DETECT" -eq 1 ]]; then
die "--iface $cell 是本机对上游主机暴露的 USB gadget 网卡,不能作为数据出口"
fi
elif [[ -f "$live" ]] && grep -qE '^CELLULAR_IFACE=.' "$live" 2>/dev/null; then
# shellcheck disable=SC1090
cell="$(awk -F= '/^CELLULAR_IFACE=/{print substr($0,index($0,"=")+1); exit}' "$live")"
if [[ -n "$cell" ]] && iface_is_usb_gadget "$cell"; then
warn "已有配置里的 $cell 是 USB gadget 网卡(面向上游主机),丢弃并重新探测"
cell=""
elif [[ -n "$cell" ]] && iface_exists "$cell"; then
info "沿用已绑定网卡: $cell"
else
cell=""
fi
fi
if [[ -z "$cell" ]]; then
if [[ "$OPT_NO_AUTO_DETECT" -eq 1 ]]; then
die "--no-auto-detect 时必须提供 --iface" die "--no-auto-detect 时必须提供 --iface"
fi else
info "自动探测数据网卡…" info "自动探测数据网卡…"
cell="$(detect_cellular_iface "" || true)" cell="$(detect_cellular_iface "" || true)"
fi fi
@@ -287,10 +213,6 @@ prepare_settings() {
die "网卡不存在: $cell" die "网卡不存在: $cell"
fi fi
if iface_is_usb_gadget "$cell"; then
die "拒绝把 USB gadget 网卡 $cell 写成数据出口,请用 --iface wwanX 指定真实数据网卡"
fi
local src_ip local src_ip
src_ip="$(detect_source_ip "$cell" || true)" src_ip="$(detect_source_ip "$cell" || true)"
set_kv "$conf" CELLULAR_IFACE "$cell" set_kv "$conf" CELLULAR_IFACE "$cell"
@@ -299,19 +221,14 @@ prepare_settings() {
fi fi
info "已写入 CELLULAR_IFACE=$cell ip=${src_ip:-none}" info "已写入 CELLULAR_IFACE=$cell ip=${src_ip:-none}"
# secret:仅全量且未指定且当前是占位时生成;有 live 则不覆盖
if [[ -n "$OPT_SECRET" ]]; then if [[ -n "$OPT_SECRET" ]]; then
set_kv "$conf" PANEL_SECRET "$OPT_SECRET" set_kv "$conf" PANEL_SECRET "$OPT_SECRET"
elif [[ ! -f "$live" ]]; then else
local cur
cur="$(awk -F= '/^PANEL_SECRET=/{print substr($0,index($0,"=")+1); exit}' "$conf" 2>/dev/null || true)"
if [[ -z "$cur" || "$cur" == "please-change-me" || "$cur" == "change-this-secret" ]]; then
local gen local gen
gen="$(openssl rand -hex 12 2>/dev/null || head -c 16 /dev/urandom | xxd -p | tr -d '\n')" gen="$(openssl rand -hex 12 2>/dev/null || head -c 16 /dev/urandom | xxd -p | tr -d '\n')"
set_kv "$conf" PANEL_SECRET "$gen" set_kv "$conf" PANEL_SECRET "$gen"
info "已自动生成 PANEL_SECRET" info "已自动生成 PANEL_SECRET"
fi fi
fi
[[ -n "$OPT_USER" ]] && set_kv "$conf" PROXY_USER "$OPT_USER" [[ -n "$OPT_USER" ]] && set_kv "$conf" PROXY_USER "$OPT_USER"
[[ -n "$OPT_PASS" ]] && set_kv "$conf" PROXY_PASS "$OPT_PASS" [[ -n "$OPT_PASS" ]] && set_kv "$conf" PROXY_PASS "$OPT_PASS"
@@ -321,35 +238,11 @@ prepare_settings() {
set_kv "$conf" INSTALL_DIR "$INSTALL_DIR" set_kv "$conf" INSTALL_DIR "$INSTALL_DIR"
set_kv "$conf" REQUIRE_CELLULAR_IFACE "true" set_kv "$conf" REQUIRE_CELLULAR_IFACE "true"
# 给子 install 用
export DETECTED_CELL="$cell" export DETECTED_CELL="$cell"
export DETECTED_SRC_IP="$src_ip" export DETECTED_SRC_IP="$src_ip"
} }
run_upgrade() {
info "======== cellular-proxy 增量升级入口 ========"
fetch_source
local args=()
[[ "$OPT_UI_ONLY" -eq 1 ]] && args+=(--ui-only)
[[ "$OPT_FORCE_BINARY" -eq 1 ]] && args+=(--force-binary)
[[ "$OPT_REBIND" -eq 1 ]] && args+=(--rebind)
# 升级默认不 verify(加快);需要时: --verify 或 UPGRADE_VERIFY=1
if [[ "${OPT_DO_VERIFY:-0}" -eq 1 ]] || [[ "${UPGRADE_VERIFY:-}" == "1" ]]; then
args+=(--verify)
fi
# 用户传了 skip-start
if [[ "$OPT_SKIP_START" -eq 1 ]]; then
args+=(--skip-start)
fi
# 把 --iface 映射为 rebind 场景下的强制:简单做法:若给了 --iface,写入 live 再 upgrade
if [[ -n "$OPT_IFACE" && -f "$INSTALL_DIR/etc/settings.conf" ]]; then
set_kv "$INSTALL_DIR/etc/settings.conf" CELLULAR_IFACE "$OPT_IFACE"
info "已写入 --iface $OPT_IFACE 到现有 settings"
fi
export INSTALL_DIR
export CONFIG_FILE="$INSTALL_DIR/etc/settings.conf"
bash "$WORK_DIR/src/scripts/upgrade.sh" "${args[@]}"
}
main() { main() {
need_root need_root
ensure_deps ensure_deps
@@ -359,33 +252,6 @@ main() {
trap 'rm -rf "$WORK_DIR"' EXIT trap 'rm -rf "$WORK_DIR"' EXIT
# --ui-only 暗示升级
if [[ "$OPT_UI_ONLY" -eq 1 ]]; then
OPT_UPGRADE=1
fi
# ★ 一条命令自动分流:已安装 → 升级;未安装 → 全量安装
if [[ "$OPT_FORCE_INSTALL" -eq 0 && "$OPT_UPGRADE" -eq 0 ]]; then
if is_installed "$INSTALL_DIR"; then
info "检测到已安装: $INSTALL_DIR → 自动增量升级(保留密钥/网卡)"
OPT_UPGRADE=1
else
info "未检测到安装 → 自动全量安装"
fi
fi
if [[ "$OPT_UPGRADE" -eq 1 ]]; then
if ! is_installed "$INSTALL_DIR" && [[ ! -f "$INSTALL_DIR/etc/settings.conf" ]]; then
die "指定了升级,但未找到已安装实例 ($INSTALL_DIR)。
直接无参执行即可(会自动全量安装):
curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash"
fi
info "模式: 增量升级"
run_upgrade
return 0
fi
info "模式: 全量安装"
info "======== cellular-proxy 全自动安装 ========" info "======== cellular-proxy 全自动安装 ========"
info "1/3 获取源码" info "1/3 获取源码"
fetch_source fetch_source
@@ -395,7 +261,7 @@ main() {
info "3/3 安装 sing-box、绑定出口、启动" info "3/3 安装 sing-box、绑定出口、启动"
export CONFIG_FILE="$WORK_DIR/src/config/settings.conf" export CONFIG_FILE="$WORK_DIR/src/config/settings.conf"
export AUTO_DETECT=false export AUTO_DETECT=false # 已在 prepare 写死网卡
export AUTO_VERIFY=true export AUTO_VERIFY=true
if [[ "$OPT_SKIP_VERIFY" -eq 1 ]]; then if [[ "$OPT_SKIP_VERIFY" -eq 1 ]]; then
export AUTO_VERIFY=false export AUTO_VERIFY=false
@@ -405,6 +271,8 @@ main() {
else else
export SKIP_START=false export SKIP_START=false
fi fi
# 确保 settings 路径对 scripts/install 可见
# scripts/install 读 ROOT/config/settings.conf
bash "$WORK_DIR/src/scripts/install.sh" bash "$WORK_DIR/src/scripts/install.sh"
# shellcheck disable=SC1090 # shellcheck disable=SC1090
@@ -423,13 +291,12 @@ main() {
语义: 走代理 → 数据流量;不走代理 → WiFi/默认 语义: 走代理 → 数据流量;不走代理 → WiFi/默认
代理: HTTP/SOCKS ${lan:-127.0.0.1}:${port} 代理: HTTP/SOCKS ${lan:-127.0.0.1}:${port}
面板: http://${lan:-<LAN-IP>}:${panel}/ui/ 面板: http://${lan:-<LAN-IP>}:${panel}/
密钥: ${secret} 密钥: ${secret}
配置: $INSTALL_DIR/etc/settings.conf 配置: $INSTALL_DIR/etc/settings.conf
验证: cpxy verify 验证: cpxy verify
重绑: cpxy rebind 重绑: cpxy rebind # 换卡/重插后重新自动探测
之后更新: 再执行同一条 curl 命令,或: sudo cpxy upgrade
日志: cpxy logs 日志: cpxy logs
EOM EOM
} }
-193
View File
@@ -1,193 +0,0 @@
#!/usr/bin/env python3
"""轻量管理 API:用面板 secret 鉴权,配置代理账号密码。
默认监听 127.0.0.1:9091UI 经同源时用相对路径 /cpxy-admin/*
(由 nginx 反代或浏览器直连时配置)。
为免引入 nginx,默认让 UI 调 http://host:9091(与 9090 同机)。
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
INSTALL_DIR = Path(os.environ.get("INSTALL_DIR", "/opt/cellular-proxy"))
SETTINGS = Path(os.environ.get("CPXY_SETTINGS", str(INSTALL_DIR / "etc" / "settings.conf")))
LISTEN_HOST = os.environ.get("ADMIN_LISTEN_HOST", "0.0.0.0")
LISTEN_PORT = int(os.environ.get("ADMIN_LISTEN_PORT", "9091"))
APPLY_SCRIPT = Path(os.environ.get("CPXY_APPLY_AUTH", str(INSTALL_DIR / "scripts" / "apply-proxy-auth.sh")))
def load_settings(path: Path) -> dict:
data = {}
if not path.is_file():
return data
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
data[k.strip()] = v.strip()
return data
def mask(s: str) -> str:
if not s:
return ""
if len(s) <= 2:
return "***"
return s[0] + "***" + s[-1]
class Handler(BaseHTTPRequestHandler):
server_version = "cellular-proxy-admin/1.0"
def log_message(self, fmt, *args):
sys.stderr.write("[admin-api] " + (fmt % args) + "\n")
def _cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
def _json(self, code: int, obj):
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self._cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _auth_ok(self) -> bool:
conf = load_settings(SETTINGS)
secret = conf.get("PANEL_SECRET", "")
if not secret:
return False
auth = self.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[7:].strip() == secret
if auth.startswith("Bearer"):
return auth[6:].strip() == secret
# 兼容 query ?secret=
return False
def do_OPTIONS(self):
self.send_response(204)
self._cors()
self.end_headers()
def do_GET(self):
path = urlparse(self.path).path.rstrip("/") or "/"
if path in ("/health", "/cpxy-admin/health"):
self._json(200, {"ok": True})
return
if path not in ("/proxy-auth", "/cpxy-admin/proxy-auth"):
self._json(404, {"error": "not found"})
return
if not self._auth_ok():
self._json(401, {"message": "Unauthorized"})
return
conf = load_settings(SETTINGS)
user = conf.get("PROXY_USER", "")
pw = conf.get("PROXY_PASS", "")
self._json(
200,
{
"proxy_user": user,
"proxy_pass_set": bool(pw),
"proxy_pass_masked": mask(pw) if pw else "",
"auth_enabled": bool(user),
"port": conf.get("PROXY_MIXED_PORT", "7890"),
},
)
def do_POST(self):
path = urlparse(self.path).path.rstrip("/") or "/"
if path not in ("/proxy-auth", "/cpxy-admin/proxy-auth"):
self._json(404, {"error": "not found"})
return
if not self._auth_ok():
self._json(401, {"message": "Unauthorized"})
return
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
body = json.loads(raw.decode("utf-8") or "{}")
except json.JSONDecodeError:
self._json(400, {"error": "invalid json"})
return
clear = bool(body.get("clear"))
user = (body.get("user") or body.get("proxy_user") or "").strip()
password = body.get("pass") or body.get("password") or body.get("proxy_pass") or ""
if not APPLY_SCRIPT.is_file():
self._json(500, {"error": f"apply script missing: {APPLY_SCRIPT}"})
return
cmd = ["bash", str(APPLY_SCRIPT)]
if clear or (not user and not password):
cmd.append("--clear")
else:
if not user:
self._json(400, {"error": "user required (or set clear:true)"})
return
if password is None:
self._json(400, {"error": "pass required"})
return
# 基本字符约束,避免注入
if not re.fullmatch(r"[A-Za-z0-9_@./+\-]{1,64}", user):
self._json(400, {"error": "user 仅允许字母数字及 _@.+/-"})
return
if len(str(password)) > 128:
self._json(400, {"error": "password too long"})
return
cmd.extend(["--user", user, "--pass", str(password)])
try:
r = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60,
env={**os.environ, "INSTALL_DIR": str(INSTALL_DIR)},
)
except subprocess.TimeoutExpired:
self._json(504, {"error": "apply timeout"})
return
out = (r.stdout or "") + (r.stderr or "")
if r.returncode != 0:
self._json(500, {"error": "apply failed", "detail": out[-2000:]})
return
conf = load_settings(SETTINGS)
self._json(
200,
{
"ok": True,
"proxy_user": conf.get("PROXY_USER", ""),
"proxy_pass_set": bool(conf.get("PROXY_PASS")),
"auth_enabled": bool(conf.get("PROXY_USER")),
"log": out[-1500:],
},
)
def main():
if not SETTINGS.is_file():
print(f"warn: settings not found yet: {SETTINGS}", file=sys.stderr)
httpd = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler)
print(f"cellular-proxy admin API on {LISTEN_HOST}:{LISTEN_PORT}", flush=True)
httpd.serve_forever()
if __name__ == "__main__":
main()
-142
View File
@@ -1,142 +0,0 @@
#!/usr/bin/env bash
# 设置/清除代理账号密码,写 settings → 重生 config → 重启服务
# 用法:
# apply-proxy-auth.sh --user U --pass P
# apply-proxy-auth.sh --clear
# apply-proxy-auth.sh --show
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=lib.sh
source "$ROOT_DIR/scripts/lib.sh"
ACTION=set
USER_V=""
PASS_V=""
usage() {
cat <<'H'
apply-proxy-auth.sh — 配置 7890 代理鉴权
--user NAME 代理用户名
--pass PASS 代理密码
--clear 清除鉴权(无密码)
--show 仅显示当前状态(密码脱敏)
-h|--help
H
}
while [[ $# -gt 0 ]]; do
case "$1" in
--user) USER_V="${2:-}"; shift 2 ;;
--pass) PASS_V="${2:-}"; shift 2 ;;
--clear) ACTION=clear; shift ;;
--show) ACTION=show; shift ;;
-h|--help) usage; exit 0 ;;
*) die "未知参数: $1" ;;
esac
done
# 优先已安装配置
if [[ -f "${INSTALL_DIR:-/opt/cellular-proxy}/etc/settings.conf" ]]; then
CONFIG_FILE="${INSTALL_DIR:-/opt/cellular-proxy}/etc/settings.conf"
SETTINGS_DIR="$(dirname "$CONFIG_FILE")"
elif [[ -f "$ROOT_DIR/config/settings.conf" ]]; then
CONFIG_FILE="$ROOT_DIR/config/settings.conf"
SETTINGS_DIR="$(dirname "$CONFIG_FILE")"
else
die "找不到 settings.conf(请先安装)"
fi
set_kv() {
local file="$1" key="$2" val="$3"
local tmp
tmp="$(mktemp)"
awk -v k="$key" -v v="$val" '
BEGIN { done=0 }
index($0, k "=")==1 { print k "=" v; done=1; next }
{ print }
END { if (!done) print k "=" v }
' "$file" > "$tmp"
mv "$tmp" "$file"
}
load_config
INSTALL_DIR="${INSTALL_DIR:-/opt/cellular-proxy}"
mask() {
local s="$1" n=${#1}
if [[ $n -le 2 ]]; then echo "***"; else echo "${s:0:1}***${s: -1}"; fi
}
if [[ "$ACTION" == "show" ]]; then
echo "settings: $CONFIG_FILE"
echo "PROXY_USER=${PROXY_USER:-<empty>}"
if [[ -n "${PROXY_PASS:-}" ]]; then
echo "PROXY_PASS=$(mask "$PROXY_PASS") (已设置)"
else
echo "PROXY_PASS=<empty> (无鉴权)"
fi
exit 0
fi
need_root
if [[ "$ACTION" == "clear" ]]; then
set_kv "$CONFIG_FILE" PROXY_USER ""
set_kv "$CONFIG_FILE" PROXY_PASS ""
info "已清除代理账号密码"
else
[[ -n "$USER_V" ]] || die "请指定 --user(或用 --clear"
[[ -n "$PASS_V" ]] || die "请指定 --pass"
set_kv "$CONFIG_FILE" PROXY_USER "$USER_V"
set_kv "$CONFIG_FILE" PROXY_PASS "$PASS_V"
info "已写入 PROXY_USER=$USER_V"
fi
# 同步到安装树副本
if [[ -f "$INSTALL_DIR/etc/settings.conf" && "$CONFIG_FILE" != "$INSTALL_DIR/etc/settings.conf" ]]; then
install -m 0644 "$CONFIG_FILE" "$INSTALL_DIR/etc/settings.conf"
fi
# 若改的是安装目录,也尽量同步源树
if [[ -d "$ROOT_DIR/config" && "$CONFIG_FILE" == "$INSTALL_DIR/etc/settings.conf" ]]; then
install -m 0644 "$CONFIG_FILE" "$ROOT_DIR/config/settings.conf" 2>/dev/null || true
fi
export CONFIG_FILE
# generate 需要能 load 到最新 settings
if [[ -x "$INSTALL_DIR/scripts/generate.sh" ]]; then
GEN="$INSTALL_DIR/scripts/generate.sh"
OUT="$INSTALL_DIR/generated"
elif [[ -x "$ROOT_DIR/scripts/generate.sh" ]]; then
GEN="$ROOT_DIR/scripts/generate.sh"
OUT="${INSTALL_DIR}/generated"
mkdir -p "$OUT"
else
die "找不到 generate.sh"
fi
info "重新生成 sing-box 配置…"
"$GEN" "$OUT"
install -m 0644 "$OUT/config.json" "$INSTALL_DIR/etc/config.json"
install -m 0644 "$OUT/runtime.env" "$INSTALL_DIR/etc/runtime.env" 2>/dev/null || true
install -m 0644 "$CONFIG_FILE" "$INSTALL_DIR/etc/settings.conf"
if systemctl is-enabled cellular-proxy.service >/dev/null 2>&1 || systemctl list-unit-files | grep -q cellular-proxy; then
systemctl restart cellular-proxy.service
sleep 1
if systemctl is-active --quiet cellular-proxy.service; then
info "服务已重启 active"
else
warn "服务未 active,请检查: systemctl status cellular-proxy"
fi
else
warn "未找到 systemd 服务,配置已写好,请手动重启 sing-box"
fi
if [[ "$ACTION" == "clear" ]]; then
echo "当前: 无代理鉴权"
else
echo "当前: 用户 $USER_V / 密码已设置"
echo "HTTP: curl -x http://${USER_V}:****@127.0.0.1:${PROXY_MIXED_PORT:-7890} https://ifconfig.me"
echo "SOCKS: curl --socks5-hostname ${USER_V}:****@127.0.0.1:${PROXY_MIXED_PORT:-7890} https://ifconfig.me"
fi
+2 -6
View File
@@ -6,7 +6,7 @@ source "$ROOT_DIR/scripts/lib.sh"
if [[ -f "${CONFIG_FILE:-$ROOT_DIR/config/settings.conf}" ]]; then if [[ -f "${CONFIG_FILE:-$ROOT_DIR/config/settings.conf}" ]]; then
load_config load_config
else else
CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,ppp,cdc,rmnet,ccmni,mbim,qmi}" CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,usb,enx,ppp,cdc,rmnet,ccmni,mbim,qmi}"
REQUIRE_CELLULAR_IFACE=false REQUIRE_CELLULAR_IFACE=false
CELLULAR_IFACE= CELLULAR_IFACE=
fi fi
@@ -24,13 +24,9 @@ printf '%-12s %-10s %-18s %-12s %s\n' "IFACE" "STATE" "IPv4" "DRIVER" "SCORE"
default_if="$(detect_default_iface || true)" default_if="$(detect_default_iface || true)"
while IFS='|' read -r name state ip; do while IFS='|' read -r name state ip; do
[[ -z "$name" ]] && continue [[ -z "$name" ]] && continue
drv="$(iface_driver "$name" || true)"
if iface_is_usb_gadget "$name"; then
printf '%-12s %-10s %-18s %-12s %s\n' "$name" "$state" "${ip:-}" "${drv:-}" "skip(USB gadget: 面向上游主机,不可作出口)"
continue
fi
is_virtual_or_skip_iface "$name" && continue is_virtual_or_skip_iface "$name" && continue
sc="$(score_iface_as_cellular "$name" "$default_if")" sc="$(score_iface_as_cellular "$name" "$default_if")"
drv="$(iface_driver "$name" || true)"
printf '%-12s %-10s %-18s %-12s %s\n' "$name" "$state" "${ip:-}" "${drv:-}" "$sc" printf '%-12s %-10s %-18s %-12s %s\n' "$name" "$state" "${ip:-}" "${drv:-}" "$sc"
done < <(list_ifaces) done < <(list_ifaces)
echo echo
+15 -179
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 仅下载/安装 sing-box(轻量核心) # 仅下载 sing-box(轻量核心)
# 优先级:local → 系统已有 → Gitea Release(整包/分片)→ GitHub 代理 → 直连 GitHub
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=lib.sh # shellcheck source=lib.sh
@@ -14,15 +13,12 @@ ARCH="$(arch_go)"
TMP="$(mktemp -d)" TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT trap 'rm -rf "$TMP"' EXIT
# GitHub 下载代理:默认 https://git.86482425.xyz
# 空字符串 = 直连 GitHub;也可设为其它镜像前缀(末尾可带或不带 /)
GITHUB_PROXY="${GITHUB_PROXY:-https://git.86482425.xyz}" GITHUB_PROXY="${GITHUB_PROXY:-https://git.86482425.xyz}"
GITEA_BASE="${GITEA_BASE:-https://gitea.chickliu.fun}"
GITEA_OWNER="${GITEA_OWNER:-Hermes}"
GITEA_REPO="${GITEA_REPO:-cellular-proxy}"
SING_BOX_RELEASE_TAG="${SING_BOX_RELEASE_TAG:-bin-v${SING_BOX_VERSION}}"
# gitea | github | auto | local | release
SING_BOX_SOURCE="${SING_BOX_SOURCE:-auto}"
github_url() { github_url() {
# 把 https://github.com/... 加上代理前缀
local raw="$1" local raw="$1"
local proxy="${GITHUB_PROXY:-}" local proxy="${GITHUB_PROXY:-}"
if [[ -z "$proxy" ]]; then if [[ -z "$proxy" ]]; then
@@ -30,9 +26,11 @@ github_url() {
return return
fi fi
proxy="${proxy%/}" proxy="${proxy%/}"
# 已是代理地址则不重复加
case "$raw" in case "$raw" in
"${proxy}"/*) echo "$raw"; return ;; "${proxy}"/*) echo "$raw"; return ;;
esac esac
# 常见 ghproxy 风格:https://proxy/https://github.com/...
echo "${proxy}/${raw}" echo "${proxy}/${raw}"
} }
@@ -40,7 +38,7 @@ download() {
local url="$1" dest="$2" local url="$1" dest="$2"
info "下载: $url" info "下载: $url"
if command -v curl >/dev/null 2>&1; then if command -v curl >/dev/null 2>&1; then
curl -fL --retry 3 --connect-timeout 20 --max-time 300 -o "$dest" "$url" curl -fL --retry 3 --connect-timeout 20 -o "$dest" "$url"
elif command -v wget >/dev/null 2>&1; then elif command -v wget >/dev/null 2>&1; then
wget -O "$dest" "$url" wget -O "$dest" "$url"
else else
@@ -48,17 +46,8 @@ download() {
fi fi
} }
# 静默探测 URL 是否可下(不写盘)
url_ok() {
local url="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsSIL --connect-timeout 8 --max-time 20 -o /dev/null "$url" 2>/dev/null
else
return 1
fi
}
download_github() { download_github() {
# 先走代理,失败再直连 GitHub
local raw="$1" dest="$2" local raw="$1" dest="$2"
local proxied local proxied
proxied="$(github_url "$raw")" proxied="$(github_url "$raw")"
@@ -72,174 +61,21 @@ download_github() {
download "$raw" "$dest" download "$raw" "$dest"
} }
install_from_tarball() {
local tgz="$1" ver="$2" arch="$3"
local name="sing-box-${ver}-linux-${arch}"
tar -xzf "$tgz" -C "$TMP"
if [[ -x "$TMP/${name}/sing-box" ]]; then
install -m 0755 "$TMP/${name}/sing-box" "$INSTALL_DIR/bin/sing-box"
elif [[ -x "$TMP/sing-box" ]]; then
install -m 0755 "$TMP/sing-box" "$INSTALL_DIR/bin/sing-box"
else
local found
found="$(find "$TMP" -type f -name sing-box 2>/dev/null | head -1)"
[[ -n "$found" && -x "$found" ]] || die "压缩包内未找到 sing-box 可执行文件"
install -m 0755 "$found" "$INSTALL_DIR/bin/sing-box"
fi
}
gitea_asset_url() {
local fname="$1"
echo "${GITEA_BASE%/}/${GITEA_OWNER}/${GITEA_REPO}/releases/download/${SING_BOX_RELEASE_TAG}/${fname}"
}
# 从 Gitea 拉整包 tar.gz
try_download_gitea_whole() {
local ver="$1" arch="$2" dest="$3"
local name url
name="sing-box-${ver}-linux-${arch}.tar.gz"
url="$(gitea_asset_url "$name")"
info "尝试 Gitea 预置整包: $url"
if download "$url" "$dest"; then
return 0
fi
return 1
}
# 代理 504 时可能只提供分片:name.tar.gz.part-00.. + name.tar.gz.SHA256SUMS
try_download_gitea_parts() {
local ver="$1" arch="$2" dest="$3"
local base name sums_url part_url part_dir i max
name="sing-box-${ver}-linux-${arch}.tar.gz"
base="${GITEA_BASE%/}/${GITEA_OWNER}/${GITEA_REPO}/releases/download/${SING_BOX_RELEASE_TAG}"
sums_url="${base}/${name}.SHA256SUMS"
# 兼容旧名 SHA256SUMS(仅 armv7 曾用)
part_dir="$TMP/parts_${arch}"
mkdir -p "$part_dir"
if download "$sums_url" "$part_dir/SHA256SUMS" 2>/dev/null; then
info "发现分片清单: $sums_url"
elif [[ "$arch" == "armv7" ]] && download "$(gitea_asset_url SHA256SUMS)" "$part_dir/SHA256SUMS" 2>/dev/null; then
info "使用兼容清单 SHA256SUMS"
else
return 1
fi
# 从清单提取 part 文件名(去掉路径前缀)
mapfile -t parts < <(awk '{print $2}' "$part_dir/SHA256SUMS" | sed 's|^\./||' | grep -E '\.part-[0-9]+$' || true)
if [[ ${#parts[@]} -eq 0 ]]; then
# 无清单文件名时按 part-00..99 探测
parts=()
for i in $(seq -w 0 99); do
part_url="${base}/${name}.part-${i}"
if url_ok "$part_url"; then
parts+=("${name}.part-${i}")
else
[[ ${#parts[@]} -gt 0 ]] && break
fi
done
fi
[[ ${#parts[@]} -gt 0 ]] || return 1
info "Gitea 分片下载 ${#parts[@]} 片 → 合并 $name"
: >"$dest"
for p in "${parts[@]}"; do
p="${p#./}"
# 若清单里只有 basename part-xx,补全前缀
case "$p" in
*.part-*) ;;
*) p="${name}.part-${p##*-}" ;;
esac
# 清单可能是 relative part name only like sing-box-...part-00
if [[ "$p" != *"/"* && "$p" != ${name}.part-* && "$p" == *.part-* ]]; then
:
fi
part_url="${base}/${p}"
if ! download "$part_url" "$part_dir/$p"; then
# 尝试无前缀名
local bn
bn="$(basename "$p")"
part_url="${base}/${bn}"
download "$part_url" "$part_dir/$bn" || return 1
p="$bn"
fi
cat "$part_dir/$p" >>"$dest"
done
# 可选校验
if command -v sha256sum >/dev/null 2>&1; then
(
cd "$part_dir"
# 规范化清单路径
sed 's| \./| |; s|^\([a-f0-9]*\) \./|\1 |' SHA256SUMS > SHA256SUMS.norm || cp SHA256SUMS SHA256SUMS.norm
if sha256sum -c SHA256SUMS.norm --status 2>/dev/null; then
info "分片 SHA256 校验通过"
else
warn "分片 SHA256 校验跳过/未严格通过(已合并,继续)"
fi
) || true
fi
# 快速完整性:tar 列表
tar -tzf "$dest" >/dev/null
return 0
}
try_download_gitea() {
local ver="$1" arch="$2" dest="$3"
if try_download_gitea_whole "$ver" "$arch" "$dest"; then
return 0
fi
warn "Gitea 整包不可用,尝试分片合并(大文件/代理 504 兜底)"
if try_download_gitea_parts "$ver" "$arch" "$dest"; then
return 0
fi
return 1
}
try_download_github_release() {
local ver="$1" arch="$2" dest="$3"
local name raw_url
name="sing-box-${ver}-linux-${arch}"
raw_url="https://github.com/SagerNet/sing-box/releases/download/v${ver}/${name}.tar.gz"
download_github "$raw_url" "$dest"
}
if [[ "$SING_BOX_SOURCE" == "local" ]]; then if [[ "$SING_BOX_SOURCE" == "local" ]]; then
[[ -n "$SING_BOX_BIN" && -x "$SING_BOX_BIN" ]] || die "SING_BOX_SOURCE=local 但 SING_BOX_BIN 无效" [[ -n "$SING_BOX_BIN" && -x "$SING_BOX_BIN" ]] || die "SING_BOX_SOURCE=local 但 SING_BOX_BIN 无效"
install -m 0755 "$SING_BOX_BIN" "$INSTALL_DIR/bin/sing-box" install -m 0755 "$SING_BOX_BIN" "$INSTALL_DIR/bin/sing-box"
info "已安装本地 sing-box" info "已安装本地 sing-box"
elif command -v sing-box >/dev/null 2>&1 && [[ -z "${FORCE_DOWNLOAD:-}" && "$SING_BOX_SOURCE" != "gitea" && "$SING_BOX_SOURCE" != "github" ]]; then elif command -v sing-box >/dev/null 2>&1 && [[ -z "${FORCE_DOWNLOAD:-}" ]]; then
install -m 0755 "$(command -v sing-box)" "$INSTALL_DIR/bin/sing-box" install -m 0755 "$(command -v sing-box)" "$INSTALL_DIR/bin/sing-box"
info "复用系统 sing-box: $(command -v sing-box)" info "复用系统 sing-box: $(command -v sing-box)"
else else
ver="$SING_BOX_VERSION" ver="$SING_BOX_VERSION"
tgz="$TMP/sb.tgz" name="sing-box-${ver}-linux-${ARCH}"
ok=0 raw_url="https://github.com/SagerNet/sing-box/releases/download/v${ver}/${name}.tar.gz"
case "$SING_BOX_SOURCE" in download_github "$raw_url" "$TMP/sb.tgz"
gitea) tar -xzf "$TMP/sb.tgz" -C "$TMP"
try_download_gitea "$ver" "$ARCH" "$tgz" || die "Gitea 预置包下载失败(tag=$SING_BOX_RELEASE_TAG arch=$ARCH" install -m 0755 "$TMP/${name}/sing-box" "$INSTALL_DIR/bin/sing-box"
ok=1 info "已安装 sing-box v${ver}"
;;
github|release)
try_download_github_release "$ver" "$ARCH" "$tgz"
ok=1
;;
auto|*)
if try_download_gitea "$ver" "$ARCH" "$tgz"; then
ok=1
info "已从 Gitea Release 获取 sing-box"
else
warn "Gitea 预置包不可用,回退 GitHub(经 ${GITHUB_PROXY:-直连}"
try_download_github_release "$ver" "$ARCH" "$tgz"
ok=1
fi
;;
esac
[[ "$ok" -eq 1 ]] || die "无法下载 sing-box"
install_from_tarball "$tgz" "$ver" "$ARCH"
info "已安装 sing-box v${ver} (${ARCH})"
"$INSTALL_DIR/bin/sing-box" version 2>/dev/null | head -3 || true
fi fi
# 安装中文静态 UI # 安装中文静态 UI
+5 -43
View File
@@ -13,46 +13,9 @@ cell="$(resolve_cellular 2>/dev/null || true)"
if [[ -z "$cell" ]]; then if [[ -z "$cell" ]]; then
resolve_cellular >/dev/null || true resolve_cellular >/dev/null || true
fi fi
src_ip="${CELLULAR_SOURCE_IP:-}"
# 最后一道闸:绝不把 USB gadget 网卡(本机 -> 上游主机的 RNDIS 链路)写成代理出口。 if [[ -z "$src_ip" && -n "$cell" ]]; then
# 一旦写错,所有出站连接都会绑到只通向 PC 的接口上,SOCKS 返回 rep=0x01。 src_ip="$(detect_source_ip "$cell" || true)"
if [[ -n "$cell" ]] && iface_is_usb_gadget "$cell"; then
warn "拒绝使用 USB gadget 网卡 $cell 作为数据出口,重新探测"
cell="$(detect_cellular_iface_via_mm 2>/dev/null || true)"
if [[ -z "$cell" ]]; then
die "无法确定数据网卡(唯一候选是 USB gadget 网卡)。请设置 CELLULAR_IFACE=wwanX"
fi
info "改用 $cell"
persist_cellular_to_settings "${CONFIG_FILE:-$ROOT_DIR/etc/settings.conf}" "$cell" "$(detect_source_ip "$cell" || true)" || true
CELLULAR_IFACE="$cell"
fi
# 绑定策略(优先更好的实现,而不是只靠定时器修 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_srcsettings 仅作元数据)"
persist_cellular_to_settings "${CONFIG_FILE:-$ROOT_DIR/etc/settings.conf}" "$cell" "$live_src" || true
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 fi
if [[ -z "$cell" ]]; then if [[ -z "$cell" ]]; then
@@ -71,7 +34,7 @@ fi
export GEN_OUT="$OUT_DIR/config.json" export GEN_OUT="$OUT_DIR/config.json"
export GEN_CELL="$cell_for_cfg" export GEN_CELL="$cell_for_cfg"
export GEN_SRC="${GEN_SRC_FOR_CFG:-}" export GEN_SRC="$src_ip"
export GEN_PROXY_HOST="$PROXY_LISTEN_HOST" export GEN_PROXY_HOST="$PROXY_LISTEN_HOST"
export GEN_PROXY_PORT="$PROXY_MIXED_PORT" export GEN_PROXY_PORT="$PROXY_MIXED_PORT"
export GEN_PROXY_USER="$PROXY_USER" export GEN_PROXY_USER="$PROXY_USER"
@@ -107,13 +70,12 @@ inbound = {
if users: if users:
inbound["users"] = users inbound["users"] = users
# 唯一业务出口:强制绑定数据网卡(默认仅 bind_interface # 唯一业务出口:强制绑定数据网卡
out_cell = { out_cell = {
"type": "direct", "type": "direct",
"tag": "cellular", "tag": "cellular",
"bind_interface": cell, "bind_interface": cell,
} }
# 仅当 GEN_SRC 非空(BIND_SOURCE_IP=true)时 pin 源 IP
if src: if src:
out_cell["inet4_bind_address"] = src out_cell["inet4_bind_address"] = src
+9 -119
View File
@@ -20,19 +20,12 @@ info "1/6 自动探测并绑定数据网卡"
"$ROOT_DIR/scripts/detect.sh" || true "$ROOT_DIR/scripts/detect.sh" || true
cell="" cell=""
if [[ -n "${CELLULAR_IFACE:-}" ]] && iface_is_usb_gadget "$CELLULAR_IFACE"; then if [[ -n "${CELLULAR_IFACE:-}" ]] && iface_exists "$CELLULAR_IFACE"; then
warn "配置的 CELLULAR_IFACE=$CELLULAR_IFACE 是 USB gadget 网卡(面向上游主机),忽略并重新探测"
cell=""
elif [[ -n "${CELLULAR_IFACE:-}" ]] && iface_exists "$CELLULAR_IFACE"; then
cell="$CELLULAR_IFACE" cell="$CELLULAR_IFACE"
info "使用配置的数据网卡: $cell" info "使用配置的数据网卡: $cell"
fi elif [[ "$AUTO_DETECT" == "true" ]]; then
if [[ -z "$cell" && "$AUTO_DETECT" == "true" ]]; then
cell="$(detect_cellular_iface "" || true)" cell="$(detect_cellular_iface "" || true)"
fi fi
if [[ -n "$cell" ]] && iface_is_usb_gadget "$cell"; then
die "拒绝把 USB gadget 网卡 $cell 写成数据出口,请手动设置 CELLULAR_IFACE=wwanX"
fi
if [[ -z "$cell" ]]; then if [[ -z "$cell" ]]; then
die "未能自动探测数据网卡。 die "未能自动探测数据网卡。
@@ -105,51 +98,29 @@ info "配置已确认 bind_interface=$cell"
install -m 0755 /dev/stdin "$INSTALL_DIR/bin/cpxy" <<'EOF' install -m 0755 /dev/stdin "$INSTALL_DIR/bin/cpxy" <<'EOF'
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# 经 /usr/local/bin/cpxy 符号链接调用时,$0 仍是链接路径;必须 resolve 真实路径 BASE="$(cd "$(dirname "$0")/.." && pwd)"
_self="${BASH_SOURCE[0]:-$0}"
if command -v readlink >/dev/null 2>&1; then
_resolved="$(readlink -f "$_self" 2>/dev/null || true)"
[[ -n "$_resolved" ]] && _self="$_resolved"
fi
BASE="$(cd "$(dirname "$_self")/.." && pwd)"
# 兜底:避免 symlink 解析失败时 BASE 变成 /usr/local
if [[ ! -d "$BASE/scripts" && -d /opt/cellular-proxy/scripts ]]; then
BASE="/opt/cellular-proxy"
fi
export CONFIG_FILE="${CONFIG_FILE:-$BASE/etc/settings.conf}" export CONFIG_FILE="${CONFIG_FILE:-$BASE/etc/settings.conf}"
cmd="${1:-help}" cmd="${1:-help}"
shift || true shift || true
case "$cmd" in case "$cmd" in
detect) exec "$BASE/scripts/detect.sh" "$@" ;; detect) exec "$BASE/scripts/detect.sh" "$@" ;;
generate) generate)
# 重新探测:若 settings 里网卡丢失则自动补;源 IP 漂移也刷新 # 重新探测:若 settings 里网卡丢失则自动补
# shellcheck source=/dev/null # shellcheck source=/dev/null
source "$BASE/scripts/lib.sh" source "$BASE/scripts/lib.sh"
load_config load_config
if [[ -z "${CELLULAR_IFACE:-}" ]] || ! iface_exists "${CELLULAR_IFACE:-}" || iface_is_usb_gadget "${CELLULAR_IFACE:-}"; then if [[ -z "${CELLULAR_IFACE:-}" ]] || ! iface_exists "${CELLULAR_IFACE:-}"; then
if [[ -n "${CELLULAR_IFACE:-}" ]] && iface_is_usb_gadget "${CELLULAR_IFACE:-}"; then
warn "CELLULAR_IFACE=$CELLULAR_IFACE 是 USB gadget 网卡,重新探测"
fi
cell="$(detect_cellular_iface "" || true)" cell="$(detect_cellular_iface "" || true)"
if [[ -n "$cell" ]] && ! iface_is_usb_gadget "$cell"; then if [[ -n "$cell" ]]; then
src="$(detect_source_ip "$cell" || true)" src="$(detect_source_ip "$cell" || true)"
persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src" persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src"
CELLULAR_IFACE="$cell" CELLULAR_IFACE="$cell"
CELLULAR_SOURCE_IP="${src:-}"
fi
elif [[ -n "${CELLULAR_IFACE:-}" ]]; then
src="$(detect_source_ip "$CELLULAR_IFACE" || true)"
if [[ -n "$src" && "${CELLULAR_SOURCE_IP:-}" != "$src" ]]; then
persist_cellular_to_settings "$CONFIG_FILE" "$CELLULAR_IFACE" "$src"
CELLULAR_SOURCE_IP="$src"
fi fi
fi fi
"$BASE/scripts/generate.sh" "$BASE/generated" "$BASE/scripts/generate.sh" "$BASE/generated"
install -m 0644 "$BASE/generated/config.json" "$BASE/etc/config.json" install -m 0644 "$BASE/generated/config.json" "$BASE/etc/config.json"
install -m 0644 "$BASE/generated/runtime.env" "$BASE/etc/runtime.env" install -m 0644 "$BASE/generated/runtime.env" "$BASE/etc/runtime.env"
if [[ "$(readlink -f "$CONFIG_FILE" 2>/dev/null || echo "$CONFIG_FILE")" != "$(readlink -f "$BASE/etc/settings.conf" 2>/dev/null || echo "$BASE/etc/settings.conf")" ]]; then
install -m 0644 "$CONFIG_FILE" "$BASE/etc/settings.conf" 2>/dev/null || true install -m 0644 "$CONFIG_FILE" "$BASE/etc/settings.conf" 2>/dev/null || true
fi
"$BASE/bin/sing-box" check -c "$BASE/etc/config.json" "$BASE/bin/sing-box" check -c "$BASE/etc/config.json"
systemctl restart cellular-proxy 2>/dev/null || true systemctl restart cellular-proxy 2>/dev/null || true
;; ;;
@@ -159,10 +130,9 @@ case "$cmd" in
load_config load_config
cell="$(detect_cellular_iface "" || true)" cell="$(detect_cellular_iface "" || true)"
[[ -n "$cell" ]] || die "未能探测数据网卡" [[ -n "$cell" ]] || die "未能探测数据网卡"
! iface_is_usb_gadget "$cell" || die "探测到的 $cell 是 USB gadget 网卡,不能作为数据出口"
src="$(detect_source_ip "$cell" || true)" src="$(detect_source_ip "$cell" || true)"
persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src" persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src"
info "已重新绑定 $cell src=${src:-}" info "已重新绑定 $cell"
exec "$0" generate exec "$0" generate
;; ;;
start) systemctl start cellular-proxy ;; start) systemctl start cellular-proxy ;;
@@ -170,35 +140,6 @@ case "$cmd" in
restart) systemctl restart cellular-proxy ;; restart) systemctl restart cellular-proxy ;;
status) systemctl status cellular-proxy --no-pager || true ;; status) systemctl status cellular-proxy --no-pager || true ;;
verify) exec "$BASE/scripts/verify.sh" "$@" ;; verify) exec "$BASE/scripts/verify.sh" "$@" ;;
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
exec "$BASE/scripts/online-upgrade.sh" "$@"
fi
# 兜底:直接 curl 入口
url="${CELLULAR_PROXY_INSTALL_URL:-https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh}"
exec bash -c 'curl -fsSL "$1" | bash -s -- --upgrade --install-dir "$2" "${@:3}"' _ "$url" "$BASE" "$@"
;;
logs) journalctl -u cellular-proxy -n "${1:-80}" -f ;; logs) journalctl -u cellular-proxy -n "${1:-80}" -f ;;
help|*) help|*)
cat <<H cat <<H
@@ -206,18 +147,10 @@ cpxy — cellular-proxy
语义: 走代理的连接一律从数据网卡出口 语义: 走代理的连接一律从数据网卡出口
cpxy detect | rebind | generate | start | stop | restart | status cpxy detect | rebind | generate | start | stop | restart | status
cpxy verify | logs [N] 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
cpxy upgrade --verify # 升级后验证出口
H H
;; ;;
esac esac
EOF EOF
chmod +x "$INSTALL_DIR/scripts/"*.sh "$INSTALL_DIR/scripts/"*.py 2>/dev/null || true
ln -sfn "$INSTALL_DIR/bin/cpxy" /usr/local/bin/cpxy ln -sfn "$INSTALL_DIR/bin/cpxy" /usr/local/bin/cpxy
info "4/6 安装 systemd" info "4/6 安装 systemd"
@@ -230,24 +163,6 @@ if [[ "$MEM" != "0" && -n "$MEM" ]]; then
else else
sed -i '/MemoryMax=@MEMORY_MAX@/d' /etc/systemd/system/cellular-proxy.service sed -i '/MemoryMax=@MEMORY_MAX@/d' /etc/systemd/system/cellular-proxy.service
fi fi
# 管理 APIUI 配置代理账密)
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 systemctl daemon-reload
info "5/6 启动服务" info "5/6 启动服务"
@@ -255,15 +170,11 @@ export SYSTEMD_PAGER=cat
export SYSTEMD_COLORS=0 export SYSTEMD_COLORS=0
if [[ "$SKIP_START" == "true" ]]; then if [[ "$SKIP_START" == "true" ]]; then
systemctl enable cellular-proxy.service systemctl enable cellular-proxy.service
systemctl enable cellular-proxy-admin.service 2>/dev/null || true
systemctl stop cellular-proxy.service 2>/dev/null || true systemctl stop cellular-proxy.service 2>/dev/null || true
systemctl stop cellular-proxy-admin.service 2>/dev/null || true
info "已按 SKIP_START 跳过启动" info "已按 SKIP_START 跳过启动"
else else
systemctl enable cellular-proxy.service systemctl enable cellular-proxy.service
systemctl enable cellular-proxy-admin.service 2>/dev/null || true
systemctl restart cellular-proxy.service systemctl restart cellular-proxy.service
systemctl restart cellular-proxy-admin.service 2>/dev/null || true
# 等待 active,最多约 8 秒(避免 status 卡住) # 等待 active,最多约 8 秒(避免 status 卡住)
ok=0 ok=0
for _ in 1 2 3 4 5 6 7 8; do for _ in 1 2 3 4 5 6 7 8; do
@@ -283,21 +194,6 @@ else
fi fi
fi fi
# 默认开启 watchENABLE_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 自动验证出口(短超时,失败不阻塞安装结束)" info "6/6 自动验证出口(短超时,失败不阻塞安装结束)"
if [[ "$SKIP_START" != "true" && "$AUTO_VERIFY" == "true" ]]; then if [[ "$SKIP_START" != "true" && "$AUTO_VERIFY" == "true" ]]; then
set +e set +e
@@ -321,16 +217,10 @@ info "安装完成(全自动探测 + 绑定)"
echo " 数据网卡: $cell (${src_ip:-no-ipv4})" echo " 数据网卡: $cell (${src_ip:-no-ipv4})"
echo " 语义: 不走代理 → 系统默认(WiFi);走代理 → 数据流量" echo " 语义: 不走代理 → 系统默认(WiFi);走代理 → 数据流量"
echo " 代理: HTTP/SOCKS ${PROXY_LISTEN_HOST}:${PROXY_MIXED_PORT}" echo " 代理: HTTP/SOCKS ${PROXY_LISTEN_HOST}:${PROXY_MIXED_PORT}"
if [[ -n "${PROXY_USER:-}" ]]; then echo " 面板: http://<LAN-IP>:${PANEL_PORT}/"
echo " 代理鉴权: 用户 ${PROXY_USER}(密码已设置)" echo " 密钥: $PANEL_SECRET"
else
echo " 代理鉴权: 无(建议: cpxy auth --user u --pass p 或面板配置)"
fi
echo " 面板: http://<LAN-IP>:${PANEL_PORT}/ui/ 密钥: $PANEL_SECRET"
echo " 管理API: http://<LAN-IP>:9091 UI 里改代理账密用,密钥同上)"
echo " 配置: $INSTALL_DIR/etc/settings.conf" echo " 配置: $INSTALL_DIR/etc/settings.conf"
echo " 管理: cpxy rebind | verify | logs | status" echo " 管理: cpxy rebind | verify | logs | status"
echo " 账密: cpxy auth --user U --pass P | --clear | --show"
echo " 若刚才像卡住:多半在测公网出口,现已改为短超时并保证退出" echo " 若刚才像卡住:多半在测公网出口,现已改为短超时并保证退出"
# 安装脚本始终以 0 结束(服务已 active);验证失败只告警 # 安装脚本始终以 0 结束(服务已 active);验证失败只告警
exit 0 exit 0
+9 -101
View File
@@ -31,17 +31,8 @@ load_config() {
LOG_DIR="${LOG_DIR:-/var/log/cellular-proxy}" LOG_DIR="${LOG_DIR:-/var/log/cellular-proxy}"
CELLULAR_IFACE="${CELLULAR_IFACE:-}" CELLULAR_IFACE="${CELLULAR_IFACE:-}"
CELLULAR_SOURCE_IP="${CELLULAR_SOURCE_IP:-}" CELLULAR_SOURCE_IP="${CELLULAR_SOURCE_IP:-}"
BIND_SOURCE_IP="${BIND_SOURCE_IP:-false}"
REQUIRE_CELLULAR_IFACE="${REQUIRE_CELLULAR_IFACE:-true}" REQUIRE_CELLULAR_IFACE="${REQUIRE_CELLULAR_IFACE:-true}"
CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,ppp,cdc,rmnet,ccmni,mbim,qmi}" 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_LISTEN_HOST="${PROXY_LISTEN_HOST:-0.0.0.0}"
PROXY_MIXED_PORT="${PROXY_MIXED_PORT:-7890}" PROXY_MIXED_PORT="${PROXY_MIXED_PORT:-7890}"
PROXY_USER="${PROXY_USER:-}" PROXY_USER="${PROXY_USER:-}"
@@ -51,13 +42,9 @@ load_config() {
PANEL_SECRET="${PANEL_SECRET:-please-change-me}" PANEL_SECRET="${PANEL_SECRET:-please-change-me}"
ENABLE_DNS="${ENABLE_DNS:-true}" ENABLE_DNS="${ENABLE_DNS:-true}"
LOG_LEVEL="${LOG_LEVEL:-warn}" LOG_LEVEL="${LOG_LEVEL:-warn}"
SING_BOX_SOURCE="${SING_BOX_SOURCE:-auto}" SING_BOX_SOURCE="${SING_BOX_SOURCE:-release}"
SING_BOX_VERSION="${SING_BOX_VERSION:-1.11.7}" SING_BOX_VERSION="${SING_BOX_VERSION:-1.11.7}"
SING_BOX_BIN="${SING_BOX_BIN:-}" SING_BOX_BIN="${SING_BOX_BIN:-}"
GITEA_BASE="${GITEA_BASE:-https://gitea.chickliu.fun}"
GITEA_OWNER="${GITEA_OWNER:-Hermes}"
GITEA_REPO="${GITEA_REPO:-cellular-proxy}"
SING_BOX_RELEASE_TAG="${SING_BOX_RELEASE_TAG:-bin-v${SING_BOX_VERSION}}"
GITHUB_PROXY="${GITHUB_PROXY:-https://git.86482425.xyz}" GITHUB_PROXY="${GITHUB_PROXY:-https://git.86482425.xyz}"
MEMORY_MAX_MB="${MEMORY_MAX_MB:-96}" MEMORY_MAX_MB="${MEMORY_MAX_MB:-96}"
EGRESS_CHECK_URL="${EGRESS_CHECK_URL:-https://ifconfig.me}" EGRESS_CHECK_URL="${EGRESS_CHECK_URL:-https://ifconfig.me}"
@@ -77,34 +64,6 @@ iface_exists() {
[[ -n "$ifc" ]] && [[ -d "/sys/class/net/$ifc" ]] [[ -n "$ifc" ]] && [[ -d "/sys/class/net/$ifc" ]]
} }
# 识别「本机当 USB 设备端」的 gadget 网卡(usb0/rndis0 等)。
# 这类接口是本机 → 上游主机(PC/路由)的下行链路,绑上去所有出站都会失败,
# sing-box 表现为 open outbound connection: context deadline exceededSOCKS 回 rep=0x01。
# 注意与真正的 USB 上行模组区分:qmi_wwan / cdc_* / rndis_host 是 host 侧驱动,可作出口。
iface_is_usb_gadget() {
local ifc="$1" drv dev
[[ -n "$ifc" ]] || return 1
drv="$(iface_driver "$ifc" 2>/dev/null || true)"
case "${drv,,}" in
configfs-gadget*|g_ether*|g_ncm*|g_ether|g_multi*|g_cdc*|gadget*|usb_f_*)
return 0
;;
esac
# configfs 组合设备的 device 实体挂在 gadget 总线下
dev="$(readlink -f "/sys/class/net/$ifc/device" 2>/dev/null || true)"
if [[ -n "$dev" && "$dev" == *"/gadget"* ]]; then
return 0
fi
if [[ -d "/sys/class/net/$ifc/device" ]]; then
local sub
sub="$(readlink -f "/sys/class/net/$ifc/device/subsystem" 2>/dev/null || true)"
if [[ "$(basename "${sub:-}")" == "gadget" ]]; then
return 0
fi
fi
return 1
}
is_virtual_or_skip_iface() { is_virtual_or_skip_iface() {
local name="$1" local name="$1"
case "$name" in case "$name" in
@@ -116,10 +75,6 @@ is_virtual_or_skip_iface() {
if [[ -d "/sys/class/net/$name/bridge" ]]; then if [[ -d "/sys/class/net/$name/bridge" ]]; then
return 0 return 0
fi fi
# 本机作为 USB gadget 暴露给上游主机的链路(RNDIS/NCM/ECM),只通向 PC,不是数据出口
if iface_is_usb_gadget "$name"; then
return 0
fi
return 1 return 1
} }
@@ -159,10 +114,8 @@ iface_driver() {
iface_looks_cellular_by_driver() { iface_looks_cellular_by_driver() {
local ifc="$1" drv local ifc="$1" drv
drv="$(iface_driver "$ifc" || true)" drv="$(iface_driver "$ifc" || true)"
# gadget 网卡即使名字像 usb0 也不算蜂窝
iface_is_usb_gadget "$ifc" && return 1
case "${drv,,}" in case "${drv,,}" in
qmi_wwan|cdc_mbim|cdc_ncm|cdc_ether|cdc_wdm|option|huawei_cdc_ncm|rndis_host|GobiNet|GobiSerial|simcom*|rmnet*|mhi_net|ipa|bam-dmux|bam_dmux|qcom-ipa|ipa_wan) qmi_wwan|cdc_mbim|cdc_ncm|cdc_ether|cdc_wdm|option|huawei_cdc_ncm|rndis_host|GobiNet|GobiSerial|simcom*|rmnet*|mhi_net|ipa)
return 0 return 0
;; ;;
esac esac
@@ -171,9 +124,7 @@ iface_looks_cellular_by_driver() {
iface_matches_patterns() { iface_matches_patterns() {
local name="$1" p local name="$1" p
# 默认不含 usb/enx:本机的 USB gadget 链路也叫 usb0,靠名字猜会把出口绑到 PC 侧。 local patterns="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,usb,enx,ppp,cdc,rmnet,ccmni,mbim,qmi}"
# 真正的 USB 上行模组由 iface_looks_cellular_by_driverrndis_host/cdc_*/qmi_wwan)识别。
local patterns="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,ppp,cdc,rmnet,ccmni,mbim,qmi}"
IFS=',' read -r -a arr <<< "$patterns" IFS=',' read -r -a arr <<< "$patterns"
for p in "${arr[@]}"; do for p in "${arr[@]}"; do
p="${p// /}" p="${p// /}"
@@ -185,23 +136,6 @@ iface_matches_patterns() {
return 1 return 1
} }
# 最权威的来源:ModemManager 自己报的 bearer 网卡名。
# 比名称/驱动启发式打分更可靠,能彻底避开 usb0 这类同分误选。
detect_cellular_iface_via_mm() {
local m b ifc
command -v mmcli >/dev/null 2>&1 || return 1
for m in $(mmcli -L 2>/dev/null | grep -oE 'Modem/[0-9]+' | grep -oE '[0-9]+$'); do
for b in $(mmcli -m "$m" 2>/dev/null | grep -oE 'Bearer/[0-9]+' | grep -oE '[0-9]+$'); do
ifc="$(mmcli -b "$b" 2>/dev/null | sed -n 's/.*interface:[[:space:]]*\([A-Za-z0-9._-]\{1,\}\).*/\1/p' | head -1)"
if [[ -n "$ifc" ]] && iface_exists "$ifc" && ! iface_is_usb_gadget "$ifc"; then
echo "$ifc"
return 0
fi
done
done
return 1
}
# 多默认路由时,metric 更大的往往是数据网(WiFi metric 更小优先) # 多默认路由时,metric 更大的往往是数据网(WiFi metric 更小优先)
detect_secondary_default_iface() { detect_secondary_default_iface() {
local primary secondary local primary secondary
@@ -239,9 +173,9 @@ score_iface_as_cellular() {
ip="$(ip -4 -o addr show dev "$name" 2>/dev/null | awk '{print $4}' | head -1 | cut -d/ -f1)" ip="$(ip -4 -o addr show dev "$name" 2>/dev/null | awk '{print $4}' | head -1 | cut -d/ -f1)"
drv="$(iface_driver "$name" || true)" drv="$(iface_driver "$name" || true)"
# 名称(仅启发式,权重必须低于驱动判定,避免 usb0 与 wwan0 打成平手) # 名称
if iface_matches_patterns "$name"; then score=$((score + 40)); fi if iface_matches_patterns "$name"; then score=$((score + 80)); fi
# 驱动(强证据) # 驱动
if iface_looks_cellular_by_driver "$name"; then score=$((score + 100)); fi if iface_looks_cellular_by_driver "$name"; then score=$((score + 100)); fi
# 非默认网卡(关键:系统默认走 WiFi) # 非默认网卡(关键:系统默认走 WiFi)
if [[ -n "$default_if" && "$name" != "$default_if" ]]; then score=$((score + 40)); fi if [[ -n "$default_if" && "$name" != "$default_if" ]]; then score=$((score + 40)); fi
@@ -262,8 +196,7 @@ score_iface_as_cellular() {
echo "$score" echo "$score"
} }
# 自动探测数据网卡:ModemManager bearer > 配置 > 名称/驱动打分 > 次默认路由 > 非默认有 IP 物理口 # 自动探测数据网卡:配置 > 名称/驱动 > 次默认路由 > 非默认有 IP 物理口
# 任何一步都不允许返回 USB gadget 网卡
detect_cellular_iface() { detect_cellular_iface() {
local configured="${1:-}" local configured="${1:-}"
local default_if name state ip best_name="" best_score=0 score local default_if name state ip best_name="" best_score=0 score
@@ -271,8 +204,6 @@ detect_cellular_iface() {
if [[ -n "$configured" ]]; then if [[ -n "$configured" ]]; then
if [[ "$configured" == "lo" ]]; then if [[ "$configured" == "lo" ]]; then
warn "CELLULAR_IFACE=lo 无效,将尝试自动探测" warn "CELLULAR_IFACE=lo 无效,将尝试自动探测"
elif iface_is_usb_gadget "$configured"; then
warn "CELLULAR_IFACE=$configured 是本机对上游主机暴露的 USB gadget 网卡,不能作为数据出口,将重新探测"
elif iface_exists "$configured"; then elif iface_exists "$configured"; then
echo "$configured" echo "$configured"
return 0 return 0
@@ -286,15 +217,6 @@ detect_cellular_iface() {
default_if="$(detect_default_iface || true)" default_if="$(detect_default_iface || true)"
# 0) ModemManager bearer 是权威答案,优先采用
if name="$(detect_cellular_iface_via_mm 2>/dev/null || true)"; then
if [[ -n "$name" ]]; then
info "按 ModemManager bearer 确定数据网卡: $name"
echo "$name"
return 0
fi
fi
# 1) 按评分扫描全部接口 # 1) 按评分扫描全部接口
while IFS='|' read -r name state ip; do while IFS='|' read -r name state ip; do
[[ -z "$name" ]] && continue [[ -z "$name" ]] && continue
@@ -315,7 +237,7 @@ detect_cellular_iface() {
# 2) 多默认路由的次要口 # 2) 多默认路由的次要口
if name="$(detect_secondary_default_iface 2>/dev/null || true)"; then if name="$(detect_secondary_default_iface 2>/dev/null || true)"; then
if [[ -n "$name" ]] && iface_exists "$name" && ! iface_is_usb_gadget "$name"; then if [[ -n "$name" ]] && iface_exists "$name"; then
info "根据次要默认路由探测数据网卡: $name" info "根据次要默认路由探测数据网卡: $name"
echo "$name" echo "$name"
return 0 return 0
@@ -400,19 +322,5 @@ resolve_cellular() {
warn "未找到数据网卡" warn "未找到数据网卡"
cell="" cell=""
fi fi
# 最后一道闸:探测链路上任何环节都不得交出 gadget 网卡
if [[ -n "$cell" ]] && iface_is_usb_gadget "$cell"; then
warn "拒绝把 USB gadget 网卡 $cell 当作数据出口,改问 ModemManager"
cell="$(detect_cellular_iface_via_mm 2>/dev/null || true)"
if [[ -z "$cell" ]]; then
if [[ "$REQUIRE_CELLULAR_IFACE" == "true" ]]; then
die "唯一候选是 USB gadget 网卡,无法确定数据出口。请设置 CELLULAR_IFACE=wwanX"
fi
warn "未找到可用数据网卡"
cell=""
else
info "改用 $cell"
fi
fi
echo "$cell" echo "$cell"
} }
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env bash
# 在线一键增量升级入口(给已安装的 cpxy 调用)
# 用法: sudo cpxy upgrade
# sudo cpxy upgrade --ui-only
# sudo cpxy upgrade --force-binary
# sudo cpxy upgrade --verify
set -euo pipefail
BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# 若作为独立脚本在 /opt/.../scripts/online-upgrade.sh
if [[ -f "$BASE/etc/settings.conf" ]]; then
:
elif [[ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../etc/settings.conf" ]]; then
BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fi
GITEA_BASE="${GITEA_BASE:-https://gitea.chickliu.fun}"
REPO_OWNER="${REPO_OWNER:-Hermes}"
REPO_NAME="${REPO_NAME:-cellular-proxy}"
BRANCH="${BRANCH:-main}"
INSTALL_URL="${CELLULAR_PROXY_INSTALL_URL:-${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/raw/branch/${BRANCH}/install.sh}"
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
echo "请用 root: sudo cpxy upgrade $*" >&2
exit 1
fi
# 从 settings 读 INSTALL_DIR
if [[ -f "${CONFIG_FILE:-$BASE/etc/settings.conf}" ]]; then
# shellcheck disable=SC1090
set -a
# shellcheck disable=SC1090
source "${CONFIG_FILE:-$BASE/etc/settings.conf}"
set +a
fi
INSTALL_DIR="${INSTALL_DIR:-$BASE}"
export INSTALL_DIR
args=(--upgrade)
# 透传用户参数(已是 upgrade 子参数)
while [[ $# -gt 0 ]]; do
args+=("$1")
shift
done
echo "[$(date '+%F %T')] INFO 一键升级: $INSTALL_URL ${args[*]}" >&2
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
if command -v curl >/dev/null 2>&1; then
curl -fsSL --retry 3 --connect-timeout 20 -o "$tmp" "$INSTALL_URL"
elif command -v wget >/dev/null 2>&1; then
wget -q -O "$tmp" "$INSTALL_URL"
else
echo "需要 curl 或 wget" >&2
exit 1
fi
chmod +x "$tmp"
# 把 install-dir 固定到当前实例
bash "$tmp" --install-dir "$INSTALL_DIR" "${args[@]}"
-194
View File
@@ -1,194 +0,0 @@
#!/usr/bin/env bash
# 将 sing-box 官方 release 预下载并上传到本仓库 Gitea Release
# 用法(维护机):
# set -a; source ~/.hermes/env/gitea.env; set +a
# ./scripts/publish-binaries-to-gitea.sh
# SING_BOX_VERSION=1.11.7 ARCHS='amd64 arm64 armv7' ./scripts/publish-binaries-to-gitea.sh
#
# GitHub 下载默认经 https://git.86482425.xyz
# 上传走 multipart>约 8MB 可能被反向代理 504,自动改 1MB 分片上传
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
: "${GITEA_URL:?请先 source ~/.hermes/env/gitea.env}"
: "${GITEA_TOKEN:?请先 source ~/.hermes/env/gitea.env}"
OWNER="${GITEA_OWNER:-Hermes}"
REPO="${GITEA_REPO:-cellular-proxy}"
VER="${SING_BOX_VERSION:-1.11.7}"
TAG="${SING_BOX_RELEASE_TAG:-bin-v${VER}}"
ARCHS="${ARCHS:-amd64 arm64 armv7}"
GITHUB_PROXY="${GITHUB_PROXY:-https://git.86482425.xyz}"
PART_BYTES="${PART_BYTES:-1048576}"
# 经验阈值:整包上传超时则分片(nginx 常见 ~80s)
WHOLE_MAX_BYTES="${WHOLE_MAX_BYTES:-9000000}"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2; }
download() {
local url="$1" dest="$2"
log "下载 $url"
curl -fL --retry 3 --connect-timeout 20 --max-time 300 -o "$dest" "$url"
}
github_fetch() {
local raw="$1" dest="$2"
local proxied="${GITHUB_PROXY%/}/${raw}"
if [[ -n "${GITHUB_PROXY:-}" ]]; then
if download "$proxied" "$dest"; then
return 0
fi
log "代理失败,直连"
fi
download "$raw" "$dest"
}
API="$GITEA_URL/api/v1/repos/$OWNER/$REPO"
auth=(-H "Authorization: token $GITEA_TOKEN")
delete_asset() {
local id="$1"
# Gitea 正确路径:/releases/{id}/assets/{asset_id}
curl -sS -X DELETE "${auth[@]}" -o /dev/null -w "DEL %{http_code}\n" \
"$API/releases/$REL_ID/assets/$id" || true
}
upload_file() {
local fpath="$1" fname="$2"
local code
code=$(curl -sS --http1.1 --max-time 300 \
"${auth[@]}" \
-F "attachment=@${fpath};filename=${fname}" \
-o "$TMP/up_${fname}.json" \
-w '%{http_code}' \
"$API/releases/$REL_ID/assets?name=${fname}" || echo 000)
if [[ "$code" != "201" && "$code" != "200" ]]; then
log "上传失败 $fname HTTP $code"
head -c 300 "$TMP/up_${fname}.json" 2>/dev/null || true
return 1
fi
python3 - <<PY
import json
d=json.load(open("$TMP/up_${fname}.json"))
assert d.get("name")=="$fname", d
print("asset ok:", d.get("name"), "size=", d.get("size"))
PY
}
ensure_release() {
local code
code=$(curl -sS "${auth[@]}" -o "$TMP/rel.json" -w '%{http_code}' "$API/releases/tags/$TAG")
if [[ "$code" == "200" ]]; then
REL_ID=$(python3 -c 'import json; print(json.load(open("'"$TMP"'/rel.json"))["id"])')
log "已有 Release tag=$TAG id=$REL_ID"
else
log "创建 Release tag=$TAG"
curl -sS "${auth[@]}" -H 'Content-Type: application/json' \
-d "$(python3 - <<PY
import json
print(json.dumps({
"tag_name": "$TAG",
"name": "sing-box $VER prebundled binaries",
"body": "预置 sing-box v$VER。安装端优先从此 Release 下载,无需访问 GitHub。\\n架构: $ARCHS\\n大文件可能以 .part-XX 分片提供。",
"draft": False,
"prerelease": False,
"target_commitish": "main",
}))
PY
)" \
-o "$TMP/rel_create.json" \
"$API/releases"
REL_ID=$(python3 -c 'import json,sys; d=json.load(open("'"$TMP"'/rel_create.json"));
print(d.get("id") or "");
assert d.get("id"), d')
log "Release id=$REL_ID"
fi
}
delete_named_assets() {
local prefix="$1"
curl -sS "${auth[@]}" "$API/releases/$REL_ID" -o "$TMP/rel_full.json"
python3 - <<PY
import json,subprocess,os
d=json.load(open("$TMP/rel_full.json"))
prefix="$prefix"
for a in d.get("assets") or []:
n=a.get("name") or ""
if n==prefix or n.startswith(prefix+".") or n.startswith(prefix+".part-") or n==prefix+".SHA256SUMS" or (prefix.endswith(".tar.gz") and n=="SHA256SUMS" and "armv7" in prefix):
print("delete", a["id"], n)
subprocess.check_call(["bash","-lc", f'curl -sS -X DELETE -H "Authorization: token $GITEA_TOKEN" -o /dev/null -w "DEL %{{http_code}}\\n" "$API/releases/$REL_ID/assets/{a["id"]}"'])
PY
}
upload_whole_or_parts() {
local fpath="$1" fname="$2"
local size
size=$(stat -c%s "$fpath")
delete_named_assets "$fname"
if [[ "$size" -le "$WHOLE_MAX_BYTES" ]]; then
log "整包上传 $fname ($size bytes)"
if upload_file "$fpath" "$fname"; then
return 0
fi
log "整包失败,改分片"
else
log "文件 $size > $WHOLE_MAX_BYTES,直接分片上传 $fname"
fi
local pdir="$TMP/parts_${fname}"
rm -rf "$pdir"
mkdir -p "$pdir"
split -b "$PART_BYTES" -d -a 2 "$fpath" "$pdir/${fname}.part-"
(
cd "$pdir"
# 清单使用纯文件名,便于客户端校验
sha256sum "${fname}.part-"* > "${fname}.SHA256SUMS"
)
local p
for p in "$pdir/${fname}.part-"* "$pdir/${fname}.SHA256SUMS"; do
bn=$(basename "$p")
log "分片上传 $bn ($(stat -c%s "$p") bytes)"
upload_file "$p" "$bn"
done
}
# 1) 下载
for arch in $ARCHS; do
name="sing-box-${VER}-linux-${arch}"
raw="https://github.com/SagerNet/sing-box/releases/download/v${VER}/${name}.tar.gz"
dest="$TMP/${name}.tar.gz"
github_fetch "$raw" "$dest"
tar -tzf "$dest" | head -3 >/dev/null
ls -lh "$dest"
done
# 2) release
ensure_release
# 3) 上传
for arch in $ARCHS; do
fname="sing-box-${VER}-linux-${arch}.tar.gz"
upload_whole_or_parts "$TMP/$fname" "$fname"
done
# 4) 公开下载自检
for arch in $ARCHS; do
fname="sing-box-${VER}-linux-${arch}.tar.gz"
url="$GITEA_URL/$OWNER/$REPO/releases/download/$TAG/$fname"
code=$(curl -sS -o /dev/null -w '%{http_code}' -L --max-time 30 "$url" || true)
if [[ "$code" == "200" || "$code" == "206" ]]; then
log "公开整包 OK $url -> $code"
else
# 分片?
p0="$GITEA_URL/$OWNER/$REPO/releases/download/$TAG/${fname}.part-00"
code2=$(curl -sS -o /dev/null -w '%{http_code}' -L --max-time 30 "$p0" || true)
log "整包 $code,分片 part-00 -> $code2 ($p0)"
fi
done
log "完成。安装端优先:"
log " $GITEA_URL/$OWNER/$REPO/releases/download/$TAG/sing-box-${VER}-linux-<arch>.tar.gz"
log " 或分片: .../${fname}.part-XX + .../${fname}.SHA256SUMS"
-455
View File
@@ -1,455 +0,0 @@
#!/usr/bin/env bash
# 增量升级:保留 settings/密钥/网卡绑定,默认不重下 sing-box、不重探测、不强制 verify
# 用法:
# sudo ./scripts/upgrade.sh
# sudo ./scripts/upgrade.sh --ui-only
# sudo ./scripts/upgrade.sh --force-binary
# sudo ./scripts/upgrade.sh --rebind
# sudo ./scripts/upgrade.sh --verify
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=lib.sh
source "$ROOT_DIR/scripts/lib.sh"
UI_ONLY=false
FORCE_BINARY=false
DO_REBIND=false
DO_VERIFY=false
SKIP_START=false
usage() {
cat <<'H'
cellular-proxy 增量升级
保留: settings.conf / PANEL_SECRET / PROXY_* / CELLULAR_IFACE
默认: 不重新探测网卡、不重下 sing-box(已存在且可执行则跳过)、不强制出口验证
选项:
--ui-only 只更新 UI 面板
--force-binary 强制重下/重装 sing-box
--rebind 升级时重新探测数据网卡并写回配置
--verify 升级后跑出口验证
--skip-start 只落盘,不 restart 服务
-h, --help
H
}
while [[ $# -gt 0 ]]; do
case "$1" in
--ui-only) UI_ONLY=true; shift ;;
--force-binary) FORCE_BINARY=true; shift ;;
--rebind) DO_REBIND=true; shift ;;
--verify) DO_VERIFY=true; shift ;;
--skip-start) SKIP_START=true; shift ;;
-h|--help) usage; exit 0 ;;
*) die "未知参数: $1" ;;
esac
done
need_root
# 优先用已安装目录的配置
INST_CANDIDATES=(
"${INSTALL_DIR:-}"
"/opt/cellular-proxy"
"$ROOT_DIR"
)
EXISTING=""
for d in "${INST_CANDIDATES[@]}"; do
[[ -n "$d" ]] || continue
if [[ -f "$d/etc/settings.conf" ]]; then
EXISTING="$d"
break
fi
done
if [[ -z "$EXISTING" ]]; then
die "未检测到已安装实例(缺少 \$INSTALL_DIR/etc/settings.conf)。
请先全量安装:
curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash"
fi
export CONFIG_FILE="$EXISTING/etc/settings.conf"
load_config
INSTALL_DIR="${INSTALL_DIR:-$EXISTING}"
# 若 settings 里 INSTALL_DIR 与探测到的不一致,以配置为准;配置空则用探测目录
if [[ -z "${INSTALL_DIR:-}" || ! -d "$INSTALL_DIR" ]]; then
INSTALL_DIR="$EXISTING"
fi
export INSTALL_DIR
info "======== cellular-proxy 增量升级 ========"
info "安装目录: $INSTALL_DIR"
info "配置文件: $CONFIG_FILE(将保留密钥/网卡/账密)"
# --- 迁移:老配置里的 usb/enx 关键词会把 USB gadget 网卡(usb0)当数据出口 ---
# 升级保留 settings.conf,因此必须在这里就地清理,否则旧机器升级后仍会误绑。
migrate_iface_patterns() {
local conf="$1" cur cleaned tmp
[[ -f "$conf" ]] || return 0
cur="$(awk -F= '/^CELLULAR_IFACE_PATTERNS=/{print substr($0,index($0,"=")+1); exit}' "$conf" 2>/dev/null || true)"
[[ -n "$cur" ]] || return 0
cleaned="$(printf '%s' "$cur" | tr ',' '\n' | sed 's/[[:space:]]//g' \
| grep -vxE 'usb|enx' | paste -sd, - 2>/dev/null || true)"
[[ -n "$cleaned" && "$cleaned" != "$cur" ]] || return 0
tmp="$(mktemp)"
awk -v v="$cleaned" '
/^CELLULAR_IFACE_PATTERNS=/ { print "CELLULAR_IFACE_PATTERNS=" v; next }
{ print }
' "$conf" > "$tmp"
mv "$tmp" "$conf"
warn "已从 CELLULAR_IFACE_PATTERNS 移除 usb/enx(会误命中 USB gadget 网卡): $cur -> $cleaned"
CELLULAR_IFACE_PATTERNS="$cleaned"
}
migrate_iface_patterns "$CONFIG_FILE"
# 迁移:已写死 gadget 网卡的旧配置,清空后走重新探测
if [[ -n "${CELLULAR_IFACE:-}" ]] && iface_is_usb_gadget "${CELLULAR_IFACE:-}"; then
warn "旧配置 CELLULAR_IFACE=$CELLULAR_IFACE 是 USB gadget 网卡,将重新探测数据出口"
CELLULAR_IFACE=""
fi
ensure_dirs
mkdir -p /var/lib/cellular-proxy "$INSTALL_DIR/bin" "$INSTALL_DIR/etc" "$INSTALL_DIR/ui" "$INSTALL_DIR/scripts" "$INSTALL_DIR/generated"
# --- UI only fast path ---
if [[ "$UI_ONLY" == "true" ]]; then
info "模式: 仅更新 UI"
if [[ ! -f "$ROOT_DIR/ui/index.html" ]]; then
die "源码缺少 ui/index.html"
fi
cp -a "$ROOT_DIR/ui/." "$INSTALL_DIR/ui/"
info "UI 已更新 -> $INSTALL_DIR/ui"
# 静态文件无需重启代理;admin 也不必
echo
info "完成(ui-only)。浏览器强刷: http://<LAN-IP>:${PANEL_PORT}/ui/"
exit 0
fi
# --- 可选 rebind ---
if [[ "$DO_REBIND" == "true" ]]; then
info "重新探测数据网卡…"
cell="$(detect_cellular_iface "" || true)"
[[ -n "$cell" ]] || die "未能探测数据网卡"
! iface_is_usb_gadget "$cell" || die "探测到的 $cell 是 USB gadget 网卡(面向上游主机),不能作为数据出口"
src="$(detect_source_ip "$cell" || true)"
persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src"
load_config
CELLULAR_IFACE="$cell"
CELLULAR_SOURCE_IP="${src:-}"
info "已 rebind: $CELLULAR_IFACE ${CELLULAR_SOURCE_IP:-}"
fi
cell="${CELLULAR_IFACE:-}"
if [[ -z "$cell" ]] || ! iface_exists "$cell" || iface_is_usb_gadget "$cell"; then
if [[ -n "$cell" ]] && iface_is_usb_gadget "$cell"; then
warn "配置中的 CELLULAR_IFACE=$cell 是 USB gadget 网卡,不能作为出口,重新探测"
else
warn "配置中的 CELLULAR_IFACE=${cell:-} 无效,尝试自动探测(仅本次)"
fi
cell="$(detect_cellular_iface "" || true)"
if [[ -n "$cell" ]] && ! iface_is_usb_gadget "$cell"; then
src="$(detect_source_ip "$cell" || true)"
persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src"
load_config
CELLULAR_IFACE="$cell"
CELLULAR_SOURCE_IP="${src:-$CELLULAR_SOURCE_IP}"
else
die "无有效数据网卡。请: cpxy rebind 或 --rebind / --iface"
fi
fi
src_ip="${CELLULAR_SOURCE_IP:-$(detect_source_ip "$cell" || true)}"
# --- binary ---
need_binary=true
if [[ "$FORCE_BINARY" != "true" && -x "$INSTALL_DIR/bin/sing-box" ]]; then
if "$INSTALL_DIR/bin/sing-box" version >/dev/null 2>&1; then
cur="$("$INSTALL_DIR/bin/sing-box" version 2>/dev/null | head -1 || true)"
info "复用已有 sing-box: $cur"
need_binary=false
fi
fi
if [[ "$need_binary" == "true" ]]; then
info "安装/更新 sing-box 二进制"
FORCE_DOWNLOAD="${FORCE_DOWNLOAD:-}"
if [[ "$FORCE_BINARY" == "true" ]]; then
export FORCE_DOWNLOAD=1
fi
# fetch 会同时拷 UI;后面还会再拷一遍 scripts/ui 没关系
export CONFIG_FILE
"$ROOT_DIR/scripts/fetch-binaries.sh"
else
info "跳过二进制下载(需要强制时加 --force-binary"
mkdir -p "$INSTALL_DIR/ui"
if [[ -f "$ROOT_DIR/ui/index.html" ]]; then
cp -a "$ROOT_DIR/ui/." "$INSTALL_DIR/ui/"
info "已更新 UI"
fi
fi
# --- scripts / cpxy / generate config ---
info "更新 scripts / 配置生成 / systemd"
# 同步新脚本到安装目录,但不要覆盖 etc/settings
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete \
--exclude 'settings.conf' \
"$ROOT_DIR/scripts/" "$INSTALL_DIR/scripts/"
else
mkdir -p "$INSTALL_DIR/scripts"
cp -a "$ROOT_DIR/scripts/." "$INSTALL_DIR/scripts/"
fi
chmod +x "$INSTALL_DIR/scripts/"*.sh "$INSTALL_DIR/scripts/"*.py 2>/dev/null || true
# 生成配置(用现有 settings,不改 secret
export CONFIG_FILE
"$ROOT_DIR/scripts/generate.sh" "$INSTALL_DIR/generated"
install -m 0644 "$INSTALL_DIR/generated/config.json" "$INSTALL_DIR/etc/config.json"
install -m 0644 "$INSTALL_DIR/generated/runtime.env" "$INSTALL_DIR/etc/runtime.env"
# 保留 settings:只确保 INSTALL_DIR 字段正确(CONFIG_FILE 已是 live 路径时勿 self-copy
if ! grep -qE '^INSTALL_DIR=' "$CONFIG_FILE" 2>/dev/null; then
printf 'INSTALL_DIR=%s\n' "$INSTALL_DIR" >> "$CONFIG_FILE"
fi
live_settings="$INSTALL_DIR/etc/settings.conf"
if [[ "$(readlink -f "$CONFIG_FILE" 2>/dev/null || echo "$CONFIG_FILE")" != "$(readlink -f "$live_settings" 2>/dev/null || echo "$live_settings")" ]]; then
install -m 0644 "$CONFIG_FILE" "$live_settings"
fi
if ! "$INSTALL_DIR/bin/sing-box" check -c "$INSTALL_DIR/etc/config.json"; then
die "sing-box 配置校验失败"
fi
# 校验 bind
if ! python3 - "$INSTALL_DIR/etc/config.json" "$cell" <<'PY'
import json,sys
c=json.load(open(sys.argv[1]))
cell=sys.argv[2]
ok=any(o.get("tag")=="cellular" and o.get("bind_interface")==cell for o in c.get("outbounds",[]))
sys.exit(0 if ok else 1)
PY
then
die "配置未正确绑定数据网卡 $cell"
fi
info "配置已确认 bind_interface=$cell"
# 安装 cpxy 包装器(与 install.sh 一致,含 upgrade 子命令)
install -m 0755 /dev/stdin "$INSTALL_DIR/bin/cpxy" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
# 经 /usr/local/bin/cpxy 符号链接调用时,$0 仍是链接路径;必须 resolve 真实路径
_self="${BASH_SOURCE[0]:-$0}"
if command -v readlink >/dev/null 2>&1; then
_resolved="$(readlink -f "$_self" 2>/dev/null || true)"
[[ -n "$_resolved" ]] && _self="$_resolved"
fi
BASE="$(cd "$(dirname "$_self")/.." && pwd)"
if [[ ! -d "$BASE/scripts" && -d /opt/cellular-proxy/scripts ]]; then
BASE="/opt/cellular-proxy"
fi
export CONFIG_FILE="${CONFIG_FILE:-$BASE/etc/settings.conf}"
cmd="${1:-help}"
shift || true
case "$cmd" in
detect) exec "$BASE/scripts/detect.sh" "$@" ;;
generate)
# shellcheck source=/dev/null
source "$BASE/scripts/lib.sh"
load_config
if [[ -z "${CELLULAR_IFACE:-}" ]] || ! iface_exists "${CELLULAR_IFACE:-}" || iface_is_usb_gadget "${CELLULAR_IFACE:-}"; then
if [[ -n "${CELLULAR_IFACE:-}" ]] && iface_is_usb_gadget "${CELLULAR_IFACE:-}"; then
warn "CELLULAR_IFACE=$CELLULAR_IFACE 是 USB gadget 网卡,重新探测"
fi
cell="$(detect_cellular_iface "" || true)"
if [[ -n "$cell" ]] && ! iface_is_usb_gadget "$cell"; then
src="$(detect_source_ip "$cell" || true)"
persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src"
CELLULAR_IFACE="$cell"
CELLULAR_SOURCE_IP="${src:-}"
fi
elif [[ -n "${CELLULAR_IFACE:-}" ]]; then
src="$(detect_source_ip "$CELLULAR_IFACE" || true)"
if [[ -n "$src" && "${CELLULAR_SOURCE_IP:-}" != "$src" ]]; then
persist_cellular_to_settings "$CONFIG_FILE" "$CELLULAR_IFACE" "$src"
CELLULAR_SOURCE_IP="$src"
fi
fi
"$BASE/scripts/generate.sh" "$BASE/generated"
install -m 0644 "$BASE/generated/config.json" "$BASE/etc/config.json"
install -m 0644 "$BASE/generated/runtime.env" "$BASE/etc/runtime.env"
if [[ "$(readlink -f "$CONFIG_FILE" 2>/dev/null || echo "$CONFIG_FILE")" != "$(readlink -f "$BASE/etc/settings.conf" 2>/dev/null || echo "$BASE/etc/settings.conf")" ]]; then
install -m 0644 "$CONFIG_FILE" "$BASE/etc/settings.conf" 2>/dev/null || true
fi
"$BASE/bin/sing-box" check -c "$BASE/etc/config.json"
systemctl restart cellular-proxy 2>/dev/null || true
systemctl restart cellular-proxy-admin 2>/dev/null || true
;;
rebind)
# shellcheck source=/dev/null
source "$BASE/scripts/lib.sh"
load_config
cell="$(detect_cellular_iface "" || true)"
[[ -n "$cell" ]] || die "未能探测数据网卡"
! iface_is_usb_gadget "$cell" || die "探测到的 $cell 是 USB gadget 网卡,不能作为数据出口"
src="$(detect_source_ip "$cell" || true)"
persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src"
info "已重新绑定 $cell src=${src:-}"
exec "$0" generate
;;
upgrade)
# 一条命令在线升级:拉最新 install.sh 再 --upgrade
if [[ -x "$BASE/scripts/online-upgrade.sh" ]]; then
exec "$BASE/scripts/online-upgrade.sh" "$@"
fi
url="${CELLULAR_PROXY_INSTALL_URL:-https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh}"
exec bash -c 'curl -fsSL "$1" | bash -s -- --upgrade --install-dir "$2" "${@:3}"' _ "$url" "$BASE" "$@"
;;
start) systemctl start cellular-proxy cellular-proxy-admin 2>/dev/null || systemctl start cellular-proxy ;;
stop) systemctl stop cellular-proxy-admin 2>/dev/null || true; systemctl stop cellular-proxy ;;
restart) systemctl restart cellular-proxy; systemctl restart cellular-proxy-admin 2>/dev/null || true ;;
status)
systemctl status cellular-proxy --no-pager || true
systemctl status cellular-proxy-admin --no-pager 2>/dev/null || true
;;
verify) exec "$BASE/scripts/verify.sh" "$@" ;;
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 <<H
cpxy — cellular-proxy
语义: 走代理的连接一律从数据网卡出口
cpxy detect | rebind | generate | start | stop | restart | status
cpxy verify | logs [N]
cpxy auth --user U --pass P | --clear | --show
cpxy watch [status|once|start|stop|logs] # WWAN 自动监控
cpxy upgrade # 一条命令在线增量升级
cpxy upgrade --ui-only | --force-binary | --verify
H
;;
esac
EOF
ln -sfn "$INSTALL_DIR/bin/cpxy" /usr/local/bin/cpxy
# systemd
MEM="${MEMORY_MAX_MB:-96}"
if [[ -f "$ROOT_DIR/systemd/cellular-proxy.service" ]]; then
install -m 0644 "$ROOT_DIR/systemd/cellular-proxy.service" /etc/systemd/system/cellular-proxy.service
sed -i "s|@INSTALL_DIR@|$INSTALL_DIR|g" /etc/systemd/system/cellular-proxy.service
sed -i "s|@LOG_DIR@|$LOG_DIR|g" /etc/systemd/system/cellular-proxy.service
if [[ "$MEM" != "0" && -n "$MEM" ]]; then
sed -i "s|@MEMORY_MAX@|${MEM}M|g" /etc/systemd/system/cellular-proxy.service
else
sed -i '/MemoryMax=@MEMORY_MAX@/d' /etc/systemd/system/cellular-proxy.service
fi
fi
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 自动监控
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
systemctl daemon-reload
export SYSTEMD_PAGER=cat
export SYSTEMD_COLORS=0
if [[ "$SKIP_START" == "true" ]]; then
systemctl enable cellular-proxy.service 2>/dev/null || true
systemctl enable cellular-proxy-admin.service 2>/dev/null || true
info "已跳过 restart--skip-start"
else
systemctl enable cellular-proxy.service 2>/dev/null || true
systemctl enable cellular-proxy-admin.service 2>/dev/null || true
systemctl restart cellular-proxy.service
systemctl restart cellular-proxy-admin.service 2>/dev/null || true
ok=0
for _ in 1 2 3 4 5 6 7 8; do
if systemctl is-active --quiet cellular-proxy.service; then
ok=1
break
fi
sleep 1
done
active_state="$(systemctl is-active cellular-proxy.service 2>/dev/null || echo unknown)"
info "服务状态: $active_state"
if [[ "$ok" -ne 1 ]]; then
err "服务未 active"
journalctl -u cellular-proxy -n 30 --no-pager 2>/dev/null || true
die "升级后启动失败"
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
VERIFY_QUICK=1 EGRESS_TIMEOUT="${EGRESS_TIMEOUT:-8}" "$INSTALL_DIR/scripts/verify.sh"
vr=$?
set -e
if [[ "$vr" -eq 0 ]]; then
info "出口验证通过"
else
warn "验证未通过(exit=$vr),可稍后: cpxy verify"
fi
else
info "已跳过出口验证(需要时: --verify 或 cpxy verify"
fi
# shellcheck disable=SC1090
source "$INSTALL_DIR/etc/settings.conf" 2>/dev/null || true
lan="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}' || true)"
cat <<EOM
========== 增量升级完成 ==========
安装目录: $INSTALL_DIR
数据网卡: ${CELLULAR_IFACE:-$cell} (${src_ip:-})
保留: PANEL_SECRET / PROXY_USER / 端口等 settings
代理: ${lan:-127.0.0.1}:${PROXY_MIXED_PORT:-7890}
面板: http://${lan:-<LAN-IP>}:${PANEL_PORT:-9090}/ui/
管理: :9091(账密配置)
下次升级:
sudo cpxy upgrade
sudo cpxy upgrade --ui-only
sudo cpxy upgrade --force-binary
EOM
exit 0
-26
View File
@@ -38,32 +38,6 @@ case "${QUICK,,}" in
*) QUICK=false ;; *) QUICK=false ;;
esac esac
# 出口一旦绑成 USB gadget 网卡,所有代理请求都会超时(客户端看到 SOCKS rep=0x01)。
# 在打网络探测之前先点名,免得只剩一个含糊的 FAIL。
cfg_json="${INSTALL_DIR:-/opt/cellular-proxy}/etc/config.json"
cfg_bind=""
if command -v python3 >/dev/null 2>&1 && [[ -f "$cfg_json" ]]; then
cfg_bind="$(python3 -c '
import json, sys
try:
cfg = json.load(open(sys.argv[1]))
for o in cfg.get("outbounds") or []:
if o.get("tag") == "cellular":
print(o.get("bind_interface") or "")
break
except Exception:
pass
' "$cfg_json" 2>/dev/null || true)"
fi
for _ifc in "${CELLULAR_IFACE:-}" "$cfg_bind"; do
[[ -n "$_ifc" ]] || continue
if iface_is_usb_gadget "$_ifc"; then
err "出口绑在 USB gadget 网卡 $_ifc 上(那是本机 -> 上游主机的下行链路),代理必定超时"
err "修复: sudo cpxy rebind && sudo cpxy generate"
exit 4
fi
done
curl_ip() { curl_ip() {
# 单次探测,严格超时,绝不无限挂起 # 单次探测,严格超时,绝不无限挂起
local extra=("$@") local extra=("$@")
-452
View File
@@ -1,452 +0,0 @@
#!/usr/bin/env bash
# 监控数据网卡 + ModemManager 重拨/重注册事件,自动等待附着并 regenerate。
# 策略:
# 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)"
# 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}"
# 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
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
}
# 将 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:-}"
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
if iface_is_usb_gadget "$cell"; then
logw "CELLULAR_IFACE=$cell is a USB gadget link (host-facing), never a data egress → rebind"
return 0
fi
if [[ -n "$cfg_iface" ]] && iface_is_usb_gadget "$cfg_iface"; then
logw "config bind_interface=$cfg_iface is a USB gadget link → rebind"
return 0
fi
live_src="$(detect_source_ip "$cell" || true)"
if [[ -z "$live_src" ]]; then
# 网卡在但无 IPv4:可能刚重拨;不立刻 die,等下一轮或 MM 恢复路径
logw "wwan up but no IPv4 yet on $cell"
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=""
local reason="${1:-manual}"
if [[ -z "$cell" ]] || ! iface_exists "$cell" || iface_is_usb_gadget "$cell"; then
if [[ -n "$cell" ]] && iface_is_usb_gadget "$cell"; then
logw "refuse USB gadget iface $cell as egress; re-detecting"
fi
cell="$(detect_cellular_iface "" || true)"
if [[ -z "$cell" ]] || iface_is_usb_gadget "$cell"; then
logw "detect failed or gadget-only; 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 reason=$reason"
return 1
fi
persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src" || true
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 || {
# 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 reason=%s iface=%s src=%s\n' "$(date -Iseconds)" "$reason" "$cell" "$src" >"$STATE_FILE" 2>/dev/null || true
logw "refresh done reason=$reason"
}
# 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
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 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 "$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 "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
(
ip -o monitor address link 2>/dev/null | while read -r line; do
cell="${CELLULAR_IFACE:-}"
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 "ip-monitor"
fi
done
) &
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 "poll"
done
-24
View File
@@ -1,24 +0,0 @@
[Unit]
Description=cellular-proxy admin API (proxy auth config)
After=network-online.target cellular-proxy.service
Wants=network-online.target
[Service]
Type=simple
Environment=INSTALL_DIR=@INSTALL_DIR@
Environment=CPXY_SETTINGS=@INSTALL_DIR@/etc/settings.conf
Environment=CPXY_APPLY_AUTH=@INSTALL_DIR@/scripts/apply-proxy-auth.sh
Environment=ADMIN_LISTEN_HOST=0.0.0.0
Environment=ADMIN_LISTEN_PORT=9091
ExecStart=/usr/bin/python3 @INSTALL_DIR@/scripts/admin-api.py
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
ReadWritePaths=@INSTALL_DIR@ /var/lib/cellular-proxy /var/log/cellular-proxy
User=root
[Install]
WantedBy=multi-user.target
-18
View File
@@ -1,18 +0,0 @@
[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
-25
View File
@@ -1,25 +0,0 @@
[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
-14
View File
@@ -1,14 +0,0 @@
[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
-187
View File
@@ -1,187 +0,0 @@
#!/usr/bin/env bash
# 离线单元测试:验证 cellular-proxy 的探测链路永不选中 USB gadget 网卡。
# 用法: bash tests/detect-gadget.sh [repo路径]
# 不依赖真实 /sys,靠覆盖探针函数模拟设备现场(usb0=configfs-gadget.g1, wwan0=bam-dmux)。
REPO="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
LIB="$REPO/scripts/lib.sh"
[[ -f "$LIB" ]] || { echo "找不到 $LIB"; exit 1; }
PASS=0
FAIL=0
check_eq() {
local name="$1" want="$2" got="$3"
if [[ "$want" == "$got" ]]; then
PASS=$((PASS+1)); printf 'ok %s\n' "$name"
else
FAIL=$((FAIL+1)); printf 'FAIL %s (want=%s got=%s)\n' "$name" "$want" "$got"
fi
}
# scenario <ifaces> <drivers> <ips> <default_if> <mm_iface> <have_mmcli> <shell代码>
# 在子 shell 里 source lib.sh,替换所有触碰真实系统的探针,然后 eval 代码。
scenario() {
(
FAKE_IFACES="$1"
FAKE_DRIVERS="$2"
FAKE_IPS="$3"
FAKE_DEFAULT="$4"
FAKE_MM_IFACE="$5"
FAKE_HAVE_MMCLI="$6"
CODE="$7"
# shellcheck disable=SC1090
source "$LIB" >/dev/null 2>&1
set +eu
lookup() {
local table="$1" key="$2" pair
for pair in $table; do
if [[ "${pair%%=*}" == "$key" ]]; then printf '%s' "${pair#*=}"; return 0; fi
done
return 1
}
iface_exists() {
local i
for i in $FAKE_IFACES; do [[ "$i" == "$1" ]] && return 0; done
return 1
}
iface_driver() { lookup "$FAKE_DRIVERS" "$1" || true; }
iface_has_carrier() { iface_exists "$1"; }
detect_default_iface() { printf '%s' "$FAKE_DEFAULT"; }
list_ifaces() {
local i ip4
for i in $FAKE_IFACES; do
ip4="$(lookup "$FAKE_IPS" "$i" || true)"
printf '%s|up|%s\n' "$i" "$ip4"
done
}
# 只需支持 ip -4 -o addr show dev Xip route show default 一律返回空
ip() {
local a prev="" dev="" v
for a in "$@"; do
[[ "$prev" == "dev" ]] && dev="$a"
prev="$a"
done
[[ -n "$dev" ]] || return 0
v="$(lookup "$FAKE_IPS" "$dev" || true)"
[[ -n "$v" ]] && printf '1: %s inet %s scope global %s\n' "$dev" "$v" "$dev"
return 0
}
if [[ "$FAKE_HAVE_MMCLI" == "yes" ]]; then
mmcli() {
case "$1" in
-L) echo " /org/freedesktop/ModemManager1/Modem/0 [QUECTEL] EC20" ;;
-m) echo " Bearer | paths: /org/freedesktop/ModemManager1/Bearer/0" ;;
-b) printf ' IPv4 configuration | interface: %s\n' "$FAKE_MM_IFACE" ;;
esac
return 0
}
else
command() {
if [[ "$1" == "-v" && "$2" == "mmcli" ]]; then return 1; fi
builtin command "$@"
}
fi
eval "$CODE"
) 2>/dev/null
}
IF_REAL="usb0 wlan0 wwan0"
DRV_REAL="usb0=configfs-gadget.g1 wwan0=bam-dmux wlan0=brcmfmac"
IPS_REAL="usb0=192.168.68.1/24 wwan0=10.2.8.250/30 wlan0=192.168.1.23/24"
real() { scenario "$IF_REAL" "$DRV_REAL" "$IPS_REAL" wlan0 "${2:-}" "${3:-no}" "$1"; }
check_eq "usb0 识别为 USB gadget" gadget \
"$(real 'iface_is_usb_gadget usb0 && echo gadget || echo no')"
check_eq "wwan0 不被误判为 gadget" no \
"$(real 'iface_is_usb_gadget wwan0 && echo gadget || echo no')"
check_eq "usb0 进入 skip 列表" skip \
"$(real 'is_virtual_or_skip_iface usb0 && echo skip || echo keep')"
check_eq "bam-dmux 命中蜂窝驱动白名单" cell \
"$(real 'iface_looks_cellular_by_driver wwan0 && echo cell || echo no')"
check_eq "gadget 驱动不算蜂窝" no \
"$(real 'iface_looks_cellular_by_driver usb0 && echo cell || echo no')"
check_eq "usb0 评分为 0" 0 \
"$(real 'score_iface_as_cellular usb0 wlan0')"
check_eq "wwan0 评分达到置信阈值" high \
"$(real 's=$(score_iface_as_cellular wwan0 wlan0); [[ $s -ge 70 ]] && echo high || echo low:$s')"
check_eq "无 mmcli 时自动探测得 wwan0" wwan0 \
"$(real 'detect_cellular_iface ""')"
check_eq "配置写死 usb0 被纠回 wwan0" wwan0 \
"$(real 'detect_cellular_iface usb0')"
check_eq "旧 patterns 含 usb/enx 仍选 wwan0" wwan0 \
"$(real 'CELLULAR_IFACE_PATTERNS="wwan,usb,enx,cdc"; detect_cellular_iface ""')"
check_eq "ModemManager bearer 优先采用" wwan0 \
"$(real 'detect_cellular_iface ""' wwan0 yes)"
check_eq "ModemManager 报 gadget 时被忽略" wwan0 \
"$(real 'detect_cellular_iface ""' usb0 yes)"
check_eq "resolve_cellular 拦截 usb0" wwan0 \
"$(real 'CELLULAR_IFACE=usb0; REQUIRE_CELLULAR_IFACE=true; resolve_cellular')"
# 只有 gadget 可选时必须失败,而不是把 usb0 绑成出口
check_eq "仅 gadget 时 resolve_cellular 拒绝返回 usb0" die \
"$(scenario "usb0 wlan0" "usb0=configfs-gadget.g1 wlan0=brcmfmac" \
"usb0=192.168.68.1/24 wlan0=192.168.1.23/24" wlan0 "" no \
'CELLULAR_IFACE=usb0; REQUIRE_CELLULAR_IFACE=true; out=$(resolve_cellular 2>/dev/null); rc=$?; if [[ $rc -ne 0 || -z $out ]]; then echo die; else echo "$out"; fi')"
# verify.sh 的前置体检:出口绑在 gadget 上时必须 exit 4,而不是跑一堆 curl 再给含糊 FAIL
verify_guard() {
local sandbox conf rc
sandbox="$(mktemp -d)"
cp -r "$REPO"/. "$sandbox/" 2>/dev/null
rm -rf "$sandbox/.git"
mkdir -p "$sandbox/config" "$sandbox/etc"
cat >"$sandbox/config/settings.conf" <<EOF
INSTALL_DIR=$sandbox
CELLULAR_IFACE=$1
REQUIRE_CELLULAR_IFACE=false
EOF
# 让 usb0 在这个沙箱里被判定为 gadget,且不真的去打网络
cat >>"$sandbox/scripts/lib.sh" <<'EOF'
iface_is_usb_gadget() { [[ "$1" == usb0 || "$1" == rndis0 ]]; }
EOF
( cd "$sandbox" && CONFIG_FILE="$sandbox/config/settings.conf" \
EGRESS_CHECK_URL="http://127.0.0.1:1/" EGRESS_TIMEOUT=1 VERIFY_QUICK=true \
bash scripts/verify.sh >/dev/null 2>&1 )
rc=$?
rm -rf "$sandbox"
printf '%s' "$rc"
}
check_eq "verify.sh 对 gadget 出口直接 exit 4" 4 "$(verify_guard usb0)"
check_eq "verify.sh 对正常出口不报 exit 4" not4 \
"$(rc="$(verify_guard wwan0)"; [[ "$rc" == 4 ]] && echo 4 || echo not4)"
# upgrade.sh 的迁移函数:旧 settings.conf 里的 usb/enx 必须被就地剔除
migrate_case() {
local conf fn out
conf="$(mktemp)"
fn="$(mktemp)"
printf 'CELLULAR_IFACE=wwan0\nCELLULAR_IFACE_PATTERNS=%s\nPROXY_MIXED_PORT=7890\n' "$1" >"$conf"
# 从 upgrade.sh 里抽出 migrate_iface_patterns 函数体,避免执行整个升级脚本
awk '/^migrate_iface_patterns\(\) \{/,/^\}/' "$REPO/scripts/upgrade.sh" >"$fn"
out="$(
set +eu
warn() { :; }
# shellcheck disable=SC1090
source "$fn"
migrate_iface_patterns "$conf" >/dev/null 2>&1
awk -F= '/^CELLULAR_IFACE_PATTERNS=/{print substr($0,index($0,"=")+1)}' "$conf"
# 顺带确认其它行没被吃掉
grep -c '^PROXY_MIXED_PORT=7890$' "$conf"
)"
rm -f "$conf" "$fn"
printf '%s' "$(printf '%s' "$out" | tr '\n' '/')"
}
check_eq "升级迁移剔除 usb/enx 且保留其它配置" "wwan,wwp,ppp,cdc/1" \
"$(migrate_case 'wwan,wwp,usb,enx,ppp,cdc')"
check_eq "已干净的 patterns 不被改动" "wwan,wwp,ppp,cdc/1" \
"$(migrate_case 'wwan,wwp,ppp,cdc')"
printf '\n通过 %d / 失败 %d\n' "$PASS" "$FAIL"
[[ "$FAIL" -eq 0 ]]
+81 -515
View File
@@ -3,473 +3,131 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>数据出口代理 · 小岛控制台</title> <title>数据出口代理 · 控制台</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800;900&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet" />
<style> <style>
/* animal-island-ui design tokens (from DESIGN_PROMPT / PROMPT.md) */
:root { :root {
--primary: #19c8b9; --bg: #0f1419; --card: #1a2332; --text: #e7ecf3; --muted: #8b9bb4;
--primary-hover: #3dd4c6; --accent: #3d8bfd; --ok: #3dd68c; --warn: #f5a524; --bad: #f31260; --border: #2a3548;
--primary-active: #11a89b;
--primary-bg: #e6f9f6;
--text: #794f27;
--text-body: #725d42;
--text-secondary: #9f927d;
--text-muted: #8a7b66;
--text-disabled: #c4b89e;
--bg: #f8f8f0;
--bg-content: rgb(247, 243, 223);
--bg-disabled: #f0ece2;
--border: #c4b89e;
--border-hover: #a89878;
--border-strong: #9f927d;
--success: #6fba2c;
--success-active: #5a9e1e;
--warning: #f5c31c;
--error: #e05a5a;
--error-active: #c94444;
--focus-yellow: #ffcc00;
--focus-yellow-d: #e0b800;
--shadow-btn: #bdaea0;
--shadow-soft: rgba(61, 52, 40, 0.06);
--r-sm: 12px;
--r-base: 18px;
--r-lg: 24px;
--r-pill: 50px;
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--d-base: 0.25s;
--app-teal: #82d5bb;
--app-blue: #889df0;
--app-yellow: #f7cd67;
--app-green: #8ac68a;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; } body { margin: 0; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
body { background: var(--bg); color: var(--text); min-height: 100vh; }
font-family: Nunito, "Noto Sans SC", -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; header { padding: 16px 20px; border-bottom: 1px solid var(--border);
font-weight: 500; display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }
letter-spacing: 0.01em; h1 { font-size: 1.15rem; margin: 0; font-weight: 600; }
color: var(--text-body); .muted { color: var(--muted); font-size: 0.85rem; }
background: main { padding: 16px; max-width: 960px; margin: 0 auto; display: grid; gap: 14px; }
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(130, 213, 187, 0.35), transparent 55%), .card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; }
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(247, 205, 103, 0.28), transparent 50%), .row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
radial-gradient(ellipse 50% 30% at 50% 100%, rgba(136, 157, 240, 0.18), transparent 55%), label { font-size: 0.85rem; color: var(--muted); }
var(--bg); input, button { background: #0d121a; color: var(--text); border: 1px solid var(--border);
} border-radius: 8px; padding: 8px 10px; font-size: 0.95rem; }
input { min-width: 160px; }
/* decorative dots (card pattern vibe) */ button { cursor: pointer; background: var(--accent); border: none; font-weight: 600; }
body::before { button.secondary { background: #2a3548; }
content: ""; button.danger { background: var(--bad); }
position: fixed; .pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 0.8rem; background: #243044; }
inset: 0; .pill.ok { background: rgba(61,214,140,.15); color: var(--ok); }
pointer-events: none; .pill.bad { background: rgba(243,18,96,.15); color: var(--bad); }
opacity: 0.35; .pill.warn { background: rgba(245,165,36,.15); color: var(--warn); }
background-image: radial-gradient(circle at 20px 20px, rgba(159, 146, 125, 0.18) 2px, transparent 2.5px);
background-size: 28px 28px;
z-index: 0;
}
.page { position: relative; z-index: 1; max-width: 980px; margin: 0 auto; padding: 20px 16px 48px; }
/* Title ribbon (simplified swallowtail) */
.title-ribbon {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 36px 10px 22px;
background: linear-gradient(180deg, #fff9e8 0%, var(--app-yellow) 100%);
color: var(--text);
font-weight: 900;
font-size: 1.05rem;
letter-spacing: 0.04em;
border: 2.5px solid #e0b85a;
border-radius: 8px 4px 4px 8px;
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 50%, calc(100% - 18px) 100%, 0 100%);
box-shadow: 3px 4px 0 0 rgba(189, 174, 160, 0.55);
margin: 0;
}
.title-ribbon .leaf {
width: 18px; height: 18px;
background: var(--success);
border-radius: 60% 0 60% 0;
transform: rotate(-25deg);
box-shadow: inset -2px -2px 0 rgba(0,0,0,0.08);
flex-shrink: 0;
}
header.top {
display: flex; flex-wrap: wrap; gap: 14px;
align-items: center; justify-content: space-between;
margin-bottom: 18px;
}
.sub { color: var(--text-muted); font-size: 0.9rem; margin-top: 8px; font-weight: 600; }
.header-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: var(--r-pill);
font-size: 0.82rem; font-weight: 700; letter-spacing: 0.02em;
border: 2px solid var(--border-strong);
background: #fff;
color: var(--text-body);
}
.pill.ok { background: #eef8e3; border-color: var(--success); color: var(--success-active); }
.pill.bad { background: #fdecec; border-color: var(--error); color: var(--error-active); }
.pill.warn { background: #fff8df; border-color: var(--warning); color: #a07a08; }
/* Card */
.card {
background: var(--bg-content);
border: 2px solid var(--border-strong);
border-radius: 20px;
padding: 16px 18px;
margin-bottom: 14px;
transition: transform var(--d-base) var(--ease);
}
.card:hover { transform: translateY(-2px); }
.card h2 {
margin: 0 0 12px; font-size: 1rem; font-weight: 800;
color: var(--text); letter-spacing: 0.02em;
display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap;
}
.card h2 .hint { font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); }
.banner {
background: linear-gradient(135deg, var(--primary-bg), #fff8df 70%);
border: 2px solid rgba(25, 200, 185, 0.45);
border-radius: 20px;
padding: 14px 16px;
margin-bottom: 14px;
color: var(--text-body);
line-height: 1.55;
font-weight: 600;
}
.banner strong { color: var(--text); }
.row { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
.field { display: flex; flex-direction: column; gap: 6px; min-width: 150px; flex: 1 1 160px; }
.field label {
font-size: 0.8rem; font-weight: 700; color: var(--text-muted);
letter-spacing: 0.03em;
}
/* Input — pill, yellow focus */
input[type="text"], input[type="password"], input:not([type]) {
height: 40px;
padding: 0 18px;
font-size: 14px;
font-weight: 500;
font-family: inherit;
color: var(--text-body);
background: var(--bg-content);
border: 2.5px solid var(--border);
border-radius: var(--r-pill);
outline: none;
transition: border-color var(--d-base) var(--ease), box-shadow var(--d-base) var(--ease);
min-width: 0;
width: 100%;
}
input:hover { border-color: var(--border-hover); }
input:focus {
border-color: var(--focus-yellow);
box-shadow: 0 0 0 3px rgba(255, 204, 0, 0.18);
}
input::placeholder { color: var(--text-disabled); font-weight: 400; }
/* Buttons */
button {
font-family: inherit;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1;
cursor: pointer;
border: 2px solid transparent;
transition: all var(--d-base) var(--ease);
}
.btn {
height: 40px;
padding: 0 18px;
border-radius: var(--r-pill);
font-size: 13px;
display: inline-flex; align-items: center; justify-content: center;
white-space: nowrap;
}
.btn-primary {
color: var(--text);
background: var(--bg);
border-color: var(--bg);
box-shadow: 0 5px 0 0 var(--shadow-btn);
}
.btn-primary:hover {
box-shadow: 0 6px 0 0 var(--shadow-btn);
transform: translateY(-1px);
}
.btn-primary:active {
box-shadow: 0 1px 0 0 var(--shadow-btn);
transform: translateY(2px);
}
.btn-primary:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.btn-default {
color: var(--text-body);
background: var(--bg);
border: 2px solid var(--border-strong);
box-shadow: 0 2px 4px 0 var(--shadow-soft);
}
.btn-default:hover {
color: var(--primary-active);
border-color: var(--primary);
box-shadow: 0 3px 10px 0 rgba(61, 52, 40, 0.10);
transform: translateY(-1px);
}
.btn-default:active { transform: translateY(0); }
.btn-danger {
color: #fff;
background: var(--error);
border-color: var(--error);
box-shadow: 0 5px 0 0 var(--error-active);
}
.btn-danger:hover { transform: translateY(-1px); box-shadow: 0 6px 0 0 var(--error-active); }
.btn-danger:active { transform: translateY(2px); box-shadow: 0 1px 0 0 var(--error-active); }
.btn-sm { height: 32px; padding: 0 14px; font-size: 12px; border-radius: 16px; }
.btn-mint {
color: #fff;
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 4px 0 0 var(--primary-active);
}
.btn-mint:hover { background: var(--primary-hover); transform: translateY(-1px); box-shadow: 0 5px 0 0 var(--primary-active); }
.btn-mint:active { transform: translateY(2px); box-shadow: 0 1px 0 0 var(--primary-active); }
.muted { color: var(--text-muted); font-size: 0.85rem; font-weight: 600; }
.msg { margin-top: 10px; font-size: 0.85rem; font-weight: 700; min-height: 1.2em; }
.msg.ok { color: var(--success-active); }
.msg.bad { color: var(--error-active); }
/* Table */
.table-wrap { overflow: auto; border-radius: 16px; border: 2px solid var(--border); background: #fffef8; }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; } table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1.5px solid rgba(196, 184, 158, 0.55); } th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--border); }
th { th { color: var(--muted); font-weight: 500; }
color: var(--text-muted); font-weight: 700; font-size: 0.78rem; pre { margin: 0; white-space: pre-wrap; word-break: break-all; font-size: 0.8rem;
letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); max-height: 220px; overflow: auto; }
background: rgba(247, 243, 223, 0.9); .banner { background: rgba(61,139,253,.12); border: 1px solid rgba(61,139,253,.35);
} border-radius: 10px; padding: 10px 12px; font-size: 0.9rem; line-height: 1.5; }
tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: rgba(230, 249, 246, 0.55); }
pre {
margin: 0; white-space: pre-wrap; word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.8rem; font-weight: 500;
color: #f0e8d8;
background: #2b2118;
border: 2px solid #3d3028;
border-radius: 16px;
padding: 12px 14px;
max-height: 240px; overflow: auto;
}
.howto pre {
color: var(--text-body);
background: #fffef8;
border: 2px solid var(--border);
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.stat {
background: #fffef8;
border: 2px solid var(--border);
border-radius: 16px;
padding: 12px 14px;
}
.stat .k { font-size: 0.75rem; font-weight: 700; color: var(--text-muted); letter-spacing: 0.04em; }
.stat .v { margin-top: 4px; font-size: 1.05rem; font-weight: 800; color: var(--text); word-break: break-all; }
.tag {
display: inline-block;
padding: 3px 10px;
border-radius: var(--r-pill);
font-size: 0.75rem;
font-weight: 700;
border: 1.5px solid var(--border-strong);
background: #fff;
color: var(--text-body);
}
.tag.teal { background: var(--primary-bg); border-color: var(--primary); color: var(--primary-active); }
.tag.green { background: #eef8e3; border-color: var(--success); color: var(--success-active); }
footer.foot {
margin-top: 22px;
text-align: center;
color: var(--text-secondary);
font-size: 0.8rem;
font-weight: 600;
}
.wave {
height: 28px;
margin: 18px 0 8px;
background:
radial-gradient(circle at 10px 18px, var(--app-teal) 8px, transparent 9px) 0 0 / 28px 28px,
radial-gradient(circle at 24px 18px, var(--app-blue) 6px, transparent 7px) 0 0 / 28px 28px;
opacity: 0.55;
border-radius: 20px;
}
@media (max-width: 560px) {
.btn { width: 100%; }
.header-actions { width: 100%; }
.title-ribbon { font-size: 0.95rem; }
}
</style> </style>
</head> </head>
<body> <body>
<div class="page"> <header>
<header class="top">
<div> <div>
<h1 class="title-ribbon"><span class="leaf" aria-hidden="true"></span>数据出口代理</h1> <h1>数据出口代理 <span class="muted">cellular-proxy</span></h1>
<div class="sub">走代理 = 走运营商流量 · animal-island 风格控制台</div> <div class="muted">走代理 = 走运营商流量 · 单进程轻量</div>
</div> </div>
<div class="header-actions"> <div class="row">
<span id="connState" class="pill warn">未连接</span> <span id="connState" class="pill warn">未连接</span>
<button type="button" class="btn btn-default" onclick="refreshAll()">刷新</button> <button class="secondary" onclick="refreshAll()">刷新</button>
</div> </div>
</header> </header>
<main>
<section class="banner"> <section class="banner">
<strong>行为说明:</strong>系统默认仍可走 WiFi。 <strong>行为说明:</strong>系统默认仍可走 WiFi。
只要客户端使用本机 HTTP/SOCKS 代理,出口一律绑定<strong>数据网卡</strong>(流量)。 只要客户端使用本机 HTTP/SOCKS 代理,出口一律绑定<strong>数据网卡</strong>(流量)。
无需配置域名分流。面板视觉参考 无需配置域名分流。
<a href="https://github.com/guokaigdg/animal-island-ui" target="_blank" rel="noopener" style="color:var(--primary-active);font-weight:800">animal-island-ui</a>
设计 token(纯静态实现,无 React 依赖)。
</section> </section>
<section class="card"> <section class="card">
<h2>连接设置 <span class="hint">密钥仅存本浏览器 localStorage</span></h2> <div class="row" style="margin-bottom:10px">
<div class="row"> <div>
<div class="field"> <label>API 地址</label><br/>
<label>Clash API9090</label> <input id="apiBase" placeholder="http://127.0.0.1:9090" />
<input id="apiBase" type="text" placeholder="http://127.0.0.1:9090" />
</div> </div>
<div class="field"> <div>
<label>管理 API9091</label> <label>密钥 secret</label><br/>
<input id="adminBase" type="text" placeholder="http://127.0.0.1:9091" /> <input id="secret" type="password" placeholder="PANEL_SECRET" />
</div> </div>
<div class="field"> <div style="align-self:end">
<label>面板密钥 secret</label> <button onclick="saveAuth(); refreshAll()">保存并连接</button>
<input id="secret" type="password" placeholder="PANEL_SECRET" autocomplete="current-password" />
</div>
<div style="padding-bottom:1px">
<button type="button" class="btn btn-primary" onclick="saveAuth(); refreshAll()">保存并连接</button>
</div> </div>
</div> </div>
<div class="muted">密钥仅存本浏览器 localStorage。</div>
</section> </section>
<section class="card"> <section class="card">
<h2> <div class="muted">版本</div>
代理账号密码(7890) <div id="verInfo"></div>
<span id="proxyAuthState" class="pill warn">未知</span>
</h2>
<div class="row">
<div class="field">
<label>用户名</label>
<input id="proxyUser" type="text" placeholder="proxy" autocomplete="username" />
</div>
<div class="field">
<label>密码</label>
<input id="proxyPass" type="password" placeholder="新密码(不回显已有密码)" autocomplete="new-password" />
</div>
<div class="row" style="flex:0 0 auto;padding-bottom:1px">
<button type="button" class="btn btn-mint" onclick="saveProxyAuth()">保存并生效</button>
<button type="button" class="btn btn-default" onclick="clearProxyAuth()">清除鉴权</button>
<button type="button" class="btn btn-default" onclick="loadProxyAuth()">刷新状态</button>
</div>
</div>
<div class="muted" style="margin-top:10px">保存后会重写 settings、重生 sing-box 配置并重启服务(约 1~2 秒)。</div>
<div id="authMsg" class="msg"></div>
</section> </section>
<section class="card"> <section class="card">
<h2>状态</h2> <div class="row" style="justify-content:space-between;margin-bottom:8px">
<div class="stat-grid"> <strong>出站</strong>
<div class="stat"><div class="k">VERSION</div><div class="v" id="verInfo"></div></div> <span class="muted">应只有 cellular(数据网)</span>
<div class="stat"><div class="k">PROXY</div><div class="v">7890 · HTTP/SOCKS</div></div>
<div class="stat"><div class="k">OUTBOUND</div><div class="v"><span class="tag teal">cellular</span></div></div>
</div> </div>
<div id="proxies">加载中…</div>
</section> </section>
<section class="card"> <section class="card">
<h2>出站 <span class="hint">应只有 cellular(数据网)</span></h2> <div class="row" style="justify-content:space-between;margin-bottom:8px">
<div id="proxies" class="muted">加载中…</div> <strong>当前连接</strong>
</section> <button class="secondary" onclick="loadConnections()">刷新</button>
</div>
<section class="card"> <div style="overflow:auto">
<h2>
当前连接
<button type="button" class="btn btn-default btn-sm" onclick="loadConnections()">刷新</button>
</h2>
<div class="table-wrap">
<table> <table>
<thead> <thead><tr><th>目标</th><th>出站链</th><th>上传</th><th>下载</th><th></th></tr></thead>
<tr><th>目标</th><th>出站链</th><th>上传</th><th>下载</th><th></th></tr>
</thead>
<tbody id="connBody"></tbody> <tbody id="connBody"></tbody>
</table> </table>
</div> </div>
</section> </section>
<section class="card howto"> <section class="card">
<h2>怎么用</h2> <strong>怎么用</strong>
<pre>1. 代理端口默认 7890HTTP + SOCKS5 同一端口) <pre>
1. 代理端口默认 7890HTTP + SOCKS5 同一端口)
2. 设备设置系统/应用代理 → Debian 的 LAN-IP:7890 2. 设备设置系统/应用代理 → Debian 的 LAN-IP:7890
3. 需要鉴权时在上方设置用户/密码,或: sudo cpxy auth --user u --pass p 3. 验证:直连 ifconfig.me 与 走代理 ifconfig.me 公网 IP 应不同
4. 验证:直连 ifconfig.me 与 走代理 ifconfig.me 公网 IP 应不同 4. 改网卡:改 settings.conf 里 CELLULAR_IFACE 后 sudo cpxy generate
5. 改网卡:sudo cpxy rebind 5. 低内存:MEMORY_MAX_MB=64~96LOG_LEVEL=warn
6. 低内存:MEMORY_MAX_MB=64~96LOG_LEVEL=warn</pre> </pre>
</section> </section>
<section class="card"> <section class="card">
<h2>API 调试</h2> <strong>API 调试</strong>
<pre id="raw"></pre> <pre id="raw"></pre>
</section> </section>
</main>
<div class="wave" aria-hidden="true"></div>
<footer class="foot">cellular-proxy · 视觉参考 animal-island-ui · 走代理 = 数据流量</footer>
</div>
<script> <script>
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
function defaultBase() { return location.origin || "http://127.0.0.1:9090"; } function defaultBase() { return location.origin || "http://127.0.0.1:9090"; }
function defaultAdminBase() {
try {
const u = new URL(defaultBase());
u.port = "9091";
return u.origin;
} catch {
return "http://127.0.0.1:9091";
}
}
function loadAuth() { function loadAuth() {
$("apiBase").value = localStorage.getItem("cpxy_api") || defaultBase(); $("apiBase").value = localStorage.getItem("cpxy_api") || defaultBase();
$("adminBase").value = localStorage.getItem("cpxy_admin") || defaultAdminBase();
$("secret").value = localStorage.getItem("cpxy_secret") || ""; $("secret").value = localStorage.getItem("cpxy_secret") || "";
} }
function saveAuth() { function saveAuth() {
localStorage.setItem("cpxy_api", $("apiBase").value.trim().replace(/\/$/, "")); localStorage.setItem("cpxy_api", $("apiBase").value.trim().replace(/\/$/, ""));
localStorage.setItem("cpxy_admin", $("adminBase").value.trim().replace(/\/$/, ""));
localStorage.setItem("cpxy_secret", $("secret").value); localStorage.setItem("cpxy_secret", $("secret").value);
} }
function base() { return ($("apiBase").value || defaultBase()).replace(/\/$/, ""); } function base() { return ($("apiBase").value || defaultBase()).replace(/\/$/, ""); }
function adminBase() { return ($("adminBase").value || defaultAdminBase()).replace(/\/$/, ""); }
function headers() { function headers() {
const h = { "Content-Type": "application/json" }; const h = { "Content-Type": "application/json" };
const s = $("secret").value; const s = $("secret").value;
@@ -482,14 +140,6 @@
const t = await r.text(); const t = await r.text();
try { return t ? JSON.parse(t) : {}; } catch { return t; } try { return t ? JSON.parse(t) : {}; } catch { return t; }
} }
async function adminApi(path, opt = {}) {
const r = await fetch(adminBase() + path, { ...opt, headers: { ...headers(), ...(opt.headers || {}) } });
const t = await r.text();
let data = {};
try { data = t ? JSON.parse(t) : {}; } catch { data = { raw: t }; }
if (!r.ok) throw new Error((data.error || data.message || t || r.status) + "");
return data;
}
function fmt(n) { function fmt(n) {
if (n == null) return "—"; if (n == null) return "—";
const u = ["B", "KB", "MB", "GB"]; const u = ["B", "KB", "MB", "GB"];
@@ -497,71 +147,6 @@
while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; } while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; }
return x.toFixed(i ? 1 : 0) + " " + u[i]; return x.toFixed(i ? 1 : 0) + " " + u[i];
} }
function setAuthMsg(text, ok) {
const el = $("authMsg");
el.textContent = text || "";
el.className = "msg" + (ok === true ? " ok" : (ok === false ? " bad" : ""));
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
async function loadProxyAuth() {
try {
const data = await adminApi("/proxy-auth");
$("proxyUser").value = data.proxy_user || "";
if (data.auth_enabled) {
$("proxyAuthState").textContent = "已启用 · " + (data.proxy_user || "");
$("proxyAuthState").className = "pill ok";
setAuthMsg("当前已设置鉴权" + (data.proxy_pass_masked ? "(密码 " + data.proxy_pass_masked + "" : ""), true);
} else {
$("proxyAuthState").textContent = "无鉴权";
$("proxyAuthState").className = "pill warn";
setAuthMsg("当前 7890 无账号密码", null);
}
$("proxyPass").value = "";
} catch (e) {
$("proxyAuthState").textContent = "管理 API 不可用";
$("proxyAuthState").className = "pill bad";
setAuthMsg("无法连接管理 API9091)。确认 cellular-proxy-admin 已启动,或: sudo cpxy auth --user u --pass p。错误: " + e.message, false);
}
}
async function saveProxyAuth() {
saveAuth();
const user = $("proxyUser").value.trim();
const pass = $("proxyPass").value;
if (!user) { setAuthMsg("请填写用户名", false); return; }
if (!pass) { setAuthMsg("请填写密码(不会回显已有密码)", false); return; }
setAuthMsg("正在应用…", null);
try {
const data = await adminApi("/proxy-auth", {
method: "POST",
body: JSON.stringify({ user, pass })
});
setAuthMsg("已生效:用户 " + (data.proxy_user || user), true);
await loadProxyAuth();
} catch (e) {
setAuthMsg("保存失败: " + e.message, false);
}
}
async function clearProxyAuth() {
if (!confirm("确定清除代理账号密码?清除后 7890 无需鉴权。")) return;
saveAuth();
setAuthMsg("正在清除…", null);
try {
await adminApi("/proxy-auth", {
method: "POST",
body: JSON.stringify({ clear: true })
});
$("proxyUser").value = "";
$("proxyPass").value = "";
setAuthMsg("已清除鉴权", true);
await loadProxyAuth();
} catch (e) {
setAuthMsg("清除失败: " + e.message, false);
}
}
async function refreshAll() { async function refreshAll() {
try { try {
const ver = await api("/version"); const ver = await api("/version");
@@ -571,12 +156,10 @@
$("raw").textContent = JSON.stringify(ver, null, 2); $("raw").textContent = JSON.stringify(ver, null, 2);
await loadProxies(); await loadProxies();
await loadConnections(); await loadConnections();
await loadProxyAuth();
} catch (e) { } catch (e) {
$("connState").textContent = "失败"; $("connState").textContent = "失败: " + e.message;
$("connState").className = "pill bad"; $("connState").className = "pill bad";
$("proxies").innerHTML = '<span class="muted">无法连接 API。请确认服务已启动,并填写正确 secret。<br/>' + esc(e.message) + "</span>"; $("proxies").textContent = "无法连接 API。请确认服务已启动,并填写正确 secret。";
await loadProxyAuth();
} }
} }
async function loadProxies() { async function loadProxies() {
@@ -584,34 +167,21 @@
const proxies = data.proxies || {}; const proxies = data.proxies || {};
const names = Object.keys(proxies).filter(k => ["cellular", "DIRECT", "REJECT"].includes(k) || proxies[k].type === "Direct"); const names = Object.keys(proxies).filter(k => ["cellular", "DIRECT", "REJECT"].includes(k) || proxies[k].type === "Direct");
const keys = names.length ? names : Object.keys(proxies).slice(0, 15); const keys = names.length ? names : Object.keys(proxies).slice(0, 15);
let html = '<div class="table-wrap"><table><thead><tr><th>名称</th><th>类型</th><th>测速</th></tr></thead><tbody>'; let html = "<table><thead><tr><th>名称</th><th>类型</th><th>测速</th></tr></thead><tbody>";
for (const name of keys) { for (const name of keys) {
const p = proxies[name] || {}; const p = proxies[name] || {};
const safe = esc(name); html += `<tr><td>${name}</td><td>${p.type || ""}</td>
html += `<tr> <td><button class="secondary" onclick="delay('${name}')">测速</button> <span id="d-${name}"></span></td></tr>`;
<td><span class="tag ${name === "cellular" ? "teal" : ""}">${safe}</span></td>
<td>${esc(p.type || "")}</td>
<td><button type="button" class="btn btn-default btn-sm" data-delay="${safe}">测速</button> <span id="d-${safe}"></span></td>
</tr>`;
} }
html += "</tbody></table></div>"; html += "</tbody></table>";
$("proxies").innerHTML = html; $("proxies").innerHTML = html;
$("proxies").querySelectorAll("[data-delay]").forEach(btn => {
btn.addEventListener("click", () => delay(btn.getAttribute("data-delay")));
});
} }
async function delay(name) { async function delay(name) {
try { try {
const r = await api("/proxies/" + encodeURIComponent(name) + "/delay?timeout=5000&url=" + const r = await api("/proxies/" + encodeURIComponent(name) + "/delay?timeout=5000&url=" +
encodeURIComponent("https://www.gstatic.com/generate_204")); encodeURIComponent("https://www.gstatic.com/generate_204"));
const el = document.getElementById("d-" + name); $("d-" + name).textContent = (r.delay != null ? r.delay + "ms" : JSON.stringify(r));
if (el) el.innerHTML = r.delay != null } catch (e) { $("d-" + name).textContent = e.message; }
? `<span class="tag green">${r.delay}ms</span>`
: esc(JSON.stringify(r));
} catch (e) {
const el = document.getElementById("d-" + name);
if (el) el.textContent = e.message;
}
} }
async function loadConnections() { async function loadConnections() {
try { try {
@@ -623,18 +193,14 @@
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const host = (c.metadata && (c.metadata.host || c.metadata.destinationIP)) || "—"; const host = (c.metadata && (c.metadata.host || c.metadata.destinationIP)) || "—";
const port = c.metadata && c.metadata.destinationPort ? ":" + c.metadata.destinationPort : ""; const port = c.metadata && c.metadata.destinationPort ? ":" + c.metadata.destinationPort : "";
tr.innerHTML = `<td>${esc(host)}${esc(port)}</td> tr.innerHTML = `<td>${host}${port}</td><td>${c.chains ? c.chains.join("→") : "—"}</td>
<td>${esc(c.chains ? c.chains.join("→") : "—")}</td> <td>${fmt(c.upload)}</td><td>${fmt(c.download)}</td>
<td>${esc(fmt(c.upload))}</td><td>${esc(fmt(c.download))}</td> <td><button class="danger" onclick="closeConn('${c.id}')">断开</button></td>`;
<td><button type="button" class="btn btn-danger btn-sm" data-close="${esc(c.id)}">断开</button></td>`;
body.appendChild(tr); body.appendChild(tr);
}); });
body.querySelectorAll("[data-close]").forEach(btn => { if (!conns.length) body.innerHTML = "<tr><td colspan=5 class=muted>暂无连接</td></tr>";
btn.addEventListener("click", () => closeConn(btn.getAttribute("data-close")));
});
if (!conns.length) body.innerHTML = '<tr><td colspan="5" class="muted">暂无连接</td></tr>';
} catch (e) { } catch (e) {
$("connBody").innerHTML = '<tr><td colspan="5">' + esc(e.message) + "</td></tr>"; $("connBody").innerHTML = "<tr><td colspan=5>" + e.message + "</td></tr>";
} }
} }
async function closeConn(id) { async function closeConn(id) {