feat(deploy): 单节点全套部署脚本 deploy/single-node(档 3 真机准备)

一台全新 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>
This commit is contained in:
wangjia
2026-06-17 00:32:47 +08:00
parent 0b6173d9cd
commit 912a495e91
9 changed files with 672 additions and 52 deletions
+118
View File
@@ -0,0 +1,118 @@
# 单节点全套部署(档 3 真实连接)
一台全新 Ubuntu/Debian VPS 上跑 **整套** pangolinMySQL + Redis + 控制面(HTTP
API + gRPC agent 服务)+ 节点 agent + sing-box 数据面。客户端连这台 VPS 的公网
IP,真实出网(出口 IP = VPS IP)。
> 不碰 EC2 生产、不用本机出网。这套是独立的端到端验证环境。
## 架构(一台机器内)
```
客户端 (mac/手机)
│ ① POST /v1/auth/code → 注册/登录 (JWT)
│ ② GET /v1/nodes → 看到本节点 (status=up)
│ ③ POST /v1/nodes/{uuid}/connect → 控制面下发 sing-box CLIENT 配置 (REALITY)
┌───────────────────────────── VPS ──────────────────────────────┐
│ pangolin-server HTTP :8080 + gRPC :9443 │
│ │ connect → Hub.Push(Upsert dp_uuid) │
│ ▼ (gRPC Subscribe 命令流, mTLS) │
│ pangolin-agent ── 渲染 sing-box SERVER 配置 → systemctl restart │
│ ▼ │
│ sing-box VLESS+REALITY :11443 (+ Hysteria2 :443) → 直连出网 │
│ MySQL :3306 (loopback) Redis :6379 (loopback) [docker] │
└─────────────────────────────────────────────────────────────────┘
```
凭证天然自洽:connect 给客户端的 REALITY `uuid = dp_uuid`、Hy2 `password =
DeriveHy2Password(dp_uuid, NODE_DERIVE_KEY)`agent 渲染 server 配置时用 **同一**
`NODE_DERIVE_KEY` 和 dp_uuid,两端一致。
## 部署(VPS 上一条命令)
前置:root、`docker`(含 compose v2)、`openssl``go``sing-box`(脚本会尝试自动装)。
```bash
# 1. 把仓库 checkout 到 VPSgit clone 或 scp
# 2. 一键起全套(VPS_IP 不传则自动探测公网 IP)
sudo VPS_IP=<你的公网IP> bash deploy/single-node/deploy.sh
```
幂等:重跑只刷新二进制 + 配置,**不**轮换 REALITY 密钥 / 数据库(已发的客户端配置不失效)。
### 真发邮件(可选)
不传 SMTP_* 时,注册验证码打到 `journalctl -u pangolin-server`。要真发:
```bash
sudo VPS_IP=<IP> \
SMTP_HOST=smtp.resend.com SMTP_PORT=587 SMTP_USERNAME=resend \
SMTP_PASSWORD=<resend-api-key> SMTP_FROM='穿山甲 <noreply@mail.51yanmei.com>' \
bash deploy/single-node/deploy.sh
```
## 验证(VPS 上)
```bash
journalctl -u pangolin-agent -n 50 --no-pager # enrolled successfully + Register
systemctl status sing-box --no-pager # active (running)
grep -A3 reality-in /etc/sing-box/config.json # listen_port 11443 + 你的 short_id
ss -tlnp | grep 11443 # sing-box 在监听
```
agent 第一次 enroll 后会渲染 `/etc/sing-box/config.json``systemctl restart
sing-box`。在 connect 之前 server 配置里 reality-in 的 users 可能为空(没有已连用户),
客户端 connect 后 Hub 推 Upsertagent 会把 `dp_uuid` 加进 users 并重启 sing-box。
## 客户端接线(本机,无需改代码)
客户端代码已就绪(connect 透传、桌面 sing-box 子进程 + sudo TUN)。只需指向本 VPS
1. **API 地址**
```bash
flutter run -d macos --dart-define=PANGOLIN_API_URL=http://<VPS_IP>:8080
```
2. **本机 sing-box 二进制**:装 `brew install sing-box`,并让 app 能找到它
(环境变量 `PANGOLIN_SINGBOX_BIN=/opt/homebrew/bin/sing-box``--dart-define`
传不进进程 env,用真实环境变量或放到 kernel_process 查找路径)。
3. **macOS TUN 提权**:给 sing-box 免密 sudoPoC 用,正式走 SMJobBless
```
# /etc/sudoers.d/pangolin-singbox
<你的用户名> ALL=(root) NOPASSWD: /opt/homebrew/bin/sing-box
```
4. **真节点 uuid**seed 的节点 uuid 非空,`connection_provider` 会自动走真实分支
`ConnectApi.fetchConfig` → `bridge.start`),无需改代码。
## 验证出口(端到端)
客户端连上后,在本机:
```bash
curl https://api.ipify.org # 出口 IP 应等于 <VPS_IP>
```
出口 IP == VPS IP 即真实远端代理打通。
## 文件 / 路径
| 路径 | 内容 |
|------|------|
| `/etc/pangolin/server.env` | 控制面环境变量(DB/JWT/CA/gRPC/derive key |
| `/etc/pangolin/{ca,grpc}.{key,crt}` | Node CA + gRPC server 证书(CA 签发,SAN localhost |
| `/etc/pangolin/reality.env` | REALITY 密钥对 + short_id(跨重部署稳定) |
| `/etc/pangolin/db.env` | MySQL root 密码 |
| `/etc/pangolin-agent/agent.env` | agent 环境变量(含一次性 bootstrap token |
| `/etc/pangolin-agent/{node.key,node.crt,ca.crt}` | agent enroll 后持久化的 mTLS 身份 |
| `/etc/sing-box/config.json` | agent 渲染的 sing-box SERVER 配置 |
| `/usr/local/bin/{pangolin-server,pangolin-agent,nodectl,pangolin-migrate}` | 二进制 |
## 排查
- **agent 起不来 / enroll 失败**`journalctl -u pangolin-agent`。检查
`pangolin-server` 是否已起 gRPC(日志应有 gRPC,不是 "gRPC not configured")——
需要 server.env 里 `GRPC_ADDR` + `CA_*` + `GRPC_*` 全配(deploy.sh 已配好)。
- **sing-box 反复重启**:首次 enroll 前 `/etc/sing-box/config.json` 不存在,属正常;
enroll 后应自愈。仍失败看 `journalctl -u sing-box`,多半是 REALITY 配置/端口占用。
- **客户端 connect 返回 4xx**:确认 JWT 有效(先登录)、节点 `status='up'`。
- **出口 IP 不是 VPS**:检查客户端是否真的起了 TUNsudo 提权)、route final=auto。
+272
View File
@@ -0,0 +1,272 @@
#!/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
+34
View File
@@ -0,0 +1,34 @@
# 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:
+28
View File
@@ -0,0 +1,28 @@
-- Single-node seed: 1 provider + 1 status='up' node, so GET /v1/nodes returns
-- this box and the client can connect to it. Placeholders (__VAR__) are filled
-- by deploy.sh from the generated REALITY keypair + the VPS public IP.
--
-- Idempotent: fixed uuid, delete-then-insert so re-running deploy.sh refreshes
-- the row without duplicating it. The REALITY keys MUST stay stable across
-- re-deploys (deploy.sh persists them in /etc/pangolin/reality.env) or already
-- issued client configs would break.
DELETE FROM nodes WHERE uuid = '__NODE_UUID__';
DELETE FROM providers WHERE name = 'single-node';
INSERT INTO providers (name, api_kind, regions, pool, enabled)
VALUES ('single-node', 'manual', '["__REGION__"]', 'consumable', TRUE);
INSERT INTO nodes
(uuid, region, name_zh, name_en, role, tier, endpoint, hy2_port,
reality_pbk, reality_prk, reality_short_id, reality_sni, provider_id, status, weight)
VALUES (
'__NODE_UUID__',
'__REGION__', '__NAME_ZH__', '__NAME_EN__',
'exit', 'free',
'__ENDPOINT__', __HY2_PORT__,
'__REALITY_PBK__', '__REALITY_PRK__', '__REALITY_SHORT_ID__',
'__REALITY_SNI__',
(SELECT id FROM providers WHERE name = 'single-node' LIMIT 1),
'up', 100
);
@@ -0,0 +1,18 @@
[Unit]
Description=Pangolin node agent (self-enrolls, manages local sing-box)
Documentation=https://github.com/wangjia/pangolin
After=network-online.target pangolin-server.service
Wants=network-online.target
Requires=pangolin-server.service
[Service]
Type=simple
EnvironmentFile=/etc/pangolin-agent/agent.env
ExecStart=/usr/local/bin/pangolin-agent
Restart=on-failure
RestartSec=5
# The agent calls `systemctl restart sing-box` after rendering config.
User=root
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,18 @@
[Unit]
Description=Pangolin control plane (HTTP API + gRPC agent server)
Documentation=https://github.com/wangjia/pangolin
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/pangolin/server.env
ExecStart=/usr/local/bin/pangolin-server
Restart=on-failure
RestartSec=3
# Control plane holds JWT/CA keys; keep them owner-only.
UMask=0077
User=root
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,21 @@
[Unit]
Description=sing-box data plane (pangolin node server)
Documentation=https://sing-box.sagernet.org
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Config is rendered by pangolin-agent; this unit may fail-restart until the
# agent's first Register populates /etc/sing-box/config.json — that is expected
# on a fresh box and self-heals once the agent enrolls.
ExecStart=/usr/local/bin/sing-box run -c /etc/sing-box/config.json
Restart=on-failure
RestartSec=5
LimitNOFILE=1048576
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
User=root
[Install]
WantedBy=multi-user.target