Files
wangjia 4baf24f6f8 feat(server): 自动更新 /version 清单端点(镜像 jiu)
GET /version(公开)读 /etc/pangolin/version.yaml(每请求重载,免重启);缺省回退。
download_urls 接 /downloads。deploy.sh 装清单(存在则不覆盖, 保住线上 bump)。含 4 测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 00:12:46 +08:00

337 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
# deploy.sh — 单机全栈部署,为小内存机(512MB)调优:SQLite(文件)+ Redis(apt)
# + 控制面 + agent + sing-box,全部以**非特权 pangolin 用户**运行。免 Docker。
#
# 幂等:重跑只刷新二进制/配置,不轮换 REALITY 密钥或数据库(已发的客户端配置不失效)。
#
# 用法(root):
# sudo VPS_IP=1.2.3.4 bash deploy/single-node/deploy.sh
#
# 二进制:机上有 `go` 则就地构建;否则需**预先**把 pangolin-{server,agent,migrate}+nodectl
# 放进 /usr/local/bin(512MB 推荐:别处交叉编译 `CGO_ENABLED=0 GOOS=linux GOARCH=amd64`,
# 因 SQLite 走纯 Go 的 modernc,无需 CGO)。
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
HERE="$REPO_ROOT/deploy/single-node"
NODE_UUID="${NODE_UUID:-22222222-2222-2222-2222-222222222222}"
REALITY_SNI="${REALITY_SNI:-www.apple.com}"
REGION="${REGION:-HK}"
NAME_ZH="${NAME_ZH:-单节点 · 测试}"
NAME_EN="${NAME_EN:-Single Node}"
HTTP_PORT="${HTTP_PORT:-8080}"
GRPC_PORT="${GRPC_PORT:-9443}"
REALITY_PORT="${REALITY_PORT:-443}" # 与 bootstrap 防火墙放行的 443/tcp 对齐
# Hy2 默认【关闭】:控制面尚未接线 Hy2 TLS 证书(handler_grpc.go 仅发 ListenPort,
# 不发 CertPath/KeyPath)→ 开启会令 sing-box 报 "missing certificate"。待代码补全
# Hy2 证书下发后再传 HY2_PORT=443 开启。
HY2_PORT="${HY2_PORT:-}"
ETC="/etc/pangolin"
AGENT_ETC="/etc/pangolin-agent"
SB_ETC="/etc/sing-box"
DATA_DIR="/var/lib/pangolin"
DB_FILE="$DATA_DIR/pangolin.db"
BIN="/usr/local/bin"
PUSER="pangolin"
log() { printf '\033[1;32m[deploy]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[deploy]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[deploy]\033[0m %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "must run as root (sudo)."
command -v openssl >/dev/null 2>&1 || die "missing dependency: openssl"
export DEBIAN_FRONTEND=noninteractive
# ── 0. pangolin 非特权用户 ────────────────────────────────────────────────────
if ! id -u "$PUSER" >/dev/null 2>&1; then
log "创建非特权用户 $PUSER"
useradd --system --create-home --home-dir "$DATA_DIR" --shell /usr/sbin/nologin "$PUSER"
fi
# ── 1. 系统依赖(apt:Redis + sqlite3;免 Docker)───────────────────────────────
miss=""
# polkitd:让非特权 pangolin 经 polkit 规则重启 sing-box(精简 Debian 默认无)。
for p in redis-server sqlite3 curl ca-certificates polkitd; do
dpkg -s "$p" >/dev/null 2>&1 || miss="$miss $p"
done
if [ -n "$miss" ]; then
log "安装依赖:$miss"
apt-get update -q
# shellcheck disable=SC2086
apt-get install -y --no-install-recommends $miss
fi
if ! command -v sing-box >/dev/null 2>&1; then
warn "sing-box 未安装 — 尝试官方脚本安装 ..."
curl -fsSL https://sing-box.app/install.sh | sh \
|| die "sing-box 安装失败;请手动装后重跑。"
fi
if [ -z "${VPS_IP:-}" ]; then
VPS_IP="$(curl -fsS --max-time 5 https://api.ipify.org || true)"
[ -n "$VPS_IP" ] || die "无法自动探测 VPS_IP;请显式传 VPS_IP=..."
log "自动探测 VPS_IP=$VPS_IP"
fi
ENDPOINT="$VPS_IP:$REALITY_PORT"
mkdir -p "$ETC" "$AGENT_ETC" "$SB_ETC" "$DATA_DIR"
# ── 2. 二进制(有 go 则构建;否则用预置)──────────────────────────────────────
if command -v go >/dev/null 2>&1; then
log "就地构建二进制(server/agent/nodectl/migrate)..."
(
cd "$REPO_ROOT/server"
for cmd in server agent nodectl migrate; do
CGO_ENABLED=0 go build -o "$BIN/pangolin-tmp-$cmd" "./cmd/$cmd"
done
)
mv -f "$BIN/pangolin-tmp-server" "$BIN/pangolin-server"
mv -f "$BIN/pangolin-tmp-agent" "$BIN/pangolin-agent"
mv -f "$BIN/pangolin-tmp-nodectl" "$BIN/nodectl"
mv -f "$BIN/pangolin-tmp-migrate" "$BIN/pangolin-migrate"
else
log "机上无 go → 使用预置二进制"
for b in pangolin-server pangolin-agent nodectl pangolin-migrate; do
[ -x "$BIN/$b" ] || die "缺二进制 $BIN/$b:机上无 go,请先交叉编译并放入 $BIN"
done
fi
# ── 3. Redis(apt 原生 systemd 服务,绑 127.0.0.1)──────────────────────────────
log "启动 Redis ..."
systemctl enable --now redis-server >/dev/null 2>&1 || systemctl enable --now redis >/dev/null 2>&1 || true
until redis-cli ping >/dev/null 2>&1; do sleep 1; done
log "Redis ready."
# ── 4. 控制面密钥(幂等)──────────────────────────────────────────────────────
if [ ! -f "$ETC/jwt_private.pem" ]; then
log "生成 RS256 JWT 密钥对 ..."
openssl genrsa -out "$ETC/jwt_private.pem" 2048
openssl rsa -in "$ETC/jwt_private.pem" -pubout -out "$ETC/jwt_public.pem"
fi
[ -f "$ETC/webhook_secret" ] || openssl rand -hex 32 > "$ETC/webhook_secret"
[ -f "$ETC/derive_key" ] || openssl rand -hex 32 > "$ETC/derive_key"
WEBHOOK_SECRET="$(cat "$ETC/webhook_secret")"
NODE_DERIVE_KEY="$(cat "$ETC/derive_key")"
# Node CA(ECDSA P-256,SEC1 "EC PRIVATE KEY";mtls.NewCA 读它)
if [ ! -f "$ETC/ca.key" ] || [ ! -f "$ETC/ca.crt" ]; then
log "生成 Node CA ..."
openssl ecparam -name prime256v1 -genkey -noout -out "$ETC/ca.key"
openssl req -x509 -new -key "$ETC/ca.key" -days 3650 -out "$ETC/ca.crt" \
-subj "/O=Pangolin/CN=Pangolin Node CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
fi
# gRPC server cert(CA 签发,SAN localhost+127.0.0.1)
if [ ! -f "$ETC/grpc.key" ] || [ ! -f "$ETC/grpc.crt" ]; then
log "签发 gRPC server 证书 ..."
openssl ecparam -name prime256v1 -genkey -noout -out "$ETC/grpc.key"
openssl req -new -key "$ETC/grpc.key" -out "$ETC/grpc.csr" -subj "/CN=pangolin-control-plane"
EXT="$(mktemp)"
cat > "$EXT" <<EOF
subjectAltName=DNS:localhost,IP:127.0.0.1
extendedKeyUsage=serverAuth
EOF
openssl x509 -req -in "$ETC/grpc.csr" -CA "$ETC/ca.crt" -CAkey "$ETC/ca.key" \
-CAcreateserial -days 825 -out "$ETC/grpc.crt" -extfile "$EXT"
rm -f "$EXT" "$ETC/grpc.csr"
fi
# ── 5. REALITY 密钥对(持久,跨重部署稳定)─────────────────────────────────────
if [ ! -f "$ETC/reality.env" ]; then
log "生成 REALITY 密钥对 ..."
KP="$(sing-box generate reality-keypair)"
R_PRK="$(printf '%s\n' "$KP" | awk '/PrivateKey/{print $2}')"
R_PBK="$(printf '%s\n' "$KP" | awk '/PublicKey/{print $2}')"
R_SID="$(openssl rand -hex 8)"
[ -n "$R_PRK" ] && [ -n "$R_PBK" ] || die "解析 reality-keypair 失败。"
{
printf 'REALITY_PRK=%s\n' "$R_PRK"
printf 'REALITY_PBK=%s\n' "$R_PBK"
printf 'REALITY_SHORT_ID=%s\n' "$R_SID"
} > "$ETC/reality.env"
fi
# shellcheck disable=SC1091
. "$ETC/reality.env"
# ── 6. server.env(SQLite)─────────────────────────────────────────────────────
log "写 server.env(DB_DRIVER=sqlite)..."
cat > "$ETC/server.env" <<EOF
DB_DRIVER=sqlite
DB_DSN=$DB_FILE
REDIS_ADDR=127.0.0.1:6379
ADDR=127.0.0.1:$HTTP_PORT
JWT_PRIVATE_KEY_PATH=$ETC/jwt_private.pem
JWT_KEY_ID=node-key-1
JWT_PUBLIC_KEYS=node-key-1:$ETC/jwt_public.pem
WEBHOOK_SECRET=$WEBHOOK_SECRET
NODE_DERIVE_KEY=$NODE_DERIVE_KEY
GRPC_ADDR=:$GRPC_PORT
CA_KEY_PATH=$ETC/ca.key
CA_CERT_PATH=$ETC/ca.crt
GRPC_CERT_PATH=$ETC/grpc.crt
GRPC_KEY_PATH=$ETC/grpc.key
PANGOLIN_PUBLIC_URL=https://api.yanmeiai.com
PANGOLIN_RULES_DIR=$DATA_DIR/rules
DOWNLOADS_DIR=$DATA_DIR/downloads
VERSION_MANIFEST=$ETC/version.yaml
EOF
if [ -n "${SMTP_HOST:-}" ]; then
cat >> "$ETC/server.env" <<EOF
SMTP_HOST=$SMTP_HOST
SMTP_PORT=${SMTP_PORT:-587}
SMTP_USERNAME=${SMTP_USERNAME:-}
SMTP_PASSWORD=${SMTP_PASSWORD:-}
SMTP_FROM=${SMTP_FROM:-no-reply@pangolin.app}
EOF
fi
# ── 6b. 国内分流 rule-set(#5)──────────────────────────────────────────────────
# 自托管 geoip-cn / geosite-cn(控制面 /v1/rules/*.srs 静态服务,客户端 sing-box
# 直连下载)。GitHub 在国内被墙,故控制面代为下发,避免鸡生蛋。
log "拉取国内分流 rule-set(geoip-cn / geosite-cn)..."
RULES_DIR="$DATA_DIR/rules"
install -d -m 755 "$RULES_DIR"
curl -fsSL -o "$RULES_DIR/geoip-cn.srs" \
https://github.com/SagerNet/sing-geoip/raw/rule-set/geoip-cn.srs \
|| log "WARN: geoip-cn.srs 拉取失败,国内分流将不可用"
curl -fsSL -o "$RULES_DIR/geosite-cn.srs" \
https://github.com/SagerNet/sing-geosite/raw/rule-set/geosite-cn.srs \
|| log "WARN: geosite-cn.srs 拉取失败,国内分流将不可用"
# ── 6c. 客户端安装包下载目录 ──────────────────────────────────────────────────
# scripts/ci/deploy-client.sh 把最新 pangolin-android.apk /
# pangolin-windows-x64-setup.exe scp 到这里(每平台只保留最新一份),控制面
# GET /downloads/<file> 静态服务、官网下载按钮直链。这里先建目录占位(CI 首次
# 部署前跑本脚本也不会因目录缺失而 404 时找不到目录本身;DownloadsHandler 本身
# 即便目录不存在也能正常注册路由,只是请求会 404)。
DOWNLOADS_DIR="$DATA_DIR/downloads"
install -d -m 755 "$DOWNLOADS_DIR"
# ── 6d. 客户端自动更新版本清单 ─────────────────────────────────────────────────
# GET /version(公开、免鉴权)按 VERSION_MANIFEST(见上 server.env)读取该文件;
# scripts/ci/release-client.sh 每次 client-v* 发版都会以本仓库这份文件为模板,
# 改写 version/build_number 后 SSH 推到这个路径覆盖 —— 幂等重跑本脚本不应该把
# 已发布的最新版本号退回仓库里的默认值,所以文件已存在时不覆盖。
if [ ! -f "$ETC/version.yaml" ]; then
install -m 644 "$HERE/version.yaml" "$ETC/version.yaml"
fi
# ── 7. 迁移 + seed(SQLite)────────────────────────────────────────────────────
log "执行迁移(sqlite)..."
DB_DRIVER=sqlite DB_DSN="$DB_FILE" "$BIN/pangolin-migrate" up
log "seed 节点行 ..."
SEED="$(mktemp)"
HY2_SEED="${HY2_PORT:-NULL}" # 空 → SQL NULL(不下发 Hy2)
# shellcheck disable=SC2153
sed \
-e "s|__NODE_UUID__|$NODE_UUID|g" \
-e "s|__REGION__|$REGION|g" \
-e "s|__NAME_ZH__|$NAME_ZH|g" \
-e "s|__NAME_EN__|$NAME_EN|g" \
-e "s|__ENDPOINT__|$ENDPOINT|g" \
-e "s|__HY2_PORT__|$HY2_SEED|g" \
-e "s|__REALITY_PBK__|$REALITY_PBK|g" \
-e "s|__REALITY_PRK__|$REALITY_PRK|g" \
-e "s|__REALITY_SHORT_ID__|$REALITY_SHORT_ID|g" \
-e "s|__REALITY_SNI__|$REALITY_SNI|g" \
"$HERE/seed-node.sql.tmpl" > "$SEED"
sqlite3 "$DB_FILE" < "$SEED"
rm -f "$SEED"
# ── 8. 发 bootstrap token + 写 agent.env ──────────────────────────────────────
log "签发 agent bootstrap token ..."
TOKEN="$(REDIS_ADDR=127.0.0.1:6379 "$BIN/nodectl" bootstrap-token -node="$NODE_UUID")"
[ -n "$TOKEN" ] || die "签发 bootstrap token 失败。"
cp -f "$ETC/ca.crt" "$AGENT_ETC/ca.crt"
cat > "$AGENT_ETC/agent.env" <<EOF
PANGOLIN_AGENT_CONTROL_PLANE=localhost:$GRPC_PORT
PANGOLIN_AGENT_SERVER_NAME=localhost
PANGOLIN_AGENT_BOOTSTRAP_TOKEN=$TOKEN
PANGOLIN_AGENT_DERIVE_KEY=$NODE_DERIVE_KEY
PANGOLIN_AGENT_SINGBOX_CONFIG=$SB_ETC/config.json
PANGOLIN_AGENT_SINGBOX_UNIT=sing-box
PANGOLIN_AGENT_STATE_DIR=$AGENT_ETC
EOF
# ── 9. 降权:目录/文件归 pangolin ─────────────────────────────────────────────
chown -R "$PUSER:$PUSER" "$ETC" "$AGENT_ETC" "$SB_ETC" "$DATA_DIR"
chmod 700 "$ETC" "$AGENT_ETC"
chmod 600 "$ETC/jwt_private.pem" "$ETC/ca.key" "$ETC/grpc.key" \
"$ETC/webhook_secret" "$ETC/derive_key" "$ETC/reality.env" \
"$ETC/server.env" "$AGENT_ETC/agent.env"
# ── 10. systemd 单元 + polkit(允许 pangolin 重启 sing-box)────────────────────
log "安装 systemd 单元 + polkit 规则 ..."
install -m 644 "$HERE/systemd/pangolin-server.service" /etc/systemd/system/
install -m 644 "$HERE/systemd/pangolin-agent.service" /etc/systemd/system/
# sing-box 路径按实际安装位置渲染(官方 .deb 装在 /usr/bin,而非 /usr/local/bin)。
SB_BIN="$(command -v sing-box)"
sed "s#/usr/local/bin/sing-box#${SB_BIN}#g" \
"$HERE/systemd/sing-box.service" > /etc/systemd/system/sing-box.service
chmod 644 /etc/systemd/system/sing-box.service
install -d -m 755 /etc/polkit-1/rules.d
install -m 644 "$HERE/polkit/49-pangolin-singbox.rules" /etc/polkit-1/rules.d/
# ── cloudflared(控制面 API 出站隧道,Cloudflare apt 源)─────────────────────────
if ! command -v cloudflared >/dev/null 2>&1; then
log "安装 cloudflared(Cloudflare apt 源)..."
install -m 0755 -d /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
-o /usr/share/keyrings/cloudflare-main.gpg
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bookworm main' \
> /etc/apt/sources.list.d/cloudflared.list
apt-get update -qq
apt-get install -y -qq cloudflared
fi
# apt 固定装到 /usr/bin;软链到 unit 期望的 /usr/local/bin(与其他 pangolin 二进制一致)。
CFD_BIN=/usr/bin/cloudflared
[ -x "$CFD_BIN" ] || die "cloudflared 安装失败:$CFD_BIN 不存在。"
ln -sf "$CFD_BIN" /usr/local/bin/cloudflared
install -m 644 "$HERE/systemd/cloudflared.service" /etc/systemd/system/
systemctl daemon-reload
log "启动控制面(pangolin-server,以 $PUSER 运行)..."
systemctl enable --now pangolin-server.service
log "等待控制面 HTTP :$HTTP_PORT ..."
until curl -fsS --max-time 2 "http://127.0.0.1:$HTTP_PORT/healthz" >/dev/null 2>&1; do sleep 1; done
log "控制面健康。"
log "启用 sing-box + 启动 agent ..."
systemctl enable sing-box.service
systemctl enable --now pangolin-agent.service
# 控制面 API 已绑 127.0.0.1(经 cloudflared 隧道对外),不放行 8080/tcp。
# ── cloudflared:按需启用(需 /etc/pangolin/cloudflared.env 提供 TUNNEL_TOKEN)───
if [ -f /etc/pangolin/cloudflared.env ]; then
log "启用 cloudflared(检测到 /etc/pangolin/cloudflared.env)..."
systemctl enable --now cloudflared.service
else
log "cloudflared 已安装但未启动:等 /etc/pangolin/cloudflared.env(TUNNEL_TOKEN)就绪后执行:"
log " systemctl enable --now cloudflared.service"
fi
# ── 11. 摘要 ──────────────────────────────────────────────────────────────────
log "完成。单机栈已起(SQLite + pangolin 用户)。"
cat <<EOF
控制面 API : https://api.yanmeiai.com (CF Tunnel;源站 127.0.0.1:$HTTP_PORT,不外露/不放行)
Node uuid : $NODE_UUID
Endpoint : $ENDPOINT SNI=$REALITY_SNI (REALITY ${REALITY_PORT}/tcp)
REALITY pbk : $REALITY_PBK
Hy2 : ${HY2_PORT:+${HY2_PORT}/udp}${HY2_PORT:-关闭(控制面 Hy2 证书未接线)}
验证:
journalctl -u pangolin-agent -n 50 --no-pager # enrolled + Register
systemctl status sing-box --no-pager # active (running)
grep -A2 reality-in $SB_ETC/config.json # listen_port $REALITY_PORT
ss -tlnp | grep -E '$HTTP_PORT|$REALITY_PORT' # 监听
EOF