style: animal-island 9090 panel + docs
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
config/settings.conf
|
||||
generated/
|
||||
*.tar.gz
|
||||
.DS_Store
|
||||
@@ -0,0 +1,121 @@
|
||||
# cellular-proxy
|
||||
|
||||
轻量 **「走代理 = 走数据流量」**(无 frp,512MB 友好)。
|
||||
|
||||
## 语义
|
||||
|
||||
| 流量 | 出口 |
|
||||
|------|------|
|
||||
| **不走** 本代理 | 系统默认(通常 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
|
||||
```
|
||||
|
||||
可选覆盖:
|
||||
|
||||
```bash
|
||||
curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash -s -- \
|
||||
--iface wwan0 \
|
||||
--secret '面板密钥' \
|
||||
--user proxy \
|
||||
--pass '代理密码'
|
||||
```
|
||||
|
||||
| 选项 | 说明 |
|
||||
|------|------|
|
||||
| (无参数) | 全自动探测 + 绑定 + 安装 + 启动 |
|
||||
| `--iface` | 强制指定数据网卡 |
|
||||
| `--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
|
||||
cpxy auth --user u --pass p # 设置 7890 代理账号密码
|
||||
cpxy auth --show # 查看鉴权状态
|
||||
cpxy auth --clear # 清除鉴权
|
||||
```
|
||||
|
||||
- 代理:`LAN-IP:7890`(HTTP + SOCKS5)
|
||||
- 面板:`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` …
|
||||
3. **不是** 系统默认路由网卡(默认通常是 WiFi)
|
||||
4. 多默认路由时取次要默认口
|
||||
5. 回退:任意非默认且有 IPv4 的物理网卡
|
||||
|
||||
探测失败会直接报错并提示用 `--iface`。
|
||||
|
||||
## 手动安装
|
||||
|
||||
```bash
|
||||
git clone https://gitea.chickliu.fun/Hermes/cellular-proxy.git
|
||||
cd cellular-proxy
|
||||
sudo ./scripts/install.sh # 同样会自动探测并绑定
|
||||
```
|
||||
|
||||
## 资源
|
||||
|
||||
- 单进程 sing-box
|
||||
- 中文静态面板(animal-island-ui 视觉 token,单文件无 React)
|
||||
- 默认 `MemoryMax=96MB`
|
||||
|
||||
## 二进制从哪里下?
|
||||
|
||||
**优先 Gitea Release 预置包**(一键安装默认走 Gitea,无需访问 GitHub):
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
下载顺序:`Gitea 整包` → `Gitea 分片合并(兜底)` → `GitHub 代理(https://git.86482425.xyz)` → `直连 GitHub`。
|
||||
|
||||
## 代理账号密码(更优雅的方式)
|
||||
|
||||
| 方式 | 说明 |
|
||||
|------|------|
|
||||
| **面板 UI** | 打开 `http://LAN-IP:9090/ui/`,填 secret → 「代理账号密码」保存 |
|
||||
| **CLI** | `sudo cpxy auth --user u --pass p` |
|
||||
| **安装时** | `--user` / `--pass` |
|
||||
| **改文件** | `settings.conf` 的 `PROXY_USER` / `PROXY_PASS` 后 `cpxy generate` |
|
||||
|
||||
管理 API:`POST http://LAN-IP:9091/proxy-auth`,Header `Authorization: Bearer <PANEL_SECRET>`。
|
||||
|
||||
维护者更新预置包(从 GitHub 经代理拉再上传 Gitea):
|
||||
|
||||
```bash
|
||||
set -a; source ~/.hermes/env/gitea.env; set +a
|
||||
ARCHS='amd64 arm64 armv7' ./scripts/publish-binaries-to-gitea.sh
|
||||
```
|
||||
|
||||
## 安全
|
||||
|
||||
局域网监听建议设置 `--user/--pass`;面板 secret 已默认随机生成。
|
||||
`REQUIRE_CELLULAR_IFACE=true`:数据口不在则不装/不生成假出口。
|
||||
@@ -0,0 +1,70 @@
|
||||
# =============================================================================
|
||||
# cellular-proxy — 轻量「代理出口 = 数据流量」
|
||||
# 512MB 友好;无 frp;本机/局域网 HTTP+SOCKS;中文 Web 面板
|
||||
#
|
||||
# 语义(请按这个理解):
|
||||
# · 系统默认路由仍可走 WiFi
|
||||
# · 只要客户端走本代理,出口一律绑数据网卡(运营商流量)
|
||||
# · 不做「部分域名走 WiFi、部分走数据」的应用层分流
|
||||
#
|
||||
# 复制:cp config/settings.conf.example config/settings.conf
|
||||
# =============================================================================
|
||||
|
||||
# ---- 安装路径 ----
|
||||
INSTALL_DIR=/opt/cellular-proxy
|
||||
LOG_DIR=/var/log/cellular-proxy
|
||||
|
||||
# ---- 数据网卡(代理出口强制绑定)----
|
||||
# 留空则自动探测 wwan/usb/enx 等;建议手动填准
|
||||
CELLULAR_IFACE=
|
||||
# 可选:数据侧源 IPv4(一般留空)
|
||||
CELLULAR_SOURCE_IP=
|
||||
# 数据网卡不存在则不启动(推荐 true,避免代理静默从 WiFi 出)
|
||||
REQUIRE_CELLULAR_IFACE=true
|
||||
# 自动探测关键词(逗号分隔)
|
||||
CELLULAR_IFACE_PATTERNS=wwan,wwp,usb,enx,ppp,cdc
|
||||
|
||||
# ---- 代理监听(局域网其它设备用 0.0.0.0)----
|
||||
# mixed = 同一端口同时支持 HTTP 代理 与 SOCKS5
|
||||
PROXY_LISTEN_HOST=0.0.0.0
|
||||
PROXY_MIXED_PORT=7890
|
||||
|
||||
# 代理鉴权(局域网强烈建议设置;留空=无鉴权)
|
||||
# 7890 代理鉴权(空=无密码)。也可用: cpxy auth --user u --pass p 或面板 UI
|
||||
PROXY_USER=
|
||||
PROXY_PASS=
|
||||
|
||||
# ---- 中文 Web 面板(内置静态页 + Clash API)----
|
||||
PANEL_LISTEN_HOST=0.0.0.0
|
||||
PANEL_PORT=9090
|
||||
# 面板密钥(浏览器里填;务必修改)
|
||||
PANEL_SECRET=please-change-me
|
||||
|
||||
# 是否启用内置 DNS(推荐 true;DNS 查询也从数据出口出,减少漏到 WiFi)
|
||||
ENABLE_DNS=true
|
||||
|
||||
# 日志:info / warn / error / debug(低内存建议 warn)
|
||||
LOG_LEVEL=warn
|
||||
|
||||
# ---- sing-box ----
|
||||
# auto = 优先 Gitea 预置包(整包/分片),失败再走 GitHub 代理
|
||||
# gitea | github | local | auto
|
||||
SING_BOX_SOURCE=auto
|
||||
SING_BOX_VERSION=1.11.7
|
||||
SING_BOX_BIN=
|
||||
# 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
|
||||
MEMORY_MAX_MB=96
|
||||
|
||||
# ---- 验证 ----
|
||||
EGRESS_CHECK_URL=https://ifconfig.me
|
||||
# 可选:填数据侧公网 IP 后,verify 会严格比对
|
||||
EXPECTED_CELLULAR_PUBLIC_IP=
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
cmd="${1:-help}"
|
||||
shift || true
|
||||
case "$cmd" in
|
||||
detect) exec "$ROOT/scripts/detect.sh" "$@" ;;
|
||||
generate) exec "$ROOT/scripts/generate.sh" "$@" ;;
|
||||
install) exec "$ROOT/scripts/install.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
|
||||
auth --user U --pass P | auth --clear | auth --show
|
||||
|
||||
语义: 走本代理的连接一律从数据网卡出口(系统默认仍可走 WiFi)
|
||||
H
|
||||
;;
|
||||
esac
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# 运维
|
||||
|
||||
## 正确用法
|
||||
|
||||
1. 系统照常上网(WiFi)— 不经过代理
|
||||
2. 需要「用流量出口」时:把应用的 HTTP/SOCKS 指到 `LAN:7890`
|
||||
3. `cpxy verify` 确认代理 IP ≠ 直连 IP
|
||||
|
||||
## 内存
|
||||
|
||||
```bash
|
||||
MEMORY_MAX_MB=64 # 更紧
|
||||
LOG_LEVEL=error
|
||||
```
|
||||
|
||||
## 卸载
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now cellular-proxy
|
||||
sudo rm -f /etc/systemd/system/cellular-proxy.service /usr/local/bin/cpxy
|
||||
sudo systemctl daemon-reload
|
||||
sudo rm -rf /opt/cellular-proxy /var/lib/cellular-proxy /var/log/cellular-proxy
|
||||
```
|
||||
Executable
+304
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env bash
|
||||
# cellular-proxy 全自动一键安装
|
||||
# 语义:走本代理的连接一律从数据网卡(流量)出口;系统默认仍可走 WiFi
|
||||
#
|
||||
# 最简(自动探测数据网卡 + 自动生成密钥 + 下载 sing-box + 启动 + 验证):
|
||||
# curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash
|
||||
#
|
||||
# 可选参数仍可用:
|
||||
# ... | sudo bash -s -- --iface wwan0 --secret xxx --user u --pass p
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
REPO_OWNER="${REPO_OWNER:-Hermes}"
|
||||
REPO_NAME="${REPO_NAME:-cellular-proxy}"
|
||||
GITEA_BASE="${GITEA_BASE:-https://gitea.chickliu.fun}"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
REF="${CELLULAR_PROXY_REF:-$BRANCH}"
|
||||
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/cellular-proxy}"
|
||||
WORK_DIR="${WORK_DIR:-/tmp/cellular-proxy-install-$$}"
|
||||
|
||||
OPT_IFACE=""
|
||||
OPT_SECRET=""
|
||||
OPT_USER=""
|
||||
OPT_PASS=""
|
||||
OPT_PORT=""
|
||||
OPT_PANEL_PORT=""
|
||||
OPT_MEMORY=""
|
||||
OPT_SKIP_START=0
|
||||
OPT_SKIP_VERIFY=0
|
||||
OPT_LOCAL_DIR=""
|
||||
OPT_NO_AUTO_DETECT=0
|
||||
|
||||
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2; }
|
||||
info() { log "INFO $*"; }
|
||||
warn() { log "WARN $*"; }
|
||||
err() { log "ERROR $*"; }
|
||||
die() { err "$*"; exit 1; }
|
||||
|
||||
usage() {
|
||||
cat <<'H'
|
||||
cellular-proxy 全自动一键安装
|
||||
|
||||
无参数即可:自动探测数据网卡 → 绑定出口 → 装 sing-box → 启动 → 验证
|
||||
|
||||
选项(全可选):
|
||||
--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)
|
||||
-h, --help
|
||||
H
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--iface) OPT_IFACE="${2:-}"; shift 2 ;;
|
||||
--secret) OPT_SECRET="${2:-}"; shift 2 ;;
|
||||
--user) OPT_USER="${2:-}"; shift 2 ;;
|
||||
--pass) OPT_PASS="${2:-}"; shift 2 ;;
|
||||
--port) OPT_PORT="${2:-}"; shift 2 ;;
|
||||
--panel-port) OPT_PANEL_PORT="${2:-}"; shift 2 ;;
|
||||
--memory) OPT_MEMORY="${2:-}"; shift 2 ;;
|
||||
--install-dir) INSTALL_DIR="${2:-}"; shift 2 ;;
|
||||
--local) OPT_LOCAL_DIR="${2:-}"; shift 2 ;;
|
||||
--ref) REF="${2:-}"; shift 2 ;;
|
||||
--skip-start) OPT_SKIP_START=1; shift ;;
|
||||
--skip-verify) OPT_SKIP_VERIFY=1; shift ;;
|
||||
--no-auto-detect) OPT_NO_AUTO_DETECT=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) die "未知参数: $1(--help)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
need_root() {
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
die "请用 root:curl -fsSL '...' | sudo bash"
|
||||
fi
|
||||
}
|
||||
|
||||
need_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "缺少命令: $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
|
||||
if [[ ${#missing[@]} -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
info "尝试自动安装依赖: ${missing[*]}"
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq ca-certificates curl tar iproute2 openssl coreutils findutils 2>/dev/null \
|
||||
|| apt-get install -y ca-certificates curl tar iproute2 openssl
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache ca-certificates curl tar iproute2 openssl
|
||||
else
|
||||
die "缺少依赖 ${missing[*]},且无法自动安装(请先装 curl/tar/iproute2)"
|
||||
fi
|
||||
}
|
||||
|
||||
download() {
|
||||
local url="$1" dest="$2"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fL --retry 3 --connect-timeout 20 -o "$dest" "$url"
|
||||
else
|
||||
wget -O "$dest" "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
set_kv() {
|
||||
local file="$1" key="$2" val="$3"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
if grep -qE "^${key}=" "$file" 2>/dev/null; then
|
||||
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"
|
||||
else
|
||||
printf '%s=%s\n' "$key" "$val" >> "$file"
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
}
|
||||
|
||||
fetch_source() {
|
||||
mkdir -p "$WORK_DIR"
|
||||
if [[ -n "$OPT_LOCAL_DIR" ]]; then
|
||||
[[ -d "$OPT_LOCAL_DIR" ]] || die "本地目录不存在: $OPT_LOCAL_DIR"
|
||||
info "使用本地源码: $OPT_LOCAL_DIR"
|
||||
mkdir -p "$WORK_DIR/src"
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a --delete \
|
||||
--exclude '.git' --exclude 'generated' --exclude 'config/settings.conf' \
|
||||
"$OPT_LOCAL_DIR"/ "$WORK_DIR/src"/
|
||||
else
|
||||
cp -a "$OPT_LOCAL_DIR"/. "$WORK_DIR/src"/
|
||||
rm -rf "$WORK_DIR/src/.git" "$WORK_DIR/src/generated" "$WORK_DIR/src/config/settings.conf" 2>/dev/null || true
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
local tarball="$WORK_DIR/src.tgz"
|
||||
local url_archive="${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/archive/${REF}.tar.gz"
|
||||
info "下载源码: $url_archive"
|
||||
if ! download "$url_archive" "$tarball"; then
|
||||
die "下载失败: $url_archive"
|
||||
fi
|
||||
mkdir -p "$WORK_DIR/extract"
|
||||
tar -xzf "$tarball" -C "$WORK_DIR/extract"
|
||||
local top
|
||||
top="$(find "$WORK_DIR/extract" -mindepth 1 -maxdepth 1 -type d | head -1)"
|
||||
[[ -n "$top" ]] || die "压缩包内容异常"
|
||||
mkdir -p "$WORK_DIR/src"
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a "$top"/ "$WORK_DIR/src"/
|
||||
else
|
||||
cp -a "$top"/. "$WORK_DIR/src"/
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_settings() {
|
||||
local src="$WORK_DIR/src"
|
||||
local conf="$src/config/settings.conf"
|
||||
[[ -f "$src/config/settings.conf.example" ]] || die "缺少 settings.conf.example"
|
||||
cp "$src/config/settings.conf.example" "$conf"
|
||||
|
||||
# 预加载 lib 做自动探测(源码里的 lib)
|
||||
# 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}"
|
||||
REQUIRE_CELLULAR_IFACE=false
|
||||
|
||||
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
|
||||
info "自动探测数据网卡…"
|
||||
cell="$(detect_cellular_iface "" || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "$cell" ]]; then
|
||||
die "自动探测失败:未找到数据网卡。
|
||||
请确认模组/拨号已 up,或指定:
|
||||
... | sudo bash -s -- --iface <网卡名>
|
||||
可先手动: ip -br a"
|
||||
fi
|
||||
|
||||
if ! iface_exists "$cell"; then
|
||||
die "网卡不存在: $cell"
|
||||
fi
|
||||
|
||||
local src_ip
|
||||
src_ip="$(detect_source_ip "$cell" || true)"
|
||||
set_kv "$conf" CELLULAR_IFACE "$cell"
|
||||
if [[ -n "$src_ip" ]]; then
|
||||
set_kv "$conf" CELLULAR_SOURCE_IP "$src_ip"
|
||||
fi
|
||||
info "已写入 CELLULAR_IFACE=$cell ip=${src_ip:-none}"
|
||||
|
||||
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"
|
||||
fi
|
||||
|
||||
[[ -n "$OPT_USER" ]] && set_kv "$conf" PROXY_USER "$OPT_USER"
|
||||
[[ -n "$OPT_PASS" ]] && set_kv "$conf" PROXY_PASS "$OPT_PASS"
|
||||
[[ -n "$OPT_PORT" ]] && set_kv "$conf" PROXY_MIXED_PORT "$OPT_PORT"
|
||||
[[ -n "$OPT_PANEL_PORT" ]] && set_kv "$conf" PANEL_PORT "$OPT_PANEL_PORT"
|
||||
[[ -n "$OPT_MEMORY" ]] && set_kv "$conf" MEMORY_MAX_MB "$OPT_MEMORY"
|
||||
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"
|
||||
}
|
||||
|
||||
main() {
|
||||
need_root
|
||||
ensure_deps
|
||||
need_cmd tar
|
||||
need_cmd awk
|
||||
need_cmd ip
|
||||
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
info "======== cellular-proxy 全自动安装 ========"
|
||||
info "1/3 获取源码"
|
||||
fetch_source
|
||||
|
||||
info "2/3 自动探测数据网卡并写配置"
|
||||
prepare_settings
|
||||
|
||||
info "3/3 安装 sing-box、绑定出口、启动"
|
||||
export CONFIG_FILE="$WORK_DIR/src/config/settings.conf"
|
||||
export AUTO_DETECT=false # 已在 prepare 写死网卡
|
||||
export AUTO_VERIFY=true
|
||||
if [[ "$OPT_SKIP_VERIFY" -eq 1 ]]; then
|
||||
export AUTO_VERIFY=false
|
||||
fi
|
||||
if [[ "$OPT_SKIP_START" -eq 1 ]]; then
|
||||
export SKIP_START=true
|
||||
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
|
||||
source "$INSTALL_DIR/etc/settings.conf" 2>/dev/null || true
|
||||
local port="${PROXY_MIXED_PORT:-7890}"
|
||||
local panel="${PANEL_PORT:-9090}"
|
||||
local secret="${PANEL_SECRET:-}"
|
||||
local cell="${CELLULAR_IFACE:-$DETECTED_CELL}"
|
||||
local lan
|
||||
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
|
||||
|
||||
========== 全自动安装完成 ==========
|
||||
数据网卡(已绑定): ${cell}
|
||||
语义: 走代理 → 数据流量;不走代理 → WiFi/默认
|
||||
|
||||
代理: HTTP/SOCKS ${lan:-127.0.0.1}:${port}
|
||||
面板: http://${lan:-<LAN-IP>}:${panel}/
|
||||
密钥: ${secret}
|
||||
配置: $INSTALL_DIR/etc/settings.conf
|
||||
|
||||
验证: cpxy verify
|
||||
重绑: cpxy rebind # 换卡/重插后重新自动探测
|
||||
日志: cpxy logs
|
||||
EOM
|
||||
}
|
||||
|
||||
main "$@"
|
||||
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
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=lib.sh
|
||||
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}"
|
||||
REQUIRE_CELLULAR_IFACE=false
|
||||
CELLULAR_IFACE=
|
||||
fi
|
||||
|
||||
echo "========== 主机 =========="
|
||||
echo "hostname : $(hostname)"
|
||||
echo "kernel : $(uname -r)"
|
||||
echo "arch : $(uname -m)"
|
||||
echo "mem_total: $(awk '/MemTotal/ {printf "%.0f MB", $2/1024}' /proc/meminfo 2>/dev/null || echo unknown)"
|
||||
echo "mem_avail: $(awk '/MemAvailable/ {printf "%.0f MB", $2/1024}' /proc/meminfo 2>/dev/null || echo unknown)"
|
||||
echo
|
||||
|
||||
echo "========== 接口列表 =========="
|
||||
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
|
||||
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
|
||||
|
||||
echo "========== 默认路由 =========="
|
||||
ip route show default 2>/dev/null || true
|
||||
echo
|
||||
echo "default iface: ${default_if:-none}"
|
||||
echo
|
||||
|
||||
echo "========== 数据网卡(代理出口) =========="
|
||||
cell="$(detect_cellular_iface "${CELLULAR_IFACE:-}" 2>/dev/null || true)"
|
||||
if [[ -n "$cell" ]]; then
|
||||
echo "CELLULAR_IFACE=$cell ip=$(detect_source_ip "$cell" || true) driver=$(iface_driver "$cell" || true)"
|
||||
echo "说明: 走本代理的流量将从该网卡出(数据流量)"
|
||||
else
|
||||
echo "未能自动探测数据网卡。"
|
||||
echo "请手动: CELLULAR_IFACE=网卡名 或 cpxy rebind"
|
||||
exit 2
|
||||
fi
|
||||
Executable
+256
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env bash
|
||||
# 仅下载/安装 sing-box(轻量核心)
|
||||
# 优先级:local → 系统已有 → Gitea Release(整包/分片)→ GitHub 代理 → 直连 GitHub
|
||||
set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=lib.sh
|
||||
source "$ROOT_DIR/scripts/lib.sh"
|
||||
load_config
|
||||
need_root
|
||||
ensure_dirs
|
||||
mkdir -p /var/lib/cellular-proxy
|
||||
|
||||
ARCH="$(arch_go)"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
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() {
|
||||
local raw="$1"
|
||||
local proxy="${GITHUB_PROXY:-}"
|
||||
if [[ -z "$proxy" ]]; then
|
||||
echo "$raw"
|
||||
return
|
||||
fi
|
||||
proxy="${proxy%/}"
|
||||
case "$raw" in
|
||||
"${proxy}"/*) echo "$raw"; return ;;
|
||||
esac
|
||||
echo "${proxy}/${raw}"
|
||||
}
|
||||
|
||||
download() {
|
||||
local url="$1" dest="$2"
|
||||
info "下载: $url"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
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
|
||||
die "需要 curl 或 wget"
|
||||
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() {
|
||||
local raw="$1" dest="$2"
|
||||
local proxied
|
||||
proxied="$(github_url "$raw")"
|
||||
if [[ "$proxied" != "$raw" ]]; then
|
||||
info "经 GitHub 代理下载: $proxied"
|
||||
if download "$proxied" "$dest"; then
|
||||
return 0
|
||||
fi
|
||||
warn "代理下载失败,尝试直连 GitHub: $raw"
|
||||
fi
|
||||
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:-}" && "$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"
|
||||
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
|
||||
mkdir -p "$INSTALL_DIR/ui"
|
||||
if [[ -f "$ROOT_DIR/ui/index.html" ]]; then
|
||||
cp -a "$ROOT_DIR/ui/." "$INSTALL_DIR/ui/"
|
||||
info "已安装内置中文面板 -> $INSTALL_DIR/ui"
|
||||
else
|
||||
warn "未找到 ui/index.html"
|
||||
fi
|
||||
|
||||
info "二进制就绪:"
|
||||
ls -la "$INSTALL_DIR/bin"
|
||||
du -sh "$INSTALL_DIR/bin" "$INSTALL_DIR/ui" 2>/dev/null || true
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env bash
|
||||
# 生成 sing-box:代理流量全部 bind 数据网卡出口
|
||||
set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=lib.sh
|
||||
source "$ROOT_DIR/scripts/lib.sh"
|
||||
load_config
|
||||
|
||||
OUT_DIR="${1:-$ROOT_DIR/generated}"
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
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)"
|
||||
fi
|
||||
|
||||
if [[ -z "$cell" ]]; then
|
||||
warn "CELLULAR_IFACE 为空,临时用 lo 生成配置(启动前务必修正)"
|
||||
cell_for_cfg=lo
|
||||
else
|
||||
cell_for_cfg="$cell"
|
||||
fi
|
||||
|
||||
if [[ "$PANEL_SECRET" == "please-change-me" || "$PANEL_SECRET" == "change-this-secret" ]]; then
|
||||
warn "PANEL_SECRET 仍是默认值,请修改 settings.conf"
|
||||
fi
|
||||
if [[ "$PROXY_LISTEN_HOST" != "127.0.0.1" && "$PROXY_LISTEN_HOST" != "::1" && -z "$PROXY_USER" ]]; then
|
||||
warn "代理监听 $PROXY_LISTEN_HOST 且未设置 PROXY_USER/PASS(局域网建议加鉴权)"
|
||||
fi
|
||||
|
||||
export GEN_OUT="$OUT_DIR/config.json"
|
||||
export GEN_CELL="$cell_for_cfg"
|
||||
export GEN_SRC="$src_ip"
|
||||
export GEN_PROXY_HOST="$PROXY_LISTEN_HOST"
|
||||
export GEN_PROXY_PORT="$PROXY_MIXED_PORT"
|
||||
export GEN_PROXY_USER="$PROXY_USER"
|
||||
export GEN_PROXY_PASS="$PROXY_PASS"
|
||||
export GEN_PANEL_HOST="$PANEL_LISTEN_HOST"
|
||||
export GEN_PANEL_PORT="$PANEL_PORT"
|
||||
export GEN_PANEL_SECRET="$PANEL_SECRET"
|
||||
export GEN_DNS="$ENABLE_DNS"
|
||||
export GEN_LOG="$LOG_LEVEL"
|
||||
export GEN_UI_DIR="$INSTALL_DIR/ui"
|
||||
|
||||
python3 <<'PY'
|
||||
import json, os
|
||||
from pathlib import Path
|
||||
|
||||
cell = os.environ["GEN_CELL"]
|
||||
src = os.environ.get("GEN_SRC") or ""
|
||||
log_level = os.environ.get("GEN_LOG") or "warn"
|
||||
ui_dir = os.environ.get("GEN_UI_DIR") or "/opt/cellular-proxy/ui"
|
||||
|
||||
users = []
|
||||
u = os.environ.get("GEN_PROXY_USER") or ""
|
||||
p = os.environ.get("GEN_PROXY_PASS") or ""
|
||||
if u:
|
||||
users.append({"username": u, "password": p})
|
||||
|
||||
inbound = {
|
||||
"type": "mixed",
|
||||
"tag": "mixed-in",
|
||||
"listen": os.environ["GEN_PROXY_HOST"],
|
||||
"listen_port": int(os.environ["GEN_PROXY_PORT"]),
|
||||
}
|
||||
if users:
|
||||
inbound["users"] = users
|
||||
|
||||
# 唯一业务出口:强制绑定数据网卡
|
||||
out_cell = {
|
||||
"type": "direct",
|
||||
"tag": "cellular",
|
||||
"bind_interface": cell,
|
||||
}
|
||||
if src:
|
||||
out_cell["inet4_bind_address"] = src
|
||||
|
||||
cfg = {
|
||||
"log": {"level": log_level, "timestamp": True},
|
||||
"inbounds": [inbound],
|
||||
"outbounds": [
|
||||
out_cell,
|
||||
{"type": "block", "tag": "block"},
|
||||
],
|
||||
# 所有代理流量最终都走 cellular(数据)
|
||||
"route": {
|
||||
"rules": [],
|
||||
"final": "cellular",
|
||||
"auto_detect_interface": False,
|
||||
},
|
||||
"experimental": {
|
||||
"clash_api": {
|
||||
"external_controller": f"{os.environ['GEN_PANEL_HOST']}:{os.environ['GEN_PANEL_PORT']}",
|
||||
"secret": os.environ["GEN_PANEL_SECRET"],
|
||||
"default_mode": "rule",
|
||||
"external_ui": ui_dir,
|
||||
},
|
||||
"cache_file": {
|
||||
"enabled": True,
|
||||
"path": "/var/lib/cellular-proxy/cache.db",
|
||||
"store_fakeip": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if (os.environ.get("GEN_DNS") or "true").lower() == "true":
|
||||
# DNS 也走数据出口,避免解析从 WiFi 出去
|
||||
cfg["dns"] = {
|
||||
"servers": [
|
||||
{
|
||||
"tag": "remote",
|
||||
"address": "1.1.1.1",
|
||||
"detour": "cellular",
|
||||
},
|
||||
],
|
||||
"final": "remote",
|
||||
"strategy": "ipv4_only",
|
||||
}
|
||||
|
||||
Path(os.environ["GEN_OUT"]).write_text(
|
||||
json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
||||
)
|
||||
print("wrote", os.environ["GEN_OUT"])
|
||||
PY
|
||||
|
||||
cat > "$OUT_DIR/runtime.env" <<EOF
|
||||
INSTALL_DIR=${INSTALL_DIR}
|
||||
LOG_DIR=${LOG_DIR}
|
||||
CELLULAR_IFACE=${cell}
|
||||
CELLULAR_SOURCE_IP=${src_ip}
|
||||
PROXY_LISTEN_HOST=${PROXY_LISTEN_HOST}
|
||||
PROXY_MIXED_PORT=${PROXY_MIXED_PORT}
|
||||
PANEL_LISTEN_HOST=${PANEL_LISTEN_HOST}
|
||||
PANEL_PORT=${PANEL_PORT}
|
||||
EGRESS_CHECK_URL=${EGRESS_CHECK_URL}
|
||||
EXPECTED_CELLULAR_PUBLIC_IP=${EXPECTED_CELLULAR_PUBLIC_IP}
|
||||
MEMORY_MAX_MB=${MEMORY_MAX_MB}
|
||||
REQUIRE_CELLULAR_IFACE=${REQUIRE_CELLULAR_IFACE}
|
||||
EOF
|
||||
|
||||
cat > "$OUT_DIR/SUMMARY.txt" <<EOF
|
||||
generated_at=$(date -Iseconds)
|
||||
语义=走代理的连接一律从数据网卡出口
|
||||
cellular_iface=${cell}
|
||||
cellular_source_ip=${src_ip}
|
||||
proxy=${PROXY_LISTEN_HOST}:${PROXY_MIXED_PORT} (HTTP+SOCKS mixed)
|
||||
panel=http://<LAN-IP>:${PANEL_PORT}/ secret=见 settings PANEL_SECRET
|
||||
memory_max_mb=${MEMORY_MAX_MB}
|
||||
EOF
|
||||
|
||||
info "生成完成: $OUT_DIR"
|
||||
cat "$OUT_DIR/SUMMARY.txt"
|
||||
Executable
+248
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=lib.sh
|
||||
source "$ROOT_DIR/scripts/lib.sh"
|
||||
|
||||
AUTO_DETECT="${AUTO_DETECT:-true}"
|
||||
AUTO_VERIFY="${AUTO_VERIFY:-true}"
|
||||
SKIP_START="${SKIP_START:-false}"
|
||||
|
||||
if [[ ! -f "$ROOT_DIR/config/settings.conf" ]]; then
|
||||
cp "$ROOT_DIR/config/settings.conf.example" "$ROOT_DIR/config/settings.conf"
|
||||
warn "已创建 config/settings.conf(将自动探测数据网卡并生成密钥)"
|
||||
fi
|
||||
|
||||
load_config
|
||||
need_root
|
||||
|
||||
info "1/6 自动探测并绑定数据网卡"
|
||||
"$ROOT_DIR/scripts/detect.sh" || true
|
||||
|
||||
cell=""
|
||||
if [[ -n "${CELLULAR_IFACE:-}" ]] && iface_exists "$CELLULAR_IFACE"; then
|
||||
cell="$CELLULAR_IFACE"
|
||||
info "使用配置的数据网卡: $cell"
|
||||
elif [[ "$AUTO_DETECT" == "true" ]]; then
|
||||
cell="$(detect_cellular_iface "" || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "$cell" ]]; then
|
||||
die "未能自动探测数据网卡。
|
||||
请插入/拨通数据模块后重试,或手动:
|
||||
1) ./scripts/detect.sh
|
||||
2) 编辑 config/settings.conf 设置 CELLULAR_IFACE=网卡名
|
||||
3) sudo ./scripts/install.sh"
|
||||
fi
|
||||
|
||||
src_ip="$(detect_source_ip "$cell" || true)"
|
||||
persist_cellular_to_settings "$ROOT_DIR/config/settings.conf" "$cell" "$src_ip"
|
||||
# 重新加载
|
||||
CELLULAR_IFACE="$cell"
|
||||
CELLULAR_SOURCE_IP="$src_ip"
|
||||
info "已绑定 CELLULAR_IFACE=$cell source_ip=${src_ip:-auto}"
|
||||
|
||||
# 随机面板密钥(若仍是默认)
|
||||
if [[ "$PANEL_SECRET" == "please-change-me" || "$PANEL_SECRET" == "change-this-secret" || -z "$PANEL_SECRET" ]]; then
|
||||
gen="$(openssl rand -hex 12 2>/dev/null || head -c 16 /dev/urandom | xxd -p | tr -d '\n')"
|
||||
tmp="$(mktemp)"
|
||||
awk -v k="PANEL_SECRET" -v v="$gen" '
|
||||
BEGIN { done=0 }
|
||||
index($0, k "=")==1 { print k "=" v; done=1; next }
|
||||
{ print }
|
||||
END { if (!done) print k "=" v }
|
||||
' "$ROOT_DIR/config/settings.conf" > "$tmp"
|
||||
mv "$tmp" "$ROOT_DIR/config/settings.conf"
|
||||
PANEL_SECRET="$gen"
|
||||
warn "已自动生成 PANEL_SECRET(见 settings.conf)"
|
||||
fi
|
||||
|
||||
# 再 load 一次保证变量一致
|
||||
load_config
|
||||
CELLULAR_IFACE="$cell"
|
||||
CELLULAR_SOURCE_IP="${src_ip:-$CELLULAR_SOURCE_IP}"
|
||||
|
||||
info "2/6 下载/安装 sing-box + UI"
|
||||
"$ROOT_DIR/scripts/fetch-binaries.sh"
|
||||
|
||||
info "3/6 生成配置(代理出口 bind = $cell)"
|
||||
"$ROOT_DIR/scripts/generate.sh" "$ROOT_DIR/generated"
|
||||
ensure_dirs
|
||||
mkdir -p /var/lib/cellular-proxy
|
||||
install -m 0644 "$ROOT_DIR/generated/config.json" "$INSTALL_DIR/etc/config.json"
|
||||
install -m 0644 "$ROOT_DIR/generated/runtime.env" "$INSTALL_DIR/etc/runtime.env"
|
||||
install -m 0644 "$ROOT_DIR/config/settings.conf" "$INSTALL_DIR/etc/settings.conf"
|
||||
cp -a "$ROOT_DIR/scripts" "$INSTALL_DIR/"
|
||||
cp -a "$ROOT_DIR/ui" "$INSTALL_DIR/" 2>/dev/null || true
|
||||
if ! "$INSTALL_DIR/bin/sing-box" check -c "$INSTALL_DIR/etc/config.json"; then
|
||||
die "sing-box 配置校验失败"
|
||||
fi
|
||||
|
||||
# 确认 bind_interface 写进配置
|
||||
if ! grep -q "\"bind_interface\": \"$cell\"" "$INSTALL_DIR/etc/config.json" \
|
||||
&& ! grep -q "\"bind_interface\": \"$cell\"" "$INSTALL_DIR/etc/config.json" 2>/dev/null; then
|
||||
# JSON 可能无空格差异
|
||||
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
|
||||
fi
|
||||
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)"
|
||||
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 里网卡丢失则自动补
|
||||
# shellcheck source=/dev/null
|
||||
source "$BASE/scripts/lib.sh"
|
||||
load_config
|
||||
if [[ -z "${CELLULAR_IFACE:-}" ]] || ! iface_exists "${CELLULAR_IFACE:-}"; then
|
||||
cell="$(detect_cellular_iface "" || true)"
|
||||
if [[ -n "$cell" ]]; then
|
||||
src="$(detect_source_ip "$cell" || true)"
|
||||
persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src"
|
||||
CELLULAR_IFACE="$cell"
|
||||
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
|
||||
"$BASE/bin/sing-box" check -c "$BASE/etc/config.json"
|
||||
systemctl restart cellular-proxy 2>/dev/null || true
|
||||
;;
|
||||
rebind)
|
||||
# shellcheck source=/dev/null
|
||||
source "$BASE/scripts/lib.sh"
|
||||
load_config
|
||||
cell="$(detect_cellular_iface "" || true)"
|
||||
[[ -n "$cell" ]] || die "未能探测数据网卡"
|
||||
src="$(detect_source_ip "$cell" || true)"
|
||||
persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src"
|
||||
info "已重新绑定 $cell"
|
||||
exec "$0" generate
|
||||
;;
|
||||
start) systemctl start cellular-proxy ;;
|
||||
stop) systemctl stop cellular-proxy ;;
|
||||
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" "$@"
|
||||
;;
|
||||
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 # 设置 7890 代理账号密码
|
||||
cpxy auth --clear # 清除鉴权
|
||||
cpxy auth --show # 查看状态
|
||||
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"
|
||||
MEM="${MEMORY_MAX_MB:-96}"
|
||||
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
|
||||
# 管理 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
|
||||
systemctl daemon-reload
|
||||
|
||||
info "5/6 启动服务"
|
||||
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
|
||||
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"
|
||||
systemctl show cellular-proxy.service -p ActiveState -p SubState -p Result -p ExecMainStatus --no-pager 2>/dev/null || true
|
||||
journalctl -u cellular-proxy -n 40 --no-pager 2>/dev/null || true
|
||||
die "启动失败,请检查数据网卡 $cell 是否 up 且有 IP"
|
||||
fi
|
||||
fi
|
||||
|
||||
info "6/6 自动验证出口(短超时,失败不阻塞安装结束)"
|
||||
if [[ "$SKIP_START" != "true" && "$AUTO_VERIFY" == "true" ]]; then
|
||||
set +e
|
||||
# 安装阶段用短超时;VERIFY_QUICK=1 跳过 detect 长输出
|
||||
VERIFY_QUICK=1 EGRESS_TIMEOUT="${EGRESS_TIMEOUT:-8}" \
|
||||
"$ROOT_DIR/scripts/verify.sh"
|
||||
vr=$?
|
||||
set -e
|
||||
if [[ "$vr" -eq 0 ]]; then
|
||||
info "出口验证通过:代理已从数据网卡出"
|
||||
else
|
||||
warn "自动验证未通过(exit=$vr)。安装仍算完成,稍后可: cpxy verify"
|
||||
warn "当前绑定: CELLULAR_IFACE=$cell ip=${src_ip:-}"
|
||||
fi
|
||||
else
|
||||
info "已跳过自动验证"
|
||||
fi
|
||||
|
||||
echo
|
||||
info "安装完成(全自动探测 + 绑定)"
|
||||
echo " 数据网卡: $cell (${src_ip:-no-ipv4})"
|
||||
echo " 语义: 不走代理 → 系统默认(WiFi);走代理 → 数据流量"
|
||||
echo " 代理: HTTP/SOCKS ${PROXY_LISTEN_HOST}:${PROXY_MIXED_PORT}"
|
||||
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
|
||||
Executable
+330
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env bash
|
||||
# 公共库
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CONFIG_FILE="${CONFIG_FILE:-$ROOT_DIR/config/settings.conf}"
|
||||
|
||||
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2; }
|
||||
info() { log "INFO $*"; }
|
||||
warn() { log "WARN $*"; }
|
||||
err() { log "ERROR $*"; }
|
||||
die() { err "$*"; exit 1; }
|
||||
|
||||
need_root() {
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
die "请用 root 运行(sudo)"
|
||||
fi
|
||||
}
|
||||
|
||||
load_config() {
|
||||
if [[ ! -f "$CONFIG_FILE" ]]; then
|
||||
die "缺少配置: $CONFIG_FILE
|
||||
请先: cp $ROOT_DIR/config/settings.conf.example $ROOT_DIR/config/settings.conf"
|
||||
fi
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$CONFIG_FILE"
|
||||
set +a
|
||||
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/cellular-proxy}"
|
||||
LOG_DIR="${LOG_DIR:-/var/log/cellular-proxy}"
|
||||
CELLULAR_IFACE="${CELLULAR_IFACE:-}"
|
||||
CELLULAR_SOURCE_IP="${CELLULAR_SOURCE_IP:-}"
|
||||
REQUIRE_CELLULAR_IFACE="${REQUIRE_CELLULAR_IFACE:-true}"
|
||||
CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,usb,enx,ppp,cdc,rmnet,ccmni,mbim,qmi}"
|
||||
PROXY_LISTEN_HOST="${PROXY_LISTEN_HOST:-0.0.0.0}"
|
||||
PROXY_MIXED_PORT="${PROXY_MIXED_PORT:-7890}"
|
||||
PROXY_USER="${PROXY_USER:-}"
|
||||
PROXY_PASS="${PROXY_PASS:-}"
|
||||
PANEL_LISTEN_HOST="${PANEL_LISTEN_HOST:-0.0.0.0}"
|
||||
PANEL_PORT="${PANEL_PORT:-9090}"
|
||||
PANEL_SECRET="${PANEL_SECRET:-please-change-me}"
|
||||
ENABLE_DNS="${ENABLE_DNS:-true}"
|
||||
LOG_LEVEL="${LOG_LEVEL:-warn}"
|
||||
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}"
|
||||
EXPECTED_CELLULAR_PUBLIC_IP="${EXPECTED_CELLULAR_PUBLIC_IP:-}"
|
||||
|
||||
for v in REQUIRE_CELLULAR_IFACE ENABLE_DNS; do
|
||||
val="${!v}"
|
||||
case "${val,,}" in
|
||||
1|true|yes|on) printf -v "$v" '%s' true ;;
|
||||
*) printf -v "$v" '%s' false ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
iface_exists() {
|
||||
local ifc="$1"
|
||||
[[ -n "$ifc" ]] && [[ -d "/sys/class/net/$ifc" ]]
|
||||
}
|
||||
|
||||
is_virtual_or_skip_iface() {
|
||||
local name="$1"
|
||||
case "$name" in
|
||||
lo|docker*|br-*|veth*|virbr*|cni*|flannel*|tun*|tap*|wg*|zt*|tailscale*|easy*|et*|nlmon*|dummy*|ifb*|bond*|team*|macvlan*|ipvlan*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
# bridge that is not a physical device
|
||||
if [[ -d "/sys/class/net/$name/bridge" ]]; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
list_ifaces() {
|
||||
local name state ip
|
||||
for name in $(ls /sys/class/net 2>/dev/null | sort); do
|
||||
[[ "$name" == "lo" ]] && continue
|
||||
state="$(cat "/sys/class/net/$name/operstate" 2>/dev/null || echo unknown)"
|
||||
ip="$(ip -4 -o addr show dev "$name" 2>/dev/null | awk '{print $4}' | head -1)"
|
||||
printf '%s|%s|%s\n' "$name" "$state" "${ip:-}"
|
||||
done
|
||||
}
|
||||
|
||||
detect_default_iface() {
|
||||
ip route show default 2>/dev/null | awk '/default/ {print $5; exit}'
|
||||
}
|
||||
|
||||
iface_has_carrier() {
|
||||
local ifc="$1" c
|
||||
c="$(cat "/sys/class/net/$ifc/carrier" 2>/dev/null || echo 0)"
|
||||
[[ "$c" == "1" ]]
|
||||
}
|
||||
|
||||
iface_driver() {
|
||||
local ifc="$1" link
|
||||
link="$(readlink -f "/sys/class/net/$ifc/device/driver" 2>/dev/null || true)"
|
||||
if [[ -n "$link" ]]; then
|
||||
basename "$link"
|
||||
return
|
||||
fi
|
||||
# some USB eth put driver under device
|
||||
if [[ -f "/sys/class/net/$ifc/device/uevent" ]]; then
|
||||
awk -F= '/^DRIVER=/{print $2; exit}' "/sys/class/net/$ifc/device/uevent" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
iface_looks_cellular_by_driver() {
|
||||
local ifc="$1" drv
|
||||
drv="$(iface_driver "$ifc" || true)"
|
||||
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)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
iface_matches_patterns() {
|
||||
local name="$1" p
|
||||
local patterns="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,usb,enx,ppp,cdc,rmnet,ccmni,mbim,qmi}"
|
||||
IFS=',' read -r -a arr <<< "$patterns"
|
||||
for p in "${arr[@]}"; do
|
||||
p="${p// /}"
|
||||
[[ -z "$p" ]] && continue
|
||||
if [[ "$name" == *"$p"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 多默认路由时,metric 更大的往往是数据网(WiFi metric 更小优先)
|
||||
detect_secondary_default_iface() {
|
||||
local primary secondary
|
||||
primary="$(detect_default_iface || true)"
|
||||
secondary="$(
|
||||
ip route show default 2>/dev/null | awk -v p="$primary" '
|
||||
/default/ {
|
||||
iface=""; metric=0
|
||||
for (i=1;i<=NF;i++) {
|
||||
if ($i=="dev") iface=$(i+1)
|
||||
if ($i=="metric") metric=$(i+1)+0
|
||||
}
|
||||
if (iface!="" && iface!=p) {
|
||||
print metric, iface
|
||||
}
|
||||
}
|
||||
' | sort -n | awk 'END {print $2}'
|
||||
)"
|
||||
if [[ -n "$secondary" && "$secondary" != "$primary" ]]; then
|
||||
echo "$secondary"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# 打分选数据网卡(stdout 仅输出网卡名)
|
||||
# 更高分优先
|
||||
score_iface_as_cellular() {
|
||||
local name="$1"
|
||||
local default_if="$2"
|
||||
local state ip score=0 drv
|
||||
|
||||
is_virtual_or_skip_iface "$name" && { echo 0; return; }
|
||||
state="$(cat "/sys/class/net/$name/operstate" 2>/dev/null || echo unknown)"
|
||||
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
|
||||
# 驱动
|
||||
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
|
||||
# 有 IPv4
|
||||
if [[ -n "$ip" ]]; then score=$((score + 30)); fi
|
||||
# up/carrier
|
||||
if [[ "$state" == "up" || "$state" == "unknown" ]]; then score=$((score + 15)); fi
|
||||
if iface_has_carrier "$name"; then score=$((score + 10)); fi
|
||||
# 默认网卡通常是 WiFi/有线,大幅降权
|
||||
if [[ -n "$default_if" && "$name" == "$default_if" ]]; then score=$((score - 60)); fi
|
||||
# docker 等已在 skip;再防 enp/eth 当默认时
|
||||
case "$name" in
|
||||
eth*|enp*|eno*|ens*|wlan*|wlp*|wl*)
|
||||
if [[ "$name" == "$default_if" ]]; then score=$((score - 20)); fi
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "$score"
|
||||
}
|
||||
|
||||
# 自动探测数据网卡:配置 > 名称/驱动 > 次默认路由 > 非默认有 IP 物理口
|
||||
detect_cellular_iface() {
|
||||
local configured="${1:-}"
|
||||
local default_if name state ip best_name="" best_score=0 score
|
||||
|
||||
if [[ -n "$configured" ]]; then
|
||||
if [[ "$configured" == "lo" ]]; then
|
||||
warn "CELLULAR_IFACE=lo 无效,将尝试自动探测"
|
||||
elif iface_exists "$configured"; then
|
||||
echo "$configured"
|
||||
return 0
|
||||
else
|
||||
warn "配置的 CELLULAR_IFACE=$configured 不存在,将尝试自动探测"
|
||||
if [[ "${REQUIRE_CELLULAR_IFACE:-true}" == "true" && -n "${FORCE_CONFIGURED_ONLY:-}" ]]; then
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
default_if="$(detect_default_iface || true)"
|
||||
|
||||
# 1) 按评分扫描全部接口
|
||||
while IFS='|' read -r name state ip; do
|
||||
[[ -z "$name" ]] && continue
|
||||
is_virtual_or_skip_iface "$name" && continue
|
||||
score="$(score_iface_as_cellular "$name" "$default_if")"
|
||||
if [[ "$score" -gt "$best_score" ]]; then
|
||||
best_score="$score"
|
||||
best_name="$name"
|
||||
fi
|
||||
done < <(list_ifaces)
|
||||
|
||||
# 需要足够置信度(避免把唯一的 WiFi 当数据)
|
||||
if [[ -n "$best_name" && "$best_score" -ge 70 ]]; then
|
||||
info "自动探测数据网卡: $best_name (score=$best_score, default=$default_if, driver=$(iface_driver "$best_name" || true))"
|
||||
echo "$best_name"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 2) 多默认路由的次要口
|
||||
if name="$(detect_secondary_default_iface 2>/dev/null || true)"; then
|
||||
if [[ -n "$name" ]] && iface_exists "$name"; then
|
||||
info "根据次要默认路由探测数据网卡: $name"
|
||||
echo "$name"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3) 任意非默认、有 IPv4、非虚拟
|
||||
while IFS='|' read -r name state ip; do
|
||||
[[ -z "$name" ]] && continue
|
||||
is_virtual_or_skip_iface "$name" && continue
|
||||
[[ -z "$ip" ]] && continue
|
||||
if [[ -n "$default_if" && "$name" == "$default_if" ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ "$state" == "up" || "$state" == "unknown" ]]; then
|
||||
info "回退选择非默认网卡: $name"
|
||||
echo "$name"
|
||||
return 0
|
||||
fi
|
||||
done < <(list_ifaces)
|
||||
|
||||
if [[ -n "$best_name" && "$best_score" -gt 0 ]]; then
|
||||
warn "置信度较低,选用: $best_name (score=$best_score)"
|
||||
echo "$best_name"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
detect_source_ip() {
|
||||
local ifc="$1"
|
||||
ip -4 -o addr show dev "$ifc" 2>/dev/null | awk '{print $4}' | head -1 | cut -d/ -f1
|
||||
}
|
||||
|
||||
# 把探测结果写回 settings.conf
|
||||
persist_cellular_to_settings() {
|
||||
local conf="${1:-$CONFIG_FILE}"
|
||||
local cell="$2"
|
||||
local src_ip="${3:-}"
|
||||
[[ -f "$conf" ]] || return 1
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
awk -v k="CELLULAR_IFACE" -v v="$cell" '
|
||||
BEGIN { done=0 }
|
||||
index($0, k "=")==1 { print k "=" v; done=1; next }
|
||||
{ print }
|
||||
END { if (!done) print k "=" v }
|
||||
' "$conf" > "$tmp"
|
||||
mv "$tmp" "$conf"
|
||||
if [[ -n "$src_ip" ]]; then
|
||||
tmp="$(mktemp)"
|
||||
awk -v k="CELLULAR_SOURCE_IP" -v v="$src_ip" '
|
||||
BEGIN { done=0 }
|
||||
index($0, k "=")==1 { print k "=" v; done=1; next }
|
||||
{ print }
|
||||
END { if (!done) print k "=" v }
|
||||
' "$conf" > "$tmp"
|
||||
mv "$tmp" "$conf"
|
||||
fi
|
||||
}
|
||||
|
||||
arch_go() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) echo amd64 ;;
|
||||
aarch64|arm64) echo arm64 ;;
|
||||
armv7l|armhf) echo armv7 ;;
|
||||
*) die "不支持的架构: $(uname -m)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
ensure_dirs() {
|
||||
mkdir -p "$INSTALL_DIR"/{bin,etc,ui,generated} "$LOG_DIR"
|
||||
}
|
||||
|
||||
resolve_cellular() {
|
||||
local cell
|
||||
if ! cell="$(detect_cellular_iface "$CELLULAR_IFACE")"; then
|
||||
if [[ "$REQUIRE_CELLULAR_IFACE" == "true" ]]; then
|
||||
die "无法确定数据网卡。请设置 CELLULAR_IFACE= 或运行 scripts/detect.sh / cpxy detect"
|
||||
fi
|
||||
warn "未找到数据网卡"
|
||||
cell=""
|
||||
fi
|
||||
echo "$cell"
|
||||
}
|
||||
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
+98
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=lib.sh
|
||||
source "$ROOT_DIR/scripts/lib.sh"
|
||||
|
||||
if [[ -f "${CONFIG_FILE:-$ROOT_DIR/config/settings.conf}" ]]; then
|
||||
load_config
|
||||
elif [[ -f "$INSTALL_DIR/etc/settings.conf" ]]; then
|
||||
CONFIG_FILE="$INSTALL_DIR/etc/settings.conf"
|
||||
load_config
|
||||
elif [[ -f "$ROOT_DIR/generated/runtime.env" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source "$ROOT_DIR/generated/runtime.env"
|
||||
set +a
|
||||
else
|
||||
# 尝试已安装路径
|
||||
if [[ -f /opt/cellular-proxy/etc/settings.conf ]]; then
|
||||
CONFIG_FILE=/opt/cellular-proxy/etc/settings.conf
|
||||
load_config
|
||||
else
|
||||
load_config
|
||||
fi
|
||||
fi
|
||||
|
||||
URL="${EGRESS_CHECK_URL:-https://ifconfig.me}"
|
||||
# 安装阶段可用 EGRESS_TIMEOUT=8 缩短;手动 verify 默认 12
|
||||
TIMEOUT="${EGRESS_TIMEOUT:-12}"
|
||||
HOST="$PROXY_LISTEN_HOST"
|
||||
if [[ "$HOST" == "0.0.0.0" || "$HOST" == "::" || -z "$HOST" ]]; then
|
||||
HOST="127.0.0.1"
|
||||
fi
|
||||
PORT="${PROXY_MIXED_PORT:-7890}"
|
||||
QUICK="${VERIFY_QUICK:-false}"
|
||||
case "${QUICK,,}" in
|
||||
1|true|yes|on) QUICK=true ;;
|
||||
*) QUICK=false ;;
|
||||
esac
|
||||
|
||||
curl_ip() {
|
||||
# 单次探测,严格超时,绝不无限挂起
|
||||
local extra=("$@")
|
||||
# --max-time 总时长;--connect-timeout 连接
|
||||
curl -fsS --connect-timeout 3 --max-time "$TIMEOUT" "${extra[@]}" "$URL" 2>/dev/null \
|
||||
| tr -d '\r\n' \
|
||||
| head -c 64
|
||||
}
|
||||
|
||||
if [[ "$QUICK" != "true" ]]; then
|
||||
echo "========== 接口 =========="
|
||||
"$ROOT_DIR/scripts/detect.sh" || true
|
||||
echo
|
||||
fi
|
||||
|
||||
echo "========== 默认出口(不走代理,通常是 WiFi) =========="
|
||||
info "探测中(最多 ${TIMEOUT}s): $URL"
|
||||
direct_ip="$(curl_ip || true)"
|
||||
[[ -n "$direct_ip" ]] || direct_ip=FAIL
|
||||
echo "direct: $direct_ip"
|
||||
echo
|
||||
|
||||
echo "========== 代理出口(应是数据流量公网 IP) =========="
|
||||
auth=()
|
||||
if [[ -n "${PROXY_USER:-}" ]]; then
|
||||
auth=(--proxy-user "${PROXY_USER}:${PROXY_PASS}")
|
||||
fi
|
||||
info "SOCKS 探测中(最多 ${TIMEOUT}s): ${HOST}:${PORT}"
|
||||
socks_ip="$(curl_ip "${auth[@]}" --socks5-hostname "${HOST}:${PORT}" || true)"
|
||||
[[ -n "$socks_ip" ]] || socks_ip=FAIL
|
||||
echo "socks : $socks_ip"
|
||||
|
||||
info "HTTP 探测中(最多 ${TIMEOUT}s): ${HOST}:${PORT}"
|
||||
http_ip="$(curl_ip "${auth[@]}" -x "http://${HOST}:${PORT}" || true)"
|
||||
[[ -n "$http_ip" ]] || http_ip=FAIL
|
||||
echo "http : $http_ip"
|
||||
echo
|
||||
|
||||
if [[ "$direct_ip" == "FAIL" || "$socks_ip" == "FAIL" ]]; then
|
||||
err "探测失败(服务/数据网是否在线? journalctl -u cellular-proxy -e)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$direct_ip" == "$socks_ip" ]]; then
|
||||
warn "代理与直连公网 IP 相同 — 可能未绑到数据网卡,或数据与 WiFi 同出口"
|
||||
warn "请检查 CELLULAR_IFACE / 数据网是否 up / bind_interface"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -n "${EXPECTED_CELLULAR_PUBLIC_IP:-}" && "$socks_ip" != "$EXPECTED_CELLULAR_PUBLIC_IP" ]]; then
|
||||
err "代理出口 $socks_ip 与 EXPECTED_CELLULAR_PUBLIC_IP=$EXPECTED_CELLULAR_PUBLIC_IP 不一致"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
info "OK: 直连(WiFi)=$direct_ip 代理(数据)=$socks_ip"
|
||||
echo
|
||||
echo "局域网: curl --socks5-hostname <LAN-IP>:${PORT} $URL"
|
||||
echo "面板: http://<LAN-IP>:${PANEL_PORT}/"
|
||||
@@ -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,24 @@
|
||||
[Unit]
|
||||
Description=cellular-proxy (proxy egress via cellular iface)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=-@INSTALL_DIR@/etc/runtime.env
|
||||
ExecStartPre=/bin/mkdir -p /var/lib/cellular-proxy @LOG_DIR@
|
||||
ExecStart=@INSTALL_DIR@/bin/sing-box run -c @INSTALL_DIR@/etc/config.json
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
LimitNOFILE=65535
|
||||
# 低内存设备软上限(install 时替换;0 则删除本行)
|
||||
MemoryMax=@MEMORY_MAX@
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
StandardOutput=append:@LOG_DIR@/stdout.log
|
||||
StandardError=append:@LOG_DIR@/stderr.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+649
@@ -0,0 +1,649 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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 {
|
||||
--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; }
|
||||
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: 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>
|
||||
<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">
|
||||
<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 class="field">
|
||||
<label>管理 API(9091)</label>
|
||||
<input id="adminBase" type="text" placeholder="http://127.0.0.1:9091" />
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<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 class="muted" style="margin-top:10px">保存后会重写 settings、重生 sing-box 配置并重启服务(约 1~2 秒)。</div>
|
||||
<div id="authMsg" class="msg"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<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>
|
||||
</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>
|
||||
<tbody id="connBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card howto">
|
||||
<h2>怎么用</h2>
|
||||
<pre>1. 代理端口默认 7890(HTTP + SOCKS5 同一端口)
|
||||
2. 设备设置系统/应用代理 → Debian 的 LAN-IP:7890
|
||||
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">
|
||||
<h2>API 调试</h2>
|
||||
<pre id="raw">—</pre>
|
||||
</section>
|
||||
|
||||
<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;
|
||||
if (s) h["Authorization"] = "Bearer " + s;
|
||||
return h;
|
||||
}
|
||||
async function api(path, opt = {}) {
|
||||
const r = await fetch(base() + path, { ...opt, headers: { ...headers(), ...(opt.headers || {}) } });
|
||||
if (!r.ok) throw new Error(path + " " + r.status + " " + (await r.text()));
|
||||
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"];
|
||||
let i = 0, x = Number(n);
|
||||
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");
|
||||
$("verInfo").textContent = ver.version || JSON.stringify(ver);
|
||||
$("connState").textContent = "已连接";
|
||||
$("connState").className = "pill ok";
|
||||
$("raw").textContent = JSON.stringify(ver, null, 2);
|
||||
await loadProxies();
|
||||
await loadConnections();
|
||||
await loadProxyAuth();
|
||||
} catch (e) {
|
||||
$("connState").textContent = "失败";
|
||||
$("connState").className = "pill bad";
|
||||
$("proxies").innerHTML = '<span class="muted">无法连接 API。请确认服务已启动,并填写正确 secret。<br/>' + esc(e.message) + "</span>";
|
||||
await loadProxyAuth();
|
||||
}
|
||||
}
|
||||
async function loadProxies() {
|
||||
const data = await api("/proxies");
|
||||
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 = '<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] || {};
|
||||
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></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"));
|
||||
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 {
|
||||
const data = await api("/connections");
|
||||
const conns = data.connections || [];
|
||||
const body = $("connBody");
|
||||
body.innerHTML = "";
|
||||
conns.slice(0, 50).forEach(c => {
|
||||
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>${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);
|
||||
});
|
||||
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">' + esc(e.message) + "</td></tr>";
|
||||
}
|
||||
}
|
||||
async function closeConn(id) {
|
||||
await api("/connections/" + id, { method: "DELETE" });
|
||||
loadConnections();
|
||||
}
|
||||
loadAuth();
|
||||
refreshAll();
|
||||
setInterval(() => { loadConnections().catch(() => {}); }, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user