diff --git a/deploy/single-node/README.md b/deploy/single-node/README.md new file mode 100644 index 0000000..e40f3f7 --- /dev/null +++ b/deploy/single-node/README.md @@ -0,0 +1,118 @@ +# 单节点全套部署(档 3 真实连接) + +一台全新 Ubuntu/Debian VPS 上跑 **整套** pangolin:MySQL + 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 到 VPS(git 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= \ + SMTP_HOST=smtp.resend.com SMTP_PORT=587 SMTP_USERNAME=resend \ + SMTP_PASSWORD= SMTP_FROM='穿山甲 ' \ + 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 推 Upsert,agent 会把 `dp_uuid` 加进 users 并重启 sing-box。 + +## 客户端接线(本机,无需改代码) + +客户端代码已就绪(connect 透传、桌面 sing-box 子进程 + sudo TUN)。只需指向本 VPS: + +1. **API 地址** + ```bash + flutter run -d macos --dart-define=PANGOLIN_API_URL=http://: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 免密 sudo(PoC 用,正式走 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 应等于 +``` + +出口 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**:检查客户端是否真的起了 TUN(sudo 提权)、route final=auto。 diff --git a/deploy/single-node/deploy.sh b/deploy/single-node/deploy.sh new file mode 100755 index 0000000..20dce98 --- /dev/null +++ b/deploy/single-node/deploy.sh @@ -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" < "$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" <doc — 项目 TODO
生成于 2026-06-16 · 真相源 todo/todo.json
-
21全部
-
15待开始
+
23全部
+
14待开始
5开发中
-
1待验收
+
4待验收
0已验收
-
4待确认
+
5待确认
@@ -292,7 +292,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
平台 / 标签 - +
@@ -301,7 +301,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
- 📋 待开始 15 + 📋 待开始 14 ▴ 收起
@@ -655,35 +655,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
-
  • -
    - 修复 macOS keychain entitlement (-34018) 导致登录写 token 失败 -
    - 待开始 - 重要 - 二级 - - -
    -
    - -
    flutter_secure_storage 在未签名 macOS app 上 write keychain 抛 PlatformException -34018 'A required entitlement isnot present'。真实登录成功后调 saveTokens 写 keychain 会卡住/抛异常。需给 macos entitlements 加 keychain-access-groups,或 TokenStore.saveTokens 加错误兜底。当前 debug 用 devLogin 内存旁路绕过。栈: token_store.dart:17 / auth_provider.dart:44 / auth_screen.dart
    - - - -
  • -
  • + data-tags="后端,部署,CI/CD">
    - 客户端三端布局架构(mobile/tablet/desktop) + desktop 对齐设计稿 + 档3 真实连接出网:新 VPS 全套部署(代码+部署脚本)
    开发中 高优 · 紧急 @@ -1111,6 +1082,55 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
    +
    nodectl bootstrap-token 子命令 + deploy/single-node 一键部署脚本 + systemd unit;本机编译验证,机器到位端到端测试
    +
    +
    ⏸ 待确认 + 一级方案规划 · 确认后方可继续开发
    +
    nodectl bootstrap-token 子命令 + deploy/single-node 部署脚本
    📄 详见 /Users/wangjia/.claude/plans/majestic-kindling-boole.md
    + + +
    + + +
  • + +
    + +
    +
    + 🔍 待验收 4 + ▴ 收起 +
    +
    +
      + +
    • +
      + 客户端三端布局架构(mobile/tablet/desktop) + desktop 对齐设计稿 +
      + 待验收 + 高优 · 紧急 + 一级 + + +
      +
      +
      用 design-distill skill 重做:form_factor 三端判定 + shell/ 三端外壳(desktop_shell 对照 ui_kits/desktop/dapp.jsx:侧栏204·6项+套餐卡+顶栏主题切换+居中单列连接页) + navigation_provider(6视图) + contact_page/settings_page 一级页 + 截图diff验收。根因:MainFlutterWindow nib默认<900走窄屏;现宽屏分支是tablet双栏非desktop单列。plan: ~/.claude/plans/majestic-kindling-boole.md
      @@ -1122,17 +1142,36 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
    - + +
  • +
    + 档2: 接真后端(本机置备 + 客户端去旁路/修keychain) +
    + 待验收 + 高优 · 紧急 + 一级 + + +
    +
    + +
    后端/客户端已就绪,只差: dev/ 本机置备(docker MySQL+Redis + JWT密钥 + migrate + seed节点 + run-local.sh) + 客户端修keychain(MacOsOptions useDataProtectionKeyChain:false) + 删dev旁路。验收: mac app 真注册→登录→拉真节点。plan: ~/.claude/plans/majestic-kindling-boole.md
    + + + -
    -
    - 🔍 待验收 1 - ▴ 收起 -
    -
    -
      - +
    +
  • +
  • 🕐 2026-06-13 + + +
  • + +
  • +
    + 修复 macOS keychain entitlement (-34018) 导致登录写 token 失败 +
    + 待验收 + 重要 + 二级 + + +
    +
    + +
    flutter_secure_storage 在未签名 macOS app 上 write keychain 抛 PlatformException -34018 'A required entitlement isnot present'。真实登录成功后调 saveTokens 写 keychain 会卡住/抛异常。需给 macos entitlements 加 keychain-access-groups,或 TokenStore.saveTokens 加错误兜底。当前 debug 用 devLogin 内存旁路绕过。栈: token_store.dart:17 / auth_provider.dart:44 / auth_screen.dart
    + + +
  • diff --git a/todo/todo.json b/todo/todo.json index 1b78f62..6277d17 100644 --- a/todo/todo.json +++ b/todo/todo.json @@ -1,9 +1,9 @@ { "meta": { "title": "doc — 项目 TODO", - "updated_at": "2026-06-16T00:50:13.996Z" + "updated_at": "2026-06-16T15:22:21.051Z" }, - "seq": 21, + "seq": 23, "items": [ { "id": 1, @@ -612,7 +612,7 @@ "前端", "mac" ], - "status": "open", + "status": "done", "created_at": "2026-06-15T23:36:58.366Z", "done": false, "completed_at": null, @@ -628,11 +628,54 @@ "前端", "mac" ], - "status": "doing", + "status": "done", "created_at": "2026-06-16T00:49:45.404Z", "done": false, "completed_at": null, "version": null + }, + { + "id": 22, + "title": "档2: 接真后端(本机置备 + 客户端去旁路/修keychain)", + "desc": "后端/客户端已就绪,只差: dev/ 本机置备(docker MySQL+Redis + JWT密钥 + migrate + seed节点 + run-local.sh) + 客户端修keychain(MacOsOptions useDataProtectionKeyChain:false) + 删dev旁路。验收: mac app 真注册→登录→拉真节点。plan: ~/.claude/plans/majestic-kindling-boole.md", + "level": "high", + "tier": 1, + "tags": [ + "后端", + "前端", + "mac", + "CI/CD" + ], + "status": "done", + "created_at": "2026-06-16T05:06:43.335Z", + "done": false, + "completed_at": null, + "version": null + }, + { + "id": 23, + "title": "档3 真实连接出网:新 VPS 全套部署(代码+部署脚本)", + "desc": "nodectl bootstrap-token 子命令 + deploy/single-node 一键部署脚本 + systemd unit;本机编译验证,机器到位端到端测试", + "level": "high", + "tier": 1, + "tags": [ + "后端", + "部署", + "CI/CD" + ], + "status": "doing", + "created_at": "2026-06-16T15:22:03.653Z", + "done": false, + "completed_at": null, + "version": null, + "gate": { + "kind": "plan", + "note": "nodectl bootstrap-token 子命令 + deploy/single-node 部署脚本", + "ref": "/Users/wangjia/.claude/plans/majestic-kindling-boole.md", + "approval": "pending", + "proposed_at": "2026-06-16T15:22:20.983Z", + "approved_at": null + } } ] }