#!/usr/bin/env bash # cellular-proxy 一条命令:未安装 → 全量安装;已安装 → 增量升级 # 语义:走本代理的连接一律从数据网卡(流量)出口;系统默认仍可走 WiFi # # ★ 唯一推荐(装 / 升 通用): # curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash # # 已装后也可: sudo cpxy upgrade # # 可选参数: # ... | sudo bash -s -- --iface wwan0 --secret xxx --user u --pass p # ... | sudo bash -s -- --force-install # 已装也强制全量 # ... | sudo bash -s -- --force-binary # 升级时强制重下 sing-box # set -euo pipefail REPO_OWNER="${REPO_OWNER:-Hermes}" REPO_NAME="${REPO_NAME:-cellular-proxy}" GITEA_BASE="${GITEA_BASE:-https://gitea.chickliu.fun}" BRANCH="${BRANCH:-main}" REF="${CELLULAR_PROXY_REF:-$BRANCH}" INSTALL_DIR="${INSTALL_DIR:-/opt/cellular-proxy}" WORK_DIR="${WORK_DIR:-/tmp/cellular-proxy-install-$$}" OPT_IFACE="" OPT_SECRET="" OPT_USER="" OPT_PASS="" OPT_PORT="" OPT_PANEL_PORT="" OPT_MEMORY="" OPT_SKIP_START=0 OPT_SKIP_VERIFY=0 OPT_LOCAL_DIR="" OPT_NO_AUTO_DETECT=0 OPT_UPGRADE=0 OPT_UI_ONLY=0 OPT_FORCE_BINARY=0 OPT_REBIND=0 OPT_FORCE_INSTALL=0 OPT_DO_VERIFY=0 log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2; } info() { log "INFO $*"; } warn() { log "WARN $*"; } err() { log "ERROR $*"; } die() { err "$*"; exit 1; } usage() { cat <<'H' cellular-proxy — 一条命令自动安装或升级 curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash 行为(无参数时自动判断): · 未检测到安装 → 全量安装(探测网卡 + 下载 sing-box + 启动) · 已安装 → 增量升级(保留密钥/网卡;默认不重下二进制) 强制选项: --upgrade 强制走增量升级(未安装会报错) --force-install 即使已安装也走全量安装 --ui-only 仅更新面板(隐含升级) --force-binary 升级时强制重下 sing-box --rebind 升级时重新探测数据网卡 --verify 升级后跑出口验证(升级默认跳过) 其它选项: --iface NAME 强制指定数据网卡 --secret STR 面板 PANEL_SECRET(默认随机;升级不改已有) --user USER / --pass PASS --port N / --panel-port N --memory MB MemoryMax(默认 96) --install-dir PATH --local DIR 本地源码 --ref REF --skip-start / --skip-verify --no-auto-detect 禁止自动探测(必须 --iface) -h, --help H } while [[ $# -gt 0 ]]; do case "$1" in --iface) OPT_IFACE="${2:-}"; shift 2 ;; --secret) OPT_SECRET="${2:-}"; shift 2 ;; --user) OPT_USER="${2:-}"; shift 2 ;; --pass) OPT_PASS="${2:-}"; shift 2 ;; --port) OPT_PORT="${2:-}"; shift 2 ;; --panel-port) OPT_PANEL_PORT="${2:-}"; shift 2 ;; --memory) OPT_MEMORY="${2:-}"; shift 2 ;; --install-dir) INSTALL_DIR="${2:-}"; shift 2 ;; --local) OPT_LOCAL_DIR="${2:-}"; shift 2 ;; --ref) REF="${2:-}"; shift 2 ;; --skip-start) OPT_SKIP_START=1; shift ;; --skip-verify) OPT_SKIP_VERIFY=1; shift ;; --no-auto-detect) OPT_NO_AUTO_DETECT=1; shift ;; --upgrade) OPT_UPGRADE=1; shift ;; --ui-only) OPT_UI_ONLY=1; shift ;; --force-binary) OPT_FORCE_BINARY=1; shift ;; --rebind) OPT_REBIND=1; shift ;; --force-install) OPT_FORCE_INSTALL=1; shift ;; --verify) OPT_DO_VERIFY=1; shift ;; -h|--help) usage; exit 0 ;; *) die "未知参数: $1(--help)" ;; esac done need_root() { if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then die "请用 root:curl -fsSL '...' | sudo bash" fi } need_cmd() { command -v "$1" >/dev/null 2>&1 || die "缺少命令: $1" } is_installed() { local d="${1:-$INSTALL_DIR}" # settings 存在即视为已装(可增量升级);兼容只有 conf 或 unit 的半装状态 if [[ -f "$d/etc/settings.conf" ]]; then return 0 fi if systemctl cat cellular-proxy.service >/dev/null 2>&1; then # unit 在但目录被删:仍尝试按 conf 探测 [[ -f /opt/cellular-proxy/etc/settings.conf ]] && return 0 fi return 1 } ensure_deps() { local missing=() for c in tar awk ip curl; do command -v "$c" >/dev/null 2>&1 || missing+=("$c") done if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then missing+=("curl") fi if [[ ${#missing[@]} -eq 0 ]]; then return 0 fi info "尝试自动安装依赖: ${missing[*]}" if command -v apt-get >/dev/null 2>&1; then export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq ca-certificates curl tar iproute2 openssl coreutils findutils 2>/dev/null \ || apt-get install -y ca-certificates curl tar iproute2 openssl elif command -v apk >/dev/null 2>&1; then apk add --no-cache ca-certificates curl tar iproute2 openssl else die "缺少依赖 ${missing[*]},且无法自动安装(请先装 curl/tar/iproute2)" fi } download() { local url="$1" dest="$2" if command -v curl >/dev/null 2>&1; then curl -fL --retry 3 --connect-timeout 20 -o "$dest" "$url" else wget -O "$dest" "$url" fi } set_kv() { local file="$1" key="$2" val="$3" local tmp tmp="$(mktemp)" if grep -qE "^${key}=" "$file" 2>/dev/null; then awk -v k="$key" -v v="$val" ' BEGIN { done=0 } index($0, k "=")==1 { print k "=" v; done=1; next } { print } END { if (!done) print k "=" v } ' "$file" > "$tmp" mv "$tmp" "$file" else printf '%s=%s\n' "$key" "$val" >> "$file" rm -f "$tmp" fi } fetch_source() { mkdir -p "$WORK_DIR" if [[ -n "$OPT_LOCAL_DIR" ]]; then [[ -d "$OPT_LOCAL_DIR" ]] || die "本地目录不存在: $OPT_LOCAL_DIR" info "使用本地源码: $OPT_LOCAL_DIR" mkdir -p "$WORK_DIR/src" if command -v rsync >/dev/null 2>&1; then rsync -a --delete \ --exclude '.git' --exclude 'generated' --exclude 'config/settings.conf' \ "$OPT_LOCAL_DIR"/ "$WORK_DIR/src"/ else cp -a "$OPT_LOCAL_DIR"/. "$WORK_DIR/src"/ rm -rf "$WORK_DIR/src/.git" "$WORK_DIR/src/generated" "$WORK_DIR/src/config/settings.conf" 2>/dev/null || true fi return fi local tarball="$WORK_DIR/src.tgz" local url_archive="${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/archive/${REF}.tar.gz" info "下载源码: $url_archive" if ! download "$url_archive" "$tarball"; then die "下载失败: $url_archive" fi mkdir -p "$WORK_DIR/extract" tar -xzf "$tarball" -C "$WORK_DIR/extract" local top top="$(find "$WORK_DIR/extract" -mindepth 1 -maxdepth 1 -type d | head -1)" [[ -n "$top" ]] || die "压缩包内容异常" mkdir -p "$WORK_DIR/src" if command -v rsync >/dev/null 2>&1; then rsync -a "$top"/ "$WORK_DIR/src"/ else cp -a "$top"/. "$WORK_DIR/src"/ fi } prepare_settings() { local src="$WORK_DIR/src" local conf="$src/config/settings.conf" local live="${INSTALL_DIR}/etc/settings.conf" [[ -f "$src/config/settings.conf.example" ]] || die "缺少 settings.conf.example" # 已有安装:以 live settings 为底,不重置密钥/网卡 if [[ -f "$live" ]]; then info "沿用已有配置: $live" cp "$live" "$conf" else cp "$src/config/settings.conf.example" "$conf" fi # shellcheck source=/dev/null source "$src/scripts/lib.sh" CELLULAR_IFACE_PATTERNS="${CELLULAR_IFACE_PATTERNS:-wwan,wwp,ppp,cdc,rmnet,ccmni,mbim,qmi}" REQUIRE_CELLULAR_IFACE=false # 迁移旧配置:usb/enx 关键词会命中本机的 USB gadget 网卡(usb0),必须剔除 local old_patterns cleaned old_patterns="$(awk -F= '/^CELLULAR_IFACE_PATTERNS=/{print substr($0,index($0,"=")+1); exit}' "$conf" 2>/dev/null || true)" if [[ -n "$old_patterns" ]]; then cleaned="$(printf '%s' "$old_patterns" | tr ',' '\n' | sed 's/[[:space:]]//g' \ | grep -vxE 'usb|enx' | paste -sd, - 2>/dev/null || true)" if [[ -n "$cleaned" && "$cleaned" != "$old_patterns" ]]; then set_kv "$conf" CELLULAR_IFACE_PATTERNS "$cleaned" CELLULAR_IFACE_PATTERNS="$cleaned" warn "已清理 CELLULAR_IFACE_PATTERNS: $old_patterns -> $cleaned" fi fi local cell="" if [[ -n "$OPT_IFACE" ]]; then cell="$OPT_IFACE" info "使用 --iface $cell" if iface_is_usb_gadget "$cell"; then die "--iface $cell 是本机对上游主机暴露的 USB gadget 网卡,不能作为数据出口" fi elif [[ -f "$live" ]] && grep -qE '^CELLULAR_IFACE=.' "$live" 2>/dev/null; then # shellcheck disable=SC1090 cell="$(awk -F= '/^CELLULAR_IFACE=/{print substr($0,index($0,"=")+1); exit}' "$live")" if [[ -n "$cell" ]] && iface_is_usb_gadget "$cell"; then warn "已有配置里的 $cell 是 USB gadget 网卡(面向上游主机),丢弃并重新探测" cell="" elif [[ -n "$cell" ]] && iface_exists "$cell"; then info "沿用已绑定网卡: $cell" else cell="" fi fi if [[ -z "$cell" ]]; then if [[ "$OPT_NO_AUTO_DETECT" -eq 1 ]]; then die "--no-auto-detect 时必须提供 --iface" fi info "自动探测数据网卡…" cell="$(detect_cellular_iface "" || true)" fi if [[ -z "$cell" ]]; then die "自动探测失败:未找到数据网卡。 请确认模组/拨号已 up,或指定: ... | sudo bash -s -- --iface <网卡名> 可先手动: ip -br a" fi if ! iface_exists "$cell"; then die "网卡不存在: $cell" fi if iface_is_usb_gadget "$cell"; then die "拒绝把 USB gadget 网卡 $cell 写成数据出口,请用 --iface wwanX 指定真实数据网卡" fi local src_ip src_ip="$(detect_source_ip "$cell" || true)" set_kv "$conf" CELLULAR_IFACE "$cell" if [[ -n "$src_ip" ]]; then set_kv "$conf" CELLULAR_SOURCE_IP "$src_ip" fi info "已写入 CELLULAR_IFACE=$cell ip=${src_ip:-none}" # secret:仅全量且未指定且当前是占位时生成;有 live 则不覆盖 if [[ -n "$OPT_SECRET" ]]; then set_kv "$conf" PANEL_SECRET "$OPT_SECRET" elif [[ ! -f "$live" ]]; then local cur cur="$(awk -F= '/^PANEL_SECRET=/{print substr($0,index($0,"=")+1); exit}' "$conf" 2>/dev/null || true)" if [[ -z "$cur" || "$cur" == "please-change-me" || "$cur" == "change-this-secret" ]]; then local gen gen="$(openssl rand -hex 12 2>/dev/null || head -c 16 /dev/urandom | xxd -p | tr -d '\n')" set_kv "$conf" PANEL_SECRET "$gen" info "已自动生成 PANEL_SECRET" fi fi [[ -n "$OPT_USER" ]] && set_kv "$conf" PROXY_USER "$OPT_USER" [[ -n "$OPT_PASS" ]] && set_kv "$conf" PROXY_PASS "$OPT_PASS" [[ -n "$OPT_PORT" ]] && set_kv "$conf" PROXY_MIXED_PORT "$OPT_PORT" [[ -n "$OPT_PANEL_PORT" ]] && set_kv "$conf" PANEL_PORT "$OPT_PANEL_PORT" [[ -n "$OPT_MEMORY" ]] && set_kv "$conf" MEMORY_MAX_MB "$OPT_MEMORY" set_kv "$conf" INSTALL_DIR "$INSTALL_DIR" set_kv "$conf" REQUIRE_CELLULAR_IFACE "true" export DETECTED_CELL="$cell" export DETECTED_SRC_IP="$src_ip" } run_upgrade() { info "======== cellular-proxy 增量升级入口 ========" fetch_source local args=() [[ "$OPT_UI_ONLY" -eq 1 ]] && args+=(--ui-only) [[ "$OPT_FORCE_BINARY" -eq 1 ]] && args+=(--force-binary) [[ "$OPT_REBIND" -eq 1 ]] && args+=(--rebind) # 升级默认不 verify(加快);需要时: --verify 或 UPGRADE_VERIFY=1 if [[ "${OPT_DO_VERIFY:-0}" -eq 1 ]] || [[ "${UPGRADE_VERIFY:-}" == "1" ]]; then args+=(--verify) fi # 用户传了 skip-start if [[ "$OPT_SKIP_START" -eq 1 ]]; then args+=(--skip-start) fi # 把 --iface 映射为 rebind 场景下的强制:简单做法:若给了 --iface,写入 live 再 upgrade if [[ -n "$OPT_IFACE" && -f "$INSTALL_DIR/etc/settings.conf" ]]; then set_kv "$INSTALL_DIR/etc/settings.conf" CELLULAR_IFACE "$OPT_IFACE" info "已写入 --iface $OPT_IFACE 到现有 settings" fi export INSTALL_DIR export CONFIG_FILE="$INSTALL_DIR/etc/settings.conf" bash "$WORK_DIR/src/scripts/upgrade.sh" "${args[@]}" } main() { need_root ensure_deps need_cmd tar need_cmd awk need_cmd ip trap 'rm -rf "$WORK_DIR"' EXIT # --ui-only 暗示升级 if [[ "$OPT_UI_ONLY" -eq 1 ]]; then OPT_UPGRADE=1 fi # ★ 一条命令自动分流:已安装 → 升级;未安装 → 全量安装 if [[ "$OPT_FORCE_INSTALL" -eq 0 && "$OPT_UPGRADE" -eq 0 ]]; then if is_installed "$INSTALL_DIR"; then info "检测到已安装: $INSTALL_DIR → 自动增量升级(保留密钥/网卡)" OPT_UPGRADE=1 else info "未检测到安装 → 自动全量安装" fi fi if [[ "$OPT_UPGRADE" -eq 1 ]]; then if ! is_installed "$INSTALL_DIR" && [[ ! -f "$INSTALL_DIR/etc/settings.conf" ]]; then die "指定了升级,但未找到已安装实例 ($INSTALL_DIR)。 直接无参执行即可(会自动全量安装): curl -fsSL 'https://gitea.chickliu.fun/Hermes/cellular-proxy/raw/branch/main/install.sh' | sudo bash" fi info "模式: 增量升级" run_upgrade return 0 fi info "模式: 全量安装" info "======== cellular-proxy 全自动安装 ========" info "1/3 获取源码" fetch_source info "2/3 自动探测数据网卡并写配置" prepare_settings info "3/3 安装 sing-box、绑定出口、启动" export CONFIG_FILE="$WORK_DIR/src/config/settings.conf" export AUTO_DETECT=false export AUTO_VERIFY=true if [[ "$OPT_SKIP_VERIFY" -eq 1 ]]; then export AUTO_VERIFY=false fi if [[ "$OPT_SKIP_START" -eq 1 ]]; then export SKIP_START=true else export SKIP_START=false fi bash "$WORK_DIR/src/scripts/install.sh" # shellcheck disable=SC1090 source "$INSTALL_DIR/etc/settings.conf" 2>/dev/null || true local port="${PROXY_MIXED_PORT:-7890}" local panel="${PANEL_PORT:-9090}" local secret="${PANEL_SECRET:-}" local cell="${CELLULAR_IFACE:-$DETECTED_CELL}" local lan lan="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}' || true)" cat <}:${panel}/ui/ 密钥: ${secret} 配置: $INSTALL_DIR/etc/settings.conf 验证: cpxy verify 重绑: cpxy rebind 之后更新: 再执行同一条 curl 命令,或: sudo cpxy upgrade 日志: cpxy logs EOM } main "$@"