Compare commits
6
Commits
bin-v1.11.7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88f2ff44aa | ||
|
|
659ad6f950 | ||
|
|
6a3015aecc | ||
|
|
06f941c519 | ||
|
|
062a3eee90 | ||
|
|
b2a7332f86 |
@@ -9,21 +9,20 @@
|
||||
| **不走** 本代理 | 系统默认(通常 WiFi) |
|
||||
| **走** 本代理 | **一律数据网卡**(自动探测并 bind) |
|
||||
|
||||
## 全自动一键安装
|
||||
|
||||
无参数即可:
|
||||
|
||||
1. 下载源码
|
||||
2. **自动探测数据网卡**(名称 / 驱动 / 次要默认路由 / 非默认物理口)
|
||||
3. **写入配置并 bind 出口**
|
||||
4. 安装 sing-box + systemd
|
||||
5. 启动并尝试验证出口 IP
|
||||
## ★ 一条命令(安装 / 升级通用)
|
||||
|
||||
```bash
|
||||
curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash
|
||||
```
|
||||
|
||||
可选覆盖:
|
||||
| 机器状态 | 行为 |
|
||||
|----------|------|
|
||||
| **未安装** | 全量安装:探测网卡 → 下 sing-box → 启动 → 验证 |
|
||||
| **已安装** | 增量升级:保留密钥/网卡/账密,更新 UI/scripts,默认不重下二进制 |
|
||||
|
||||
不需要记 `--upgrade` / `--install`,**同一条命令反复执行即可**。
|
||||
|
||||
可选覆盖(首次安装常用):
|
||||
|
||||
```bash
|
||||
curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash -s -- \
|
||||
@@ -35,38 +34,98 @@ 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` | 强制指定数据网卡 |
|
||||
| `--secret` | 面板密钥(默认随机) |
|
||||
| `--secret` | 面板密钥(默认随机;升级不改已有) |
|
||||
| `--user` / `--pass` | 代理鉴权 |
|
||||
| `--port` / `--panel-port` | 默认 7890 / 9090 |
|
||||
| `--memory` | MemoryMax MB,默认 96 |
|
||||
| `--skip-start` / `--skip-verify` | 跳过启动或验证 |
|
||||
|
||||
装完:
|
||||
## 已装后的快捷方式
|
||||
|
||||
装过一次后也可用:
|
||||
|
||||
```bash
|
||||
cpxy verify # 直连 IP ≠ 代理 IP
|
||||
cpxy rebind # 重插卡后重新自动探测并绑定
|
||||
cpxy detect # 查看评分/驱动
|
||||
cpxy status | logs
|
||||
sudo cpxy upgrade # 等同再跑上面那条 curl(增量)
|
||||
sudo cpxy upgrade --ui-only
|
||||
sudo cpxy upgrade --force-binary
|
||||
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 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)
|
||||
- 面板:`http://LAN-IP:9090/`(密钥在安装结束输出 / `settings.conf`)
|
||||
- 面板:`http://LAN-IP:9090/ui/`(密钥在安装结束输出 / `settings.conf`)
|
||||
- **UI 可直接配置代理账号密码**(管理 API 默认 `9091`,与面板 secret 相同)
|
||||
|
||||
## 自动探测逻辑(摘要)
|
||||
|
||||
按分数选择,优先:
|
||||
顺序如下,前一步命中即返回:
|
||||
|
||||
1. 驱动像蜂窝/USB 拨号:`qmi_wwan`、`cdc_mbim`、`cdc_ether`、`rndis_host` 等
|
||||
2. 名称含:`wwan` / `wwp` / `usb` / `enx` / `ppp` / `rmnet` / `ccmni` …
|
||||
0. **ModemManager bearer**(`mmcli -b` 报的 `interface:`)—— 最权威,优先采用
|
||||
1. 驱动像蜂窝/USB 拨号:`qmi_wwan`、`cdc_mbim`、`cdc_ether`、`rndis_host`、`bam-dmux`、`rmnet*` 等(+100)
|
||||
2. 名称含 `wwan` / `wwp` / `ppp` / `rmnet` / `ccmni` …(+40,弱启发式)
|
||||
3. **不是** 系统默认路由网卡(默认通常是 WiFi)
|
||||
4. 多默认路由时取次要默认口
|
||||
5. 回退:任意非默认且有 IPv4 的物理网卡
|
||||
|
||||
探测失败会直接报错并提示用 `--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
|
||||
@@ -78,20 +137,27 @@ sudo ./scripts/install.sh # 同样会自动探测并绑定
|
||||
## 资源
|
||||
|
||||
- 单进程 sing-box
|
||||
- 中文静态面板 ~20KB
|
||||
- 中文静态面板(animal-island-ui 视觉 token,单文件无 React)
|
||||
- 默认 `MemoryMax=96MB`
|
||||
|
||||
## GitHub 下载代理
|
||||
## 二进制从哪里下?
|
||||
|
||||
安装时下载 sing-box release 默认走:
|
||||
**优先 Gitea Release 预置包**(一键安装默认走 Gitea,无需访问 GitHub):
|
||||
|
||||
```text
|
||||
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-amd64.tar.gz
|
||||
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
|
||||
```
|
||||
|
||||
可在 `settings.conf` 改 `GITHUB_PROXY=`(置空则直连 GitHub)。代理失败会自动回退直连。
|
||||
下载顺序:`Gitea 整包` → `Gitea 分片合并(兜底)` → `GitHub 代理(https://git.86482425.xyz)` → `直连 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`。
|
||||
|
||||
@@ -15,14 +15,37 @@ INSTALL_DIR=/opt/cellular-proxy
|
||||
LOG_DIR=/var/log/cellular-proxy
|
||||
|
||||
# ---- 数据网卡(代理出口强制绑定)----
|
||||
# 留空则自动探测 wwan/usb/enx 等;建议手动填准
|
||||
# 留空则自动探测(优先 ModemManager bearer,其次驱动/名称打分);建议手动填准
|
||||
# 注意:不要填本机对上游主机暴露的 USB gadget 网卡(常见名 usb0/rndis0,驱动
|
||||
# configfs-gadget/g_ether)。那是「本机 -> PC」的下行链路,绑上去所有出站都会超时,
|
||||
# SOCKS5 会返回 rep=0x01。脚本会拒绝这类网卡并自动重新探测。
|
||||
CELLULAR_IFACE=
|
||||
# 可选:数据侧源 IPv4(一般留空)
|
||||
# 元数据:数据侧当前源 IPv4(generate/watch 会更新;默认不写入 sing-box)
|
||||
CELLULAR_SOURCE_IP=
|
||||
# 是否把源 IP pin 进 sing-box inet4_bind_address
|
||||
# false(默认,推荐):只 bind_interface=网卡名 → WWAN 重拨换私网 IP 时通常无需 regenerate
|
||||
# true:额外写 inet4_bind_address(需 watch/rebind 同步,否则易 stale → SOCKS rep=0x01)
|
||||
BIND_SOURCE_IP=false
|
||||
# 数据网卡不存在则不启动(推荐 true,避免代理静默从 WiFi 出)
|
||||
REQUIRE_CELLULAR_IFACE=true
|
||||
# 自动探测关键词(逗号分隔)
|
||||
CELLULAR_IFACE_PATTERNS=wwan,wwp,usb,enx,ppp,cdc
|
||||
# 自动探测关键词(逗号分隔)。仅作弱启发式,权重低于驱动判定。
|
||||
# 刻意不含 usb/enx:会命中 USB gadget 网卡 usb0,导致出口绑到 PC 侧链路。
|
||||
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)----
|
||||
# mixed = 同一端口同时支持 HTTP 代理 与 SOCKS5
|
||||
@@ -30,6 +53,7 @@ PROXY_LISTEN_HOST=0.0.0.0
|
||||
PROXY_MIXED_PORT=7890
|
||||
|
||||
# 代理鉴权(局域网强烈建议设置;留空=无鉴权)
|
||||
# 7890 代理鉴权(空=无密码)。也可用: cpxy auth --user u --pass p 或面板 UI
|
||||
PROXY_USER=
|
||||
PROXY_PASS=
|
||||
|
||||
@@ -46,12 +70,18 @@ ENABLE_DNS=true
|
||||
LOG_LEVEL=warn
|
||||
|
||||
# ---- sing-box ----
|
||||
SING_BOX_SOURCE=release
|
||||
# auto = 优先 Gitea 预置包(整包/分片),失败再走 GitHub 代理
|
||||
# gitea | github | local | auto
|
||||
SING_BOX_SOURCE=auto
|
||||
SING_BOX_VERSION=1.11.7
|
||||
SING_BOX_BIN=
|
||||
# GitHub 下载代理前缀(下载 sing-box release 时使用)
|
||||
# 默认 https://git.86482425.xyz → 实际 URL: 代理/https://github.com/...
|
||||
# 置空则直连 GitHub
|
||||
# Gitea 预置包 Release(publish-binaries-to-gitea.sh 上传)
|
||||
GITEA_BASE=https://gitea.chickliu.fun
|
||||
GITEA_OWNER=Hermes
|
||||
GITEA_REPO=cellular-proxy
|
||||
# 默认 tag: bin-v{SING_BOX_VERSION}
|
||||
SING_BOX_RELEASE_TAG=
|
||||
# 维护端从 GitHub 拉包、安装端回退 GitHub 时使用;置空则直连
|
||||
GITHUB_PROXY=https://git.86482425.xyz
|
||||
|
||||
# 进程内存上限(MB,0=不限制)。512MB 设备建议 64~96
|
||||
|
||||
@@ -7,14 +7,22 @@ case "$cmd" in
|
||||
detect) exec "$ROOT/scripts/detect.sh" "$@" ;;
|
||||
generate) exec "$ROOT/scripts/generate.sh" "$@" ;;
|
||||
install) exec "$ROOT/scripts/install.sh" "$@" ;;
|
||||
upgrade) exec "$ROOT/scripts/online-upgrade.sh" "$@" ;;
|
||||
verify) exec "$ROOT/scripts/verify.sh" "$@" ;;
|
||||
fetch) exec "$ROOT/scripts/fetch-binaries.sh" "$@" ;;
|
||||
auth) exec "$ROOT/scripts/apply-proxy-auth.sh" "$@" ;;
|
||||
help|*)
|
||||
cat <<H
|
||||
./cpxy <cmd>
|
||||
detect | generate | install | verify | fetch
|
||||
detect | generate | install | upgrade | verify | fetch
|
||||
auth --user U --pass P | auth --clear | auth --show
|
||||
|
||||
语义: 走本代理的连接一律从数据网卡出口(系统默认仍可走 WiFi)
|
||||
|
||||
已装机器一条命令升级:
|
||||
sudo cpxy upgrade
|
||||
sudo cpxy upgrade --ui-only
|
||||
sudo cpxy upgrade --force-binary
|
||||
H
|
||||
;;
|
||||
esac
|
||||
|
||||
+11
@@ -6,6 +6,17 @@
|
||||
2. 需要「用流量出口」时:把应用的 HTTP/SOCKS 指到 `LAN:7890`
|
||||
3. `cpxy verify` 确认代理 IP ≠ 直连 IP
|
||||
|
||||
## 重拨检测(ModemManager 日志标准)
|
||||
|
||||
watch 默认以 **本机 ModemManager journal** 作为「数据网络重拨/重注册」信号,**不消耗蜂窝公网流量**:
|
||||
|
||||
- 典型命中:`registration state changed (home -> searching)`、`packet service state changed (attached -> detached)`、bearer 断连、access tech 抖动
|
||||
- 动作:`WATCH_MM_SETTLE_SEC` 后等待 `wwan` IPv4(可选 mmcli home),再 `generate/restart` 清半开连接
|
||||
- 同一轮抖动用 `WATCH_MM_COALESCE_SEC` 合并,避免连刷三次
|
||||
- 首次启动写 `mm.cursor`,**不回放历史日志**,避免升级后误 rebind
|
||||
- 相关日志:`/var/log/cellular-proxy/watch.log`、`mm.last_event`、`watch.state`
|
||||
- 关闭:`WATCH_MM_EVENTS=false` 后 `systemctl restart cellular-proxy-watch`
|
||||
|
||||
## 内存
|
||||
|
||||
```bash
|
||||
|
||||
+170
-37
@@ -1,12 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# cellular-proxy 全自动一键安装
|
||||
# cellular-proxy 一条命令:未安装 → 全量安装;已安装 → 增量升级
|
||||
# 语义:走本代理的连接一律从数据网卡(流量)出口;系统默认仍可走 WiFi
|
||||
#
|
||||
# 最简(自动探测数据网卡 + 自动生成密钥 + 下载 sing-box + 启动 + 验证):
|
||||
# ★ 唯一推荐(装 / 升 通用):
|
||||
# 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 -- --force-install # 已装也强制全量
|
||||
# ... | sudo bash -s -- --force-binary # 升级时强制重下 sing-box
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
@@ -30,6 +34,12 @@ OPT_SKIP_START=0
|
||||
OPT_SKIP_VERIFY=0
|
||||
OPT_LOCAL_DIR=""
|
||||
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; }
|
||||
info() { log "INFO $*"; }
|
||||
@@ -39,24 +49,33 @@ die() { err "$*"; exit 1; }
|
||||
|
||||
usage() {
|
||||
cat <<'H'
|
||||
cellular-proxy 全自动一键安装
|
||||
cellular-proxy — 一条命令自动安装或升级
|
||||
|
||||
无参数即可:自动探测数据网卡 → 绑定出口 → 装 sing-box → 启动 → 验证
|
||||
curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash
|
||||
|
||||
选项(全可选):
|
||||
--iface NAME 强制指定数据网卡(跳过自动探测)
|
||||
--secret STR 面板 PANEL_SECRET(默认随机生成)
|
||||
--user USER 代理账号
|
||||
--pass PASS 代理密码
|
||||
--port N 代理端口(默认 7890)
|
||||
--panel-port N 面板端口(默认 9090)
|
||||
--memory MB MemoryMax(默认 96)
|
||||
--install-dir PATH 安装目录
|
||||
--local DIR 本地源码,不拉 Gitea
|
||||
--ref REF 分支/tag
|
||||
--skip-start 不启动服务
|
||||
--skip-verify 跳过出口验证
|
||||
--no-auto-detect 禁止自动探测(必须 --iface)
|
||||
行为(无参数时自动判断):
|
||||
· 未检测到安装 → 全量安装(探测网卡 + 下载 sing-box + 启动)
|
||||
· 已安装 → 增量升级(保留密钥/网卡;默认不重下二进制)
|
||||
|
||||
强制选项:
|
||||
--upgrade 强制走增量升级(未安装会报错)
|
||||
--force-install 即使已安装也走全量安装
|
||||
--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)
|
||||
--install-dir PATH
|
||||
--local DIR 本地源码
|
||||
--ref REF
|
||||
--skip-start / --skip-verify
|
||||
--no-auto-detect 禁止自动探测(必须 --iface)
|
||||
-h, --help
|
||||
H
|
||||
}
|
||||
@@ -76,6 +95,12 @@ while [[ $# -gt 0 ]]; do
|
||||
--skip-start) OPT_SKIP_START=1; shift ;;
|
||||
--skip-verify) OPT_SKIP_VERIFY=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 ;;
|
||||
*) die "未知参数: $1(--help)" ;;
|
||||
esac
|
||||
@@ -91,12 +116,24 @@ need_cmd() {
|
||||
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() {
|
||||
local missing=()
|
||||
for c in tar awk ip curl; do
|
||||
command -v "$c" >/dev/null 2>&1 || missing+=("$c")
|
||||
done
|
||||
# curl 可用 wget 替代
|
||||
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
|
||||
missing+=("curl")
|
||||
fi
|
||||
@@ -182,22 +219,59 @@ fetch_source() {
|
||||
prepare_settings() {
|
||||
local src="$WORK_DIR/src"
|
||||
local conf="$src/config/settings.conf"
|
||||
local live="${INSTALL_DIR}/etc/settings.conf"
|
||||
[[ -f "$src/config/settings.conf.example" ]] || die "缺少 settings.conf.example"
|
||||
cp "$src/config/settings.conf.example" "$conf"
|
||||
|
||||
# 预加载 lib 做自动探测(源码里的 lib)
|
||||
# 已有安装:以 live settings 为底,不重置密钥/网卡
|
||||
if [[ -f "$live" ]]; then
|
||||
info "沿用已有配置: $live"
|
||||
cp "$live" "$conf"
|
||||
else
|
||||
cp "$src/config/settings.conf.example" "$conf"
|
||||
fi
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$src/scripts/lib.sh"
|
||||
CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,usb,enx,ppp,cdc,rmnet,ccmni,mbim,qmi}"
|
||||
CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,ppp,cdc,rmnet,ccmni,mbim,qmi}"
|
||||
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=""
|
||||
if [[ -n "$OPT_IFACE" ]]; then
|
||||
cell="$OPT_IFACE"
|
||||
info "使用 --iface $cell"
|
||||
elif [[ "$OPT_NO_AUTO_DETECT" -eq 1 ]]; then
|
||||
die "--no-auto-detect 时必须提供 --iface"
|
||||
else
|
||||
if iface_is_usb_gadget "$cell"; 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"
|
||||
fi
|
||||
info "自动探测数据网卡…"
|
||||
cell="$(detect_cellular_iface "" || true)"
|
||||
fi
|
||||
@@ -213,6 +287,10 @@ prepare_settings() {
|
||||
die "网卡不存在: $cell"
|
||||
fi
|
||||
|
||||
if iface_is_usb_gadget "$cell"; then
|
||||
die "拒绝把 USB gadget 网卡 $cell 写成数据出口,请用 --iface wwanX 指定真实数据网卡"
|
||||
fi
|
||||
|
||||
local src_ip
|
||||
src_ip="$(detect_source_ip "$cell" || true)"
|
||||
set_kv "$conf" CELLULAR_IFACE "$cell"
|
||||
@@ -221,13 +299,18 @@ prepare_settings() {
|
||||
fi
|
||||
info "已写入 CELLULAR_IFACE=$cell ip=${src_ip:-none}"
|
||||
|
||||
# secret:仅全量且未指定且当前是占位时生成;有 live 则不覆盖
|
||||
if [[ -n "$OPT_SECRET" ]]; then
|
||||
set_kv "$conf" PANEL_SECRET "$OPT_SECRET"
|
||||
else
|
||||
local gen
|
||||
gen="$(openssl rand -hex 12 2>/dev/null || head -c 16 /dev/urandom | xxd -p | tr -d '\n')"
|
||||
set_kv "$conf" PANEL_SECRET "$gen"
|
||||
info "已自动生成 PANEL_SECRET"
|
||||
elif [[ ! -f "$live" ]]; then
|
||||
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
|
||||
gen="$(openssl rand -hex 12 2>/dev/null || head -c 16 /dev/urandom | xxd -p | tr -d '\n')"
|
||||
set_kv "$conf" PANEL_SECRET "$gen"
|
||||
info "已自动生成 PANEL_SECRET"
|
||||
fi
|
||||
fi
|
||||
|
||||
[[ -n "$OPT_USER" ]] && set_kv "$conf" PROXY_USER "$OPT_USER"
|
||||
@@ -238,11 +321,35 @@ prepare_settings() {
|
||||
set_kv "$conf" INSTALL_DIR "$INSTALL_DIR"
|
||||
set_kv "$conf" REQUIRE_CELLULAR_IFACE "true"
|
||||
|
||||
# 给子 install 用
|
||||
export DETECTED_CELL="$cell"
|
||||
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() {
|
||||
need_root
|
||||
ensure_deps
|
||||
@@ -252,6 +359,33 @@ main() {
|
||||
|
||||
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 "1/3 获取源码"
|
||||
fetch_source
|
||||
@@ -261,7 +395,7 @@ main() {
|
||||
|
||||
info "3/3 安装 sing-box、绑定出口、启动"
|
||||
export CONFIG_FILE="$WORK_DIR/src/config/settings.conf"
|
||||
export AUTO_DETECT=false # 已在 prepare 写死网卡
|
||||
export AUTO_DETECT=false
|
||||
export AUTO_VERIFY=true
|
||||
if [[ "$OPT_SKIP_VERIFY" -eq 1 ]]; then
|
||||
export AUTO_VERIFY=false
|
||||
@@ -271,8 +405,6 @@ main() {
|
||||
else
|
||||
export SKIP_START=false
|
||||
fi
|
||||
# 确保 settings 路径对 scripts/install 可见
|
||||
# scripts/install 读 ROOT/config/settings.conf
|
||||
bash "$WORK_DIR/src/scripts/install.sh"
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
@@ -291,12 +423,13 @@ main() {
|
||||
语义: 走代理 → 数据流量;不走代理 → WiFi/默认
|
||||
|
||||
代理: HTTP/SOCKS ${lan:-127.0.0.1}:${port}
|
||||
面板: http://${lan:-<LAN-IP>}:${panel}/
|
||||
面板: http://${lan:-<LAN-IP>}:${panel}/ui/
|
||||
密钥: ${secret}
|
||||
配置: $INSTALL_DIR/etc/settings.conf
|
||||
|
||||
验证: cpxy verify
|
||||
重绑: cpxy rebind # 换卡/重插后重新自动探测
|
||||
重绑: cpxy rebind
|
||||
之后更新: 再执行同一条 curl 命令,或: sudo cpxy upgrade
|
||||
日志: cpxy logs
|
||||
EOM
|
||||
}
|
||||
|
||||
Executable
+193
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
"""轻量管理 API:用面板 secret 鉴权,配置代理账号密码。
|
||||
|
||||
默认监听 127.0.0.1:9091,UI 经同源时用相对路径 /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()
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/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
|
||||
+6
-2
@@ -6,7 +6,7 @@ source "$ROOT_DIR/scripts/lib.sh"
|
||||
if [[ -f "${CONFIG_FILE:-$ROOT_DIR/config/settings.conf}" ]]; then
|
||||
load_config
|
||||
else
|
||||
CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,usb,enx,ppp,cdc,rmnet,ccmni,mbim,qmi}"
|
||||
CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,ppp,cdc,rmnet,ccmni,mbim,qmi}"
|
||||
REQUIRE_CELLULAR_IFACE=false
|
||||
CELLULAR_IFACE=
|
||||
fi
|
||||
@@ -24,9 +24,13 @@ printf '%-12s %-10s %-18s %-12s %s\n' "IFACE" "STATE" "IPv4" "DRIVER" "SCORE"
|
||||
default_if="$(detect_default_iface || true)"
|
||||
while IFS='|' read -r name state ip; do
|
||||
[[ -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
|
||||
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"
|
||||
done < <(list_ifaces)
|
||||
echo
|
||||
|
||||
+179
-15
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# 仅下载 sing-box(轻量核心)
|
||||
# 仅下载/安装 sing-box(轻量核心)
|
||||
# 优先级:local → 系统已有 → Gitea Release(整包/分片)→ GitHub 代理 → 直连 GitHub
|
||||
set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=lib.sh
|
||||
@@ -13,12 +14,15 @@ ARCH="$(arch_go)"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# GitHub 下载代理:默认 https://git.86482425.xyz
|
||||
# 空字符串 = 直连 GitHub;也可设为其它镜像前缀(末尾可带或不带 /)
|
||||
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() {
|
||||
# 把 https://github.com/... 加上代理前缀
|
||||
local raw="$1"
|
||||
local proxy="${GITHUB_PROXY:-}"
|
||||
if [[ -z "$proxy" ]]; then
|
||||
@@ -26,11 +30,9 @@ github_url() {
|
||||
return
|
||||
fi
|
||||
proxy="${proxy%/}"
|
||||
# 已是代理地址则不重复加
|
||||
case "$raw" in
|
||||
"${proxy}"/*) echo "$raw"; return ;;
|
||||
esac
|
||||
# 常见 ghproxy 风格:https://proxy/https://github.com/...
|
||||
echo "${proxy}/${raw}"
|
||||
}
|
||||
|
||||
@@ -38,7 +40,7 @@ download() {
|
||||
local url="$1" dest="$2"
|
||||
info "下载: $url"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fL --retry 3 --connect-timeout 20 -o "$dest" "$url"
|
||||
curl -fL --retry 3 --connect-timeout 20 --max-time 300 -o "$dest" "$url"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -O "$dest" "$url"
|
||||
else
|
||||
@@ -46,8 +48,17 @@ download() {
|
||||
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() {
|
||||
# 先走代理,失败再直连 GitHub
|
||||
local raw="$1" dest="$2"
|
||||
local proxied
|
||||
proxied="$(github_url "$raw")"
|
||||
@@ -61,21 +72,174 @@ download_github() {
|
||||
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
|
||||
[[ -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"
|
||||
info "已安装本地 sing-box"
|
||||
elif command -v sing-box >/dev/null 2>&1 && [[ -z "${FORCE_DOWNLOAD:-}" ]]; then
|
||||
elif command -v sing-box >/dev/null 2>&1 && [[ -z "${FORCE_DOWNLOAD:-}" && "$SING_BOX_SOURCE" != "gitea" && "$SING_BOX_SOURCE" != "github" ]]; then
|
||||
install -m 0755 "$(command -v sing-box)" "$INSTALL_DIR/bin/sing-box"
|
||||
info "复用系统 sing-box: $(command -v sing-box)"
|
||||
else
|
||||
ver="$SING_BOX_VERSION"
|
||||
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" "$TMP/sb.tgz"
|
||||
tar -xzf "$TMP/sb.tgz" -C "$TMP"
|
||||
install -m 0755 "$TMP/${name}/sing-box" "$INSTALL_DIR/bin/sing-box"
|
||||
info "已安装 sing-box v${ver}"
|
||||
tgz="$TMP/sb.tgz"
|
||||
ok=0
|
||||
case "$SING_BOX_SOURCE" in
|
||||
gitea)
|
||||
try_download_gitea "$ver" "$ARCH" "$tgz" || die "Gitea 预置包下载失败(tag=$SING_BOX_RELEASE_TAG arch=$ARCH)"
|
||||
ok=1
|
||||
;;
|
||||
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
|
||||
|
||||
# 安装中文静态 UI
|
||||
|
||||
+43
-5
@@ -13,9 +13,46 @@ cell="$(resolve_cellular 2>/dev/null || true)"
|
||||
if [[ -z "$cell" ]]; then
|
||||
resolve_cellular >/dev/null || true
|
||||
fi
|
||||
src_ip="${CELLULAR_SOURCE_IP:-}"
|
||||
if [[ -z "$src_ip" && -n "$cell" ]]; then
|
||||
src_ip="$(detect_source_ip "$cell" || true)"
|
||||
|
||||
# 最后一道闸:绝不把 USB gadget 网卡(本机 -> 上游主机的 RNDIS 链路)写成代理出口。
|
||||
# 一旦写错,所有出站连接都会绑到只通向 PC 的接口上,SOCKS 返回 rep=0x01。
|
||||
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_src(settings 仅作元数据)"
|
||||
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
|
||||
|
||||
if [[ -z "$cell" ]]; then
|
||||
@@ -34,7 +71,7 @@ fi
|
||||
|
||||
export GEN_OUT="$OUT_DIR/config.json"
|
||||
export GEN_CELL="$cell_for_cfg"
|
||||
export GEN_SRC="$src_ip"
|
||||
export GEN_SRC="${GEN_SRC_FOR_CFG:-}"
|
||||
export GEN_PROXY_HOST="$PROXY_LISTEN_HOST"
|
||||
export GEN_PROXY_PORT="$PROXY_MIXED_PORT"
|
||||
export GEN_PROXY_USER="$PROXY_USER"
|
||||
@@ -70,12 +107,13 @@ inbound = {
|
||||
if users:
|
||||
inbound["users"] = users
|
||||
|
||||
# 唯一业务出口:强制绑定数据网卡
|
||||
# 唯一业务出口:强制绑定数据网卡(默认仅 bind_interface)
|
||||
out_cell = {
|
||||
"type": "direct",
|
||||
"tag": "cellular",
|
||||
"bind_interface": cell,
|
||||
}
|
||||
# 仅当 GEN_SRC 非空(BIND_SOURCE_IP=true)时 pin 源 IP
|
||||
if src:
|
||||
out_cell["inet4_bind_address"] = src
|
||||
|
||||
|
||||
+120
-10
@@ -20,12 +20,19 @@ info "1/6 自动探测并绑定数据网卡"
|
||||
"$ROOT_DIR/scripts/detect.sh" || true
|
||||
|
||||
cell=""
|
||||
if [[ -n "${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 网卡(面向上游主机),忽略并重新探测"
|
||||
cell=""
|
||||
elif [[ -n "${CELLULAR_IFACE:-}" ]] && iface_exists "$CELLULAR_IFACE"; then
|
||||
cell="$CELLULAR_IFACE"
|
||||
info "使用配置的数据网卡: $cell"
|
||||
elif [[ "$AUTO_DETECT" == "true" ]]; then
|
||||
fi
|
||||
if [[ -z "$cell" && "$AUTO_DETECT" == "true" ]]; then
|
||||
cell="$(detect_cellular_iface "" || true)"
|
||||
fi
|
||||
if [[ -n "$cell" ]] && iface_is_usb_gadget "$cell"; then
|
||||
die "拒绝把 USB gadget 网卡 $cell 写成数据出口,请手动设置 CELLULAR_IFACE=wwanX"
|
||||
fi
|
||||
|
||||
if [[ -z "$cell" ]]; then
|
||||
die "未能自动探测数据网卡。
|
||||
@@ -98,29 +105,51 @@ info "配置已确认 bind_interface=$cell"
|
||||
install -m 0755 /dev/stdin "$INSTALL_DIR/bin/cpxy" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
BASE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# 经 /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)"
|
||||
# 兜底:避免 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}"
|
||||
cmd="${1:-help}"
|
||||
shift || true
|
||||
case "$cmd" in
|
||||
detect) exec "$BASE/scripts/detect.sh" "$@" ;;
|
||||
generate)
|
||||
# 重新探测:若 settings 里网卡丢失则自动补
|
||||
# 重新探测:若 settings 里网卡丢失则自动补;源 IP 漂移也刷新
|
||||
# shellcheck source=/dev/null
|
||||
source "$BASE/scripts/lib.sh"
|
||||
load_config
|
||||
if [[ -z "${CELLULAR_IFACE:-}" ]] || ! iface_exists "${CELLULAR_IFACE:-}"; then
|
||||
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" ]]; then
|
||||
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"
|
||||
install -m 0644 "$CONFIG_FILE" "$BASE/etc/settings.conf" 2>/dev/null || true
|
||||
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
|
||||
;;
|
||||
@@ -130,9 +159,10 @@ case "$cmd" in
|
||||
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"
|
||||
info "已重新绑定 $cell src=${src:-}"
|
||||
exec "$0" generate
|
||||
;;
|
||||
start) systemctl start cellular-proxy ;;
|
||||
@@ -140,6 +170,35 @@ case "$cmd" in
|
||||
restart) systemctl restart cellular-proxy ;;
|
||||
status) systemctl status cellular-proxy --no-pager || 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
|
||||
;;
|
||||
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 ;;
|
||||
help|*)
|
||||
cat <<H
|
||||
@@ -147,10 +206,18 @@ cpxy — cellular-proxy
|
||||
语义: 走代理的连接一律从数据网卡出口
|
||||
cpxy detect | rebind | generate | start | stop | restart | status
|
||||
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
|
||||
;;
|
||||
esac
|
||||
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
|
||||
|
||||
info "4/6 安装 systemd"
|
||||
@@ -163,6 +230,24 @@ if [[ "$MEM" != "0" && -n "$MEM" ]]; then
|
||||
else
|
||||
sed -i '/MemoryMax=@MEMORY_MAX@/d' /etc/systemd/system/cellular-proxy.service
|
||||
fi
|
||||
# 管理 API(UI 配置代理账密)
|
||||
if [[ -f "$ROOT_DIR/systemd/cellular-proxy-admin.service" ]]; then
|
||||
install -m 0644 "$ROOT_DIR/systemd/cellular-proxy-admin.service" /etc/systemd/system/cellular-proxy-admin.service
|
||||
sed -i "s|@INSTALL_DIR@|$INSTALL_DIR|g" /etc/systemd/system/cellular-proxy-admin.service
|
||||
fi
|
||||
# WWAN 自动监控:事件驱动 + 定时兜底
|
||||
install_watch_units() {
|
||||
local u
|
||||
for u in cellular-proxy-watch.service cellular-proxy-watch-once.service cellular-proxy-watch.timer; do
|
||||
if [[ -f "$ROOT_DIR/systemd/$u" ]]; then
|
||||
install -m 0644 "$ROOT_DIR/systemd/$u" "/etc/systemd/system/$u"
|
||||
sed -i "s|@INSTALL_DIR@|$INSTALL_DIR|g" "/etc/systemd/system/$u"
|
||||
sed -i "s|@LOG_DIR@|${LOG_DIR}|g" "/etc/systemd/system/$u"
|
||||
fi
|
||||
done
|
||||
chmod +x "$INSTALL_DIR/scripts/watch-cellular.sh" 2>/dev/null || true
|
||||
}
|
||||
install_watch_units
|
||||
systemctl daemon-reload
|
||||
|
||||
info "5/6 启动服务"
|
||||
@@ -170,11 +255,15 @@ export SYSTEMD_PAGER=cat
|
||||
export SYSTEMD_COLORS=0
|
||||
if [[ "$SKIP_START" == "true" ]]; then
|
||||
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-admin.service 2>/dev/null || true
|
||||
info "已按 SKIP_START 跳过启动"
|
||||
else
|
||||
systemctl enable cellular-proxy.service
|
||||
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
|
||||
# 等待 active,最多约 8 秒(避免 status 卡住)
|
||||
ok=0
|
||||
for _ in 1 2 3 4 5 6 7 8; do
|
||||
@@ -194,6 +283,21 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# 默认开启 watch(ENABLE_CELLULAR_WATCH!=false)
|
||||
watch_flag="$(echo "${ENABLE_CELLULAR_WATCH:-true}" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "$watch_flag" == "false" || "$watch_flag" == "0" || "$watch_flag" == "no" ]]; then
|
||||
systemctl disable --now cellular-proxy-watch.service cellular-proxy-watch.timer 2>/dev/null || true
|
||||
info "ENABLE_CELLULAR_WATCH=false,未启用自动监控"
|
||||
else
|
||||
systemctl enable cellular-proxy-watch.service 2>/dev/null || true
|
||||
systemctl enable cellular-proxy-watch.timer 2>/dev/null || true
|
||||
if [[ "$SKIP_START" != "true" ]]; then
|
||||
systemctl restart cellular-proxy-watch.service 2>/dev/null || true
|
||||
systemctl restart cellular-proxy-watch.timer 2>/dev/null || true
|
||||
fi
|
||||
info "已启用 cellular-proxy-watch(网卡/源 IP 变化自动 rebind)"
|
||||
fi
|
||||
|
||||
info "6/6 自动验证出口(短超时,失败不阻塞安装结束)"
|
||||
if [[ "$SKIP_START" != "true" && "$AUTO_VERIFY" == "true" ]]; then
|
||||
set +e
|
||||
@@ -217,10 +321,16 @@ info "安装完成(全自动探测 + 绑定)"
|
||||
echo " 数据网卡: $cell (${src_ip:-no-ipv4})"
|
||||
echo " 语义: 不走代理 → 系统默认(WiFi);走代理 → 数据流量"
|
||||
echo " 代理: HTTP/SOCKS ${PROXY_LISTEN_HOST}:${PROXY_MIXED_PORT}"
|
||||
echo " 面板: http://<LAN-IP>:${PANEL_PORT}/"
|
||||
echo " 密钥: $PANEL_SECRET"
|
||||
if [[ -n "${PROXY_USER:-}" ]]; then
|
||||
echo " 代理鉴权: 用户 ${PROXY_USER}(密码已设置)"
|
||||
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 " 管理: cpxy rebind | verify | logs | status"
|
||||
echo " 账密: cpxy auth --user U --pass P | --clear | --show"
|
||||
echo " 若刚才像卡住:多半在测公网出口,现已改为短超时并保证退出"
|
||||
# 安装脚本始终以 0 结束(服务已 active);验证失败只告警
|
||||
exit 0
|
||||
|
||||
+101
-9
@@ -31,8 +31,17 @@ load_config() {
|
||||
LOG_DIR="${LOG_DIR:-/var/log/cellular-proxy}"
|
||||
CELLULAR_IFACE="${CELLULAR_IFACE:-}"
|
||||
CELLULAR_SOURCE_IP="${CELLULAR_SOURCE_IP:-}"
|
||||
BIND_SOURCE_IP="${BIND_SOURCE_IP:-false}"
|
||||
REQUIRE_CELLULAR_IFACE="${REQUIRE_CELLULAR_IFACE:-true}"
|
||||
CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,usb,enx,ppp,cdc,rmnet,ccmni,mbim,qmi}"
|
||||
CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,ppp,cdc,rmnet,ccmni,mbim,qmi}"
|
||||
ENABLE_CELLULAR_WATCH="${ENABLE_CELLULAR_WATCH:-true}"
|
||||
WATCH_INTERVAL_SEC="${WATCH_INTERVAL_SEC:-60}"
|
||||
WATCH_MM_EVENTS="${WATCH_MM_EVENTS:-true}"
|
||||
WATCH_MM_UNIT="${WATCH_MM_UNIT:-ModemManager}"
|
||||
WATCH_MM_SETTLE_SEC="${WATCH_MM_SETTLE_SEC:-5}"
|
||||
WATCH_MM_READY_TIMEOUT_SEC="${WATCH_MM_READY_TIMEOUT_SEC:-90}"
|
||||
WATCH_MM_READY_POLL_SEC="${WATCH_MM_READY_POLL_SEC:-2}"
|
||||
WATCH_MM_COALESCE_SEC="${WATCH_MM_COALESCE_SEC:-20}"
|
||||
PROXY_LISTEN_HOST="${PROXY_LISTEN_HOST:-0.0.0.0}"
|
||||
PROXY_MIXED_PORT="${PROXY_MIXED_PORT:-7890}"
|
||||
PROXY_USER="${PROXY_USER:-}"
|
||||
@@ -42,9 +51,13 @@ load_config() {
|
||||
PANEL_SECRET="${PANEL_SECRET:-please-change-me}"
|
||||
ENABLE_DNS="${ENABLE_DNS:-true}"
|
||||
LOG_LEVEL="${LOG_LEVEL:-warn}"
|
||||
SING_BOX_SOURCE="${SING_BOX_SOURCE:-release}"
|
||||
SING_BOX_SOURCE="${SING_BOX_SOURCE:-auto}"
|
||||
SING_BOX_VERSION="${SING_BOX_VERSION:-1.11.7}"
|
||||
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}"
|
||||
MEMORY_MAX_MB="${MEMORY_MAX_MB:-96}"
|
||||
EGRESS_CHECK_URL="${EGRESS_CHECK_URL:-https://ifconfig.me}"
|
||||
@@ -64,6 +77,34 @@ iface_exists() {
|
||||
[[ -n "$ifc" ]] && [[ -d "/sys/class/net/$ifc" ]]
|
||||
}
|
||||
|
||||
# 识别「本机当 USB 设备端」的 gadget 网卡(usb0/rndis0 等)。
|
||||
# 这类接口是本机 → 上游主机(PC/路由)的下行链路,绑上去所有出站都会失败,
|
||||
# sing-box 表现为 open outbound connection: context deadline exceeded,SOCKS 回 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() {
|
||||
local name="$1"
|
||||
case "$name" in
|
||||
@@ -75,6 +116,10 @@ is_virtual_or_skip_iface() {
|
||||
if [[ -d "/sys/class/net/$name/bridge" ]]; then
|
||||
return 0
|
||||
fi
|
||||
# 本机作为 USB gadget 暴露给上游主机的链路(RNDIS/NCM/ECM),只通向 PC,不是数据出口
|
||||
if iface_is_usb_gadget "$name"; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -114,8 +159,10 @@ iface_driver() {
|
||||
iface_looks_cellular_by_driver() {
|
||||
local ifc="$1" drv
|
||||
drv="$(iface_driver "$ifc" || true)"
|
||||
# gadget 网卡即使名字像 usb0 也不算蜂窝
|
||||
iface_is_usb_gadget "$ifc" && return 1
|
||||
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)
|
||||
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)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
@@ -124,7 +171,9 @@ iface_looks_cellular_by_driver() {
|
||||
|
||||
iface_matches_patterns() {
|
||||
local name="$1" p
|
||||
local patterns="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,usb,enx,ppp,cdc,rmnet,ccmni,mbim,qmi}"
|
||||
# 默认不含 usb/enx:本机的 USB gadget 链路也叫 usb0,靠名字猜会把出口绑到 PC 侧。
|
||||
# 真正的 USB 上行模组由 iface_looks_cellular_by_driver(rndis_host/cdc_*/qmi_wwan)识别。
|
||||
local patterns="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,ppp,cdc,rmnet,ccmni,mbim,qmi}"
|
||||
IFS=',' read -r -a arr <<< "$patterns"
|
||||
for p in "${arr[@]}"; do
|
||||
p="${p// /}"
|
||||
@@ -136,6 +185,23 @@ iface_matches_patterns() {
|
||||
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 更小优先)
|
||||
detect_secondary_default_iface() {
|
||||
local primary secondary
|
||||
@@ -173,9 +239,9 @@ score_iface_as_cellular() {
|
||||
ip="$(ip -4 -o addr show dev "$name" 2>/dev/null | awk '{print $4}' | head -1 | cut -d/ -f1)"
|
||||
drv="$(iface_driver "$name" || true)"
|
||||
|
||||
# 名称
|
||||
if iface_matches_patterns "$name"; then score=$((score + 80)); fi
|
||||
# 驱动
|
||||
# 名称(仅启发式,权重必须低于驱动判定,避免 usb0 与 wwan0 打成平手)
|
||||
if iface_matches_patterns "$name"; then score=$((score + 40)); fi
|
||||
# 驱动(强证据)
|
||||
if iface_looks_cellular_by_driver "$name"; then score=$((score + 100)); fi
|
||||
# 非默认网卡(关键:系统默认走 WiFi)
|
||||
if [[ -n "$default_if" && "$name" != "$default_if" ]]; then score=$((score + 40)); fi
|
||||
@@ -196,7 +262,8 @@ score_iface_as_cellular() {
|
||||
echo "$score"
|
||||
}
|
||||
|
||||
# 自动探测数据网卡:配置 > 名称/驱动 > 次默认路由 > 非默认有 IP 物理口
|
||||
# 自动探测数据网卡:ModemManager bearer > 配置 > 名称/驱动打分 > 次默认路由 > 非默认有 IP 物理口
|
||||
# 任何一步都不允许返回 USB gadget 网卡
|
||||
detect_cellular_iface() {
|
||||
local configured="${1:-}"
|
||||
local default_if name state ip best_name="" best_score=0 score
|
||||
@@ -204,6 +271,8 @@ detect_cellular_iface() {
|
||||
if [[ -n "$configured" ]]; then
|
||||
if [[ "$configured" == "lo" ]]; then
|
||||
warn "CELLULAR_IFACE=lo 无效,将尝试自动探测"
|
||||
elif iface_is_usb_gadget "$configured"; then
|
||||
warn "CELLULAR_IFACE=$configured 是本机对上游主机暴露的 USB gadget 网卡,不能作为数据出口,将重新探测"
|
||||
elif iface_exists "$configured"; then
|
||||
echo "$configured"
|
||||
return 0
|
||||
@@ -217,6 +286,15 @@ detect_cellular_iface() {
|
||||
|
||||
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) 按评分扫描全部接口
|
||||
while IFS='|' read -r name state ip; do
|
||||
[[ -z "$name" ]] && continue
|
||||
@@ -237,7 +315,7 @@ detect_cellular_iface() {
|
||||
|
||||
# 2) 多默认路由的次要口
|
||||
if name="$(detect_secondary_default_iface 2>/dev/null || true)"; then
|
||||
if [[ -n "$name" ]] && iface_exists "$name"; then
|
||||
if [[ -n "$name" ]] && iface_exists "$name" && ! iface_is_usb_gadget "$name"; then
|
||||
info "根据次要默认路由探测数据网卡: $name"
|
||||
echo "$name"
|
||||
return 0
|
||||
@@ -322,5 +400,19 @@ resolve_cellular() {
|
||||
warn "未找到数据网卡"
|
||||
cell=""
|
||||
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"
|
||||
}
|
||||
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/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[@]}"
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/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"
|
||||
Executable
+455
@@ -0,0 +1,455 @@
|
||||
#!/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
|
||||
@@ -38,6 +38,32 @@ case "${QUICK,,}" in
|
||||
*) QUICK=false ;;
|
||||
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() {
|
||||
# 单次探测,严格超时,绝不无限挂起
|
||||
local extra=("$@")
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,24 @@
|
||||
[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
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=cellular-proxy WWAN watch once (timer)
|
||||
After=network-online.target
|
||||
ConditionPathExists=@INSTALL_DIR@/etc/settings.conf
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
EnvironmentFile=-@INSTALL_DIR@/etc/runtime.env
|
||||
EnvironmentFile=-@INSTALL_DIR@/etc/settings.conf
|
||||
ExecStart=@INSTALL_DIR@/scripts/watch-cellular.sh --once
|
||||
Nice=10
|
||||
MemoryMax=16M
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
StandardOutput=append:@LOG_DIR@/watch.log
|
||||
StandardError=append:@LOG_DIR@/watch.log
|
||||
@@ -0,0 +1,25 @@
|
||||
[Unit]
|
||||
Description=cellular-proxy WWAN watch (auto rebind on IP/link change)
|
||||
After=network-online.target cellular-proxy.service
|
||||
Wants=network-online.target
|
||||
PartOf=cellular-proxy.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=-@INSTALL_DIR@/etc/runtime.env
|
||||
EnvironmentFile=-@INSTALL_DIR@/etc/settings.conf
|
||||
# 低内存:watch 脚本本身极轻;间隔可在 settings WATCH_INTERVAL_SEC 覆盖
|
||||
ExecStart=@INSTALL_DIR@/scripts/watch-cellular.sh
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
MemoryMax=16M
|
||||
Nice=10
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
StandardOutput=append:@LOG_DIR@/watch.log
|
||||
StandardError=append:@LOG_DIR@/watch.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=cellular-proxy WWAN watch timer (fallback oneshot)
|
||||
Requires=cellular-proxy-watch-once.service
|
||||
|
||||
[Timer]
|
||||
# 兜底:即使 long-running watch 挂了,也每 2 分钟 oneshot 检查
|
||||
OnBootSec=1min
|
||||
OnUnitActiveSec=2min
|
||||
AccuracySec=30s
|
||||
Persistent=true
|
||||
Unit=cellular-proxy-watch-once.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
Executable
+187
@@ -0,0 +1,187 @@
|
||||
#!/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 X;ip 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 ]]
|
||||
+522
-88
@@ -3,131 +3,473 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
/* animal-island-ui design tokens (from DESIGN_PROMPT / PROMPT.md) */
|
||||
:root {
|
||||
--bg: #0f1419; --card: #1a2332; --text: #e7ecf3; --muted: #8b9bb4;
|
||||
--accent: #3d8bfd; --ok: #3dd68c; --warn: #f5a524; --bad: #f31260; --border: #2a3548;
|
||||
--primary: #19c8b9;
|
||||
--primary-hover: #3dd4c6;
|
||||
--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; }
|
||||
body { margin: 0; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||
header { padding: 16px 20px; border-bottom: 1px solid var(--border);
|
||||
display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }
|
||||
h1 { font-size: 1.15rem; margin: 0; font-weight: 600; }
|
||||
.muted { color: var(--muted); font-size: 0.85rem; }
|
||||
main { padding: 16px; max-width: 960px; margin: 0 auto; display: grid; gap: 14px; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; }
|
||||
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||||
label { font-size: 0.85rem; color: var(--muted); }
|
||||
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; }
|
||||
button { cursor: pointer; background: var(--accent); border: none; font-weight: 600; }
|
||||
button.secondary { background: #2a3548; }
|
||||
button.danger { background: var(--bad); }
|
||||
.pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 0.8rem; background: #243044; }
|
||||
.pill.ok { background: rgba(61,214,140,.15); color: var(--ok); }
|
||||
.pill.bad { background: rgba(243,18,96,.15); color: var(--bad); }
|
||||
.pill.warn { background: rgba(245,165,36,.15); color: var(--warn); }
|
||||
html, body { margin: 0; min-height: 100%; }
|
||||
body {
|
||||
font-family: Nunito, "Noto Sans SC", -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--text-body);
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(130, 213, 187, 0.35), transparent 55%),
|
||||
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(247, 205, 103, 0.28), transparent 50%),
|
||||
radial-gradient(ellipse 50% 30% at 50% 100%, rgba(136, 157, 240, 0.18), transparent 55%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
/* decorative dots (card pattern vibe) */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.35;
|
||||
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; }
|
||||
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--border); }
|
||||
th { color: var(--muted); font-weight: 500; }
|
||||
pre { margin: 0; white-space: pre-wrap; word-break: break-all; font-size: 0.8rem;
|
||||
color: var(--muted); max-height: 220px; overflow: auto; }
|
||||
.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; }
|
||||
th, td { text-align: left; padding: 10px 12px; border-bottom: 1.5px solid rgba(196, 184, 158, 0.55); }
|
||||
th {
|
||||
color: var(--text-muted); font-weight: 700; font-size: 0.78rem;
|
||||
letter-spacing: 0.04em; text-transform: uppercase;
|
||||
background: rgba(247, 243, 223, 0.9);
|
||||
}
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<h1>数据出口代理 <span class="muted">cellular-proxy</span></h1>
|
||||
<div class="muted">走代理 = 走运营商流量 · 单进程轻量</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span id="connState" class="pill warn">未连接</span>
|
||||
<button class="secondary" onclick="refreshAll()">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div class="page">
|
||||
<header class="top">
|
||||
<div>
|
||||
<h1 class="title-ribbon"><span class="leaf" aria-hidden="true"></span>数据出口代理</h1>
|
||||
<div class="sub">走代理 = 走运营商流量 · animal-island 风格控制台</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<span id="connState" class="pill warn">未连接</span>
|
||||
<button type="button" class="btn btn-default" onclick="refreshAll()">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="banner">
|
||||
<strong>行为说明:</strong>系统默认仍可走 WiFi。
|
||||
只要客户端使用本机 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 class="card">
|
||||
<div class="row" style="margin-bottom:10px">
|
||||
<div>
|
||||
<label>API 地址</label><br/>
|
||||
<input id="apiBase" placeholder="http://127.0.0.1:9090" />
|
||||
<h2>连接设置 <span class="hint">密钥仅存本浏览器 localStorage</span></h2>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label>Clash API(9090)</label>
|
||||
<input id="apiBase" type="text" placeholder="http://127.0.0.1:9090" />
|
||||
</div>
|
||||
<div>
|
||||
<label>密钥 secret</label><br/>
|
||||
<input id="secret" type="password" placeholder="PANEL_SECRET" />
|
||||
<div class="field">
|
||||
<label>管理 API(9091)</label>
|
||||
<input id="adminBase" type="text" placeholder="http://127.0.0.1:9091" />
|
||||
</div>
|
||||
<div style="align-self:end">
|
||||
<button onclick="saveAuth(); refreshAll()">保存并连接</button>
|
||||
<div class="field">
|
||||
<label>面板密钥 secret</label>
|
||||
<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 class="muted">密钥仅存本浏览器 localStorage。</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="muted">版本</div>
|
||||
<div id="verInfo">—</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content:space-between;margin-bottom:8px">
|
||||
<strong>出站</strong>
|
||||
<span class="muted">应只有 cellular(数据网)</span>
|
||||
<h2>
|
||||
代理账号密码(7890)
|
||||
<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 id="proxies">加载中…</div>
|
||||
<div class="muted" style="margin-top:10px">保存后会重写 settings、重生 sing-box 配置并重启服务(约 1~2 秒)。</div>
|
||||
<div id="authMsg" class="msg"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content:space-between;margin-bottom:8px">
|
||||
<strong>当前连接</strong>
|
||||
<button class="secondary" onclick="loadConnections()">刷新</button>
|
||||
<h2>状态</h2>
|
||||
<div class="stat-grid">
|
||||
<div class="stat"><div class="k">VERSION</div><div class="v" id="verInfo">—</div></div>
|
||||
<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 style="overflow:auto">
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>出站 <span class="hint">应只有 cellular(数据网)</span></h2>
|
||||
<div id="proxies" class="muted">加载中…</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>
|
||||
当前连接
|
||||
<button type="button" class="btn btn-default btn-sm" onclick="loadConnections()">刷新</button>
|
||||
</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>目标</th><th>出站链</th><th>上传</th><th>下载</th><th></th></tr></thead>
|
||||
<thead>
|
||||
<tr><th>目标</th><th>出站链</th><th>上传</th><th>下载</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="connBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<strong>怎么用</strong>
|
||||
<pre>
|
||||
1. 代理端口默认 7890(HTTP + SOCKS5 同一端口)
|
||||
<section class="card howto">
|
||||
<h2>怎么用</h2>
|
||||
<pre>1. 代理端口默认 7890(HTTP + SOCKS5 同一端口)
|
||||
2. 设备设置系统/应用代理 → Debian 的 LAN-IP:7890
|
||||
3. 验证:直连 ifconfig.me 与 走代理 ifconfig.me 公网 IP 应不同
|
||||
4. 改网卡:改 settings.conf 里 CELLULAR_IFACE 后 sudo cpxy generate
|
||||
5. 低内存:MEMORY_MAX_MB=64~96,LOG_LEVEL=warn
|
||||
</pre>
|
||||
3. 需要鉴权时在上方设置用户/密码,或: sudo cpxy auth --user u --pass p
|
||||
4. 验证:直连 ifconfig.me 与 走代理 ifconfig.me 公网 IP 应不同
|
||||
5. 改网卡:sudo cpxy rebind
|
||||
6. 低内存:MEMORY_MAX_MB=64~96,LOG_LEVEL=warn</pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<strong>API 调试</strong>
|
||||
<h2>API 调试</h2>
|
||||
<pre id="raw">—</pre>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="wave" aria-hidden="true"></div>
|
||||
<footer class="foot">cellular-proxy · 视觉参考 animal-island-ui · 走代理 = 数据流量</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
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() {
|
||||
$("apiBase").value = localStorage.getItem("cpxy_api") || defaultBase();
|
||||
$("adminBase").value = localStorage.getItem("cpxy_admin") || defaultAdminBase();
|
||||
$("secret").value = localStorage.getItem("cpxy_secret") || "";
|
||||
}
|
||||
function saveAuth() {
|
||||
localStorage.setItem("cpxy_api", $("apiBase").value.trim().replace(/\/$/, ""));
|
||||
localStorage.setItem("cpxy_admin", $("adminBase").value.trim().replace(/\/$/, ""));
|
||||
localStorage.setItem("cpxy_secret", $("secret").value);
|
||||
}
|
||||
function base() { return ($("apiBase").value || defaultBase()).replace(/\/$/, ""); }
|
||||
function adminBase() { return ($("adminBase").value || defaultAdminBase()).replace(/\/$/, ""); }
|
||||
function headers() {
|
||||
const h = { "Content-Type": "application/json" };
|
||||
const s = $("secret").value;
|
||||
@@ -140,6 +482,14 @@
|
||||
const t = await r.text();
|
||||
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) {
|
||||
if (n == null) return "—";
|
||||
const u = ["B", "KB", "MB", "GB"];
|
||||
@@ -147,6 +497,71 @@
|
||||
while (x >= 1024 && i < u.length - 1) { x /= 1024; 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, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
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("无法连接管理 API(9091)。确认 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() {
|
||||
try {
|
||||
const ver = await api("/version");
|
||||
@@ -156,10 +571,12 @@
|
||||
$("raw").textContent = JSON.stringify(ver, null, 2);
|
||||
await loadProxies();
|
||||
await loadConnections();
|
||||
await loadProxyAuth();
|
||||
} catch (e) {
|
||||
$("connState").textContent = "失败: " + e.message;
|
||||
$("connState").textContent = "失败";
|
||||
$("connState").className = "pill bad";
|
||||
$("proxies").textContent = "无法连接 API。请确认服务已启动,并填写正确 secret。";
|
||||
$("proxies").innerHTML = '<span class="muted">无法连接 API。请确认服务已启动,并填写正确 secret。<br/>' + esc(e.message) + "</span>";
|
||||
await loadProxyAuth();
|
||||
}
|
||||
}
|
||||
async function loadProxies() {
|
||||
@@ -167,21 +584,34 @@
|
||||
const proxies = data.proxies || {};
|
||||
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);
|
||||
let html = "<table><thead><tr><th>名称</th><th>类型</th><th>测速</th></tr></thead><tbody>";
|
||||
let html = '<div class="table-wrap"><table><thead><tr><th>名称</th><th>类型</th><th>测速</th></tr></thead><tbody>';
|
||||
for (const name of keys) {
|
||||
const p = proxies[name] || {};
|
||||
html += `<tr><td>${name}</td><td>${p.type || ""}</td>
|
||||
<td><button class="secondary" onclick="delay('${name}')">测速</button> <span id="d-${name}"></span></td></tr>`;
|
||||
const safe = esc(name);
|
||||
html += `<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>";
|
||||
html += "</tbody></table></div>";
|
||||
$("proxies").innerHTML = html;
|
||||
$("proxies").querySelectorAll("[data-delay]").forEach(btn => {
|
||||
btn.addEventListener("click", () => delay(btn.getAttribute("data-delay")));
|
||||
});
|
||||
}
|
||||
async function delay(name) {
|
||||
try {
|
||||
const r = await api("/proxies/" + encodeURIComponent(name) + "/delay?timeout=5000&url=" +
|
||||
encodeURIComponent("https://www.gstatic.com/generate_204"));
|
||||
$("d-" + name).textContent = (r.delay != null ? r.delay + "ms" : JSON.stringify(r));
|
||||
} catch (e) { $("d-" + name).textContent = e.message; }
|
||||
const el = document.getElementById("d-" + name);
|
||||
if (el) el.innerHTML = r.delay != null
|
||||
? `<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() {
|
||||
try {
|
||||
@@ -193,14 +623,18 @@
|
||||
const tr = document.createElement("tr");
|
||||
const host = (c.metadata && (c.metadata.host || c.metadata.destinationIP)) || "—";
|
||||
const port = c.metadata && c.metadata.destinationPort ? ":" + c.metadata.destinationPort : "";
|
||||
tr.innerHTML = `<td>${host}${port}</td><td>${c.chains ? c.chains.join("→") : "—"}</td>
|
||||
<td>${fmt(c.upload)}</td><td>${fmt(c.download)}</td>
|
||||
<td><button class="danger" onclick="closeConn('${c.id}')">断开</button></td>`;
|
||||
tr.innerHTML = `<td>${esc(host)}${esc(port)}</td>
|
||||
<td>${esc(c.chains ? c.chains.join("→") : "—")}</td>
|
||||
<td>${esc(fmt(c.upload))}</td><td>${esc(fmt(c.download))}</td>
|
||||
<td><button type="button" class="btn btn-danger btn-sm" data-close="${esc(c.id)}">断开</button></td>`;
|
||||
body.appendChild(tr);
|
||||
});
|
||||
if (!conns.length) body.innerHTML = "<tr><td colspan=5 class=muted>暂无连接</td></tr>";
|
||||
body.querySelectorAll("[data-close]").forEach(btn => {
|
||||
btn.addEventListener("click", () => closeConn(btn.getAttribute("data-close")));
|
||||
});
|
||||
if (!conns.length) body.innerHTML = '<tr><td colspan="5" class="muted">暂无连接</td></tr>';
|
||||
} catch (e) {
|
||||
$("connBody").innerHTML = "<tr><td colspan=5>" + e.message + "</td></tr>";
|
||||
$("connBody").innerHTML = '<tr><td colspan="5">' + esc(e.message) + "</td></tr>";
|
||||
}
|
||||
}
|
||||
async function closeConn(id) {
|
||||
|
||||
Reference in New Issue
Block a user