feat: cellular-proxy 一键安装 — 走代理一律数据出口
- sing-box 单进程 bind 数据网卡 - mixed HTTP+SOCKS + 中文静态面板 - install.sh 支持 curl | sudo bash - 512MB 友好 MemoryMax 默认 96MB
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
config/settings.conf
|
||||
generated/
|
||||
*.tar.gz
|
||||
.DS_Store
|
||||
@@ -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 遵循上游许可证。
|
||||
@@ -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=
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
+248
@@ -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 <<EOM
|
||||
|
||||
========== 安装完成 ==========
|
||||
语义: 走代理 → 数据流量;不走代理 → 系统默认(WiFi)
|
||||
|
||||
代理: HTTP/SOCKS ${lan:-<LAN-IP>}:${port}
|
||||
面板: http://${lan:-<LAN-IP>}:${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 "$@"
|
||||
Executable
+44
@@ -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
|
||||
Executable
+56
@@ -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
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env bash
|
||||
# 生成 sing-box:代理流量全部 bind 数据网卡出口
|
||||
set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=lib.sh
|
||||
source "$ROOT_DIR/scripts/lib.sh"
|
||||
load_config
|
||||
|
||||
OUT_DIR="${1:-$ROOT_DIR/generated}"
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
cell="$(resolve_cellular 2>/dev/null || true)"
|
||||
if [[ -z "$cell" ]]; then
|
||||
resolve_cellular >/dev/null || true
|
||||
fi
|
||||
src_ip="${CELLULAR_SOURCE_IP:-}"
|
||||
if [[ -z "$src_ip" && -n "$cell" ]]; then
|
||||
src_ip="$(detect_source_ip "$cell" || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "$cell" ]]; then
|
||||
warn "CELLULAR_IFACE 为空,临时用 lo 生成配置(启动前务必修正)"
|
||||
cell_for_cfg=lo
|
||||
else
|
||||
cell_for_cfg="$cell"
|
||||
fi
|
||||
|
||||
if [[ "$PANEL_SECRET" == "please-change-me" || "$PANEL_SECRET" == "change-this-secret" ]]; then
|
||||
warn "PANEL_SECRET 仍是默认值,请修改 settings.conf"
|
||||
fi
|
||||
if [[ "$PROXY_LISTEN_HOST" != "127.0.0.1" && "$PROXY_LISTEN_HOST" != "::1" && -z "$PROXY_USER" ]]; then
|
||||
warn "代理监听 $PROXY_LISTEN_HOST 且未设置 PROXY_USER/PASS(局域网建议加鉴权)"
|
||||
fi
|
||||
|
||||
export GEN_OUT="$OUT_DIR/config.json"
|
||||
export GEN_CELL="$cell_for_cfg"
|
||||
export GEN_SRC="$src_ip"
|
||||
export GEN_PROXY_HOST="$PROXY_LISTEN_HOST"
|
||||
export GEN_PROXY_PORT="$PROXY_MIXED_PORT"
|
||||
export GEN_PROXY_USER="$PROXY_USER"
|
||||
export GEN_PROXY_PASS="$PROXY_PASS"
|
||||
export GEN_PANEL_HOST="$PANEL_LISTEN_HOST"
|
||||
export GEN_PANEL_PORT="$PANEL_PORT"
|
||||
export GEN_PANEL_SECRET="$PANEL_SECRET"
|
||||
export GEN_DNS="$ENABLE_DNS"
|
||||
export GEN_LOG="$LOG_LEVEL"
|
||||
export GEN_UI_DIR="$INSTALL_DIR/ui"
|
||||
|
||||
python3 <<'PY'
|
||||
import json, os
|
||||
from pathlib import Path
|
||||
|
||||
cell = os.environ["GEN_CELL"]
|
||||
src = os.environ.get("GEN_SRC") or ""
|
||||
log_level = os.environ.get("GEN_LOG") or "warn"
|
||||
ui_dir = os.environ.get("GEN_UI_DIR") or "/opt/cellular-proxy/ui"
|
||||
|
||||
users = []
|
||||
u = os.environ.get("GEN_PROXY_USER") or ""
|
||||
p = os.environ.get("GEN_PROXY_PASS") or ""
|
||||
if u:
|
||||
users.append({"username": u, "password": p})
|
||||
|
||||
inbound = {
|
||||
"type": "mixed",
|
||||
"tag": "mixed-in",
|
||||
"listen": os.environ["GEN_PROXY_HOST"],
|
||||
"listen_port": int(os.environ["GEN_PROXY_PORT"]),
|
||||
}
|
||||
if users:
|
||||
inbound["users"] = users
|
||||
|
||||
# 唯一业务出口:强制绑定数据网卡
|
||||
out_cell = {
|
||||
"type": "direct",
|
||||
"tag": "cellular",
|
||||
"bind_interface": cell,
|
||||
}
|
||||
if src:
|
||||
out_cell["inet4_bind_address"] = src
|
||||
|
||||
cfg = {
|
||||
"log": {"level": log_level, "timestamp": True},
|
||||
"inbounds": [inbound],
|
||||
"outbounds": [
|
||||
out_cell,
|
||||
{"type": "block", "tag": "block"},
|
||||
],
|
||||
# 所有代理流量最终都走 cellular(数据)
|
||||
"route": {
|
||||
"rules": [],
|
||||
"final": "cellular",
|
||||
"auto_detect_interface": False,
|
||||
},
|
||||
"experimental": {
|
||||
"clash_api": {
|
||||
"external_controller": f"{os.environ['GEN_PANEL_HOST']}:{os.environ['GEN_PANEL_PORT']}",
|
||||
"secret": os.environ["GEN_PANEL_SECRET"],
|
||||
"default_mode": "rule",
|
||||
"external_ui": ui_dir,
|
||||
},
|
||||
"cache_file": {
|
||||
"enabled": True,
|
||||
"path": "/var/lib/cellular-proxy/cache.db",
|
||||
"store_fakeip": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if (os.environ.get("GEN_DNS") or "true").lower() == "true":
|
||||
# DNS 也走数据出口,避免解析从 WiFi 出去
|
||||
cfg["dns"] = {
|
||||
"servers": [
|
||||
{
|
||||
"tag": "remote",
|
||||
"address": "1.1.1.1",
|
||||
"detour": "cellular",
|
||||
},
|
||||
],
|
||||
"final": "remote",
|
||||
"strategy": "ipv4_only",
|
||||
}
|
||||
|
||||
Path(os.environ["GEN_OUT"]).write_text(
|
||||
json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
||||
)
|
||||
print("wrote", os.environ["GEN_OUT"])
|
||||
PY
|
||||
|
||||
cat > "$OUT_DIR/runtime.env" <<EOF
|
||||
INSTALL_DIR=${INSTALL_DIR}
|
||||
LOG_DIR=${LOG_DIR}
|
||||
CELLULAR_IFACE=${cell}
|
||||
CELLULAR_SOURCE_IP=${src_ip}
|
||||
PROXY_LISTEN_HOST=${PROXY_LISTEN_HOST}
|
||||
PROXY_MIXED_PORT=${PROXY_MIXED_PORT}
|
||||
PANEL_LISTEN_HOST=${PANEL_LISTEN_HOST}
|
||||
PANEL_PORT=${PANEL_PORT}
|
||||
EGRESS_CHECK_URL=${EGRESS_CHECK_URL}
|
||||
EXPECTED_CELLULAR_PUBLIC_IP=${EXPECTED_CELLULAR_PUBLIC_IP}
|
||||
MEMORY_MAX_MB=${MEMORY_MAX_MB}
|
||||
REQUIRE_CELLULAR_IFACE=${REQUIRE_CELLULAR_IFACE}
|
||||
EOF
|
||||
|
||||
cat > "$OUT_DIR/SUMMARY.txt" <<EOF
|
||||
generated_at=$(date -Iseconds)
|
||||
语义=走代理的连接一律从数据网卡出口
|
||||
cellular_iface=${cell}
|
||||
cellular_source_ip=${src_ip}
|
||||
proxy=${PROXY_LISTEN_HOST}:${PROXY_MIXED_PORT} (HTTP+SOCKS mixed)
|
||||
panel=http://<LAN-IP>:${PANEL_PORT}/ secret=见 settings PANEL_SECRET
|
||||
memory_max_mb=${MEMORY_MAX_MB}
|
||||
EOF
|
||||
|
||||
info "生成完成: $OUT_DIR"
|
||||
cat "$OUT_DIR/SUMMARY.txt"
|
||||
Executable
+96
@@ -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 <<H
|
||||
cpxy — cellular-proxy
|
||||
语义: 走代理的连接一律从数据网卡出口
|
||||
cpxy detect | generate | start | stop | restart | status
|
||||
cpxy verify | logs [N]
|
||||
H
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
ln -sfn "$INSTALL_DIR/bin/cpxy" /usr/local/bin/cpxy
|
||||
|
||||
info "4/5 安装 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/5 启动"
|
||||
systemctl enable cellular-proxy.service
|
||||
systemctl restart cellular-proxy.service
|
||||
sleep 1
|
||||
systemctl --no-pager --full status cellular-proxy.service || true
|
||||
|
||||
echo
|
||||
info "安装完成"
|
||||
echo " 语义: 不走代理 → 系统默认(WiFi);走代理 → 数据流量"
|
||||
echo " 代理: HTTP/SOCKS ${PROXY_LISTEN_HOST}:${PROXY_MIXED_PORT}"
|
||||
echo " 面板: http://<LAN-IP>:${PANEL_PORT}/ 密钥: PANEL_SECRET"
|
||||
echo " 验证: cpxy verify"
|
||||
Executable
+159
@@ -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"
|
||||
}
|
||||
Executable
+64
@@ -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 <LAN-IP>:${PORT} $URL"
|
||||
echo "面板: http://<LAN-IP>:${PANEL_PORT}/"
|
||||
@@ -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
|
||||
+215
@@ -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. 代理端口默认 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
|
||||
</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>
|
||||
Reference in New Issue
Block a user