fix: 安装启动后不再卡住,保证脚本退出

- 去掉可能阻塞的 systemctl status 全量输出
- verify 使用 connect/max 超时;安装阶段 8s 快速验证
- 验证失败只告警,不阻塞安装结束
This commit is contained in:
Hermes
2026-07-21 11:26:30 +00:00
commit aec388682b
14 changed files with 1697 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
config/settings.conf
generated/
*.tar.gz
.DS_Store
+97
View File
@@ -0,0 +1,97 @@
# 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`
## GitHub 下载代理
安装时下载 sing-box release 默认走:
```text
https://git.86482425.xyz/https://github.com/SagerNet/sing-box/releases/...
```
可在 `settings.conf``GITHUB_PROXY=`(置空则直连 GitHub)。代理失败会自动回退直连。
## 安全
局域网监听建议设置 `--user/--pass`;面板 secret 已默认随机生成。
`REQUIRE_CELLULAR_IFACE=true`:数据口不在则不装/不生成假出口。
+63
View File
@@ -0,0 +1,63 @@
# =============================================================================
# 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=
# GitHub 下载代理前缀(下载 sing-box release 时使用)
# 默认 https://git.86482425.xyz → 实际 URL: 代理/https://github.com/...
# 置空则直连 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=
Executable
+20
View File
@@ -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 <<H
./cpxy <cmd>
detect | generate | install | verify | fetch
语义: 走本代理的连接一律从数据网卡出口(系统默认仍可走 WiFi)
H
;;
esac
+23
View File
@@ -0,0 +1,23 @@
# 运维
## 正确用法
1. 系统照常上网(WiFi)— 不经过代理
2. 需要「用流量出口」时:把应用的 HTTP/SOCKS 指到 `LAN:7890`
3. `cpxy verify` 确认代理 IP ≠ 直连 IP
## 内存
```bash
MEMORY_MAX_MB=64 # 更紧
LOG_LEVEL=error
```
## 卸载
```bash
sudo systemctl disable --now cellular-proxy
sudo rm -f /etc/systemd/system/cellular-proxy.service /usr/local/bin/cpxy
sudo systemctl daemon-reload
sudo rm -rf /opt/cellular-proxy /var/lib/cellular-proxy /var/log/cellular-proxy
```
Executable
+304
View File
@@ -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 "请用 rootcurl -fsSL '...' | sudo bash"
fi
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || die "缺少命令: $1"
}
ensure_deps() {
local missing=()
for c in tar awk ip curl; do
command -v "$c" >/dev/null 2>&1 || missing+=("$c")
done
# curl 可用 wget 替代
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
missing+=("curl")
fi
if [[ ${#missing[@]} -eq 0 ]]; then
return 0
fi
info "尝试自动安装依赖: ${missing[*]}"
if command -v apt-get >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq ca-certificates curl tar iproute2 openssl coreutils findutils 2>/dev/null \
|| apt-get install -y ca-certificates curl tar iproute2 openssl
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache ca-certificates curl tar iproute2 openssl
else
die "缺少依赖 ${missing[*]},且无法自动安装(请先装 curl/tar/iproute2"
fi
}
download() {
local url="$1" dest="$2"
if command -v curl >/dev/null 2>&1; then
curl -fL --retry 3 --connect-timeout 20 -o "$dest" "$url"
else
wget -O "$dest" "$url"
fi
}
set_kv() {
local file="$1" key="$2" val="$3"
local tmp
tmp="$(mktemp)"
if grep -qE "^${key}=" "$file" 2>/dev/null; then
awk -v k="$key" -v v="$val" '
BEGIN { done=0 }
index($0, k "=")==1 { print k "=" v; done=1; next }
{ print }
END { if (!done) print k "=" v }
' "$file" > "$tmp"
mv "$tmp" "$file"
else
printf '%s=%s\n' "$key" "$val" >> "$file"
rm -f "$tmp"
fi
}
fetch_source() {
mkdir -p "$WORK_DIR"
if [[ -n "$OPT_LOCAL_DIR" ]]; then
[[ -d "$OPT_LOCAL_DIR" ]] || die "本地目录不存在: $OPT_LOCAL_DIR"
info "使用本地源码: $OPT_LOCAL_DIR"
mkdir -p "$WORK_DIR/src"
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete \
--exclude '.git' --exclude 'generated' --exclude 'config/settings.conf' \
"$OPT_LOCAL_DIR"/ "$WORK_DIR/src"/
else
cp -a "$OPT_LOCAL_DIR"/. "$WORK_DIR/src"/
rm -rf "$WORK_DIR/src/.git" "$WORK_DIR/src/generated" "$WORK_DIR/src/config/settings.conf" 2>/dev/null || true
fi
return
fi
local tarball="$WORK_DIR/src.tgz"
local url_archive="${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/archive/${REF}.tar.gz"
info "下载源码: $url_archive"
if ! download "$url_archive" "$tarball"; then
die "下载失败: $url_archive"
fi
mkdir -p "$WORK_DIR/extract"
tar -xzf "$tarball" -C "$WORK_DIR/extract"
local top
top="$(find "$WORK_DIR/extract" -mindepth 1 -maxdepth 1 -type d | head -1)"
[[ -n "$top" ]] || die "压缩包内容异常"
mkdir -p "$WORK_DIR/src"
if command -v rsync >/dev/null 2>&1; then
rsync -a "$top"/ "$WORK_DIR/src"/
else
cp -a "$top"/. "$WORK_DIR/src"/
fi
}
prepare_settings() {
local src="$WORK_DIR/src"
local conf="$src/config/settings.conf"
[[ -f "$src/config/settings.conf.example" ]] || die "缺少 settings.conf.example"
cp "$src/config/settings.conf.example" "$conf"
# 预加载 lib 做自动探测(源码里的 lib)
# shellcheck source=/dev/null
source "$src/scripts/lib.sh"
CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,usb,enx,ppp,cdc,rmnet,ccmni,mbim,qmi}"
REQUIRE_CELLULAR_IFACE=false
local cell=""
if [[ -n "$OPT_IFACE" ]]; then
cell="$OPT_IFACE"
info "使用 --iface $cell"
elif [[ "$OPT_NO_AUTO_DETECT" -eq 1 ]]; then
die "--no-auto-detect 时必须提供 --iface"
else
info "自动探测数据网卡…"
cell="$(detect_cellular_iface "" || true)"
fi
if [[ -z "$cell" ]]; then
die "自动探测失败:未找到数据网卡。
请确认模组/拨号已 up,或指定:
... | sudo bash -s -- --iface <网卡名>
可先手动: ip -br a"
fi
if ! iface_exists "$cell"; then
die "网卡不存在: $cell"
fi
local src_ip
src_ip="$(detect_source_ip "$cell" || true)"
set_kv "$conf" CELLULAR_IFACE "$cell"
if [[ -n "$src_ip" ]]; then
set_kv "$conf" CELLULAR_SOURCE_IP "$src_ip"
fi
info "已写入 CELLULAR_IFACE=$cell ip=${src_ip:-none}"
if [[ -n "$OPT_SECRET" ]]; then
set_kv "$conf" PANEL_SECRET "$OPT_SECRET"
else
local gen
gen="$(openssl rand -hex 12 2>/dev/null || head -c 16 /dev/urandom | xxd -p | tr -d '\n')"
set_kv "$conf" PANEL_SECRET "$gen"
info "已自动生成 PANEL_SECRET"
fi
[[ -n "$OPT_USER" ]] && set_kv "$conf" PROXY_USER "$OPT_USER"
[[ -n "$OPT_PASS" ]] && set_kv "$conf" PROXY_PASS "$OPT_PASS"
[[ -n "$OPT_PORT" ]] && set_kv "$conf" PROXY_MIXED_PORT "$OPT_PORT"
[[ -n "$OPT_PANEL_PORT" ]] && set_kv "$conf" PANEL_PORT "$OPT_PANEL_PORT"
[[ -n "$OPT_MEMORY" ]] && set_kv "$conf" MEMORY_MAX_MB "$OPT_MEMORY"
set_kv "$conf" INSTALL_DIR "$INSTALL_DIR"
set_kv "$conf" REQUIRE_CELLULAR_IFACE "true"
# 给子 install 用
export DETECTED_CELL="$cell"
export DETECTED_SRC_IP="$src_ip"
}
main() {
need_root
ensure_deps
need_cmd tar
need_cmd awk
need_cmd ip
trap 'rm -rf "$WORK_DIR"' EXIT
info "======== cellular-proxy 全自动安装 ========"
info "1/3 获取源码"
fetch_source
info "2/3 自动探测数据网卡并写配置"
prepare_settings
info "3/3 安装 sing-box、绑定出口、启动"
export CONFIG_FILE="$WORK_DIR/src/config/settings.conf"
export AUTO_DETECT=false # 已在 prepare 写死网卡
export AUTO_VERIFY=true
if [[ "$OPT_SKIP_VERIFY" -eq 1 ]]; then
export AUTO_VERIFY=false
fi
if [[ "$OPT_SKIP_START" -eq 1 ]]; then
export SKIP_START=true
else
export SKIP_START=false
fi
# 确保 settings 路径对 scripts/install 可见
# scripts/install 读 ROOT/config/settings.conf
bash "$WORK_DIR/src/scripts/install.sh"
# shellcheck disable=SC1090
source "$INSTALL_DIR/etc/settings.conf" 2>/dev/null || true
local port="${PROXY_MIXED_PORT:-7890}"
local panel="${PANEL_PORT:-9090}"
local secret="${PANEL_SECRET:-}"
local cell="${CELLULAR_IFACE:-$DETECTED_CELL}"
local lan
lan="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}' || true)"
cat <<EOM
========== 全自动安装完成 ==========
数据网卡(已绑定): ${cell}
语义: 走代理 → 数据流量;不走代理 → WiFi/默认
代理: HTTP/SOCKS ${lan:-127.0.0.1}:${port}
面板: http://${lan:-<LAN-IP>}:${panel}/
密钥: ${secret}
配置: $INSTALL_DIR/etc/settings.conf
验证: cpxy verify
重绑: cpxy rebind # 换卡/重插后重新自动探测
日志: cpxy logs
EOM
}
main "$@"
+49
View File
@@ -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
+92
View File
@@ -0,0 +1,92 @@
#!/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
# GitHub 下载代理:默认 https://git.86482425.xyz
# 空字符串 = 直连 GitHub;也可设为其它镜像前缀(末尾可带或不带 /)
GITHUB_PROXY="${GITHUB_PROXY:-https://git.86482425.xyz}"
github_url() {
# 把 https://github.com/... 加上代理前缀
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
# 常见 ghproxy 风格:https://proxy/https://github.com/...
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 -o "$dest" "$url"
elif command -v wget >/dev/null 2>&1; then
wget -O "$dest" "$url"
else
die "需要 curl 或 wget"
fi
}
download_github() {
# 先走代理,失败再直连 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"
}
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}"
raw_url="https://github.com/SagerNet/sing-box/releases/download/v${ver}/${name}.tar.gz"
download_github "$raw_url" "$TMP/sb.tgz"
tar -xzf "$TMP/sb.tgz" -C "$TMP"
install -m 0755 "$TMP/${name}/sing-box" "$INSTALL_DIR/bin/sing-box"
info "已安装 sing-box v${ver}"
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
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# 生成 sing-box:代理流量全部 bind 数据网卡出口
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=lib.sh
source "$ROOT_DIR/scripts/lib.sh"
load_config
OUT_DIR="${1:-$ROOT_DIR/generated}"
mkdir -p "$OUT_DIR"
cell="$(resolve_cellular 2>/dev/null || true)"
if [[ -z "$cell" ]]; then
resolve_cellular >/dev/null || true
fi
src_ip="${CELLULAR_SOURCE_IP:-}"
if [[ -z "$src_ip" && -n "$cell" ]]; then
src_ip="$(detect_source_ip "$cell" || true)"
fi
if [[ -z "$cell" ]]; then
warn "CELLULAR_IFACE 为空,临时用 lo 生成配置(启动前务必修正)"
cell_for_cfg=lo
else
cell_for_cfg="$cell"
fi
if [[ "$PANEL_SECRET" == "please-change-me" || "$PANEL_SECRET" == "change-this-secret" ]]; then
warn "PANEL_SECRET 仍是默认值,请修改 settings.conf"
fi
if [[ "$PROXY_LISTEN_HOST" != "127.0.0.1" && "$PROXY_LISTEN_HOST" != "::1" && -z "$PROXY_USER" ]]; then
warn "代理监听 $PROXY_LISTEN_HOST 且未设置 PROXY_USER/PASS(局域网建议加鉴权)"
fi
export GEN_OUT="$OUT_DIR/config.json"
export GEN_CELL="$cell_for_cfg"
export GEN_SRC="$src_ip"
export GEN_PROXY_HOST="$PROXY_LISTEN_HOST"
export GEN_PROXY_PORT="$PROXY_MIXED_PORT"
export GEN_PROXY_USER="$PROXY_USER"
export GEN_PROXY_PASS="$PROXY_PASS"
export GEN_PANEL_HOST="$PANEL_LISTEN_HOST"
export GEN_PANEL_PORT="$PANEL_PORT"
export GEN_PANEL_SECRET="$PANEL_SECRET"
export GEN_DNS="$ENABLE_DNS"
export GEN_LOG="$LOG_LEVEL"
export GEN_UI_DIR="$INSTALL_DIR/ui"
python3 <<'PY'
import json, os
from pathlib import Path
cell = os.environ["GEN_CELL"]
src = os.environ.get("GEN_SRC") or ""
log_level = os.environ.get("GEN_LOG") or "warn"
ui_dir = os.environ.get("GEN_UI_DIR") or "/opt/cellular-proxy/ui"
users = []
u = os.environ.get("GEN_PROXY_USER") or ""
p = os.environ.get("GEN_PROXY_PASS") or ""
if u:
users.append({"username": u, "password": p})
inbound = {
"type": "mixed",
"tag": "mixed-in",
"listen": os.environ["GEN_PROXY_HOST"],
"listen_port": int(os.environ["GEN_PROXY_PORT"]),
}
if users:
inbound["users"] = users
# 唯一业务出口:强制绑定数据网卡
out_cell = {
"type": "direct",
"tag": "cellular",
"bind_interface": cell,
}
if src:
out_cell["inet4_bind_address"] = src
cfg = {
"log": {"level": log_level, "timestamp": True},
"inbounds": [inbound],
"outbounds": [
out_cell,
{"type": "block", "tag": "block"},
],
# 所有代理流量最终都走 cellular(数据)
"route": {
"rules": [],
"final": "cellular",
"auto_detect_interface": False,
},
"experimental": {
"clash_api": {
"external_controller": f"{os.environ['GEN_PANEL_HOST']}:{os.environ['GEN_PANEL_PORT']}",
"secret": os.environ["GEN_PANEL_SECRET"],
"default_mode": "rule",
"external_ui": ui_dir,
},
"cache_file": {
"enabled": True,
"path": "/var/lib/cellular-proxy/cache.db",
"store_fakeip": False,
},
},
}
if (os.environ.get("GEN_DNS") or "true").lower() == "true":
# DNS 也走数据出口,避免解析从 WiFi 出去
cfg["dns"] = {
"servers": [
{
"tag": "remote",
"address": "1.1.1.1",
"detour": "cellular",
},
],
"final": "remote",
"strategy": "ipv4_only",
}
Path(os.environ["GEN_OUT"]).write_text(
json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
print("wrote", os.environ["GEN_OUT"])
PY
cat > "$OUT_DIR/runtime.env" <<EOF
INSTALL_DIR=${INSTALL_DIR}
LOG_DIR=${LOG_DIR}
CELLULAR_IFACE=${cell}
CELLULAR_SOURCE_IP=${src_ip}
PROXY_LISTEN_HOST=${PROXY_LISTEN_HOST}
PROXY_MIXED_PORT=${PROXY_MIXED_PORT}
PANEL_LISTEN_HOST=${PANEL_LISTEN_HOST}
PANEL_PORT=${PANEL_PORT}
EGRESS_CHECK_URL=${EGRESS_CHECK_URL}
EXPECTED_CELLULAR_PUBLIC_IP=${EXPECTED_CELLULAR_PUBLIC_IP}
MEMORY_MAX_MB=${MEMORY_MAX_MB}
REQUIRE_CELLULAR_IFACE=${REQUIRE_CELLULAR_IFACE}
EOF
cat > "$OUT_DIR/SUMMARY.txt" <<EOF
generated_at=$(date -Iseconds)
语义=走代理的连接一律从数据网卡出口
cellular_iface=${cell}
cellular_source_ip=${src_ip}
proxy=${PROXY_LISTEN_HOST}:${PROXY_MIXED_PORT} (HTTP+SOCKS mixed)
panel=http://<LAN-IP>:${PANEL_PORT}/ secret=见 settings PANEL_SECRET
memory_max_mb=${MEMORY_MAX_MB}
EOF
info "生成完成: $OUT_DIR"
cat "$OUT_DIR/SUMMARY.txt"
+226
View File
@@ -0,0 +1,226 @@
#!/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 <<H
cpxy — cellular-proxy
语义: 走代理的连接一律从数据网卡出口
cpxy detect | rebind | generate | start | stop | restart | status
cpxy verify | logs [N]
H
;;
esac
EOF
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
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 stop cellular-proxy.service 2>/dev/null || true
info "已按 SKIP_START 跳过启动"
else
systemctl enable cellular-proxy.service
systemctl restart cellular-proxy.service
# 等待 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}"
echo " 面板: http://<LAN-IP>:${PANEL_PORT}/"
echo " 密钥: $PANEL_SECRET"
echo " 配置: $INSTALL_DIR/etc/settings.conf"
echo " 管理: cpxy rebind | verify | logs | status"
echo " 若刚才像卡住:多半在测公网出口,现已改为短超时并保证退出"
# 安装脚本始终以 0 结束(服务已 active);验证失败只告警
exit 0
Executable
+326
View File
@@ -0,0 +1,326 @@
#!/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:-}"
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"
}
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=lib.sh
source "$ROOT_DIR/scripts/lib.sh"
if [[ -f "${CONFIG_FILE:-$ROOT_DIR/config/settings.conf}" ]]; then
load_config
elif [[ -f "$INSTALL_DIR/etc/settings.conf" ]]; then
CONFIG_FILE="$INSTALL_DIR/etc/settings.conf"
load_config
elif [[ -f "$ROOT_DIR/generated/runtime.env" ]]; then
set -a
# shellcheck disable=SC1091
source "$ROOT_DIR/generated/runtime.env"
set +a
else
# 尝试已安装路径
if [[ -f /opt/cellular-proxy/etc/settings.conf ]]; then
CONFIG_FILE=/opt/cellular-proxy/etc/settings.conf
load_config
else
load_config
fi
fi
URL="${EGRESS_CHECK_URL:-https://ifconfig.me}"
# 安装阶段可用 EGRESS_TIMEOUT=8 缩短;手动 verify 默认 12
TIMEOUT="${EGRESS_TIMEOUT:-12}"
HOST="$PROXY_LISTEN_HOST"
if [[ "$HOST" == "0.0.0.0" || "$HOST" == "::" || -z "$HOST" ]]; then
HOST="127.0.0.1"
fi
PORT="${PROXY_MIXED_PORT:-7890}"
QUICK="${VERIFY_QUICK:-false}"
case "${QUICK,,}" in
1|true|yes|on) QUICK=true ;;
*) QUICK=false ;;
esac
curl_ip() {
# 单次探测,严格超时,绝不无限挂起
local extra=("$@")
# --max-time 总时长;--connect-timeout 连接
curl -fsS --connect-timeout 3 --max-time "$TIMEOUT" "${extra[@]}" "$URL" 2>/dev/null \
| tr -d '\r\n' \
| head -c 64
}
if [[ "$QUICK" != "true" ]]; then
echo "========== 接口 =========="
"$ROOT_DIR/scripts/detect.sh" || true
echo
fi
echo "========== 默认出口(不走代理,通常是 WiFi =========="
info "探测中(最多 ${TIMEOUT}s: $URL"
direct_ip="$(curl_ip || true)"
[[ -n "$direct_ip" ]] || direct_ip=FAIL
echo "direct: $direct_ip"
echo
echo "========== 代理出口(应是数据流量公网 IP =========="
auth=()
if [[ -n "${PROXY_USER:-}" ]]; then
auth=(--proxy-user "${PROXY_USER}:${PROXY_PASS}")
fi
info "SOCKS 探测中(最多 ${TIMEOUT}s: ${HOST}:${PORT}"
socks_ip="$(curl_ip "${auth[@]}" --socks5-hostname "${HOST}:${PORT}" || true)"
[[ -n "$socks_ip" ]] || socks_ip=FAIL
echo "socks : $socks_ip"
info "HTTP 探测中(最多 ${TIMEOUT}s: ${HOST}:${PORT}"
http_ip="$(curl_ip "${auth[@]}" -x "http://${HOST}:${PORT}" || true)"
[[ -n "$http_ip" ]] || http_ip=FAIL
echo "http : $http_ip"
echo
if [[ "$direct_ip" == "FAIL" || "$socks_ip" == "FAIL" ]]; then
err "探测失败(服务/数据网是否在线? journalctl -u cellular-proxy -e"
exit 1
fi
if [[ "$direct_ip" == "$socks_ip" ]]; then
warn "代理与直连公网 IP 相同 — 可能未绑到数据网卡,或数据与 WiFi 同出口"
warn "请检查 CELLULAR_IFACE / 数据网是否 up / bind_interface"
exit 2
fi
if [[ -n "${EXPECTED_CELLULAR_PUBLIC_IP:-}" && "$socks_ip" != "$EXPECTED_CELLULAR_PUBLIC_IP" ]]; then
err "代理出口 $socks_ip 与 EXPECTED_CELLULAR_PUBLIC_IP=$EXPECTED_CELLULAR_PUBLIC_IP 不一致"
exit 3
fi
info "OK: 直连(WiFi)=$direct_ip 代理(数据)=$socks_ip"
echo
echo "局域网: curl --socks5-hostname <LAN-IP>:${PORT} $URL"
echo "面板: http://<LAN-IP>:${PANEL_PORT}/"
+24
View File
@@ -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
+215
View File
@@ -0,0 +1,215 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>数据出口代理 · 控制台</title>
<style>
:root {
--bg: #0f1419; --card: #1a2332; --text: #e7ecf3; --muted: #8b9bb4;
--accent: #3d8bfd; --ok: #3dd68c; --warn: #f5a524; --bad: #f31260; --border: #2a3548;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg); color: var(--text); min-height: 100vh; }
header { padding: 16px 20px; border-bottom: 1px solid var(--border);
display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }
h1 { font-size: 1.15rem; margin: 0; font-weight: 600; }
.muted { color: var(--muted); font-size: 0.85rem; }
main { padding: 16px; max-width: 960px; margin: 0 auto; display: grid; gap: 14px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; }
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
label { font-size: 0.85rem; color: var(--muted); }
input, button { background: #0d121a; color: var(--text); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 10px; font-size: 0.95rem; }
input { min-width: 160px; }
button { cursor: pointer; background: var(--accent); border: none; font-weight: 600; }
button.secondary { background: #2a3548; }
button.danger { background: var(--bad); }
.pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 0.8rem; background: #243044; }
.pill.ok { background: rgba(61,214,140,.15); color: var(--ok); }
.pill.bad { background: rgba(243,18,96,.15); color: var(--bad); }
.pill.warn { background: rgba(245,165,36,.15); color: var(--warn); }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 500; }
pre { margin: 0; white-space: pre-wrap; word-break: break-all; font-size: 0.8rem;
color: var(--muted); max-height: 220px; overflow: auto; }
.banner { background: rgba(61,139,253,.12); border: 1px solid rgba(61,139,253,.35);
border-radius: 10px; padding: 10px 12px; font-size: 0.9rem; line-height: 1.5; }
</style>
</head>
<body>
<header>
<div>
<h1>数据出口代理 <span class="muted">cellular-proxy</span></h1>
<div class="muted">走代理 = 走运营商流量 · 单进程轻量</div>
</div>
<div class="row">
<span id="connState" class="pill warn">未连接</span>
<button class="secondary" onclick="refreshAll()">刷新</button>
</div>
</header>
<main>
<section class="banner">
<strong>行为说明:</strong>系统默认仍可走 WiFi。
只要客户端使用本机 HTTP/SOCKS 代理,出口一律绑定<strong>数据网卡</strong>(流量)。
无需配置域名分流。
</section>
<section class="card">
<div class="row" style="margin-bottom:10px">
<div>
<label>API 地址</label><br/>
<input id="apiBase" placeholder="http://127.0.0.1:9090" />
</div>
<div>
<label>密钥 secret</label><br/>
<input id="secret" type="password" placeholder="PANEL_SECRET" />
</div>
<div style="align-self:end">
<button onclick="saveAuth(); refreshAll()">保存并连接</button>
</div>
</div>
<div class="muted">密钥仅存本浏览器 localStorage。</div>
</section>
<section class="card">
<div class="muted">版本</div>
<div id="verInfo"></div>
</section>
<section class="card">
<div class="row" style="justify-content:space-between;margin-bottom:8px">
<strong>出站</strong>
<span class="muted">应只有 cellular(数据网)</span>
</div>
<div id="proxies">加载中…</div>
</section>
<section class="card">
<div class="row" style="justify-content:space-between;margin-bottom:8px">
<strong>当前连接</strong>
<button class="secondary" onclick="loadConnections()">刷新</button>
</div>
<div style="overflow:auto">
<table>
<thead><tr><th>目标</th><th>出站链</th><th>上传</th><th>下载</th><th></th></tr></thead>
<tbody id="connBody"></tbody>
</table>
</div>
</section>
<section class="card">
<strong>怎么用</strong>
<pre>
1. 代理端口默认 7890HTTP + 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~96LOG_LEVEL=warn
</pre>
</section>
<section class="card">
<strong>API 调试</strong>
<pre id="raw"></pre>
</section>
</main>
<script>
const $ = (id) => document.getElementById(id);
function defaultBase() { return location.origin || "http://127.0.0.1:9090"; }
function loadAuth() {
$("apiBase").value = localStorage.getItem("cpxy_api") || defaultBase();
$("secret").value = localStorage.getItem("cpxy_secret") || "";
}
function saveAuth() {
localStorage.setItem("cpxy_api", $("apiBase").value.trim().replace(/\/$/, ""));
localStorage.setItem("cpxy_secret", $("secret").value);
}
function base() { return ($("apiBase").value || defaultBase()).replace(/\/$/, ""); }
function headers() {
const h = { "Content-Type": "application/json" };
const s = $("secret").value;
if (s) h["Authorization"] = "Bearer " + s;
return h;
}
async function api(path, opt = {}) {
const r = await fetch(base() + path, { ...opt, headers: { ...headers(), ...(opt.headers || {}) } });
if (!r.ok) throw new Error(path + " " + r.status + " " + (await r.text()));
const t = await r.text();
try { return t ? JSON.parse(t) : {}; } catch { return t; }
}
function fmt(n) {
if (n == null) return "—";
const u = ["B", "KB", "MB", "GB"];
let i = 0, x = Number(n);
while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; }
return x.toFixed(i ? 1 : 0) + " " + u[i];
}
async function refreshAll() {
try {
const ver = await api("/version");
$("verInfo").textContent = ver.version || JSON.stringify(ver);
$("connState").textContent = "已连接";
$("connState").className = "pill ok";
$("raw").textContent = JSON.stringify(ver, null, 2);
await loadProxies();
await loadConnections();
} catch (e) {
$("connState").textContent = "失败: " + e.message;
$("connState").className = "pill bad";
$("proxies").textContent = "无法连接 API。请确认服务已启动,并填写正确 secret。";
}
}
async function loadProxies() {
const data = await api("/proxies");
const proxies = data.proxies || {};
const names = Object.keys(proxies).filter(k => ["cellular", "DIRECT", "REJECT"].includes(k) || proxies[k].type === "Direct");
const keys = names.length ? names : Object.keys(proxies).slice(0, 15);
let html = "<table><thead><tr><th>名称</th><th>类型</th><th>测速</th></tr></thead><tbody>";
for (const name of keys) {
const p = proxies[name] || {};
html += `<tr><td>${name}</td><td>${p.type || ""}</td>
<td><button class="secondary" onclick="delay('${name}')">测速</button> <span id="d-${name}"></span></td></tr>`;
}
html += "</tbody></table>";
$("proxies").innerHTML = html;
}
async function delay(name) {
try {
const r = await api("/proxies/" + encodeURIComponent(name) + "/delay?timeout=5000&url=" +
encodeURIComponent("https://www.gstatic.com/generate_204"));
$("d-" + name).textContent = (r.delay != null ? r.delay + "ms" : JSON.stringify(r));
} catch (e) { $("d-" + name).textContent = e.message; }
}
async function loadConnections() {
try {
const data = await api("/connections");
const conns = data.connections || [];
const body = $("connBody");
body.innerHTML = "";
conns.slice(0, 50).forEach(c => {
const tr = document.createElement("tr");
const host = (c.metadata && (c.metadata.host || c.metadata.destinationIP)) || "—";
const port = c.metadata && c.metadata.destinationPort ? ":" + c.metadata.destinationPort : "";
tr.innerHTML = `<td>${host}${port}</td><td>${c.chains ? c.chains.join("→") : "—"}</td>
<td>${fmt(c.upload)}</td><td>${fmt(c.download)}</td>
<td><button class="danger" onclick="closeConn('${c.id}')">断开</button></td>`;
body.appendChild(tr);
});
if (!conns.length) body.innerHTML = "<tr><td colspan=5 class=muted>暂无连接</td></tr>";
} catch (e) {
$("connBody").innerHTML = "<tr><td colspan=5>" + e.message + "</td></tr>";
}
}
async function closeConn(id) {
await api("/connections/" + id, { method: "DELETE" });
loadConnections();
}
loadAuth();
refreshAll();
setInterval(() => { loadConnections().catch(() => {}); }, 5000);
</script>
</body>
</html>