commit d1a3dc00d59ecb72fab30522325749cf84a5f9f6 Author: Hermes Date: Tue Jul 21 12:37:18 2026 +0000 style: 9090 面板改用 animal-island-ui 视觉 - 奶油纸背景、薄荷主色、pill 输入/按钮、3D 按压阴影 - 焦点黄高亮、Title 飘带、圆角卡片表 - 仍为单文件静态 HTML,无 React/npm,适合 512MB diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cf20d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +config/settings.conf +generated/ +*.tar.gz +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2f18ad --- /dev/null +++ b/README.md @@ -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 +- 中文静态面板 ~20KB +- 默认 `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 `。 + +维护者更新预置包(从 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`:数据口不在则不装/不生成假出口。 diff --git a/config/settings.conf.example b/config/settings.conf.example new file mode 100644 index 0000000..507dc78 --- /dev/null +++ b/config/settings.conf.example @@ -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= diff --git a/cpxy b/cpxy new file mode 100755 index 0000000..bb4f43a --- /dev/null +++ b/cpxy @@ -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 < + detect | generate | install | verify | fetch + auth --user U --pass P | auth --clear | auth --show + +语义: 走本代理的连接一律从数据网卡出口(系统默认仍可走 WiFi) +H + ;; +esac diff --git a/docs/ops.md b/docs/ops.md new file mode 100644 index 0000000..f9c4044 --- /dev/null +++ b/docs/ops.md @@ -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 +``` diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..798dc1c --- /dev/null +++ b/install.sh @@ -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 <}:${panel}/ +密钥: ${secret} +配置: $INSTALL_DIR/etc/settings.conf + +验证: cpxy verify +重绑: cpxy rebind # 换卡/重插后重新自动探测 +日志: cpxy logs +EOM +} + +main "$@" diff --git a/scripts/admin-api.py b/scripts/admin-api.py new file mode 100755 index 0000000..d691241 --- /dev/null +++ b/scripts/admin-api.py @@ -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() diff --git a/scripts/apply-proxy-auth.sh b/scripts/apply-proxy-auth.sh new file mode 100755 index 0000000..b9553f3 --- /dev/null +++ b/scripts/apply-proxy-auth.sh @@ -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:-}" + if [[ -n "${PROXY_PASS:-}" ]]; then + echo "PROXY_PASS=$(mask "$PROXY_PASS") (已设置)" + else + echo "PROXY_PASS= (无鉴权)" + 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 diff --git a/scripts/detect.sh b/scripts/detect.sh new file mode 100755 index 0000000..e6bdd79 --- /dev/null +++ b/scripts/detect.sh @@ -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 diff --git a/scripts/fetch-binaries.sh b/scripts/fetch-binaries.sh new file mode 100755 index 0000000..1253978 --- /dev/null +++ b/scripts/fetch-binaries.sh @@ -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 diff --git a/scripts/generate.sh b/scripts/generate.sh new file mode 100755 index 0000000..c619309 --- /dev/null +++ b/scripts/generate.sh @@ -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" < "$OUT_DIR/SUMMARY.txt" <:${PANEL_PORT}/ secret=见 settings PANEL_SECRET +memory_max_mb=${MEMORY_MAX_MB} +EOF + +info "生成完成: $OUT_DIR" +cat "$OUT_DIR/SUMMARY.txt" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..01fa7a8 --- /dev/null +++ b/scripts/install.sh @@ -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 </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://:${PANEL_PORT}/ui/ 密钥: $PANEL_SECRET" +echo " 管理API: http://: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 diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100755 index 0000000..aea8d62 --- /dev/null +++ b/scripts/lib.sh @@ -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" +} diff --git a/scripts/publish-binaries-to-gitea.sh b/scripts/publish-binaries-to-gitea.sh new file mode 100755 index 0000000..1266a09 --- /dev/null +++ b/scripts/publish-binaries-to-gitea.sh @@ -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 - < $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-.tar.gz" +log " 或分片: .../${fname}.part-XX + .../${fname}.SHA256SUMS" diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..9cae7fa --- /dev/null +++ b/scripts/verify.sh @@ -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 :${PORT} $URL" +echo "面板: http://:${PANEL_PORT}/" diff --git a/systemd/cellular-proxy-admin.service b/systemd/cellular-proxy-admin.service new file mode 100644 index 0000000..59ce1af --- /dev/null +++ b/systemd/cellular-proxy-admin.service @@ -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 diff --git a/systemd/cellular-proxy.service b/systemd/cellular-proxy.service new file mode 100644 index 0000000..905e7bd --- /dev/null +++ b/systemd/cellular-proxy.service @@ -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 diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..c8777c0 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,649 @@ + + + + + + 数据出口代理 · 小岛控制台 + + + + + + +
+
+
+

数据出口代理

+
走代理 = 走运营商流量 · animal-island 风格控制台
+
+
+ 未连接 + +
+
+ + + +
+

连接设置 密钥仅存本浏览器 localStorage

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+

+ 代理账号密码(7890) + 未知 +

+
+
+ + +
+
+ + +
+
+ + + +
+
+
保存后会重写 settings、重生 sing-box 配置并重启服务(约 1~2 秒)。
+
+
+ +
+

状态

+
+
VERSION
+
PROXY
7890 · HTTP/SOCKS
+
OUTBOUND
cellular
+
+
+ +
+

出站 应只有 cellular(数据网)

+
加载中…
+
+ +
+

+ 当前连接 + +

+
+ + + + + +
目标出站链上传下载
+
+
+ +
+

怎么用

+
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
+
+ +
+

API 调试

+
+
+ + +
cellular-proxy · 视觉参考 animal-island-ui · 走代理 = 数据流量
+
+ + + +