From 2d1168ef9e7f720a11e66c5a04ba014e7f88da3d Mon Sep 17 00:00:00 2001 From: Hermes Date: Tue, 21 Jul 2026 11:11:57 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=A8=E8=87=AA=E5=8A=A8=E5=AE=89?= =?UTF-8?q?=E8=A3=85=20=E2=80=94=20=E6=8E=A2=E6=B5=8B=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=BD=91=E5=8D=A1=E5=B9=B6=E7=BB=91=E5=AE=9A=E5=87=BA=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 驱动/名称/次默认路由/非默认口 评分探测 - install.sh 无参数:探测→写配置→装内核→启动→验证 - cpxy rebind 重插卡后重新绑定 - 自动生成 PANEL_SECRET;可选 apt 装依赖 --- .gitignore | 4 + README.md | 87 +++++++++ config/settings.conf.example | 59 ++++++ cpxy | 20 ++ docs/ops.md | 23 +++ install.sh | 304 ++++++++++++++++++++++++++++++ scripts/detect.sh | 49 +++++ scripts/fetch-binaries.sh | 56 ++++++ scripts/generate.sh | 156 ++++++++++++++++ scripts/install.sh | 204 +++++++++++++++++++++ scripts/lib.sh | 325 +++++++++++++++++++++++++++++++++ scripts/verify.sh | 64 +++++++ systemd/cellular-proxy.service | 24 +++ ui/index.html | 215 ++++++++++++++++++++++ 14 files changed, 1590 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/settings.conf.example create mode 100755 cpxy create mode 100644 docs/ops.md create mode 100755 install.sh create mode 100755 scripts/detect.sh create mode 100755 scripts/fetch-binaries.sh create mode 100755 scripts/generate.sh create mode 100755 scripts/install.sh create mode 100755 scripts/lib.sh create mode 100755 scripts/verify.sh create mode 100644 systemd/cellular-proxy.service create mode 100644 ui/index.html 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..38055fa --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# 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 +``` + +- 代理:`LAN-IP:7890`(HTTP + SOCKS5) +- 面板:`http://LAN-IP:9090/`(密钥在安装结束输出 / `settings.conf`) + +## 自动探测逻辑(摘要) + +按分数选择,优先: + +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` + +## 安全 + +局域网监听建议设置 `--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..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 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/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..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..2f8dae7 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,204 @@ +#!/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" "$@" ;; + logs) journalctl -u cellular-proxy -n "${1:-80}" -f ;; + help|*) + cat </dev/null || true + info "已按 SKIP_START 跳过启动" +else + systemctl enable cellular-proxy.service + systemctl restart cellular-proxy.service + sleep 2 + if ! systemctl is-active --quiet cellular-proxy.service; then + err "服务未处于 active" + systemctl --no-pager --full status cellular-proxy.service || true + journalctl -u cellular-proxy -n 40 --no-pager || true + die "启动失败,请检查数据网卡 $cell 是否 up 且有 IP" + fi + systemctl --no-pager --full status cellular-proxy.service || true +fi + +info "6/6 自动验证出口" +if [[ "$SKIP_START" != "true" && "$AUTO_VERIFY" == "true" ]]; then + if "$ROOT_DIR/scripts/verify.sh"; then + info "出口验证通过:代理已从数据网卡出" + else + warn "自动验证未通过(可能数据侧暂无公网/与 WiFi 同出口)。请稍后: cpxy verify" + warn "当前绑定: CELLULAR_IFACE=$cell ip=${src_ip:-}" + fi +fi + +echo +info "安装完成(全自动探测 + 绑定)" +echo " 数据网卡: $cell (${src_ip:-no-ipv4})" +echo " 语义: 不走代理 → 系统默认(WiFi);走代理 → 数据流量" +echo " 代理: HTTP/SOCKS ${PROXY_LISTEN_HOST}:${PROXY_MIXED_PORT}" +echo " 面板: http://:${PANEL_PORT}/" +echo " 密钥: $PANEL_SECRET" +echo " 配置: $INSTALL_DIR/etc/settings.conf" +echo " 管理: cpxy rebind | verify | logs | status" diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100755 index 0000000..471427a --- /dev/null +++ b/scripts/lib.sh @@ -0,0 +1,325 @@ +#!/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:-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*|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/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 调试 +
+
+
+ + +