#!/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" < "$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" <> "$ETC/server.env" < "$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" <