feat(single-node): 改 SQLite + 非特权 pangolin 用户 + 免 Docker(512MB 可跑)
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled

为小内存机收尾单机栈,已在 racknerd(107.172.55.251)实跑验证通过:
- 数据库:MySQL(docker)→ SQLite 文件(DB_DRIVER=sqlite);Redis 改 apt 原生
  (免 Docker);依赖 redis-server/sqlite3/polkitd/sing-box 由 deploy.sh 自动装
- 运行身份:三 systemd 单元 User=root → User=pangolin;sing-box 经
  AmbientCapabilities 绑 443;新增 polkit 规则让 pangolin 仅可重启 sing-box
- 二进制:支持预置(机上无 go 时用交叉编译产物;SQLite 纯 Go 免 CGO)
- 端口:REALITY 默认 443/tcp(对齐 bootstrap 防火墙);deploy.sh 放行 8080/tcp
- 修 sing-box.service:ExecStart 按实际安装路径渲染(官方 .deb 在 /usr/bin)
- Hy2 默认关闭:控制面尚未下发 Hy2 TLS 证书(handler_grpc.go 仅发 ListenPort)
  → 开启会 missing certificate;待补全证书后传 HY2_PORT=443 开启
- 删 docker-compose.yml;seed TRUE→1;README 同步

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-18 07:46:54 +08:00
parent 1d76c00f66
commit 5d90610649
8 changed files with 181 additions and 181 deletions
+27 -12
View File
@@ -1,10 +1,11 @@
# 单节点全套部署(档 3 真实连接) # 单节点全套部署(真实连接,SQLite + 非特权用户
一台全新 Ubuntu/Debian VPS 上跑 **整套** pangolinMySQL + Redis + 控制面HTTP 一台全新 Debian 12 VPS 上跑 **整套** pangolin**SQLite(文件)+ Redisapt** + 控制面
API + gRPC agent 服务)+ 节点 agent + sing-box 数据面。客户端连这台 VPS 的公网 HTTP API + gRPC agent 服务)+ 节点 agent + sing-box 数据面**全部以非特权 `pangolin`
IP真实出网(出口 IP = VPS IP)。 用户运行,免 Docker**。客户端连这台 VPS 的公网 IP,真实出网(出口 IP = VPS IP)。
> 不碰 EC2 生产、不用本机出网。这套是独立的端到端验证环境 > 为小内存机(512MB)调优:SQLite 取代 MySQL、apt 原生 Redis 取代 docker 容器
> 配合 `deploy/bootstrap/`(系统加固/防火墙/监控)使用。
## 架构(一台机器内) ## 架构(一台机器内)
@@ -19,23 +20,37 @@ IP,真实出网(出口 IP = VPS IP)。
│ │ connect → Hub.Push(Upsert dp_uuid) │ │ │ connect → Hub.Push(Upsert dp_uuid) │
│ ▼ (gRPC Subscribe 命令流, mTLS) │ │ ▼ (gRPC Subscribe 命令流, mTLS) │
│ pangolin-agent ── 渲染 sing-box SERVER 配置 → systemctl restart │ │ pangolin-agent ── 渲染 sing-box SERVER 配置 → systemctl restart │
│ ▼ │ ▼ (polkit 规则允许 pangolin 仅重启 sing-box)
│ sing-box VLESS+REALITY :11443 (+ Hysteria2 :443) → 直连出网 │ sing-box VLESS+REALITY :443/tcp → 直连出网
MySQL :3306 (loopback) Redis :6379 (loopback) [docker] │ SQLite /var/lib/pangolin/pangolin.db Redis :6379 (apt, loopback) │
└─────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────┘
(三服务均 User=pangolin;sing-box 经 AmbientCapabilities 绑 443
``` ```
> ⚠️ **Hy2 默认关闭**:控制面尚未接线 Hy2 的 TLS 证书下发(`handler_grpc.go` 只发
> ListenPort,不发 CertPath/KeyPath),开启会导致 sing-box `missing certificate`。
> 待补全证书下发后,传 `HY2_PORT=443` 开启。当前数据面=REALITY(443/tcp)。
凭证天然自洽:connect 给客户端的 REALITY `uuid = dp_uuid`、Hy2 `password = 凭证天然自洽:connect 给客户端的 REALITY `uuid = dp_uuid`、Hy2 `password =
DeriveHy2Password(dp_uuid, NODE_DERIVE_KEY)`agent 渲染 server 配置时用 **同一** DeriveHy2Password(dp_uuid, NODE_DERIVE_KEY)`agent 渲染 server 配置时用 **同一**
`NODE_DERIVE_KEY` 和 dp_uuid,两端一致。 `NODE_DERIVE_KEY` 和 dp_uuid,两端一致。
## 部署VPS 上一条命令) ## 部署
前置root、`docker`(含 compose v2)、`openssl``go``sing-box`(脚本会尝试自动装 前置:root、`openssl``redis-server`/`sqlite3`/`polkitd`/`sing-box` 由 deploy.sh 自动装。
二进制:机上有 `go` 则就地构建;**512MB 机器推荐别处交叉编译后预置**(无需机上装 Go):
```bash ```bash
# 1. 把仓库 checkout 到 VPSgit clone 或 scp # 在开发机交叉编译(SQLite 纯 Go,免 CGO),scp 到 VPS 的 /usr/local/bin/
# 2. 一键起全套(VPS_IP 不传则自动探测公网 IP) cd server && for c in server agent nodectl migrate; do
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /tmp/p-$c ./cmd/$c
done
scp /tmp/p-server <vps>:/usr/local/bin/pangolin-server
scp /tmp/p-agent <vps>:/usr/local/bin/pangolin-agent
scp /tmp/p-migrate <vps>:/usr/local/bin/pangolin-migrate
scp /tmp/p-nodectl <vps>:/usr/local/bin/nodectl
# scp 本目录到 VPS 的 <repo>/deploy/single-node/,再一键起(VPS_IP 不传则自动探测)
sudo VPS_IP=<你的公网IP> bash deploy/single-node/deploy.sh sudo VPS_IP=<你的公网IP> bash deploy/single-node/deploy.sh
``` ```
+133 -127
View File
@@ -1,27 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# deploy.sh — one-command single-node deploy of the FULL pangolin stack on a # deploy.sh — 单机全栈部署,为小内存机(512MB)调优:SQLite(文件)+ Redis(apt)
# fresh Ubuntu/Debian VPS: MySQL + Redis (docker) + control plane (HTTP+gRPC) + # + 控制面 + agent + sing-box,全部以**非特权 pangolin 用户**运行。免 Docker。
# 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 # 幂等:重跑只刷新二进制/配置,不轮换 REALITY 密钥或数据库(已发的客户端配置不失效)。
# keypair or datastore (so already-issued client configs keep working).
# #
# Usage (as root, from the repo checkout on the VPS): # 用法(root):
# sudo VPS_IP=203.0.113.10 bash deploy/single-node/deploy.sh # sudo VPS_IP=1.2.3.4 bash deploy/single-node/deploy.sh
# #
# Required: # 二进制:机上有 `go` 则就地构建;否则需**预先**把 pangolin-{server,agent,migrate}+nodectl
# VPS_IP public IPv4 the client dials (auto-detected if unset) # 放进 /usr/local/bin(512MB 推荐:别处交叉编译 `CGO_ENABLED=0 GOOS=linux GOARCH=amd64`,
# Optional (sensible defaults): # 因 SQLite 走纯 Go 的 modernc,无需 CGO)。
# 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 set -euo pipefail
# ── 0. config & preflight ────────────────────────────────────────────────────
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
HERE="$REPO_ROOT/deploy/single-node" HERE="$REPO_ROOT/deploy/single-node"
@@ -32,104 +22,115 @@ NAME_ZH="${NAME_ZH:-单节点 · 测试}"
NAME_EN="${NAME_EN:-Single Node}" NAME_EN="${NAME_EN:-Single Node}"
HTTP_PORT="${HTTP_PORT:-8080}" HTTP_PORT="${HTTP_PORT:-8080}"
GRPC_PORT="${GRPC_PORT:-9443}" GRPC_PORT="${GRPC_PORT:-9443}"
REALITY_PORT="${REALITY_PORT:-11443}" REALITY_PORT="${REALITY_PORT:-443}" # 与 bootstrap 防火墙放行的 443/tcp 对齐
HY2_PORT="${HY2_PORT:-443}" # Hy2 默认【关闭】:控制面尚未接线 Hy2 TLS 证书(handler_grpc.go 仅发 ListenPort,
# 不发 CertPath/KeyPath)→ 开启会令 sing-box 报 "missing certificate"。待代码补全
# Hy2 证书下发后再传 HY2_PORT=443 开启。
HY2_PORT="${HY2_PORT:-}"
ETC="/etc/pangolin" ETC="/etc/pangolin"
AGENT_ETC="/etc/pangolin-agent" AGENT_ETC="/etc/pangolin-agent"
SB_ETC="/etc/sing-box" SB_ETC="/etc/sing-box"
DATA_DIR="/var/lib/pangolin"
DB_FILE="$DATA_DIR/pangolin.db"
BIN="/usr/local/bin" BIN="/usr/local/bin"
PUSER="pangolin"
log() { printf '\033[1;32m[deploy]\033[0m %s\n' "$*"; } log() { printf '\033[1;32m[deploy]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[deploy]\033[0m %s\n' "$*" >&2; } warn() { printf '\033[1;33m[deploy]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[deploy]\033[0m %s\n' "$*" >&2; exit 1; } die() { printf '\033[1;31m[deploy]\033[0m %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "must run as root (sudo)." [ "$(id -u)" -eq 0 ] || die "must run as root (sudo)."
command -v openssl >/dev/null 2>&1 || die "missing dependency: openssl"
for c in openssl docker go; do export DEBIAN_FRONTEND=noninteractive
command -v "$c" >/dev/null 2>&1 || die "missing dependency: $c (install it first)."
# ── 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 done
docker compose version >/dev/null 2>&1 || die "docker compose v2 plugin required." 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 if ! command -v sing-box >/dev/null 2>&1; then
warn "sing-box not found — attempting official install ..." warn "sing-box 未安装 — 尝试官方脚本安装 ..."
curl -fsSL https://sing-box.app/install.sh | sh \ curl -fsSL https://sing-box.app/install.sh | sh \
|| die "sing-box install failed; install it manually then re-run." || die "sing-box 安装失败;请手动装后重跑。"
fi fi
if [ -z "${VPS_IP:-}" ]; then if [ -z "${VPS_IP:-}" ]; then
VPS_IP="$(curl -fsS --max-time 5 https://api.ipify.org || true)" 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." [ -n "$VPS_IP" ] || die "无法自动探测 VPS_IP;请显式传 VPS_IP=..."
log "auto-detected VPS_IP=$VPS_IP" log "自动探测 VPS_IP=$VPS_IP"
fi fi
ENDPOINT="$VPS_IP:$REALITY_PORT" ENDPOINT="$VPS_IP:$REALITY_PORT"
mkdir -p "$ETC" "$AGENT_ETC" "$SB_ETC" mkdir -p "$ETC" "$AGENT_ETC" "$SB_ETC" "$DATA_DIR"
chmod 700 "$ETC" "$AGENT_ETC"
# ── 1. build & install binaries ────────────────────────────────────────────── # ── 2. 二进制(有 go 则构建;否则用预置)──────────────────────────────────────
log "building binaries (server, agent, nodectl, migrate) ..." 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 cd "$REPO_ROOT/server"
go build -o "$BIN/pangolin-tmp-$cmd" "./cmd/$cmd" 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 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 fi
# shellcheck disable=SC1091
. "$ETC/db.env"
log "starting MySQL + Redis ..." # ── 3. Redis(apt 原生 systemd 服务,绑 127.0.0.1)──────────────────────────────
docker compose --env-file "$ETC/db.env" -f "$HERE/docker-compose.yml" up -d log "启动 Redis ..."
log "waiting for MySQL ..." systemctl enable --now redis-server >/dev/null 2>&1 || systemctl enable --now redis >/dev/null 2>&1 || true
until docker exec pangolin-mysql mysqladmin ping -uroot -p"$MYSQL_ROOT_PASSWORD" --silent >/dev/null 2>&1; do until redis-cli ping >/dev/null 2>&1; do sleep 1; done
sleep 1 log "Redis ready."
done
log "MySQL ready."
# ── 3. control-plane secrets (idempotent) ──────────────────────────────────── # ── 4. 控制面密钥(幂等)──────────────────────────────────────────────────────
if [ ! -f "$ETC/jwt_private.pem" ]; then if [ ! -f "$ETC/jwt_private.pem" ]; then
log "generating RS256 JWT keypair ..." log "生成 RS256 JWT 密钥对 ..."
openssl genrsa -out "$ETC/jwt_private.pem" 2048 openssl genrsa -out "$ETC/jwt_private.pem" 2048
openssl rsa -in "$ETC/jwt_private.pem" -pubout -out "$ETC/jwt_public.pem" openssl rsa -in "$ETC/jwt_private.pem" -pubout -out "$ETC/jwt_public.pem"
chmod 600 "$ETC/jwt_private.pem"
fi fi
[ -f "$ETC/webhook_secret" ] || openssl rand -hex 32 > "$ETC/webhook_secret" [ -f "$ETC/webhook_secret" ] || openssl rand -hex 32 > "$ETC/webhook_secret"
[ -f "$ETC/derive_key" ] || openssl rand -hex 32 > "$ETC/derive_key" [ -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")" WEBHOOK_SECRET="$(cat "$ETC/webhook_secret")"
NODE_DERIVE_KEY="$(cat "$ETC/derive_key")" NODE_DERIVE_KEY="$(cat "$ETC/derive_key")"
# Node CA: generate here (ECDSA P-256, SEC1 "EC PRIVATE KEY" — the format # Node CA(ECDSA P-256,SEC1 "EC PRIVATE KEY";mtls.NewCA 读它)
# 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 if [ ! -f "$ETC/ca.key" ] || [ ! -f "$ETC/ca.crt" ]; then
log "generating Node CA ..." log "生成 Node CA ..."
openssl ecparam -name prime256v1 -genkey -noout -out "$ETC/ca.key" openssl ecparam -name prime256v1 -genkey -noout -out "$ETC/ca.key"
openssl req -x509 -new -key "$ETC/ca.key" -days 3650 -out "$ETC/ca.crt" \ openssl req -x509 -new -key "$ETC/ca.key" -days 3650 -out "$ETC/ca.crt" \
-subj "/O=Pangolin/CN=Pangolin Node CA" \ -subj "/O=Pangolin/CN=Pangolin Node CA" \
-addext "basicConstraints=critical,CA:TRUE" \ -addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign" -addext "keyUsage=critical,keyCertSign,cRLSign"
chmod 600 "$ETC/ca.key"
fi fi
# gRPC server cert: CA-signed, serverAuth EKU, SAN localhost+127.0.0.1 (the agent # gRPC server cert(CA 签发,SAN localhost+127.0.0.1)
# dials localhost:GRPC_PORT and validates the cert against the Node CA).
if [ ! -f "$ETC/grpc.key" ] || [ ! -f "$ETC/grpc.crt" ]; then if [ ! -f "$ETC/grpc.key" ] || [ ! -f "$ETC/grpc.crt" ]; then
log "issuing gRPC server cert (signed by Node CA) ..." log "签发 gRPC server 证书 ..."
openssl ecparam -name prime256v1 -genkey -noout -out "$ETC/grpc.key" openssl ecparam -name prime256v1 -genkey -noout -out "$ETC/grpc.key"
openssl req -new -key "$ETC/grpc.key" -out "$ETC/grpc.csr" \ openssl req -new -key "$ETC/grpc.key" -out "$ETC/grpc.csr" -subj "/CN=pangolin-control-plane"
-subj "/CN=pangolin-control-plane"
EXT="$(mktemp)" EXT="$(mktemp)"
cat > "$EXT" <<EOF cat > "$EXT" <<EOF
subjectAltName=DNS:localhost,IP:127.0.0.1 subjectAltName=DNS:localhost,IP:127.0.0.1
@@ -138,31 +139,30 @@ EOF
openssl x509 -req -in "$ETC/grpc.csr" -CA "$ETC/ca.crt" -CAkey "$ETC/ca.key" \ openssl x509 -req -in "$ETC/grpc.csr" -CA "$ETC/ca.crt" -CAkey "$ETC/ca.key" \
-CAcreateserial -days 825 -out "$ETC/grpc.crt" -extfile "$EXT" -CAcreateserial -days 825 -out "$ETC/grpc.crt" -extfile "$EXT"
rm -f "$EXT" "$ETC/grpc.csr" rm -f "$EXT" "$ETC/grpc.csr"
chmod 600 "$ETC/grpc.key"
fi fi
# ── 4. REALITY keypair + short_id (persisted; stable across re-deploys) ─────── # ── 5. REALITY 密钥对(持久,跨重部署稳定)─────────────────────────────────────
if [ ! -f "$ETC/reality.env" ]; then if [ ! -f "$ETC/reality.env" ]; then
log "generating REALITY keypair ..." log "生成 REALITY 密钥对 ..."
KP="$(sing-box generate reality-keypair)" KP="$(sing-box generate reality-keypair)"
R_PRK="$(printf '%s\n' "$KP" | awk '/PrivateKey/{print $2}')" R_PRK="$(printf '%s\n' "$KP" | awk '/PrivateKey/{print $2}')"
R_PBK="$(printf '%s\n' "$KP" | awk '/PublicKey/{print $2}')" R_PBK="$(printf '%s\n' "$KP" | awk '/PublicKey/{print $2}')"
R_SID="$(openssl rand -hex 8)" R_SID="$(openssl rand -hex 8)"
[ -n "$R_PRK" ] && [ -n "$R_PBK" ] || die "failed to parse reality-keypair output." [ -n "$R_PRK" ] && [ -n "$R_PBK" ] || die "解析 reality-keypair 失败。"
{ {
printf 'REALITY_PRK=%s\n' "$R_PRK" printf 'REALITY_PRK=%s\n' "$R_PRK"
printf 'REALITY_PBK=%s\n' "$R_PBK" printf 'REALITY_PBK=%s\n' "$R_PBK"
printf 'REALITY_SHORT_ID=%s\n' "$R_SID" printf 'REALITY_SHORT_ID=%s\n' "$R_SID"
} > "$ETC/reality.env" } > "$ETC/reality.env"
chmod 600 "$ETC/reality.env"
fi fi
# shellcheck disable=SC1091 # shellcheck disable=SC1091
. "$ETC/reality.env" . "$ETC/reality.env"
# ── 5. server.env ───────────────────────────────────────────────────────────── # ── 6. server.env(SQLite)─────────────────────────────────────────────────────
log "writing server.env ..." log " server.env(DB_DRIVER=sqlite)..."
cat > "$ETC/server.env" <<EOF cat > "$ETC/server.env" <<EOF
DB_DSN=root:$MYSQL_ROOT_PASSWORD@tcp(127.0.0.1:3306)/pangolin?parseTime=true&loc=UTC&multiStatements=true DB_DRIVER=sqlite
DB_DSN=$DB_FILE
REDIS_ADDR=127.0.0.1:6379 REDIS_ADDR=127.0.0.1:6379
ADDR=:$HTTP_PORT ADDR=:$HTTP_PORT
JWT_PRIVATE_KEY_PATH=$ETC/jwt_private.pem JWT_PRIVATE_KEY_PATH=$ETC/jwt_private.pem
@@ -176,7 +176,6 @@ CA_CERT_PATH=$ETC/ca.crt
GRPC_CERT_PATH=$ETC/grpc.crt GRPC_CERT_PATH=$ETC/grpc.crt
GRPC_KEY_PATH=$ETC/grpc.key GRPC_KEY_PATH=$ETC/grpc.key
EOF EOF
# Optional real SMTP (else LogMailer prints codes to the journal).
if [ -n "${SMTP_HOST:-}" ]; then if [ -n "${SMTP_HOST:-}" ]; then
cat >> "$ETC/server.env" <<EOF cat >> "$ETC/server.env" <<EOF
SMTP_HOST=$SMTP_HOST SMTP_HOST=$SMTP_HOST
@@ -186,16 +185,14 @@ SMTP_PASSWORD=${SMTP_PASSWORD:-}
SMTP_FROM=${SMTP_FROM:-no-reply@pangolin.app} SMTP_FROM=${SMTP_FROM:-no-reply@pangolin.app}
EOF EOF
fi fi
chmod 600 "$ETC/server.env"
# ── 6. migrate + seed node ──────────────────────────────────────────────────── # ── 7. 迁移 + seed(SQLite)────────────────────────────────────────────────────
log "running migrations ..." log "执行迁移(sqlite)..."
DB_DSN="root:$MYSQL_ROOT_PASSWORD@tcp(127.0.0.1:3306)/pangolin?parseTime=true&loc=UTC&multiStatements=true" \ DB_DRIVER=sqlite DB_DSN="$DB_FILE" "$BIN/pangolin-migrate" up
"$BIN/pangolin-migrate" up
log "seeding node row ..." log "seed 节点行 ..."
SEED="$(mktemp)" SEED="$(mktemp)"
# REALITY_PRK/PBK/SHORT_ID are sourced from reality.env above. HY2_SEED="${HY2_PORT:-NULL}" # 空 → SQL NULL(不下发 Hy2)
# shellcheck disable=SC2153 # shellcheck disable=SC2153
sed \ sed \
-e "s|__NODE_UUID__|$NODE_UUID|g" \ -e "s|__NODE_UUID__|$NODE_UUID|g" \
@@ -203,40 +200,20 @@ sed \
-e "s|__NAME_ZH__|$NAME_ZH|g" \ -e "s|__NAME_ZH__|$NAME_ZH|g" \
-e "s|__NAME_EN__|$NAME_EN|g" \ -e "s|__NAME_EN__|$NAME_EN|g" \
-e "s|__ENDPOINT__|$ENDPOINT|g" \ -e "s|__ENDPOINT__|$ENDPOINT|g" \
-e "s|__HY2_PORT__|$HY2_PORT|g" \ -e "s|__HY2_PORT__|$HY2_SEED|g" \
-e "s|__REALITY_PBK__|$REALITY_PBK|g" \ -e "s|__REALITY_PBK__|$REALITY_PBK|g" \
-e "s|__REALITY_PRK__|$REALITY_PRK|g" \ -e "s|__REALITY_PRK__|$REALITY_PRK|g" \
-e "s|__REALITY_SHORT_ID__|$REALITY_SHORT_ID|g" \ -e "s|__REALITY_SHORT_ID__|$REALITY_SHORT_ID|g" \
-e "s|__REALITY_SNI__|$REALITY_SNI|g" \ -e "s|__REALITY_SNI__|$REALITY_SNI|g" \
"$HERE/seed-node.sql.tmpl" > "$SEED" "$HERE/seed-node.sql.tmpl" > "$SEED"
docker exec -i pangolin-mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" pangolin < "$SEED" sqlite3 "$DB_FILE" < "$SEED"
rm -f "$SEED" rm -f "$SEED"
# ── 7. install systemd units & start control plane ─────────────────────────── # ── 8. 发 bootstrap token + 写 agent.env ──────────────────────────────────────
log "installing systemd units ..." log "签发 agent bootstrap token ..."
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")" TOKEN="$(REDIS_ADDR=127.0.0.1:6379 "$BIN/nodectl" bootstrap-token -node="$NODE_UUID")"
[ -n "$TOKEN" ] || die "failed to issue bootstrap token." [ -n "$TOKEN" ] || die "签发 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" cp -f "$ETC/ca.crt" "$AGENT_ETC/ca.crt"
cat > "$AGENT_ETC/agent.env" <<EOF cat > "$AGENT_ETC/agent.env" <<EOF
PANGOLIN_AGENT_CONTROL_PLANE=localhost:$GRPC_PORT PANGOLIN_AGENT_CONTROL_PLANE=localhost:$GRPC_PORT
PANGOLIN_AGENT_SERVER_NAME=localhost PANGOLIN_AGENT_SERVER_NAME=localhost
@@ -246,27 +223,56 @@ PANGOLIN_AGENT_SINGBOX_CONFIG=$SB_ETC/config.json
PANGOLIN_AGENT_SINGBOX_UNIT=sing-box PANGOLIN_AGENT_SINGBOX_UNIT=sing-box
PANGOLIN_AGENT_STATE_DIR=$AGENT_ETC PANGOLIN_AGENT_STATE_DIR=$AGENT_ETC
EOF EOF
chmod 600 "$AGENT_ETC/agent.env"
log "enabling sing-box + starting agent ..." # ── 9. 降权:目录/文件归 pangolin ─────────────────────────────────────────────
systemctl enable sing-box.service # agent triggers the first start 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/
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 systemctl enable --now pangolin-agent.service
# ── 9. summary ──────────────────────────────────────────────────────────────── # 放行控制面 API 端口(若 ufw 启用)。⚠️ 明文,生产应前置 TLS。
log "done. Stack is up on this VPS." if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then
ufw allow "${HTTP_PORT}/tcp" >/dev/null 2>&1 || true
log "ufw 放行 ${HTTP_PORT}/tcp(控制面 API,明文)"
fi
# ── 11. 摘要 ──────────────────────────────────────────────────────────────────
log "完成。单机栈已起(SQLite + pangolin 用户)。"
cat <<EOF cat <<EOF
Control plane API : http://$VPS_IP:$HTTP_PORT 控制面 API : http://$VPS_IP:$HTTP_PORT (⚠️ 明文;防火墙需放行 ${HTTP_PORT}/tcp)
Node uuid : $NODE_UUID Node uuid : $NODE_UUID
Endpoint (REALITY): $ENDPOINT SNI=$REALITY_SNI Endpoint : $ENDPOINT SNI=$REALITY_SNI (REALITY ${REALITY_PORT}/tcp)
REALITY public key: $REALITY_PBK REALITY pbk : $REALITY_PBK
Hy2 port : $HY2_PORT Hy2 : ${HY2_PORT:+${HY2_PORT}/udp}${HY2_PORT:-关闭(控制面 Hy2 证书未接线)}
Next — verify enrollment & data plane: 验证:
journalctl -u pangolin-agent -n 50 --no-pager # expect: enrolled + Register journalctl -u pangolin-agent -n 50 --no-pager # enrolled + Register
systemctl status sing-box --no-pager # expect: active (running) systemctl status sing-box --no-pager # active (running)
cat $SB_ETC/config.json | grep -A2 reality-in # expect: listen_port $REALITY_PORT grep -A2 reality-in $SB_ETC/config.json # listen_port $REALITY_PORT
ss -tlnp | grep -E '$HTTP_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 EOF
-34
View File
@@ -1,34 +0,0 @@
# Single-node datastores: MySQL + Redis, bound to loopback only (the control
# plane runs on the same host). Credentials come from db.env (generated by
# deploy.sh, gitignored). Standard ports — this is a dedicated box.
services:
mysql:
image: mysql:8.0
container_name: pangolin-mysql
restart: unless-stopped
command: ["--default-authentication-plugin=caching_sha2_password"]
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?set in db.env}
MYSQL_DATABASE: pangolin
ports:
- "127.0.0.1:3306:3306"
volumes:
- pangolin-mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-uroot", "-p${MYSQL_ROOT_PASSWORD}", "--silent"]
interval: 5s
timeout: 5s
retries: 20
redis:
image: redis:7-alpine
container_name: pangolin-redis
restart: unless-stopped
ports:
- "127.0.0.1:6379:6379"
volumes:
- pangolin-redis-data:/data
volumes:
pangolin-mysql-data:
pangolin-redis-data:
@@ -0,0 +1,10 @@
// 允许非特权 pangolin 用户管理【仅】sing-box.service —— pangolin-agent 渲染完
// sing-box 配置后会 `systemctl restart sing-box`,非 root 需 polkit 放行。
// 不授予对其它单元/系统的任何权限。
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.systemd1.manage-units" &&
action.lookup("unit") == "sing-box.service" &&
subject.user == "pangolin") {
return polkit.Result.YES;
}
});
+1 -1
View File
@@ -11,7 +11,7 @@ DELETE FROM nodes WHERE uuid = '__NODE_UUID__';
DELETE FROM providers WHERE name = 'single-node'; DELETE FROM providers WHERE name = 'single-node';
INSERT INTO providers (name, api_kind, regions, pool, enabled) INSERT INTO providers (name, api_kind, regions, pool, enabled)
VALUES ('single-node', 'manual', '["__REGION__"]', 'consumable', TRUE); VALUES ('single-node', 'manual', '["__REGION__"]', 'consumable', 1);
INSERT INTO nodes INSERT INTO nodes
(uuid, region, name_zh, name_en, role, tier, endpoint, hy2_port, (uuid, region, name_zh, name_en, role, tier, endpoint, hy2_port,
@@ -11,8 +11,10 @@ EnvironmentFile=/etc/pangolin-agent/agent.env
ExecStart=/usr/local/bin/pangolin-agent ExecStart=/usr/local/bin/pangolin-agent
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
# The agent calls `systemctl restart sing-box` after rendering config. # The agent calls `systemctl restart sing-box` after rendering config;
User=root # a polkit rule (49-pangolin-singbox.rules) lets the unprivileged pangolin
# user manage *only* sing-box.service.
User=pangolin
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
@@ -1,8 +1,8 @@
[Unit] [Unit]
Description=Pangolin control plane (HTTP API + gRPC agent server) Description=Pangolin control plane (HTTP API + gRPC agent server)
Documentation=https://github.com/wangjia/pangolin Documentation=https://github.com/wangjia/pangolin
After=network-online.target docker.service After=network-online.target redis-server.service
Wants=network-online.target Wants=network-online.target redis-server.service
[Service] [Service]
Type=simple Type=simple
@@ -10,9 +10,9 @@ EnvironmentFile=/etc/pangolin/server.env
ExecStart=/usr/local/bin/pangolin-server ExecStart=/usr/local/bin/pangolin-server
Restart=on-failure Restart=on-failure
RestartSec=3 RestartSec=3
# Control plane holds JWT/CA keys; keep them owner-only. # Control plane holds JWT/CA keys; keep them owner-only. Runs unprivileged.
UMask=0077 UMask=0077
User=root User=pangolin
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+2 -1
View File
@@ -13,9 +13,10 @@ ExecStart=/usr/local/bin/sing-box run -c /etc/sing-box/config.json
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
LimitNOFILE=1048576 LimitNOFILE=1048576
# 非特权运行;AmbientCapabilities 让 pangolin 也能绑 443(REALITY/Hy2)。
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
User=root User=pangolin
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target