feat: 一条 curl 自动装/升(未装安装、已装升级)

This commit is contained in:
Hermes
2026-07-21 14:23:31 +00:00
commit b2a7332f86
20 changed files with 3459 additions and 0 deletions
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""轻量管理 API:用面板 secret 鉴权,配置代理账号密码。
默认监听 127.0.0.1:9091UI 经同源时用相对路径 /cpxy-admin/*
(由 nginx 反代或浏览器直连时配置)。
为免引入 nginx,默认让 UI 调 http://host:9091(与 9090 同机)。
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
INSTALL_DIR = Path(os.environ.get("INSTALL_DIR", "/opt/cellular-proxy"))
SETTINGS = Path(os.environ.get("CPXY_SETTINGS", str(INSTALL_DIR / "etc" / "settings.conf")))
LISTEN_HOST = os.environ.get("ADMIN_LISTEN_HOST", "0.0.0.0")
LISTEN_PORT = int(os.environ.get("ADMIN_LISTEN_PORT", "9091"))
APPLY_SCRIPT = Path(os.environ.get("CPXY_APPLY_AUTH", str(INSTALL_DIR / "scripts" / "apply-proxy-auth.sh")))
def load_settings(path: Path) -> dict:
data = {}
if not path.is_file():
return data
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
data[k.strip()] = v.strip()
return data
def mask(s: str) -> str:
if not s:
return ""
if len(s) <= 2:
return "***"
return s[0] + "***" + s[-1]
class Handler(BaseHTTPRequestHandler):
server_version = "cellular-proxy-admin/1.0"
def log_message(self, fmt, *args):
sys.stderr.write("[admin-api] " + (fmt % args) + "\n")
def _cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
def _json(self, code: int, obj):
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self._cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _auth_ok(self) -> bool:
conf = load_settings(SETTINGS)
secret = conf.get("PANEL_SECRET", "")
if not secret:
return False
auth = self.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[7:].strip() == secret
if auth.startswith("Bearer"):
return auth[6:].strip() == secret
# 兼容 query ?secret=
return False
def do_OPTIONS(self):
self.send_response(204)
self._cors()
self.end_headers()
def do_GET(self):
path = urlparse(self.path).path.rstrip("/") or "/"
if path in ("/health", "/cpxy-admin/health"):
self._json(200, {"ok": True})
return
if path not in ("/proxy-auth", "/cpxy-admin/proxy-auth"):
self._json(404, {"error": "not found"})
return
if not self._auth_ok():
self._json(401, {"message": "Unauthorized"})
return
conf = load_settings(SETTINGS)
user = conf.get("PROXY_USER", "")
pw = conf.get("PROXY_PASS", "")
self._json(
200,
{
"proxy_user": user,
"proxy_pass_set": bool(pw),
"proxy_pass_masked": mask(pw) if pw else "",
"auth_enabled": bool(user),
"port": conf.get("PROXY_MIXED_PORT", "7890"),
},
)
def do_POST(self):
path = urlparse(self.path).path.rstrip("/") or "/"
if path not in ("/proxy-auth", "/cpxy-admin/proxy-auth"):
self._json(404, {"error": "not found"})
return
if not self._auth_ok():
self._json(401, {"message": "Unauthorized"})
return
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
body = json.loads(raw.decode("utf-8") or "{}")
except json.JSONDecodeError:
self._json(400, {"error": "invalid json"})
return
clear = bool(body.get("clear"))
user = (body.get("user") or body.get("proxy_user") or "").strip()
password = body.get("pass") or body.get("password") or body.get("proxy_pass") or ""
if not APPLY_SCRIPT.is_file():
self._json(500, {"error": f"apply script missing: {APPLY_SCRIPT}"})
return
cmd = ["bash", str(APPLY_SCRIPT)]
if clear or (not user and not password):
cmd.append("--clear")
else:
if not user:
self._json(400, {"error": "user required (or set clear:true)"})
return
if password is None:
self._json(400, {"error": "pass required"})
return
# 基本字符约束,避免注入
if not re.fullmatch(r"[A-Za-z0-9_@./+\-]{1,64}", user):
self._json(400, {"error": "user 仅允许字母数字及 _@.+/-"})
return
if len(str(password)) > 128:
self._json(400, {"error": "password too long"})
return
cmd.extend(["--user", user, "--pass", str(password)])
try:
r = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60,
env={**os.environ, "INSTALL_DIR": str(INSTALL_DIR)},
)
except subprocess.TimeoutExpired:
self._json(504, {"error": "apply timeout"})
return
out = (r.stdout or "") + (r.stderr or "")
if r.returncode != 0:
self._json(500, {"error": "apply failed", "detail": out[-2000:]})
return
conf = load_settings(SETTINGS)
self._json(
200,
{
"ok": True,
"proxy_user": conf.get("PROXY_USER", ""),
"proxy_pass_set": bool(conf.get("PROXY_PASS")),
"auth_enabled": bool(conf.get("PROXY_USER")),
"log": out[-1500:],
},
)
def main():
if not SETTINGS.is_file():
print(f"warn: settings not found yet: {SETTINGS}", file=sys.stderr)
httpd = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler)
print(f"cellular-proxy admin API on {LISTEN_HOST}:{LISTEN_PORT}", flush=True)
httpd.serve_forever()
if __name__ == "__main__":
main()
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# 设置/清除代理账号密码,写 settings → 重生 config → 重启服务
# 用法:
# apply-proxy-auth.sh --user U --pass P
# apply-proxy-auth.sh --clear
# apply-proxy-auth.sh --show
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=lib.sh
source "$ROOT_DIR/scripts/lib.sh"
ACTION=set
USER_V=""
PASS_V=""
usage() {
cat <<'H'
apply-proxy-auth.sh — 配置 7890 代理鉴权
--user NAME 代理用户名
--pass PASS 代理密码
--clear 清除鉴权(无密码)
--show 仅显示当前状态(密码脱敏)
-h|--help
H
}
while [[ $# -gt 0 ]]; do
case "$1" in
--user) USER_V="${2:-}"; shift 2 ;;
--pass) PASS_V="${2:-}"; shift 2 ;;
--clear) ACTION=clear; shift ;;
--show) ACTION=show; shift ;;
-h|--help) usage; exit 0 ;;
*) die "未知参数: $1" ;;
esac
done
# 优先已安装配置
if [[ -f "${INSTALL_DIR:-/opt/cellular-proxy}/etc/settings.conf" ]]; then
CONFIG_FILE="${INSTALL_DIR:-/opt/cellular-proxy}/etc/settings.conf"
SETTINGS_DIR="$(dirname "$CONFIG_FILE")"
elif [[ -f "$ROOT_DIR/config/settings.conf" ]]; then
CONFIG_FILE="$ROOT_DIR/config/settings.conf"
SETTINGS_DIR="$(dirname "$CONFIG_FILE")"
else
die "找不到 settings.conf(请先安装)"
fi
set_kv() {
local file="$1" key="$2" val="$3"
local tmp
tmp="$(mktemp)"
awk -v k="$key" -v v="$val" '
BEGIN { done=0 }
index($0, k "=")==1 { print k "=" v; done=1; next }
{ print }
END { if (!done) print k "=" v }
' "$file" > "$tmp"
mv "$tmp" "$file"
}
load_config
INSTALL_DIR="${INSTALL_DIR:-/opt/cellular-proxy}"
mask() {
local s="$1" n=${#1}
if [[ $n -le 2 ]]; then echo "***"; else echo "${s:0:1}***${s: -1}"; fi
}
if [[ "$ACTION" == "show" ]]; then
echo "settings: $CONFIG_FILE"
echo "PROXY_USER=${PROXY_USER:-<empty>}"
if [[ -n "${PROXY_PASS:-}" ]]; then
echo "PROXY_PASS=$(mask "$PROXY_PASS") (已设置)"
else
echo "PROXY_PASS=<empty> (无鉴权)"
fi
exit 0
fi
need_root
if [[ "$ACTION" == "clear" ]]; then
set_kv "$CONFIG_FILE" PROXY_USER ""
set_kv "$CONFIG_FILE" PROXY_PASS ""
info "已清除代理账号密码"
else
[[ -n "$USER_V" ]] || die "请指定 --user(或用 --clear"
[[ -n "$PASS_V" ]] || die "请指定 --pass"
set_kv "$CONFIG_FILE" PROXY_USER "$USER_V"
set_kv "$CONFIG_FILE" PROXY_PASS "$PASS_V"
info "已写入 PROXY_USER=$USER_V"
fi
# 同步到安装树副本
if [[ -f "$INSTALL_DIR/etc/settings.conf" && "$CONFIG_FILE" != "$INSTALL_DIR/etc/settings.conf" ]]; then
install -m 0644 "$CONFIG_FILE" "$INSTALL_DIR/etc/settings.conf"
fi
# 若改的是安装目录,也尽量同步源树
if [[ -d "$ROOT_DIR/config" && "$CONFIG_FILE" == "$INSTALL_DIR/etc/settings.conf" ]]; then
install -m 0644 "$CONFIG_FILE" "$ROOT_DIR/config/settings.conf" 2>/dev/null || true
fi
export CONFIG_FILE
# generate 需要能 load 到最新 settings
if [[ -x "$INSTALL_DIR/scripts/generate.sh" ]]; then
GEN="$INSTALL_DIR/scripts/generate.sh"
OUT="$INSTALL_DIR/generated"
elif [[ -x "$ROOT_DIR/scripts/generate.sh" ]]; then
GEN="$ROOT_DIR/scripts/generate.sh"
OUT="${INSTALL_DIR}/generated"
mkdir -p "$OUT"
else
die "找不到 generate.sh"
fi
info "重新生成 sing-box 配置…"
"$GEN" "$OUT"
install -m 0644 "$OUT/config.json" "$INSTALL_DIR/etc/config.json"
install -m 0644 "$OUT/runtime.env" "$INSTALL_DIR/etc/runtime.env" 2>/dev/null || true
install -m 0644 "$CONFIG_FILE" "$INSTALL_DIR/etc/settings.conf"
if systemctl is-enabled cellular-proxy.service >/dev/null 2>&1 || systemctl list-unit-files | grep -q cellular-proxy; then
systemctl restart cellular-proxy.service
sleep 1
if systemctl is-active --quiet cellular-proxy.service; then
info "服务已重启 active"
else
warn "服务未 active,请检查: systemctl status cellular-proxy"
fi
else
warn "未找到 systemd 服务,配置已写好,请手动重启 sing-box"
fi
if [[ "$ACTION" == "clear" ]]; then
echo "当前: 无代理鉴权"
else
echo "当前: 用户 $USER_V / 密码已设置"
echo "HTTP: curl -x http://${USER_V}:****@127.0.0.1:${PROXY_MIXED_PORT:-7890} https://ifconfig.me"
echo "SOCKS: curl --socks5-hostname ${USER_V}:****@127.0.0.1:${PROXY_MIXED_PORT:-7890} https://ifconfig.me"
fi
+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
+256
View File
@@ -0,0 +1,256 @@
#!/usr/bin/env bash
# 仅下载/安装 sing-box(轻量核心)
# 优先级:local → 系统已有 → Gitea Release(整包/分片)→ GitHub 代理 → 直连 GitHub
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=lib.sh
source "$ROOT_DIR/scripts/lib.sh"
load_config
need_root
ensure_dirs
mkdir -p /var/lib/cellular-proxy
ARCH="$(arch_go)"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
GITHUB_PROXY="${GITHUB_PROXY:-https://git.86482425.xyz}"
GITEA_BASE="${GITEA_BASE:-https://gitea.chickliu.fun}"
GITEA_OWNER="${GITEA_OWNER:-Hermes}"
GITEA_REPO="${GITEA_REPO:-cellular-proxy}"
SING_BOX_RELEASE_TAG="${SING_BOX_RELEASE_TAG:-bin-v${SING_BOX_VERSION}}"
# gitea | github | auto | local | release
SING_BOX_SOURCE="${SING_BOX_SOURCE:-auto}"
github_url() {
local raw="$1"
local proxy="${GITHUB_PROXY:-}"
if [[ -z "$proxy" ]]; then
echo "$raw"
return
fi
proxy="${proxy%/}"
case "$raw" in
"${proxy}"/*) echo "$raw"; return ;;
esac
echo "${proxy}/${raw}"
}
download() {
local url="$1" dest="$2"
info "下载: $url"
if command -v curl >/dev/null 2>&1; then
curl -fL --retry 3 --connect-timeout 20 --max-time 300 -o "$dest" "$url"
elif command -v wget >/dev/null 2>&1; then
wget -O "$dest" "$url"
else
die "需要 curl 或 wget"
fi
}
# 静默探测 URL 是否可下(不写盘)
url_ok() {
local url="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsSIL --connect-timeout 8 --max-time 20 -o /dev/null "$url" 2>/dev/null
else
return 1
fi
}
download_github() {
local raw="$1" dest="$2"
local proxied
proxied="$(github_url "$raw")"
if [[ "$proxied" != "$raw" ]]; then
info "经 GitHub 代理下载: $proxied"
if download "$proxied" "$dest"; then
return 0
fi
warn "代理下载失败,尝试直连 GitHub: $raw"
fi
download "$raw" "$dest"
}
install_from_tarball() {
local tgz="$1" ver="$2" arch="$3"
local name="sing-box-${ver}-linux-${arch}"
tar -xzf "$tgz" -C "$TMP"
if [[ -x "$TMP/${name}/sing-box" ]]; then
install -m 0755 "$TMP/${name}/sing-box" "$INSTALL_DIR/bin/sing-box"
elif [[ -x "$TMP/sing-box" ]]; then
install -m 0755 "$TMP/sing-box" "$INSTALL_DIR/bin/sing-box"
else
local found
found="$(find "$TMP" -type f -name sing-box 2>/dev/null | head -1)"
[[ -n "$found" && -x "$found" ]] || die "压缩包内未找到 sing-box 可执行文件"
install -m 0755 "$found" "$INSTALL_DIR/bin/sing-box"
fi
}
gitea_asset_url() {
local fname="$1"
echo "${GITEA_BASE%/}/${GITEA_OWNER}/${GITEA_REPO}/releases/download/${SING_BOX_RELEASE_TAG}/${fname}"
}
# 从 Gitea 拉整包 tar.gz
try_download_gitea_whole() {
local ver="$1" arch="$2" dest="$3"
local name url
name="sing-box-${ver}-linux-${arch}.tar.gz"
url="$(gitea_asset_url "$name")"
info "尝试 Gitea 预置整包: $url"
if download "$url" "$dest"; then
return 0
fi
return 1
}
# 代理 504 时可能只提供分片:name.tar.gz.part-00.. + name.tar.gz.SHA256SUMS
try_download_gitea_parts() {
local ver="$1" arch="$2" dest="$3"
local base name sums_url part_url part_dir i max
name="sing-box-${ver}-linux-${arch}.tar.gz"
base="${GITEA_BASE%/}/${GITEA_OWNER}/${GITEA_REPO}/releases/download/${SING_BOX_RELEASE_TAG}"
sums_url="${base}/${name}.SHA256SUMS"
# 兼容旧名 SHA256SUMS(仅 armv7 曾用)
part_dir="$TMP/parts_${arch}"
mkdir -p "$part_dir"
if download "$sums_url" "$part_dir/SHA256SUMS" 2>/dev/null; then
info "发现分片清单: $sums_url"
elif [[ "$arch" == "armv7" ]] && download "$(gitea_asset_url SHA256SUMS)" "$part_dir/SHA256SUMS" 2>/dev/null; then
info "使用兼容清单 SHA256SUMS"
else
return 1
fi
# 从清单提取 part 文件名(去掉路径前缀)
mapfile -t parts < <(awk '{print $2}' "$part_dir/SHA256SUMS" | sed 's|^\./||' | grep -E '\.part-[0-9]+$' || true)
if [[ ${#parts[@]} -eq 0 ]]; then
# 无清单文件名时按 part-00..99 探测
parts=()
for i in $(seq -w 0 99); do
part_url="${base}/${name}.part-${i}"
if url_ok "$part_url"; then
parts+=("${name}.part-${i}")
else
[[ ${#parts[@]} -gt 0 ]] && break
fi
done
fi
[[ ${#parts[@]} -gt 0 ]] || return 1
info "Gitea 分片下载 ${#parts[@]} 片 → 合并 $name"
: >"$dest"
for p in "${parts[@]}"; do
p="${p#./}"
# 若清单里只有 basename part-xx,补全前缀
case "$p" in
*.part-*) ;;
*) p="${name}.part-${p##*-}" ;;
esac
# 清单可能是 relative part name only like sing-box-...part-00
if [[ "$p" != *"/"* && "$p" != ${name}.part-* && "$p" == *.part-* ]]; then
:
fi
part_url="${base}/${p}"
if ! download "$part_url" "$part_dir/$p"; then
# 尝试无前缀名
local bn
bn="$(basename "$p")"
part_url="${base}/${bn}"
download "$part_url" "$part_dir/$bn" || return 1
p="$bn"
fi
cat "$part_dir/$p" >>"$dest"
done
# 可选校验
if command -v sha256sum >/dev/null 2>&1; then
(
cd "$part_dir"
# 规范化清单路径
sed 's| \./| |; s|^\([a-f0-9]*\) \./|\1 |' SHA256SUMS > SHA256SUMS.norm || cp SHA256SUMS SHA256SUMS.norm
if sha256sum -c SHA256SUMS.norm --status 2>/dev/null; then
info "分片 SHA256 校验通过"
else
warn "分片 SHA256 校验跳过/未严格通过(已合并,继续)"
fi
) || true
fi
# 快速完整性:tar 列表
tar -tzf "$dest" >/dev/null
return 0
}
try_download_gitea() {
local ver="$1" arch="$2" dest="$3"
if try_download_gitea_whole "$ver" "$arch" "$dest"; then
return 0
fi
warn "Gitea 整包不可用,尝试分片合并(大文件/代理 504 兜底)"
if try_download_gitea_parts "$ver" "$arch" "$dest"; then
return 0
fi
return 1
}
try_download_github_release() {
local ver="$1" arch="$2" dest="$3"
local name raw_url
name="sing-box-${ver}-linux-${arch}"
raw_url="https://github.com/SagerNet/sing-box/releases/download/v${ver}/${name}.tar.gz"
download_github "$raw_url" "$dest"
}
if [[ "$SING_BOX_SOURCE" == "local" ]]; then
[[ -n "$SING_BOX_BIN" && -x "$SING_BOX_BIN" ]] || die "SING_BOX_SOURCE=local 但 SING_BOX_BIN 无效"
install -m 0755 "$SING_BOX_BIN" "$INSTALL_DIR/bin/sing-box"
info "已安装本地 sing-box"
elif command -v sing-box >/dev/null 2>&1 && [[ -z "${FORCE_DOWNLOAD:-}" && "$SING_BOX_SOURCE" != "gitea" && "$SING_BOX_SOURCE" != "github" ]]; then
install -m 0755 "$(command -v sing-box)" "$INSTALL_DIR/bin/sing-box"
info "复用系统 sing-box: $(command -v sing-box)"
else
ver="$SING_BOX_VERSION"
tgz="$TMP/sb.tgz"
ok=0
case "$SING_BOX_SOURCE" in
gitea)
try_download_gitea "$ver" "$ARCH" "$tgz" || die "Gitea 预置包下载失败(tag=$SING_BOX_RELEASE_TAG arch=$ARCH"
ok=1
;;
github|release)
try_download_github_release "$ver" "$ARCH" "$tgz"
ok=1
;;
auto|*)
if try_download_gitea "$ver" "$ARCH" "$tgz"; then
ok=1
info "已从 Gitea Release 获取 sing-box"
else
warn "Gitea 预置包不可用,回退 GitHub(经 ${GITHUB_PROXY:-直连}"
try_download_github_release "$ver" "$ARCH" "$tgz"
ok=1
fi
;;
esac
[[ "$ok" -eq 1 ]] || die "无法下载 sing-box"
install_from_tarball "$tgz" "$ver" "$ARCH"
info "已安装 sing-box v${ver} (${ARCH})"
"$INSTALL_DIR/bin/sing-box" version 2>/dev/null | head -3 || true
fi
# 安装中文静态 UI
mkdir -p "$INSTALL_DIR/ui"
if [[ -f "$ROOT_DIR/ui/index.html" ]]; then
cp -a "$ROOT_DIR/ui/." "$INSTALL_DIR/ui/"
info "已安装内置中文面板 -> $INSTALL_DIR/ui"
else
warn "未找到 ui/index.html"
fi
info "二进制就绪:"
ls -la "$INSTALL_DIR/bin"
du -sh "$INSTALL_DIR/bin" "$INSTALL_DIR/ui" 2>/dev/null || true
+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"
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=lib.sh
source "$ROOT_DIR/scripts/lib.sh"
AUTO_DETECT="${AUTO_DETECT:-true}"
AUTO_VERIFY="${AUTO_VERIFY:-true}"
SKIP_START="${SKIP_START:-false}"
if [[ ! -f "$ROOT_DIR/config/settings.conf" ]]; then
cp "$ROOT_DIR/config/settings.conf.example" "$ROOT_DIR/config/settings.conf"
warn "已创建 config/settings.conf(将自动探测数据网卡并生成密钥)"
fi
load_config
need_root
info "1/6 自动探测并绑定数据网卡"
"$ROOT_DIR/scripts/detect.sh" || true
cell=""
if [[ -n "${CELLULAR_IFACE:-}" ]] && iface_exists "$CELLULAR_IFACE"; then
cell="$CELLULAR_IFACE"
info "使用配置的数据网卡: $cell"
elif [[ "$AUTO_DETECT" == "true" ]]; then
cell="$(detect_cellular_iface "" || true)"
fi
if [[ -z "$cell" ]]; then
die "未能自动探测数据网卡。
请插入/拨通数据模块后重试,或手动:
1) ./scripts/detect.sh
2) 编辑 config/settings.conf 设置 CELLULAR_IFACE=网卡名
3) sudo ./scripts/install.sh"
fi
src_ip="$(detect_source_ip "$cell" || true)"
persist_cellular_to_settings "$ROOT_DIR/config/settings.conf" "$cell" "$src_ip"
# 重新加载
CELLULAR_IFACE="$cell"
CELLULAR_SOURCE_IP="$src_ip"
info "已绑定 CELLULAR_IFACE=$cell source_ip=${src_ip:-auto}"
# 随机面板密钥(若仍是默认)
if [[ "$PANEL_SECRET" == "please-change-me" || "$PANEL_SECRET" == "change-this-secret" || -z "$PANEL_SECRET" ]]; then
gen="$(openssl rand -hex 12 2>/dev/null || head -c 16 /dev/urandom | xxd -p | tr -d '\n')"
tmp="$(mktemp)"
awk -v k="PANEL_SECRET" -v v="$gen" '
BEGIN { done=0 }
index($0, k "=")==1 { print k "=" v; done=1; next }
{ print }
END { if (!done) print k "=" v }
' "$ROOT_DIR/config/settings.conf" > "$tmp"
mv "$tmp" "$ROOT_DIR/config/settings.conf"
PANEL_SECRET="$gen"
warn "已自动生成 PANEL_SECRET(见 settings.conf"
fi
# 再 load 一次保证变量一致
load_config
CELLULAR_IFACE="$cell"
CELLULAR_SOURCE_IP="${src_ip:-$CELLULAR_SOURCE_IP}"
info "2/6 下载/安装 sing-box + UI"
"$ROOT_DIR/scripts/fetch-binaries.sh"
info "3/6 生成配置(代理出口 bind = $cell"
"$ROOT_DIR/scripts/generate.sh" "$ROOT_DIR/generated"
ensure_dirs
mkdir -p /var/lib/cellular-proxy
install -m 0644 "$ROOT_DIR/generated/config.json" "$INSTALL_DIR/etc/config.json"
install -m 0644 "$ROOT_DIR/generated/runtime.env" "$INSTALL_DIR/etc/runtime.env"
install -m 0644 "$ROOT_DIR/config/settings.conf" "$INSTALL_DIR/etc/settings.conf"
cp -a "$ROOT_DIR/scripts" "$INSTALL_DIR/"
cp -a "$ROOT_DIR/ui" "$INSTALL_DIR/" 2>/dev/null || true
if ! "$INSTALL_DIR/bin/sing-box" check -c "$INSTALL_DIR/etc/config.json"; then
die "sing-box 配置校验失败"
fi
# 确认 bind_interface 写进配置
if ! grep -q "\"bind_interface\": \"$cell\"" "$INSTALL_DIR/etc/config.json" \
&& ! grep -q "\"bind_interface\": \"$cell\"" "$INSTALL_DIR/etc/config.json" 2>/dev/null; then
# JSON 可能无空格差异
if ! python3 - "$INSTALL_DIR/etc/config.json" "$cell" <<'PY'
import json,sys
c=json.load(open(sys.argv[1]))
cell=sys.argv[2]
ok=any(o.get("tag")=="cellular" and o.get("bind_interface")==cell for o in c.get("outbounds",[]))
sys.exit(0 if ok else 1)
PY
then
die "配置未正确绑定数据网卡 $cell"
fi
fi
info "配置已确认 bind_interface=$cell"
install -m 0755 /dev/stdin "$INSTALL_DIR/bin/cpxy" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
BASE="$(cd "$(dirname "$0")/.." && pwd)"
export CONFIG_FILE="${CONFIG_FILE:-$BASE/etc/settings.conf}"
cmd="${1:-help}"
shift || true
case "$cmd" in
detect) exec "$BASE/scripts/detect.sh" "$@" ;;
generate)
# 重新探测:若 settings 里网卡丢失则自动补
# shellcheck source=/dev/null
source "$BASE/scripts/lib.sh"
load_config
if [[ -z "${CELLULAR_IFACE:-}" ]] || ! iface_exists "${CELLULAR_IFACE:-}"; then
cell="$(detect_cellular_iface "" || true)"
if [[ -n "$cell" ]]; then
src="$(detect_source_ip "$cell" || true)"
persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src"
CELLULAR_IFACE="$cell"
fi
fi
"$BASE/scripts/generate.sh" "$BASE/generated"
install -m 0644 "$BASE/generated/config.json" "$BASE/etc/config.json"
install -m 0644 "$BASE/generated/runtime.env" "$BASE/etc/runtime.env"
install -m 0644 "$CONFIG_FILE" "$BASE/etc/settings.conf" 2>/dev/null || true
"$BASE/bin/sing-box" check -c "$BASE/etc/config.json"
systemctl restart cellular-proxy 2>/dev/null || true
;;
rebind)
# shellcheck source=/dev/null
source "$BASE/scripts/lib.sh"
load_config
cell="$(detect_cellular_iface "" || true)"
[[ -n "$cell" ]] || die "未能探测数据网卡"
src="$(detect_source_ip "$cell" || true)"
persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src"
info "已重新绑定 $cell"
exec "$0" generate
;;
start) systemctl start cellular-proxy ;;
stop) systemctl stop cellular-proxy ;;
restart) systemctl restart cellular-proxy ;;
status) systemctl status cellular-proxy --no-pager || true ;;
verify) exec "$BASE/scripts/verify.sh" "$@" ;;
auth)
exec "$BASE/scripts/apply-proxy-auth.sh" "$@"
;;
upgrade)
# 一条命令在线增量升级(保留密钥/网卡)
if [[ -x "$BASE/scripts/online-upgrade.sh" ]]; then
exec "$BASE/scripts/online-upgrade.sh" "$@"
fi
# 兜底:直接 curl 入口
url="${CELLULAR_PROXY_INSTALL_URL:-https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh}"
exec bash -c 'curl -fsSL "$1" | bash -s -- --upgrade --install-dir "$2" "${@:3}"' _ "$url" "$BASE" "$@"
;;
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]
cpxy auth --user U --pass P # 设置 7890 代理账号密码
cpxy auth --clear | --show
cpxy upgrade # 一条命令在线增量升级
cpxy upgrade --ui-only # 只更新面板
cpxy upgrade --force-binary # 强制重下 sing-box
cpxy upgrade --verify # 升级后验证出口
H
;;
esac
EOF
chmod +x "$INSTALL_DIR/scripts/"*.sh "$INSTALL_DIR/scripts/"*.py 2>/dev/null || true
ln -sfn "$INSTALL_DIR/bin/cpxy" /usr/local/bin/cpxy
info "4/6 安装 systemd"
MEM="${MEMORY_MAX_MB:-96}"
install -m 0644 "$ROOT_DIR/systemd/cellular-proxy.service" /etc/systemd/system/cellular-proxy.service
sed -i "s|@INSTALL_DIR@|$INSTALL_DIR|g" /etc/systemd/system/cellular-proxy.service
sed -i "s|@LOG_DIR@|$LOG_DIR|g" /etc/systemd/system/cellular-proxy.service
if [[ "$MEM" != "0" && -n "$MEM" ]]; then
sed -i "s|@MEMORY_MAX@|${MEM}M|g" /etc/systemd/system/cellular-proxy.service
else
sed -i '/MemoryMax=@MEMORY_MAX@/d' /etc/systemd/system/cellular-proxy.service
fi
# 管理 APIUI 配置代理账密)
if [[ -f "$ROOT_DIR/systemd/cellular-proxy-admin.service" ]]; then
install -m 0644 "$ROOT_DIR/systemd/cellular-proxy-admin.service" /etc/systemd/system/cellular-proxy-admin.service
sed -i "s|@INSTALL_DIR@|$INSTALL_DIR|g" /etc/systemd/system/cellular-proxy-admin.service
fi
systemctl daemon-reload
info "5/6 启动服务"
export SYSTEMD_PAGER=cat
export SYSTEMD_COLORS=0
if [[ "$SKIP_START" == "true" ]]; then
systemctl enable cellular-proxy.service
systemctl enable cellular-proxy-admin.service 2>/dev/null || true
systemctl stop cellular-proxy.service 2>/dev/null || true
systemctl stop cellular-proxy-admin.service 2>/dev/null || true
info "已按 SKIP_START 跳过启动"
else
systemctl enable cellular-proxy.service
systemctl enable cellular-proxy-admin.service 2>/dev/null || true
systemctl restart cellular-proxy.service
systemctl restart cellular-proxy-admin.service 2>/dev/null || true
# 等待 active,最多约 8 秒(避免 status 卡住)
ok=0
for _ in 1 2 3 4 5 6 7 8; do
if systemctl is-active --quiet cellular-proxy.service; then
ok=1
break
fi
sleep 1
done
active_state="$(systemctl is-active cellular-proxy.service 2>/dev/null || echo unknown)"
info "服务状态: $active_state"
if [[ "$ok" -ne 1 ]]; then
err "服务未处于 active"
systemctl show cellular-proxy.service -p ActiveState -p SubState -p Result -p ExecMainStatus --no-pager 2>/dev/null || true
journalctl -u cellular-proxy -n 40 --no-pager 2>/dev/null || true
die "启动失败,请检查数据网卡 $cell 是否 up 且有 IP"
fi
fi
info "6/6 自动验证出口(短超时,失败不阻塞安装结束)"
if [[ "$SKIP_START" != "true" && "$AUTO_VERIFY" == "true" ]]; then
set +e
# 安装阶段用短超时;VERIFY_QUICK=1 跳过 detect 长输出
VERIFY_QUICK=1 EGRESS_TIMEOUT="${EGRESS_TIMEOUT:-8}" \
"$ROOT_DIR/scripts/verify.sh"
vr=$?
set -e
if [[ "$vr" -eq 0 ]]; then
info "出口验证通过:代理已从数据网卡出"
else
warn "自动验证未通过(exit=$vr)。安装仍算完成,稍后可: cpxy verify"
warn "当前绑定: CELLULAR_IFACE=$cell ip=${src_ip:-}"
fi
else
info "已跳过自动验证"
fi
echo
info "安装完成(全自动探测 + 绑定)"
echo " 数据网卡: $cell (${src_ip:-no-ipv4})"
echo " 语义: 不走代理 → 系统默认(WiFi);走代理 → 数据流量"
echo " 代理: HTTP/SOCKS ${PROXY_LISTEN_HOST}:${PROXY_MIXED_PORT}"
if [[ -n "${PROXY_USER:-}" ]]; then
echo " 代理鉴权: 用户 ${PROXY_USER}(密码已设置)"
else
echo " 代理鉴权: 无(建议: cpxy auth --user u --pass p 或面板配置)"
fi
echo " 面板: http://<LAN-IP>:${PANEL_PORT}/ui/ 密钥: $PANEL_SECRET"
echo " 管理API: http://<LAN-IP>:9091 UI 里改代理账密用,密钥同上)"
echo " 配置: $INSTALL_DIR/etc/settings.conf"
echo " 管理: cpxy rebind | verify | logs | status"
echo " 账密: cpxy auth --user U --pass P | --clear | --show"
echo " 若刚才像卡住:多半在测公网出口,现已改为短超时并保证退出"
# 安装脚本始终以 0 结束(服务已 active);验证失败只告警
exit 0
Executable
+330
View File
@@ -0,0 +1,330 @@
#!/usr/bin/env bash
# 公共库
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONFIG_FILE="${CONFIG_FILE:-$ROOT_DIR/config/settings.conf}"
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2; }
info() { log "INFO $*"; }
warn() { log "WARN $*"; }
err() { log "ERROR $*"; }
die() { err "$*"; exit 1; }
need_root() {
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
die "请用 root 运行(sudo"
fi
}
load_config() {
if [[ ! -f "$CONFIG_FILE" ]]; then
die "缺少配置: $CONFIG_FILE
请先: cp $ROOT_DIR/config/settings.conf.example $ROOT_DIR/config/settings.conf"
fi
set -a
# shellcheck disable=SC1090
source "$CONFIG_FILE"
set +a
INSTALL_DIR="${INSTALL_DIR:-/opt/cellular-proxy}"
LOG_DIR="${LOG_DIR:-/var/log/cellular-proxy}"
CELLULAR_IFACE="${CELLULAR_IFACE:-}"
CELLULAR_SOURCE_IP="${CELLULAR_SOURCE_IP:-}"
REQUIRE_CELLULAR_IFACE="${REQUIRE_CELLULAR_IFACE:-true}"
CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,usb,enx,ppp,cdc,rmnet,ccmni,mbim,qmi}"
PROXY_LISTEN_HOST="${PROXY_LISTEN_HOST:-0.0.0.0}"
PROXY_MIXED_PORT="${PROXY_MIXED_PORT:-7890}"
PROXY_USER="${PROXY_USER:-}"
PROXY_PASS="${PROXY_PASS:-}"
PANEL_LISTEN_HOST="${PANEL_LISTEN_HOST:-0.0.0.0}"
PANEL_PORT="${PANEL_PORT:-9090}"
PANEL_SECRET="${PANEL_SECRET:-please-change-me}"
ENABLE_DNS="${ENABLE_DNS:-true}"
LOG_LEVEL="${LOG_LEVEL:-warn}"
SING_BOX_SOURCE="${SING_BOX_SOURCE:-auto}"
SING_BOX_VERSION="${SING_BOX_VERSION:-1.11.7}"
SING_BOX_BIN="${SING_BOX_BIN:-}"
GITEA_BASE="${GITEA_BASE:-https://gitea.chickliu.fun}"
GITEA_OWNER="${GITEA_OWNER:-Hermes}"
GITEA_REPO="${GITEA_REPO:-cellular-proxy}"
SING_BOX_RELEASE_TAG="${SING_BOX_RELEASE_TAG:-bin-v${SING_BOX_VERSION}}"
GITHUB_PROXY="${GITHUB_PROXY:-https://git.86482425.xyz}"
MEMORY_MAX_MB="${MEMORY_MAX_MB:-96}"
EGRESS_CHECK_URL="${EGRESS_CHECK_URL:-https://ifconfig.me}"
EXPECTED_CELLULAR_PUBLIC_IP="${EXPECTED_CELLULAR_PUBLIC_IP:-}"
for v in REQUIRE_CELLULAR_IFACE ENABLE_DNS; do
val="${!v}"
case "${val,,}" in
1|true|yes|on) printf -v "$v" '%s' true ;;
*) printf -v "$v" '%s' false ;;
esac
done
}
iface_exists() {
local ifc="$1"
[[ -n "$ifc" ]] && [[ -d "/sys/class/net/$ifc" ]]
}
is_virtual_or_skip_iface() {
local name="$1"
case "$name" in
lo|docker*|br-*|veth*|virbr*|cni*|flannel*|tun*|tap*|wg*|zt*|tailscale*|easy*|et*|nlmon*|dummy*|ifb*|bond*|team*|macvlan*|ipvlan*)
return 0
;;
esac
# bridge that is not a physical device
if [[ -d "/sys/class/net/$name/bridge" ]]; then
return 0
fi
return 1
}
list_ifaces() {
local name state ip
for name in $(ls /sys/class/net 2>/dev/null | sort); do
[[ "$name" == "lo" ]] && continue
state="$(cat "/sys/class/net/$name/operstate" 2>/dev/null || echo unknown)"
ip="$(ip -4 -o addr show dev "$name" 2>/dev/null | awk '{print $4}' | head -1)"
printf '%s|%s|%s\n' "$name" "$state" "${ip:-}"
done
}
detect_default_iface() {
ip route show default 2>/dev/null | awk '/default/ {print $5; exit}'
}
iface_has_carrier() {
local ifc="$1" c
c="$(cat "/sys/class/net/$ifc/carrier" 2>/dev/null || echo 0)"
[[ "$c" == "1" ]]
}
iface_driver() {
local ifc="$1" link
link="$(readlink -f "/sys/class/net/$ifc/device/driver" 2>/dev/null || true)"
if [[ -n "$link" ]]; then
basename "$link"
return
fi
# some USB eth put driver under device
if [[ -f "/sys/class/net/$ifc/device/uevent" ]]; then
awk -F= '/^DRIVER=/{print $2; exit}' "/sys/class/net/$ifc/device/uevent" 2>/dev/null || true
fi
}
iface_looks_cellular_by_driver() {
local ifc="$1" drv
drv="$(iface_driver "$ifc" || true)"
case "${drv,,}" in
qmi_wwan|cdc_mbim|cdc_ncm|cdc_ether|cdc_wdm|option|huawei_cdc_ncm|rndis_host|GobiNet|GobiSerial|simcom*|rmnet*|mhi_net|ipa)
return 0
;;
esac
return 1
}
iface_matches_patterns() {
local name="$1" p
local patterns="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,usb,enx,ppp,cdc,rmnet,ccmni,mbim,qmi}"
IFS=',' read -r -a arr <<< "$patterns"
for p in "${arr[@]}"; do
p="${p// /}"
[[ -z "$p" ]] && continue
if [[ "$name" == *"$p"* ]]; then
return 0
fi
done
return 1
}
# 多默认路由时,metric 更大的往往是数据网(WiFi metric 更小优先)
detect_secondary_default_iface() {
local primary secondary
primary="$(detect_default_iface || true)"
secondary="$(
ip route show default 2>/dev/null | awk -v p="$primary" '
/default/ {
iface=""; metric=0
for (i=1;i<=NF;i++) {
if ($i=="dev") iface=$(i+1)
if ($i=="metric") metric=$(i+1)+0
}
if (iface!="" && iface!=p) {
print metric, iface
}
}
' | sort -n | awk 'END {print $2}'
)"
if [[ -n "$secondary" && "$secondary" != "$primary" ]]; then
echo "$secondary"
return 0
fi
return 1
}
# 打分选数据网卡(stdout 仅输出网卡名)
# 更高分优先
score_iface_as_cellular() {
local name="$1"
local default_if="$2"
local state ip score=0 drv
is_virtual_or_skip_iface "$name" && { echo 0; return; }
state="$(cat "/sys/class/net/$name/operstate" 2>/dev/null || echo unknown)"
ip="$(ip -4 -o addr show dev "$name" 2>/dev/null | awk '{print $4}' | head -1 | cut -d/ -f1)"
drv="$(iface_driver "$name" || true)"
# 名称
if iface_matches_patterns "$name"; then score=$((score + 80)); fi
# 驱动
if iface_looks_cellular_by_driver "$name"; then score=$((score + 100)); fi
# 非默认网卡(关键:系统默认走 WiFi)
if [[ -n "$default_if" && "$name" != "$default_if" ]]; then score=$((score + 40)); fi
# 有 IPv4
if [[ -n "$ip" ]]; then score=$((score + 30)); fi
# up/carrier
if [[ "$state" == "up" || "$state" == "unknown" ]]; then score=$((score + 15)); fi
if iface_has_carrier "$name"; then score=$((score + 10)); fi
# 默认网卡通常是 WiFi/有线,大幅降权
if [[ -n "$default_if" && "$name" == "$default_if" ]]; then score=$((score - 60)); fi
# docker 等已在 skip;再防 enp/eth 当默认时
case "$name" in
eth*|enp*|eno*|ens*|wlan*|wlp*|wl*)
if [[ "$name" == "$default_if" ]]; then score=$((score - 20)); fi
;;
esac
echo "$score"
}
# 自动探测数据网卡:配置 > 名称/驱动 > 次默认路由 > 非默认有 IP 物理口
detect_cellular_iface() {
local configured="${1:-}"
local default_if name state ip best_name="" best_score=0 score
if [[ -n "$configured" ]]; then
if [[ "$configured" == "lo" ]]; then
warn "CELLULAR_IFACE=lo 无效,将尝试自动探测"
elif iface_exists "$configured"; then
echo "$configured"
return 0
else
warn "配置的 CELLULAR_IFACE=$configured 不存在,将尝试自动探测"
if [[ "${REQUIRE_CELLULAR_IFACE:-true}" == "true" && -n "${FORCE_CONFIGURED_ONLY:-}" ]]; then
return 1
fi
fi
fi
default_if="$(detect_default_iface || true)"
# 1) 按评分扫描全部接口
while IFS='|' read -r name state ip; do
[[ -z "$name" ]] && continue
is_virtual_or_skip_iface "$name" && continue
score="$(score_iface_as_cellular "$name" "$default_if")"
if [[ "$score" -gt "$best_score" ]]; then
best_score="$score"
best_name="$name"
fi
done < <(list_ifaces)
# 需要足够置信度(避免把唯一的 WiFi 当数据)
if [[ -n "$best_name" && "$best_score" -ge 70 ]]; then
info "自动探测数据网卡: $best_name (score=$best_score, default=$default_if, driver=$(iface_driver "$best_name" || true))"
echo "$best_name"
return 0
fi
# 2) 多默认路由的次要口
if name="$(detect_secondary_default_iface 2>/dev/null || true)"; then
if [[ -n "$name" ]] && iface_exists "$name"; then
info "根据次要默认路由探测数据网卡: $name"
echo "$name"
return 0
fi
fi
# 3) 任意非默认、有 IPv4、非虚拟
while IFS='|' read -r name state ip; do
[[ -z "$name" ]] && continue
is_virtual_or_skip_iface "$name" && continue
[[ -z "$ip" ]] && continue
if [[ -n "$default_if" && "$name" == "$default_if" ]]; then
continue
fi
if [[ "$state" == "up" || "$state" == "unknown" ]]; then
info "回退选择非默认网卡: $name"
echo "$name"
return 0
fi
done < <(list_ifaces)
if [[ -n "$best_name" && "$best_score" -gt 0 ]]; then
warn "置信度较低,选用: $best_name (score=$best_score)"
echo "$best_name"
return 0
fi
return 1
}
detect_source_ip() {
local ifc="$1"
ip -4 -o addr show dev "$ifc" 2>/dev/null | awk '{print $4}' | head -1 | cut -d/ -f1
}
# 把探测结果写回 settings.conf
persist_cellular_to_settings() {
local conf="${1:-$CONFIG_FILE}"
local cell="$2"
local src_ip="${3:-}"
[[ -f "$conf" ]] || return 1
local tmp
tmp="$(mktemp)"
awk -v k="CELLULAR_IFACE" -v v="$cell" '
BEGIN { done=0 }
index($0, k "=")==1 { print k "=" v; done=1; next }
{ print }
END { if (!done) print k "=" v }
' "$conf" > "$tmp"
mv "$tmp" "$conf"
if [[ -n "$src_ip" ]]; then
tmp="$(mktemp)"
awk -v k="CELLULAR_SOURCE_IP" -v v="$src_ip" '
BEGIN { done=0 }
index($0, k "=")==1 { print k "=" v; done=1; next }
{ print }
END { if (!done) print k "=" v }
' "$conf" > "$tmp"
mv "$tmp" "$conf"
fi
}
arch_go() {
case "$(uname -m)" in
x86_64|amd64) echo amd64 ;;
aarch64|arm64) echo arm64 ;;
armv7l|armhf) echo armv7 ;;
*) die "不支持的架构: $(uname -m)" ;;
esac
}
ensure_dirs() {
mkdir -p "$INSTALL_DIR"/{bin,etc,ui,generated} "$LOG_DIR"
}
resolve_cellular() {
local cell
if ! cell="$(detect_cellular_iface "$CELLULAR_IFACE")"; then
if [[ "$REQUIRE_CELLULAR_IFACE" == "true" ]]; then
die "无法确定数据网卡。请设置 CELLULAR_IFACE= 或运行 scripts/detect.sh / cpxy detect"
fi
warn "未找到数据网卡"
cell=""
fi
echo "$cell"
}
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# 在线一键增量升级入口(给已安装的 cpxy 调用)
# 用法: sudo cpxy upgrade
# sudo cpxy upgrade --ui-only
# sudo cpxy upgrade --force-binary
# sudo cpxy upgrade --verify
set -euo pipefail
BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# 若作为独立脚本在 /opt/.../scripts/online-upgrade.sh
if [[ -f "$BASE/etc/settings.conf" ]]; then
:
elif [[ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../etc/settings.conf" ]]; then
BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fi
GITEA_BASE="${GITEA_BASE:-https://gitea.chickliu.fun}"
REPO_OWNER="${REPO_OWNER:-Hermes}"
REPO_NAME="${REPO_NAME:-cellular-proxy}"
BRANCH="${BRANCH:-main}"
INSTALL_URL="${CELLULAR_PROXY_INSTALL_URL:-${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/raw/branch/${BRANCH}/install.sh}"
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
echo "请用 root: sudo cpxy upgrade $*" >&2
exit 1
fi
# 从 settings 读 INSTALL_DIR
if [[ -f "${CONFIG_FILE:-$BASE/etc/settings.conf}" ]]; then
# shellcheck disable=SC1090
set -a
# shellcheck disable=SC1090
source "${CONFIG_FILE:-$BASE/etc/settings.conf}"
set +a
fi
INSTALL_DIR="${INSTALL_DIR:-$BASE}"
export INSTALL_DIR
args=(--upgrade)
# 透传用户参数(已是 upgrade 子参数)
while [[ $# -gt 0 ]]; do
args+=("$1")
shift
done
echo "[$(date '+%F %T')] INFO 一键升级: $INSTALL_URL ${args[*]}" >&2
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
if command -v curl >/dev/null 2>&1; then
curl -fsSL --retry 3 --connect-timeout 20 -o "$tmp" "$INSTALL_URL"
elif command -v wget >/dev/null 2>&1; then
wget -q -O "$tmp" "$INSTALL_URL"
else
echo "需要 curl 或 wget" >&2
exit 1
fi
chmod +x "$tmp"
# 把 install-dir 固定到当前实例
bash "$tmp" --install-dir "$INSTALL_DIR" "${args[@]}"
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env bash
# 将 sing-box 官方 release 预下载并上传到本仓库 Gitea Release
# 用法(维护机):
# set -a; source ~/.hermes/env/gitea.env; set +a
# ./scripts/publish-binaries-to-gitea.sh
# SING_BOX_VERSION=1.11.7 ARCHS='amd64 arm64 armv7' ./scripts/publish-binaries-to-gitea.sh
#
# GitHub 下载默认经 https://git.86482425.xyz
# 上传走 multipart>约 8MB 可能被反向代理 504,自动改 1MB 分片上传
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
: "${GITEA_URL:?请先 source ~/.hermes/env/gitea.env}"
: "${GITEA_TOKEN:?请先 source ~/.hermes/env/gitea.env}"
OWNER="${GITEA_OWNER:-Hermes}"
REPO="${GITEA_REPO:-cellular-proxy}"
VER="${SING_BOX_VERSION:-1.11.7}"
TAG="${SING_BOX_RELEASE_TAG:-bin-v${VER}}"
ARCHS="${ARCHS:-amd64 arm64 armv7}"
GITHUB_PROXY="${GITHUB_PROXY:-https://git.86482425.xyz}"
PART_BYTES="${PART_BYTES:-1048576}"
# 经验阈值:整包上传超时则分片(nginx 常见 ~80s)
WHOLE_MAX_BYTES="${WHOLE_MAX_BYTES:-9000000}"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2; }
download() {
local url="$1" dest="$2"
log "下载 $url"
curl -fL --retry 3 --connect-timeout 20 --max-time 300 -o "$dest" "$url"
}
github_fetch() {
local raw="$1" dest="$2"
local proxied="${GITHUB_PROXY%/}/${raw}"
if [[ -n "${GITHUB_PROXY:-}" ]]; then
if download "$proxied" "$dest"; then
return 0
fi
log "代理失败,直连"
fi
download "$raw" "$dest"
}
API="$GITEA_URL/api/v1/repos/$OWNER/$REPO"
auth=(-H "Authorization: token $GITEA_TOKEN")
delete_asset() {
local id="$1"
# Gitea 正确路径:/releases/{id}/assets/{asset_id}
curl -sS -X DELETE "${auth[@]}" -o /dev/null -w "DEL %{http_code}\n" \
"$API/releases/$REL_ID/assets/$id" || true
}
upload_file() {
local fpath="$1" fname="$2"
local code
code=$(curl -sS --http1.1 --max-time 300 \
"${auth[@]}" \
-F "attachment=@${fpath};filename=${fname}" \
-o "$TMP/up_${fname}.json" \
-w '%{http_code}' \
"$API/releases/$REL_ID/assets?name=${fname}" || echo 000)
if [[ "$code" != "201" && "$code" != "200" ]]; then
log "上传失败 $fname HTTP $code"
head -c 300 "$TMP/up_${fname}.json" 2>/dev/null || true
return 1
fi
python3 - <<PY
import json
d=json.load(open("$TMP/up_${fname}.json"))
assert d.get("name")=="$fname", d
print("asset ok:", d.get("name"), "size=", d.get("size"))
PY
}
ensure_release() {
local code
code=$(curl -sS "${auth[@]}" -o "$TMP/rel.json" -w '%{http_code}' "$API/releases/tags/$TAG")
if [[ "$code" == "200" ]]; then
REL_ID=$(python3 -c 'import json; print(json.load(open("'"$TMP"'/rel.json"))["id"])')
log "已有 Release tag=$TAG id=$REL_ID"
else
log "创建 Release tag=$TAG"
curl -sS "${auth[@]}" -H 'Content-Type: application/json' \
-d "$(python3 - <<PY
import json
print(json.dumps({
"tag_name": "$TAG",
"name": "sing-box $VER prebundled binaries",
"body": "预置 sing-box v$VER。安装端优先从此 Release 下载,无需访问 GitHub。\\n架构: $ARCHS\\n大文件可能以 .part-XX 分片提供。",
"draft": False,
"prerelease": False,
"target_commitish": "main",
}))
PY
)" \
-o "$TMP/rel_create.json" \
"$API/releases"
REL_ID=$(python3 -c 'import json,sys; d=json.load(open("'"$TMP"'/rel_create.json"));
print(d.get("id") or "");
assert d.get("id"), d')
log "Release id=$REL_ID"
fi
}
delete_named_assets() {
local prefix="$1"
curl -sS "${auth[@]}" "$API/releases/$REL_ID" -o "$TMP/rel_full.json"
python3 - <<PY
import json,subprocess,os
d=json.load(open("$TMP/rel_full.json"))
prefix="$prefix"
for a in d.get("assets") or []:
n=a.get("name") or ""
if n==prefix or n.startswith(prefix+".") or n.startswith(prefix+".part-") or n==prefix+".SHA256SUMS" or (prefix.endswith(".tar.gz") and n=="SHA256SUMS" and "armv7" in prefix):
print("delete", a["id"], n)
subprocess.check_call(["bash","-lc", f'curl -sS -X DELETE -H "Authorization: token $GITEA_TOKEN" -o /dev/null -w "DEL %{{http_code}}\\n" "$API/releases/$REL_ID/assets/{a["id"]}"'])
PY
}
upload_whole_or_parts() {
local fpath="$1" fname="$2"
local size
size=$(stat -c%s "$fpath")
delete_named_assets "$fname"
if [[ "$size" -le "$WHOLE_MAX_BYTES" ]]; then
log "整包上传 $fname ($size bytes)"
if upload_file "$fpath" "$fname"; then
return 0
fi
log "整包失败,改分片"
else
log "文件 $size > $WHOLE_MAX_BYTES,直接分片上传 $fname"
fi
local pdir="$TMP/parts_${fname}"
rm -rf "$pdir"
mkdir -p "$pdir"
split -b "$PART_BYTES" -d -a 2 "$fpath" "$pdir/${fname}.part-"
(
cd "$pdir"
# 清单使用纯文件名,便于客户端校验
sha256sum "${fname}.part-"* > "${fname}.SHA256SUMS"
)
local p
for p in "$pdir/${fname}.part-"* "$pdir/${fname}.SHA256SUMS"; do
bn=$(basename "$p")
log "分片上传 $bn ($(stat -c%s "$p") bytes)"
upload_file "$p" "$bn"
done
}
# 1) 下载
for arch in $ARCHS; do
name="sing-box-${VER}-linux-${arch}"
raw="https://github.com/SagerNet/sing-box/releases/download/v${VER}/${name}.tar.gz"
dest="$TMP/${name}.tar.gz"
github_fetch "$raw" "$dest"
tar -tzf "$dest" | head -3 >/dev/null
ls -lh "$dest"
done
# 2) release
ensure_release
# 3) 上传
for arch in $ARCHS; do
fname="sing-box-${VER}-linux-${arch}.tar.gz"
upload_whole_or_parts "$TMP/$fname" "$fname"
done
# 4) 公开下载自检
for arch in $ARCHS; do
fname="sing-box-${VER}-linux-${arch}.tar.gz"
url="$GITEA_URL/$OWNER/$REPO/releases/download/$TAG/$fname"
code=$(curl -sS -o /dev/null -w '%{http_code}' -L --max-time 30 "$url" || true)
if [[ "$code" == "200" || "$code" == "206" ]]; then
log "公开整包 OK $url -> $code"
else
# 分片?
p0="$GITEA_URL/$OWNER/$REPO/releases/download/$TAG/${fname}.part-00"
code2=$(curl -sS -o /dev/null -w '%{http_code}' -L --max-time 30 "$p0" || true)
log "整包 $code,分片 part-00 -> $code2 ($p0)"
fi
done
log "完成。安装端优先:"
log " $GITEA_URL/$OWNER/$REPO/releases/download/$TAG/sing-box-${VER}-linux-<arch>.tar.gz"
log " 或分片: .../${fname}.part-XX + .../${fname}.SHA256SUMS"
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env bash
# 增量升级:保留 settings/密钥/网卡绑定,默认不重下 sing-box、不重探测、不强制 verify
# 用法:
# sudo ./scripts/upgrade.sh
# sudo ./scripts/upgrade.sh --ui-only
# sudo ./scripts/upgrade.sh --force-binary
# sudo ./scripts/upgrade.sh --rebind
# sudo ./scripts/upgrade.sh --verify
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=lib.sh
source "$ROOT_DIR/scripts/lib.sh"
UI_ONLY=false
FORCE_BINARY=false
DO_REBIND=false
DO_VERIFY=false
SKIP_START=false
usage() {
cat <<'H'
cellular-proxy 增量升级
保留: settings.conf / PANEL_SECRET / PROXY_* / CELLULAR_IFACE
默认: 不重新探测网卡、不重下 sing-box(已存在且可执行则跳过)、不强制出口验证
选项:
--ui-only 只更新 UI 面板
--force-binary 强制重下/重装 sing-box
--rebind 升级时重新探测数据网卡并写回配置
--verify 升级后跑出口验证
--skip-start 只落盘,不 restart 服务
-h, --help
H
}
while [[ $# -gt 0 ]]; do
case "$1" in
--ui-only) UI_ONLY=true; shift ;;
--force-binary) FORCE_BINARY=true; shift ;;
--rebind) DO_REBIND=true; shift ;;
--verify) DO_VERIFY=true; shift ;;
--skip-start) SKIP_START=true; shift ;;
-h|--help) usage; exit 0 ;;
*) die "未知参数: $1" ;;
esac
done
need_root
# 优先用已安装目录的配置
INST_CANDIDATES=(
"${INSTALL_DIR:-}"
"/opt/cellular-proxy"
"$ROOT_DIR"
)
EXISTING=""
for d in "${INST_CANDIDATES[@]}"; do
[[ -n "$d" ]] || continue
if [[ -f "$d/etc/settings.conf" ]]; then
EXISTING="$d"
break
fi
done
if [[ -z "$EXISTING" ]]; then
die "未检测到已安装实例(缺少 \$INSTALL_DIR/etc/settings.conf)。
请先全量安装:
curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash"
fi
export CONFIG_FILE="$EXISTING/etc/settings.conf"
load_config
INSTALL_DIR="${INSTALL_DIR:-$EXISTING}"
# 若 settings 里 INSTALL_DIR 与探测到的不一致,以配置为准;配置空则用探测目录
if [[ -z "${INSTALL_DIR:-}" || ! -d "$INSTALL_DIR" ]]; then
INSTALL_DIR="$EXISTING"
fi
export INSTALL_DIR
info "======== cellular-proxy 增量升级 ========"
info "安装目录: $INSTALL_DIR"
info "配置文件: $CONFIG_FILE(将保留密钥/网卡/账密)"
ensure_dirs
mkdir -p /var/lib/cellular-proxy "$INSTALL_DIR/bin" "$INSTALL_DIR/etc" "$INSTALL_DIR/ui" "$INSTALL_DIR/scripts" "$INSTALL_DIR/generated"
# --- UI only fast path ---
if [[ "$UI_ONLY" == "true" ]]; then
info "模式: 仅更新 UI"
if [[ ! -f "$ROOT_DIR/ui/index.html" ]]; then
die "源码缺少 ui/index.html"
fi
cp -a "$ROOT_DIR/ui/." "$INSTALL_DIR/ui/"
info "UI 已更新 -> $INSTALL_DIR/ui"
# 静态文件无需重启代理;admin 也不必
echo
info "完成(ui-only)。浏览器强刷: http://<LAN-IP>:${PANEL_PORT}/ui/"
exit 0
fi
# --- 可选 rebind ---
if [[ "$DO_REBIND" == "true" ]]; then
info "重新探测数据网卡…"
cell="$(detect_cellular_iface "" || true)"
[[ -n "$cell" ]] || die "未能探测数据网卡"
src="$(detect_source_ip "$cell" || true)"
persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src"
load_config
CELLULAR_IFACE="$cell"
CELLULAR_SOURCE_IP="${src:-}"
info "已 rebind: $CELLULAR_IFACE ${CELLULAR_SOURCE_IP:-}"
fi
cell="${CELLULAR_IFACE:-}"
if [[ -z "$cell" ]] || ! iface_exists "$cell"; then
warn "配置中的 CELLULAR_IFACE=${cell:-} 无效,尝试自动探测(仅本次)"
cell="$(detect_cellular_iface "" || true)"
if [[ -n "$cell" ]]; then
src="$(detect_source_ip "$cell" || true)"
persist_cellular_to_settings "$CONFIG_FILE" "$cell" "$src"
load_config
CELLULAR_IFACE="$cell"
CELLULAR_SOURCE_IP="${src:-$CELLULAR_SOURCE_IP}"
else
die "无有效数据网卡。请: cpxy rebind 或 --rebind / --iface"
fi
fi
src_ip="${CELLULAR_SOURCE_IP:-$(detect_source_ip "$cell" || true)}"
# --- binary ---
need_binary=true
if [[ "$FORCE_BINARY" != "true" && -x "$INSTALL_DIR/bin/sing-box" ]]; then
if "$INSTALL_DIR/bin/sing-box" version >/dev/null 2>&1; then
cur="$("$INSTALL_DIR/bin/sing-box" version 2>/dev/null | head -1 || true)"
info "复用已有 sing-box: $cur"
need_binary=false
fi
fi
if [[ "$need_binary" == "true" ]]; then
info "安装/更新 sing-box 二进制"
FORCE_DOWNLOAD="${FORCE_DOWNLOAD:-}"
if [[ "$FORCE_BINARY" == "true" ]]; then
export FORCE_DOWNLOAD=1
fi
# fetch 会同时拷 UI;后面还会再拷一遍 scripts/ui 没关系
export CONFIG_FILE
"$ROOT_DIR/scripts/fetch-binaries.sh"
else
info "跳过二进制下载(需要强制时加 --force-binary"
mkdir -p "$INSTALL_DIR/ui"
if [[ -f "$ROOT_DIR/ui/index.html" ]]; then
cp -a "$ROOT_DIR/ui/." "$INSTALL_DIR/ui/"
info "已更新 UI"
fi
fi
# --- scripts / cpxy / generate config ---
info "更新 scripts / 配置生成 / systemd"
# 同步新脚本到安装目录,但不要覆盖 etc/settings
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete \
--exclude 'settings.conf' \
"$ROOT_DIR/scripts/" "$INSTALL_DIR/scripts/"
else
mkdir -p "$INSTALL_DIR/scripts"
cp -a "$ROOT_DIR/scripts/." "$INSTALL_DIR/scripts/"
fi
chmod +x "$INSTALL_DIR/scripts/"*.sh "$INSTALL_DIR/scripts/"*.py 2>/dev/null || true
# 生成配置(用现有 settings,不改 secret
export CONFIG_FILE
"$ROOT_DIR/scripts/generate.sh" "$INSTALL_DIR/generated"
install -m 0644 "$INSTALL_DIR/generated/config.json" "$INSTALL_DIR/etc/config.json"
install -m 0644 "$INSTALL_DIR/generated/runtime.env" "$INSTALL_DIR/etc/runtime.env"
# 保留 settings:只确保 INSTALL_DIR 字段正确
if ! grep -qE '^INSTALL_DIR=' "$CONFIG_FILE" 2>/dev/null; then
printf 'INSTALL_DIR=%s\n' "$INSTALL_DIR" >> "$CONFIG_FILE"
fi
install -m 0644 "$CONFIG_FILE" "$INSTALL_DIR/etc/settings.conf"
if ! "$INSTALL_DIR/bin/sing-box" check -c "$INSTALL_DIR/etc/config.json"; then
die "sing-box 配置校验失败"
fi
# 校验 bind
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
info "配置已确认 bind_interface=$cell"
# 安装 cpxy 包装器(与 install.sh 一致,含 upgrade 子命令)
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)
# 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
systemctl restart cellular-proxy-admin 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
;;
upgrade)
# 一条命令在线升级:拉最新 install.sh 再 --upgrade
if [[ -x "$BASE/scripts/online-upgrade.sh" ]]; then
exec "$BASE/scripts/online-upgrade.sh" "$@"
fi
url="${CELLULAR_PROXY_INSTALL_URL:-https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh}"
exec bash -c 'curl -fsSL "$1" | bash -s -- --upgrade --install-dir "$2" "${@:3}"' _ "$url" "$BASE" "$@"
;;
start) systemctl start cellular-proxy cellular-proxy-admin 2>/dev/null || systemctl start cellular-proxy ;;
stop) systemctl stop cellular-proxy-admin 2>/dev/null || true; systemctl stop cellular-proxy ;;
restart) systemctl restart cellular-proxy; systemctl restart cellular-proxy-admin 2>/dev/null || true ;;
status)
systemctl status cellular-proxy --no-pager || true
systemctl status cellular-proxy-admin --no-pager 2>/dev/null || true
;;
verify) exec "$BASE/scripts/verify.sh" "$@" ;;
auth)
exec "$BASE/scripts/apply-proxy-auth.sh" "$@"
;;
logs) journalctl -u cellular-proxy -n "${1:-80}" -f ;;
help|*)
cat <<H
cpxy — cellular-proxy
语义: 走代理的连接一律从数据网卡出口
cpxy detect | rebind | generate | start | stop | restart | status
cpxy verify | logs [N]
cpxy auth --user U --pass P | --clear | --show
cpxy upgrade # 一条命令在线增量升级
cpxy upgrade --ui-only | --force-binary | --verify
H
;;
esac
EOF
ln -sfn "$INSTALL_DIR/bin/cpxy" /usr/local/bin/cpxy
# systemd
MEM="${MEMORY_MAX_MB:-96}"
if [[ -f "$ROOT_DIR/systemd/cellular-proxy.service" ]]; then
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
fi
if [[ -f "$ROOT_DIR/systemd/cellular-proxy-admin.service" ]]; then
install -m 0644 "$ROOT_DIR/systemd/cellular-proxy-admin.service" /etc/systemd/system/cellular-proxy-admin.service
sed -i "s|@INSTALL_DIR@|$INSTALL_DIR|g" /etc/systemd/system/cellular-proxy-admin.service
fi
systemctl daemon-reload
export SYSTEMD_PAGER=cat
export SYSTEMD_COLORS=0
if [[ "$SKIP_START" == "true" ]]; then
systemctl enable cellular-proxy.service 2>/dev/null || true
systemctl enable cellular-proxy-admin.service 2>/dev/null || true
info "已跳过 restart--skip-start"
else
systemctl enable cellular-proxy.service 2>/dev/null || true
systemctl enable cellular-proxy-admin.service 2>/dev/null || true
systemctl restart cellular-proxy.service
systemctl restart cellular-proxy-admin.service 2>/dev/null || true
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"
journalctl -u cellular-proxy -n 30 --no-pager 2>/dev/null || true
die "升级后启动失败"
fi
fi
if [[ "$DO_VERIFY" == "true" && "$SKIP_START" != "true" ]]; then
info "出口验证…"
set +e
VERIFY_QUICK=1 EGRESS_TIMEOUT="${EGRESS_TIMEOUT:-8}" "$INSTALL_DIR/scripts/verify.sh"
vr=$?
set -e
if [[ "$vr" -eq 0 ]]; then
info "出口验证通过"
else
warn "验证未通过(exit=$vr),可稍后: cpxy verify"
fi
else
info "已跳过出口验证(需要时: --verify 或 cpxy verify"
fi
# shellcheck disable=SC1090
source "$INSTALL_DIR/etc/settings.conf" 2>/dev/null || true
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
========== 增量升级完成 ==========
安装目录: $INSTALL_DIR
数据网卡: ${CELLULAR_IFACE:-$cell} (${src_ip:-})
保留: PANEL_SECRET / PROXY_USER / 端口等 settings
代理: ${lan:-127.0.0.1}:${PROXY_MIXED_PORT:-7890}
面板: http://${lan:-<LAN-IP>}:${PANEL_PORT:-9090}/ui/
管理: :9091(账密配置)
下次升级:
sudo cpxy upgrade
sudo cpxy upgrade --ui-only
sudo cpxy upgrade --force-binary
EOM
exit 0
+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}/"