912a495e91
一台全新 Ubuntu/Debian VPS 上一条命令起整套 pangolin:MySQL + Redis(docker)
+ 控制面(HTTP + gRPC agent 服务)+ 节点 agent + sing-box 数据面,客户端连该
VPS 公网 IP 真实出网。
- deploy.sh:幂等编排——构建二进制 → docker MySQL/Redis → 生成 JWT/webhook/
derive 密钥 + Node CA + CA 签发的 gRPC server 证书(SAN localhost,匹配 agent
enroll 后的 mTLS 校验)+ REALITY 密钥对(持久化跨重部署)→ migrate + seed 节点
→ 起控制面(走完整 gRPC 分支)→ nodectl bootstrap-token → 起 agent + sing-box。
- systemd/{pangolin-server,pangolin-agent,sing-box}.service、docker-compose.yml
(MySQL/Redis 仅 loopback)、seed-node.sql.tmpl。
- README.md:架构图 + 部署/验证步骤 + 客户端接线(API URL / PANGOLIN_SINGBOX_BIN
/ sudoers / 真节点 uuid,客户端代码无改动)+ 排查指引。
本机验证:shellcheck + bash -n 通过;openssl CA ↔ mtls.NewCA + gRPC 证书链互通
已单测实测通过。VPS 端到端(出口 IP == VPS IP)待真机到位后执行。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
273 lines
11 KiB
Bash
Executable File
273 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# deploy.sh — one-command single-node deploy of the FULL pangolin stack on a
|
|
# fresh Ubuntu/Debian VPS: MySQL + Redis (docker) + control plane (HTTP+gRPC) +
|
|
# node agent + sing-box data plane, all on this one box. The client connects to
|
|
# this VPS's public IP and egresses through it.
|
|
#
|
|
# Idempotent: re-running refreshes binaries/config without rotating the REALITY
|
|
# keypair or datastore (so already-issued client configs keep working).
|
|
#
|
|
# Usage (as root, from the repo checkout on the VPS):
|
|
# sudo VPS_IP=203.0.113.10 bash deploy/single-node/deploy.sh
|
|
#
|
|
# Required:
|
|
# VPS_IP public IPv4 the client dials (auto-detected if unset)
|
|
# Optional (sensible defaults):
|
|
# NODE_UUID fixed node uuid (default below)
|
|
# REALITY_SNI masquerade SNI (www.apple.com)
|
|
# REGION / NAME_ZH / NAME_EN
|
|
# HTTP_PORT GRPC_PORT REALITY_PORT HY2_PORT
|
|
# SMTP_HOST SMTP_PORT SMTP_USERNAME SMTP_PASSWORD SMTP_FROM (real email; else
|
|
# verification codes are printed to the server journal)
|
|
set -euo pipefail
|
|
|
|
# ── 0. config & preflight ────────────────────────────────────────────────────
|
|
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:-11443}"
|
|
HY2_PORT="${HY2_PORT:-443}"
|
|
|
|
ETC="/etc/pangolin"
|
|
AGENT_ETC="/etc/pangolin-agent"
|
|
SB_ETC="/etc/sing-box"
|
|
BIN="/usr/local/bin"
|
|
|
|
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)."
|
|
|
|
for c in openssl docker go; do
|
|
command -v "$c" >/dev/null 2>&1 || die "missing dependency: $c (install it first)."
|
|
done
|
|
docker compose version >/dev/null 2>&1 || die "docker compose v2 plugin required."
|
|
|
|
if ! command -v sing-box >/dev/null 2>&1; then
|
|
warn "sing-box not found — attempting official install ..."
|
|
curl -fsSL https://sing-box.app/install.sh | sh \
|
|
|| die "sing-box install failed; install it manually then re-run."
|
|
fi
|
|
|
|
if [ -z "${VPS_IP:-}" ]; then
|
|
VPS_IP="$(curl -fsS --max-time 5 https://api.ipify.org || true)"
|
|
[ -n "$VPS_IP" ] || die "could not auto-detect VPS_IP; pass VPS_IP=... explicitly."
|
|
log "auto-detected VPS_IP=$VPS_IP"
|
|
fi
|
|
ENDPOINT="$VPS_IP:$REALITY_PORT"
|
|
|
|
mkdir -p "$ETC" "$AGENT_ETC" "$SB_ETC"
|
|
chmod 700 "$ETC" "$AGENT_ETC"
|
|
|
|
# ── 1. build & install binaries ──────────────────────────────────────────────
|
|
log "building binaries (server, agent, nodectl, migrate) ..."
|
|
(
|
|
cd "$REPO_ROOT/server"
|
|
for cmd in server agent nodectl migrate; do
|
|
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"
|
|
|
|
# ── 2. datastores (MySQL + Redis via docker compose) ─────────────────────────
|
|
if [ ! -f "$ETC/db.env" ]; then
|
|
log "generating MySQL root password ..."
|
|
printf 'MYSQL_ROOT_PASSWORD=%s\n' "$(openssl rand -hex 24)" > "$ETC/db.env"
|
|
chmod 600 "$ETC/db.env"
|
|
fi
|
|
# shellcheck disable=SC1091
|
|
. "$ETC/db.env"
|
|
|
|
log "starting MySQL + Redis ..."
|
|
docker compose --env-file "$ETC/db.env" -f "$HERE/docker-compose.yml" up -d
|
|
log "waiting for MySQL ..."
|
|
until docker exec pangolin-mysql mysqladmin ping -uroot -p"$MYSQL_ROOT_PASSWORD" --silent >/dev/null 2>&1; do
|
|
sleep 1
|
|
done
|
|
log "MySQL ready."
|
|
|
|
# ── 3. control-plane secrets (idempotent) ────────────────────────────────────
|
|
if [ ! -f "$ETC/jwt_private.pem" ]; then
|
|
log "generating RS256 JWT keypair ..."
|
|
openssl genrsa -out "$ETC/jwt_private.pem" 2048
|
|
openssl rsa -in "$ETC/jwt_private.pem" -pubout -out "$ETC/jwt_public.pem"
|
|
chmod 600 "$ETC/jwt_private.pem"
|
|
fi
|
|
[ -f "$ETC/webhook_secret" ] || openssl rand -hex 32 > "$ETC/webhook_secret"
|
|
[ -f "$ETC/derive_key" ] || openssl rand -hex 32 > "$ETC/derive_key"
|
|
chmod 600 "$ETC/webhook_secret" "$ETC/derive_key"
|
|
WEBHOOK_SECRET="$(cat "$ETC/webhook_secret")"
|
|
NODE_DERIVE_KEY="$(cat "$ETC/derive_key")"
|
|
|
|
# Node CA: generate here (ECDSA P-256, SEC1 "EC PRIVATE KEY" — the format
|
|
# mtls.NewCA loads). The control plane loads this existing CA on start; the agent
|
|
# pins it during enroll, and it signs the gRPC server cert below.
|
|
if [ ! -f "$ETC/ca.key" ] || [ ! -f "$ETC/ca.crt" ]; then
|
|
log "generating 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"
|
|
chmod 600 "$ETC/ca.key"
|
|
fi
|
|
|
|
# gRPC server cert: CA-signed, serverAuth EKU, SAN localhost+127.0.0.1 (the agent
|
|
# dials localhost:GRPC_PORT and validates the cert against the Node CA).
|
|
if [ ! -f "$ETC/grpc.key" ] || [ ! -f "$ETC/grpc.crt" ]; then
|
|
log "issuing gRPC server cert (signed by Node CA) ..."
|
|
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"
|
|
chmod 600 "$ETC/grpc.key"
|
|
fi
|
|
|
|
# ── 4. REALITY keypair + short_id (persisted; stable across re-deploys) ───────
|
|
if [ ! -f "$ETC/reality.env" ]; then
|
|
log "generating REALITY keypair ..."
|
|
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 "failed to parse reality-keypair output."
|
|
{
|
|
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"
|
|
chmod 600 "$ETC/reality.env"
|
|
fi
|
|
# shellcheck disable=SC1091
|
|
. "$ETC/reality.env"
|
|
|
|
# ── 5. server.env ─────────────────────────────────────────────────────────────
|
|
log "writing server.env ..."
|
|
cat > "$ETC/server.env" <<EOF
|
|
DB_DSN=root:$MYSQL_ROOT_PASSWORD@tcp(127.0.0.1:3306)/pangolin?parseTime=true&loc=UTC&multiStatements=true
|
|
REDIS_ADDR=127.0.0.1:6379
|
|
ADDR=:$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
|
|
EOF
|
|
# Optional real SMTP (else LogMailer prints codes to the journal).
|
|
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
|
|
chmod 600 "$ETC/server.env"
|
|
|
|
# ── 6. migrate + seed node ────────────────────────────────────────────────────
|
|
log "running migrations ..."
|
|
DB_DSN="root:$MYSQL_ROOT_PASSWORD@tcp(127.0.0.1:3306)/pangolin?parseTime=true&loc=UTC&multiStatements=true" \
|
|
"$BIN/pangolin-migrate" up
|
|
|
|
log "seeding node row ..."
|
|
SEED="$(mktemp)"
|
|
# REALITY_PRK/PBK/SHORT_ID are sourced from reality.env above.
|
|
# 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_PORT|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"
|
|
docker exec -i pangolin-mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" pangolin < "$SEED"
|
|
rm -f "$SEED"
|
|
|
|
# ── 7. install systemd units & start control plane ───────────────────────────
|
|
log "installing systemd units ..."
|
|
install -m 644 "$HERE/systemd/pangolin-server.service" /etc/systemd/system/
|
|
install -m 644 "$HERE/systemd/pangolin-agent.service" /etc/systemd/system/
|
|
install -m 644 "$HERE/systemd/sing-box.service" /etc/systemd/system/
|
|
systemctl daemon-reload
|
|
|
|
log "starting control plane (pangolin-server) ..."
|
|
systemctl enable --now pangolin-server.service
|
|
# Wait for the gRPC server to accept the agent.
|
|
log "waiting for control plane 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 "control plane healthy."
|
|
|
|
# ── 8. issue bootstrap token + wire agent ────────────────────────────────────
|
|
log "issuing agent bootstrap token ..."
|
|
TOKEN="$(REDIS_ADDR=127.0.0.1:6379 "$BIN/nodectl" bootstrap-token -node="$NODE_UUID")"
|
|
[ -n "$TOKEN" ] || die "failed to issue bootstrap token."
|
|
|
|
# Pre-pin the CA so the agent's first enroll verifies the server (else it falls
|
|
# back to InsecureSkipVerify for that single call).
|
|
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
|
|
chmod 600 "$AGENT_ETC/agent.env"
|
|
|
|
log "enabling sing-box + starting agent ..."
|
|
systemctl enable sing-box.service # agent triggers the first start
|
|
systemctl enable --now pangolin-agent.service
|
|
|
|
# ── 9. summary ────────────────────────────────────────────────────────────────
|
|
log "done. Stack is up on this VPS."
|
|
cat <<EOF
|
|
|
|
Control plane API : http://$VPS_IP:$HTTP_PORT
|
|
Node uuid : $NODE_UUID
|
|
Endpoint (REALITY): $ENDPOINT SNI=$REALITY_SNI
|
|
REALITY public key: $REALITY_PBK
|
|
Hy2 port : $HY2_PORT
|
|
|
|
Next — verify enrollment & data plane:
|
|
journalctl -u pangolin-agent -n 50 --no-pager # expect: enrolled + Register
|
|
systemctl status sing-box --no-pager # expect: active (running)
|
|
cat $SB_ETC/config.json | grep -A2 reality-in # expect: listen_port $REALITY_PORT
|
|
|
|
Then point the client at this box (see deploy/single-node/README.md):
|
|
flutter run -d macos --dart-define=PANGOLIN_API_URL=http://$VPS_IP:$HTTP_PORT
|
|
EOF
|