commit 8835646d3420e20c902c3050d28357dc4fdc6b76 Author: Hermes Date: Tue Jul 21 10:56:27 2026 +0000 feat: cellular-proxy 一键安装 — 走代理一律数据出口 - sing-box 单进程 bind 数据网卡 - mixed HTTP+SOCKS + 中文静态面板 - install.sh 支持 curl | sudo bash - 512MB 友好 MemoryMax 默认 96MB 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..d8ce11c --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# cellular-proxy + +轻量 **「走代理 = 走数据流量」** 方案(无 frp,512MB 友好)。 + +## 语义(重要) + +| 流量 | 出口 | +|------|------| +| **不走** 本代理 | 系统默认(通常 WiFi) | +| **走** 本代理(HTTP/SOCKS) | **一律数据网卡**(运营商流量) | + +```text +客户端 → mixed:7890 → sing-box → bind_interface=数据网卡 → 运营商 +系统直连 → 仍走 WiFi +``` + +## 一键安装 + +```bash +# 最简(会生成随机面板密钥;装完后请改 CELLULAR_IFACE) +curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash + +# 推荐:指定数据网卡 + 面板密钥 + 代理账号 +curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash -s -- \ + --iface wwan0 \ + --secret '你的面板密钥' \ + --user proxy \ + --pass '你的代理密码' + +# 本地源码安装(不拉 Gitea) +sudo bash install.sh --local /path/to/cellular-proxy --iface wwan0 +``` + +| 选项 | 说明 | +|------|------| +| `--iface` | 数据网卡名 | +| `--secret` | 面板 `PANEL_SECRET` | +| `--user` / `--pass` | 代理鉴权 | +| `--port` / `--panel-port` | 默认 7890 / 9090 | +| `--memory` | MemoryMax MB,默认 96 | +| `--skip-start` | 只装不启 | + +装完验证: + +```bash +sudo cpxy verify +# 期望:直连 IP(WiFi) ≠ 代理 IP(数据) +``` + +- 代理:`LAN-IP:7890`(HTTP + SOCKS5) +- 面板:`http://LAN-IP:9090/` + +## 手动安装 + +```bash +git clone https://gitea.chickliu.fun/Hermes/cellular-proxy.git +cd cellular-proxy +cp config/settings.conf.example config/settings.conf +# 编辑 CELLULAR_IFACE / PANEL_SECRET / PROXY_USER / PROXY_PASS +sudo ./scripts/install.sh +``` + +## 资源占用 + +- 单进程 **sing-box** +- 中文面板:内置静态 HTML(~20KB) +- 默认 `MemoryMax=96MB`,`LOG_LEVEL=warn` + +## 管理 + +```bash +cpxy detect | generate | start | stop | restart | status | verify | logs +``` + +改配置后:`sudo cpxy generate` + +## 配置摘要 + +见 `config/settings.conf.example`: + +- `CELLULAR_IFACE` — 数据网卡 +- `PROXY_MIXED_PORT` — HTTP+SOCKS 端口 +- `PROXY_USER` / `PROXY_PASS` — 代理鉴权 +- `PANEL_PORT` / `PANEL_SECRET` — 中文面板 +- `ENABLE_DNS` — DNS 也从数据出 +- `MEMORY_MAX_MB` — 内存上限 + +## 安全 + +局域网 `0.0.0.0` 时请设代理密码 + 面板 secret,防火墙限制来源网段。 +`REQUIRE_CELLULAR_IFACE=true` 避免数据断开时静默走 WiFi。 + +## License + +MIT(脚本与配置)。sing-box 遵循上游许可证。 diff --git a/config/settings.conf.example b/config/settings.conf.example new file mode 100644 index 0000000..b4f8b03 --- /dev/null +++ b/config/settings.conf.example @@ -0,0 +1,59 @@ +# ============================================================================= +# 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 + +# 代理鉴权(局域网强烈建议设置;留空=无鉴权) +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 ---- +SING_BOX_SOURCE=release +SING_BOX_VERSION=1.11.7 +SING_BOX_BIN= + +# 进程内存上限(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..0440797 --- /dev/null +++ b/cpxy @@ -0,0 +1,20 @@ +#!/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" "$@" ;; + help|*) + cat < + detect | generate | install | verify | fetch + +语义: 走本代理的连接一律从数据网卡出口(系统默认仍可走 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 100644 index 0000000..b3c09e3 --- /dev/null +++ b/install.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# cellular-proxy 一键安装 +# 语义:走本代理的连接一律从数据网卡(流量)出口;系统默认仍可走 WiFi +# +# 用法示例: +# curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash +# curl -fsSL '...' | sudo bash -s -- --iface wwan0 --secret 'xxx' --user u --pass p +# sudo bash install.sh --iface usb0 --port 7890 --panel-port 9090 +# +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}" +# 可用环境变量覆盖:CELLULAR_PROXY_REF=v1.0.0 或 commit sha +REF="${CELLULAR_PROXY_REF:-$BRANCH}" + +INSTALL_DIR="${INSTALL_DIR:-/opt/cellular-proxy}" +WORK_DIR="${WORK_DIR:-/tmp/cellular-proxy-install-$$}" + +# 可选 CLI 预填(也可装完再改 settings) +OPT_IFACE="" +OPT_SECRET="" +OPT_USER="" +OPT_PASS="" +OPT_PORT="" +OPT_PANEL_PORT="" +OPT_MEMORY="" +OPT_SKIP_START=0 +OPT_LOCAL_DIR="" + +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 一键安装 + +选项: + --iface NAME 数据网卡名(如 wwan0 / usb0) + --secret STR 面板 PANEL_SECRET + --user USER 代理账号 PROXY_USER + --pass PASS 代理密码 PROXY_PASS + --port N 代理 mixed 端口(默认 7890) + --panel-port N 面板端口(默认 9090) + --memory MB MemoryMax MB(默认 96;0=不限制) + --install-dir PATH 安装目录(默认 /opt/cellular-proxy) + --local DIR 使用本地源码目录,不从 Gitea 下载 + --ref REF 分支/tag/commit(默认 main) + --skip-start 只安装不启动 + -h, --help 帮助 + +环境变量: + GITEA_BASE REPO_OWNER REPO_NAME CELLULAR_PROXY_REF INSTALL_DIR +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 ;; + -h|--help) usage; exit 0 ;; + *) die "未知参数: $1(--help 查看用法)" ;; + esac +done + +need_root() { + if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then + die "请用 root 运行:sudo bash install.sh ..." + fi +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || die "缺少命令: $1" +} + +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" + elif command -v wget >/dev/null 2>&1; then + wget -O "$dest" "$url" + else + die "需要 curl 或 wget" + fi +} + +set_kv() { + # set_kv file KEY value — 存在则替换行,否则追加 + local file="$1" key="$2" val="$3" + local tmp + tmp="$(mktemp)" + if grep -qE "^${key}=" "$file" 2>/dev/null; then + # 用 awk 避免 sed 对特殊字符敏感 + 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 "下载失败。请检查网络或: git clone ${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}.git" + 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" + + if [[ -n "$OPT_IFACE" ]]; then + set_kv "$conf" CELLULAR_IFACE "$OPT_IFACE" + fi + if [[ -n "$OPT_SECRET" ]]; then + set_kv "$conf" PANEL_SECRET "$OPT_SECRET" + else + # 随机 secret,避免默认 please-change-me 裸奔 + local gen + gen="$(openssl rand -hex 12 2>/dev/null || head -c 16 /dev/urandom | xxd -p)" + set_kv "$conf" PANEL_SECRET "$gen" + warn "已生成随机 PANEL_SECRET(见 $INSTALL_DIR/etc/settings.conf)" + fi + if [[ -n "$OPT_USER" ]]; then + set_kv "$conf" PROXY_USER "$OPT_USER" + fi + if [[ -n "$OPT_PASS" ]]; then + set_kv "$conf" PROXY_PASS "$OPT_PASS" + fi + if [[ -n "$OPT_PORT" ]]; then + set_kv "$conf" PROXY_MIXED_PORT "$OPT_PORT" + fi + if [[ -n "$OPT_PANEL_PORT" ]]; then + set_kv "$conf" PANEL_PORT "$OPT_PANEL_PORT" + fi + if [[ -n "$OPT_MEMORY" ]]; then + set_kv "$conf" MEMORY_MAX_MB "$OPT_MEMORY" + fi + set_kv "$conf" INSTALL_DIR "$INSTALL_DIR" +} + +main() { + need_root + need_cmd tar + need_cmd awk + command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 || die "需要 curl 或 wget" + + trap 'rm -rf "$WORK_DIR"' EXIT + + info "1/4 获取源码" + fetch_source + + info "2/4 写入 settings.conf" + prepare_settings + + info "3/4 调用官方 install.sh(下载 sing-box + systemd)" + # 临时把 install 行为调整:settings 已存在 + export CONFIG_FILE="$WORK_DIR/src/config/settings.conf" + bash "$WORK_DIR/src/scripts/install.sh" + + if [[ "$OPT_SKIP_START" -eq 1 ]]; then + systemctl stop cellular-proxy 2>/dev/null || true + info "已按 --skip-start 停止服务" + fi + + info "4/4 完成" + local port panel secret + # shellcheck disable=SC1090 + source "$INSTALL_DIR/etc/settings.conf" 2>/dev/null || true + port="${PROXY_MIXED_PORT:-7890}" + panel="${PANEL_PORT:-9090}" + secret="${PANEL_SECRET:-见 $INSTALL_DIR/etc/settings.conf}" + 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 <}:${port} +面板: http://${lan:-}:${panel}/ +密钥: ${secret} +配置: $INSTALL_DIR/etc/settings.conf + +验证: + cpxy verify + curl --socks5-hostname 127.0.0.1:${port} https://ifconfig.me + +管理: + cpxy status | logs | generate | restart +EOM +} + +main "$@" diff --git a/scripts/detect.sh b/scripts/detect.sh new file mode 100755 index 0000000..e210e8c --- /dev/null +++ b/scripts/detect.sh @@ -0,0 +1,44 @@ +#!/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 "$ROOT_DIR/config/settings.conf" ]]; then + load_config +else + CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,usb,enx,ppp,cdc}" + 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 %s\n' "IFACE" "STATE" "IPv4" +while IFS='|' read -r name state ip; do + printf '%-12s %-10s %s\n' "$name" "$state" "${ip:-}" +done < <(list_ifaces) +echo + +echo "========== 默认路由 ==========" +ip route show default 2>/dev/null || true +echo +echo "default iface: $(detect_default_iface || echo 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)" + echo "说明: 走本代理的流量将从该网卡出(数据流量)" +else + echo "未能自动探测数据网卡。请手动设置 CELLULAR_IFACE=" + echo "可参考上面接口列表中的 wwan*/usb*/enx* 等。" + exit 2 +fi diff --git a/scripts/fetch-binaries.sh b/scripts/fetch-binaries.sh new file mode 100755 index 0000000..951d445 --- /dev/null +++ b/scripts/fetch-binaries.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# 仅下载 sing-box(轻量核心) +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 + +download() { + local url="$1" dest="$2" + info "下载: $url" + if command -v curl >/dev/null 2>&1; then + curl -fL --retry 3 --connect-timeout 20 -o "$dest" "$url" + elif command -v wget >/dev/null 2>&1; then + wget -O "$dest" "$url" + else + die "需要 curl 或 wget" + fi +} + +if [[ "$SING_BOX_SOURCE" == "local" ]]; then + [[ -n "$SING_BOX_BIN" && -x "$SING_BOX_BIN" ]] || die "SING_BOX_SOURCE=local 但 SING_BOX_BIN 无效" + install -m 0755 "$SING_BOX_BIN" "$INSTALL_DIR/bin/sing-box" + info "已安装本地 sing-box" +elif command -v sing-box >/dev/null 2>&1 && [[ -z "${FORCE_DOWNLOAD:-}" ]]; then + install -m 0755 "$(command -v sing-box)" "$INSTALL_DIR/bin/sing-box" + info "复用系统 sing-box: $(command -v sing-box)" +else + ver="$SING_BOX_VERSION" + name="sing-box-${ver}-linux-${ARCH}" + url="https://github.com/SagerNet/sing-box/releases/download/v${ver}/${name}.tar.gz" + download "$url" "$TMP/sb.tgz" + tar -xzf "$TMP/sb.tgz" -C "$TMP" + install -m 0755 "$TMP/${name}/sing-box" "$INSTALL_DIR/bin/sing-box" + info "已安装 sing-box v${ver}" +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..c2976a7 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,96 @@ +#!/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 "$ROOT_DIR/config/settings.conf" ]]; then + cp "$ROOT_DIR/config/settings.conf.example" "$ROOT_DIR/config/settings.conf" + warn "已创建 config/settings.conf,请至少修改:" + warn " CELLULAR_IFACE / PANEL_SECRET /(建议)PROXY_USER+PROXY_PASS" + warn "然后重新执行: sudo $ROOT_DIR/scripts/install.sh" + warn "可先: $ROOT_DIR/scripts/detect.sh" + exit 1 +fi + +load_config +need_root + +info "1/5 探测数据网卡" +"$ROOT_DIR/scripts/detect.sh" || true + +info "2/5 下载/安装 sing-box + UI" +"$ROOT_DIR/scripts/fetch-binaries.sh" + +info "3/5 生成配置(代理出口 = 数据网卡)" +"$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 + +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) + "$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 + ;; + 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" "$@" ;; + logs) journalctl -u cellular-proxy -n "${1:-80}" -f ;; + help|*) + cat <:${PANEL_PORT}/ 密钥: PANEL_SECRET" +echo " 验证: cpxy verify" diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100755 index 0000000..29648bf --- /dev/null +++ b/scripts/lib.sh @@ -0,0 +1,159 @@ +#!/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}" + 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:-release}" + SING_BOX_VERSION="${SING_BOX_VERSION:-1.11.7}" + SING_BOX_BIN="${SING_BOX_BIN:-}" + 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*) + return 0 + ;; + esac + 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}' +} + +detect_cellular_iface() { + local configured="$1" + 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" ]]; then + return 1 + fi + fi + fi + + local patterns name state ip p cand="" + IFS=',' read -r -a patterns <<< "$CELLULAR_IFACE_PATTERNS" + while IFS='|' read -r name state ip; do + [[ -z "$name" ]] && continue + is_virtual_or_skip_iface "$name" && continue + for p in "${patterns[@]}"; do + p="${p// /}" + [[ -z "$p" ]] && continue + if [[ "$name" == *"$p"* ]]; then + if [[ "$state" == "up" || "$state" == "unknown" ]] && [[ -n "$ip" ]]; then + echo "$name" + return 0 + fi + cand="${cand:-$name}" + fi + done + done < <(list_ifaces) + + if [[ -n "$cand" ]]; then + echo "$cand" + 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 +} + +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" + fi + warn "未找到数据网卡" + cell="" + fi + echo "$cell" +} diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..b9d2aac --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,64 @@ +#!/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 "$ROOT_DIR/generated/runtime.env" ]]; then + set -a + # shellcheck disable=SC1091 + source "$ROOT_DIR/generated/runtime.env" + set +a +else + load_config +fi + +URL="${EGRESS_CHECK_URL:-https://ifconfig.me}" +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}" + +echo "========== 接口 ==========" +"$ROOT_DIR/scripts/detect.sh" || true +echo + +echo "========== 默认出口(不走代理,通常是 WiFi) ==========" +direct_ip="$(curl -fsS --max-time 15 "$URL" || echo FAIL)" +echo "direct: $direct_ip" +echo + +echo "========== 代理出口(应是数据流量公网 IP) ==========" +auth=() +if [[ -n "${PROXY_USER:-}" ]]; then + auth=(--proxy-user "${PROXY_USER}:${PROXY_PASS}") +fi +socks_ip="$(curl -fsS --max-time 20 "${auth[@]}" --socks5-hostname "${HOST}:${PORT}" "$URL" || echo FAIL)" +echo "socks : $socks_ip" +http_ip="$(curl -fsS --max-time 20 "${auth[@]}" -x "http://${HOST}:${PORT}" "$URL" || echo 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.service b/systemd/cellular-proxy.service new file mode 100644 index 0000000..b95b656 --- /dev/null +++ b/systemd/cellular-proxy.service @@ -0,0 +1,24 @@ +[Unit] +Description=cellular-proxy (sing-box dual-WAN split, Chinese UI) +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..270da86 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,215 @@ + + + + + + 数据出口代理 · 控制台 + + + +
+
+

数据出口代理 cellular-proxy

+
走代理 = 走运营商流量 · 单进程轻量
+
+
+ 未连接 + +
+
+
+ + +
+
+
+
+ +
+
+
+ +
+
+ +
+
+
密钥仅存本浏览器 localStorage。
+
+ +
+
版本
+
+
+ +
+
+ 出站 + 应只有 cellular(数据网) +
+
加载中…
+
+ +
+
+ 当前连接 + +
+
+ + + +
目标出站链上传下载
+
+
+ +
+ 怎么用 +
+1. 代理端口默认 7890(HTTP + SOCKS5 同一端口)
+2. 设备设置系统/应用代理 → Debian 的 LAN-IP:7890
+3. 验证:直连 ifconfig.me 与 走代理 ifconfig.me 公网 IP 应不同
+4. 改网卡:改 settings.conf 里 CELLULAR_IFACE 后 sudo cpxy generate
+5. 低内存:MEMORY_MAX_MB=64~96,LOG_LEVEL=warn
+      
+
+ +
+ API 调试 +
+
+
+ + +