Compare commits

..

28 Commits

Author SHA1 Message Date
wangjia 973c2c021b fix(ci): 网络操作加超时(runner 长任务 ~14min 无日志崩溃 → 快失败可诊断)
Deploy Server / deploy-server (push) Successful in 3m24s
Setup Go 下载 + Forgejo GET/create/upload 的 curl 原本无 --max-time,网络停顿即
无限挂起(疑似 runner 长任务崩溃且不留日志的根因)。加 --max-time(+GET/下载 retry),
让停顿在超时内快失败并留下日志,而非静默 14 分钟后崩。POST 不 retry(避免重复建/传)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 18:43:59 +08:00
wangjia f1950ffb6f fix(ci): release-server 首次实跑修复 + go 工具链 sha256 pin
Deploy Server / deploy-server (push) Failing after 13m45s
- lib-forgejo/release-server: 5 处 read -r X < file 在 set -e 下遇无结尾换行的
  EOF 会返回 1 静默中止(ver_from_tag printf 无换行、curl -w http_code 无换行);
  逐处加 || true 容错(值已赋)。
- lib-forgejo: 空数组 ${FORGEJO_CURL_TLS[@]} 在 set -u 下老 bash 报 unbound;
  改 empty-safe 展开(可移植 + 本地可测)。
- deploy-server.yml: Setup Go 加 sha256 校验(供应链完整性, 防镜像篡改注入工具链)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 18:04:51 +08:00
wangjia 8850c94e96 fix(ci): deploy-server 先装 go(runner 无 go → compile exit 127)
Deploy Server / deploy-server (push) Failing after 2m16s
catthehacker ubuntu:act-latest 自带 node 不带 go;直接 go build 报 command not found。
加 Setup Go 步骤从 golang.google.cn(CN 镜像, 避开墙内不稳的 go.dev)装 1.25.10。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 17:55:33 +08:00
wangjia 4d67a90659 fix(ci): deploy-server 隧道 healthcheck 改非致命(本地 healthz 才是权威闸)
Deploy Server / deploy-server (push) Failing after 22s
隧道 /healthz 依赖 cloudflared/CF 边缘,与本次二进制是否健康是两回事;
CF 边缘抖动或隧道尚未 provision 不应判整次部署失败。改为失败仅告警。
(最终 whole-branch review 的 Minor A 决议)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 16:59:52 +08:00
wangjia cccbb7b56b docs: 控制面 CF Tunnel/端口布局说明 + 方案 HTML 登记 index
CLAUDE.md 补充控制面经 Cloudflare Tunnel 对外的端口/URL 布局说明;生成
docs/control-plane-tls-tunnel.html 阅读版并登记 docs/index.html「实现计划」;
顺带修正 deploy/single-node/deploy.sh 摘要 echo 里残留的旧明文口描述(Task 4/5
落地后措辞漂移,实际已是 CF Tunnel + loopback)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 16:53:36 +08:00
wangjia 06ba76e22b feat(deploy): 8080 收 loopback + 关 8080 防火墙 + 健康检查改本地/隧道(退役明文控制口)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 16:47:32 +08:00
wangjia e2ffce90d3 feat(deploy): PANGOLIN_PUBLIC_URL 改 https://api.yanmeiai.com(客户端规则集走隧道)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 16:45:00 +08:00
wangjia 1b091f3be0 ci: 守护 Android manifest 禁全局明文(#25 控制面已 https)
新增 ci/scan-cleartext.sh 扫描 client/android AndroidManifest.xml,
防止将来把 usesCleartextTraffic="true" 重新加回(退回明文态)。
CI 新增 cleartext-scan job,并把该脚本纳入 lint job 的 shellcheck 覆盖
(单独挂载 ci/ 目录,不动 scripts/ci 既有挂载)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 16:41:59 +08:00
wangjia a5aea9438a feat(client): 控制面基址默认 https://api.yanmeiai.com + 移除 Android 明文开关
TDD: 先加守护测试(断言 kApiBaseUrl 走 https 且不含硬编码 IP),确认失败后
再改 api_config.dart 默认值 + 去掉 AndroidManifest 的 usesCleartextTraffic。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 16:37:42 +08:00
wangjia 9b56e2667b feat(deploy): cloudflared 出站隧道前置控制面 API(api.yanmeiai.com→127.0.0.1:8080)
deploy.sh 在 systemd 安装段新增 cloudflared 二进制(apt 源)+ unit 安装;
enable-on-boot 用 /etc/pangolin/cloudflared.env(TUNNEL_TOKEN)存在与否作
门槛,避免全新单机部署在 token 未落地前因 cloudflared 起不来而失败。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 16:32:32 +08:00
wangjia bac79017ae docs(plan): #25 控制面 CF Tunnel 实现方案
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 16:30:11 +08:00
wangjia 5c647235aa chore(todo): #25 方案 propose/approve + #32 fallback 登记
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 16:28:45 +08:00
wangjia f8e870fd6a fix(ci): backup 大小 sanity 阈值 50k→1k(小库压缩后 <50k 被误删)
远端 .backup 出的库 gzip 后可能 <50KB,原 >50k 检查把有效备份当'异常小'删了。
空 gzip 仅 ~20 字节,>1k 足以区分空/断流与有效备份。
2026-07-06 15:34:18 +08:00
wangjia 84b042394d ci(backup): 备份步骤输出 tee 到 NAS _last-run.log(便于排障) 2026-07-06 15:32:10 +08:00
wangjia e557a94ed0 fix(ci): lib-ssh 加 IdentitiesOnly+BatchMode(修 Too many auth failures)
CI ssh 到 pangolin1 报 Too many authentication failures + 回落密码提示:ssh 把
容器默认/agent key 也一并递上,撞 MaxAuthTries。加 IdentitiesOnly=yes 只用 -i key,
BatchMode=yes 纯非交互。影响 deploy-server / backup 等所有走 lib-ssh 的部署。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 15:05:44 +08:00
wangjia e1a17acbe4 feat(ci): SQLite 每日备份到 NAS(异地容灾,#26/F2)
backup-db.sh:ssh pangolin1 → sqlite3 online .backup 取 WAL 一致快照 + 远端
integrity_check → gzip 流回 → 存 NAS /volume1/docker/backups/pangolin,保留 30 天。
backup.yml:每日 02:00(北京)cron + 手动,ubuntu-latest,复用 DEPLOY_SSH_KEY,
Telegram 通知。异地=家里 NAS≠VPS 机房。不在 pangolin1 装/常驻任何东西。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:55:11 +08:00
wangjia 3efd67297e ci(server): 编译/测试直接在 runner 跑,去嵌套 docker(修 DinD;go 工具链自动下载)
compile-backend/test.sh 原在 golang:1.25 容器里跑,job 容器内 $PWD 在宿主不存在
→ DinD 挂载失败。改直接跑;go.mod 要求 1.25.10,靠 Go 工具链经 goproxy.cn 自动拉取。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:40:09 +08:00
wangjia f6e565ffdc ci(site): 官网构建/部署直接在 runner 跑,去掉嵌套 docker(修 DinD 挂载)
Deploy Site / deploy-site (push) Successful in 1m23s
job 容器内 $PWD(/workspace/...)在宿主不存在,docker run -v $PWD 挂载失败。
runner 镜像 catthehacker/ubuntu:act-latest 自带 node/npx,直接 bash 跑即可。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:34:12 +08:00
wangjia ba5f71b716 ci: runs-on nas → ubuntu-latest(匹配 nas runner 实际 label)
Deploy Site / deploy-site (push) Failing after 1m36s
nas 上的 forgejo-act-runner 注册 label 是 ubuntu-latest(非 nas),导致所有
runs-on: nas 的工作流无匹配 runner。改为 ubuntu-latest 对齐 runner 现状。
(备选:给 runner 加 nas 标签保约定,未采用。)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:33:27 +08:00
wangjia 7c26050cdb docs(spec): 官网部署改 Cloudflare Pages(架构变更说明)
Deploy Site / deploy-site (push) Has been cancelled
节点 :443 被 VPN 占用 + CF 免费套餐改回源端口需 Enterprise → 官网改 CF Pages 托管
(纯静态/全程 HTTPS/CSP 生效/无 :443 冲突),已上线 pangolin.yanmeiai.com。
deploy-site.sh 用 wrangler,需 CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:08:20 +08:00
wangjia 673914783d refactor(ci): 官网部署改用 Cloudflare Pages(替代 rsync 到 pangolin1)
节点 :443 被 sing-box(VPN 数据面)占用,CF 免费套餐回源只能打 :80/:443、改端口需
Enterprise —— 故官网改由 CF Pages 托管(项目 pangolin-site,自定义域 pangolin.yanmeiai.com),
纯静态、全程 HTTPS、_headers/CSP 自动生效、不落 VPS、彻底无 :443 冲突。
deploy-site.sh 改为 wrangler pages deploy(需 CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID
Gitea secret);deploy-site.yml deploy 步骤在 node:20 容器内跑 wrangler。lib-ssh 仍供服务端部署用。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 09:59:58 +08:00
wangjia 443d4e3a11 feat(ci): test.sh(go test / flutter test)
Add scripts/ci/test.sh <server|client>: server runs `go test ./...`
inside golang:1.25 (mirrors ci.yml's go-server job docker invocation +
gomod/gobuild caches + GOPROXY from _env.sh); client runs `flutter test`
inside ghcr.io/cirruslabs/flutter:stable (mirrors ci.yml's flutter-client
job's pub-cache mount + test dirs). Wired into deploy-server.yml's Test
step already added in Task 4. Registers the script in ci.yml's shellcheck
file list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 00:37:48 +08:00
wangjia 0825170044 fix(ci): 服务端流水线注入加固(tag 校验/JSON 转义/workflow env)+ gitignore server/out
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 00:33:47 +08:00
wangjia 4fb3fe3fee feat(ci): 服务端 server-v* 编译+release+部署(备份/迁移/回滚,复用 lib-ssh/lib-forgejo)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 00:24:18 +08:00
wangjia 91d1355721 fix(ci): 部署脚本 —— 私钥清理 trap 前移 + rsync 空目录守卫 + 主机密钥 accept-new(评审 Critical/Important)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 00:17:37 +08:00
wangjia 9bac5c8dcb feat(ci): 官网 site-v* 构建+部署脚本(compile-site/deploy-site/lib-ssh/workflow)
- scripts/ci/compile-site.sh: node:20 容器内构建 web/website(Astro),
  SITE_URL 注入 canonical 域名 https://pangolin.yanmeiai.com。
- scripts/ci/lib-ssh.sh: 新增共享 setup_ssh/teardown_ssh(写临时私钥+
  known_hosts),供 site 与后续 server 部署复用;目标写死 IP 103.119.13.48
  (runner 无法解析用户本机 ~/.ssh/config 的 pangolin1 别名)。
- scripts/ci/deploy-site.sh: rsync dist/ 到 pangolin1
  /var/www/pangolin-site/,root 部署。
- .gitea/workflows/deploy-site.yml: site-v* tag + workflow_dispatch 触发,
  concurrency 组 deploy-site 防并发。
- .gitea/workflows/ci.yml: shellcheck 列表纳入三个新脚本。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 00:10:53 +08:00
wangjia f0a8c6dd98 ci(checks): 触发路径纳入 scripts/ci(否则 CI 脚本改动不触发 lint)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 00:05:26 +08:00
wangjia 0b9f291ff1 ci(checks): shellcheck 覆盖 scripts/ci
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 00:03:05 +08:00
29 changed files with 1353 additions and 46 deletions
+39
View File
@@ -0,0 +1,39 @@
name: DB Backup
on:
# 每日北京 02:00(= UTC 18:00)自动备份;也可手动触发。
schedule:
- cron: '0 18 * * *'
workflow_dispatch:
concurrency:
group: db-backup
cancel-in-progress: false
jobs:
backup:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Backup pangolin1 SQLite → NAS
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
mkdir -p /volume1/docker/backups/pangolin
bash scripts/ci/backup-db.sh 2>&1 | tee /volume1/docker/backups/pangolin/_last-run.log
exit "${PIPESTATUS[0]}"
- name: Notify (Telegram)
if: always()
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
. scripts/ci/notify.sh
if [ "${{ job.status }}" = "success" ]; then
notify_ok "pangolin1 SQLite 备份成功(NAS)"
else
notify_fail "pangolin1 SQLite 备份失败"
fi
+48 -10
View File
@@ -10,6 +10,7 @@ on:
- 'client/**' - 'client/**'
- 'server/**' - 'server/**'
- 'ci/**' - 'ci/**'
- 'scripts/ci/**'
- '.gitea/workflows/ci.yml' - '.gitea/workflows/ci.yml'
pull_request: pull_request:
branches: [main] branches: [main]
@@ -20,7 +21,7 @@ jobs:
# ── Job 1: Lint (shellcheck) ───────────────────────────────────────────── # ── Job 1: Lint (shellcheck) ─────────────────────────────────────────────
lint: lint:
name: Lint — shellcheck name: Lint — shellcheck
runs-on: nas runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -38,10 +39,36 @@ jobs:
/mnt/deploy/bootstrap/monitor/deadman-watch.sh \ /mnt/deploy/bootstrap/monitor/deadman-watch.sh \
/mnt/deploy/single-node/deploy.sh /mnt/deploy/single-node/deploy.sh
- name: shellcheck CI 脚本(scripts/ci)
run: |
docker run --rm \
-v "$PWD/scripts/ci:/mnt/scripts/ci:ro" \
koalaman/shellcheck:stable \
-S warning \
/mnt/scripts/ci/_env.sh \
/mnt/scripts/ci/lib-forgejo.sh \
/mnt/scripts/ci/notify.sh \
/mnt/scripts/ci/lib-ssh.sh \
/mnt/scripts/ci/compile-site.sh \
/mnt/scripts/ci/deploy-site.sh \
/mnt/scripts/ci/compile-backend.sh \
/mnt/scripts/ci/release-server.sh \
/mnt/scripts/ci/deploy-server.sh \
/mnt/scripts/ci/test.sh \
/mnt/scripts/ci/backup-db.sh
- name: shellcheck CI 脚本(ci/)
run: |
docker run --rm \
-v "$PWD/ci:/mnt/ci:ro" \
koalaman/shellcheck:stable \
-S warning \
/mnt/ci/scan-cleartext.sh
# ── Job 2: OpenAPI Sync Check ──────────────────────────────────────────── # ── Job 2: OpenAPI Sync Check ────────────────────────────────────────────
openapi-check: openapi-check:
name: OpenAPI Sync Check name: OpenAPI Sync Check
runs-on: nas runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -58,7 +85,7 @@ jobs:
# ── Job 3: Redline Word Scan (脱敏) ────────────────────────────────────── # ── Job 3: Redline Word Scan (脱敏) ──────────────────────────────────────
redline-scan: redline-scan:
name: Redline Scan — 脱敏 (UI 文案) name: Redline Scan — 脱敏 (UI 文案)
runs-on: nas runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -66,10 +93,21 @@ jobs:
- name: scan UI text resources for prohibited words - name: scan UI text resources for prohibited words
run: bash ci/scan-redline.sh run: bash ci/scan-redline.sh
# ── Job 3b: Cleartext Scan (Android 禁全局明文,#25 控制面已 https) ──────
cleartext-scan:
name: Cleartext Scan — Android 禁明文
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: scan Android manifest for global cleartext
run: bash ci/scan-cleartext.sh
# ── Job 4: Flutter 客户端(分析 + 单测/组件测试)──────────────────────── # ── Job 4: Flutter 客户端(分析 + 单测/组件测试)────────────────────────
flutter-client: flutter-client:
name: Flutter — analyze + test name: Flutter — analyze + test
runs-on: nas runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -92,7 +130,7 @@ jobs:
# 规则与豁免见 ci/scan-portable-sql.sh 头注 + docs/dev-conventions.html 支柱 3。 # 规则与豁免见 ci/scan-portable-sql.sh 头注 + docs/dev-conventions.html 支柱 3。
portable-sql-scan: portable-sql-scan:
name: Portable SQL — 可移植性 (mysql/sqlite) name: Portable SQL — 可移植性 (mysql/sqlite)
runs-on: nas runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -105,7 +143,7 @@ jobs:
# (改了 design/colors_and_type.css 没重生成,或手改了生成物)。 # (改了 design/colors_and_type.css 没重生成,或手改了生成物)。
codegen-drift: codegen-drift:
name: Codegen Drift — token 生成物未漂移 name: Codegen Drift — token 生成物未漂移
runs-on: nas runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -122,7 +160,7 @@ jobs:
# -tags integration(需 docker 起 mysql/redis),见 go-integration job。 # -tags integration(需 docker 起 mysql/redis),见 go-integration job。
go-server: go-server:
name: Go — build + test name: Go — build + test
runs-on: nas runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -148,7 +186,7 @@ jobs:
# 详见 scripts/e2e-smoke.sh + server/test/e2e/。 # 详见 scripts/e2e-smoke.sh + server/test/e2e/。
e2e-smoke: e2e-smoke:
name: E2E Smoke — L4 进程级端到端 name: E2E Smoke — L4 进程级端到端
runs-on: nas runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -172,7 +210,7 @@ jobs:
# · -p 1 串行:一次只起一个 mysql 容器,避免并发把 Docker Desktop 压垮/端口资源争用。 # · -p 1 串行:一次只起一个 mysql 容器,避免并发把 Docker Desktop 压垮/端口资源争用。
go-integration: go-integration:
name: Go — integration (mysql/redis testcontainers) name: Go — integration (mysql/redis testcontainers)
runs-on: nas runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -187,7 +225,7 @@ jobs:
# tablet/desktop-stats golden 与 stats-overhaul 工作区耦合,待其合并后并入本 job。 # tablet/desktop-stats golden 与 stats-overhaul 工作区耦合,待其合并后并入本 job。
golden: golden:
name: Golden — 视觉回归 (components + auth) name: Golden — 视觉回归 (components + auth)
runs-on: nas runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
+58
View File
@@ -0,0 +1,58 @@
name: Deploy Server
on:
push:
tags:
- 'server-v[0-9]*.[0-9]*.[0-9]*'
workflow_dispatch:
concurrency:
group: deploy-server
cancel-in-progress: false
jobs:
deploy-server:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# runner 镜像(catthehacker ubuntu:act-latest,label ubuntu-latest)自带 node
# 但**不带 go** → 直接 `go build` 会 `go: command not found`(exit 127)。
# 故先装 go:从 Go 官方**中国镜像** golang.google.cn 取(go.dev 在墙内不稳),
# 版本对齐 server/go.mod 的 1.25.10;装到 /usr/local/go 并加进 $GITHUB_PATH
# 供后续 Compile/Test 步骤共用。模块下载仍走 GOPROXY=goproxy.cn(见 _env.sh)。
- name: Setup Go 1.25.10(CN 镜像)
run: |
GO_VER=1.25.10
# 供应链完整性:校验 sha256(取自 Go 官方 release JSON,pin 为字面量),
# 防镜像被篡改/MITM 注入恶意工具链(它会编译要上生产的二进制)。校验失败即中止。
GO_SHA256=42d4f7a32316aa66591eca7e89867256057a4264451aca10570a715b3637ba70
curl -fsSL --max-time 180 --retry 3 --retry-delay 5 --retry-connrefused \
"https://golang.google.cn/dl/go${GO_VER}.linux-amd64.tar.gz" -o /tmp/go.tgz
echo "${GO_SHA256} /tmp/go.tgz" | sha256sum -c -
rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tgz
echo "/usr/local/go/bin" >> "$GITHUB_PATH"
export PATH=/usr/local/go/bin:$PATH
go version
# 直接在 runner 跑(不嵌套 docker,避免 DinD 挂载失败;go 由上一步装好)。
- name: Compile (Go 控制面)
run: bash scripts/ci/compile-backend.sh
- name: Test (go test)
run: bash scripts/ci/test.sh server
- name: Release → Forgejo
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
TAG: ${{ gitea.ref_name }}
run: bash scripts/ci/release-server.sh "$TAG"
- name: Deploy → pangolin1
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
TAG: ${{ gitea.ref_name }}
run: bash scripts/ci/deploy-server.sh "$TAG"
+31
View File
@@ -0,0 +1,31 @@
name: Deploy Site
on:
push:
tags:
- 'site-v[0-9]*.[0-9]*.[0-9]*'
workflow_dispatch:
concurrency:
group: deploy-site
cancel-in-progress: false
jobs:
deploy-site:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# runner 镜像 catthehacker/ubuntu:act-latest 自带 node/npx,直接跑;
# 不用嵌套 docker run(job 容器内的 $PWD 在宿主上不存在,DinD 挂载会失败)。
- name: Compile (Astro 官网)
env:
SITE_URL: https://pangolin.yanmeiai.com
run: bash scripts/ci/compile-site.sh
- name: Deploy → Cloudflare Pages
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: bash scripts/ci/deploy-site.sh
+1
View File
@@ -48,6 +48,7 @@ app/kernel/.build/
# Go 编译产物(mock server 等) # Go 编译产物(mock server 等)
server/mockserver server/mockserver
server/pangolin-server server/pangolin-server
server/out/
# Flutter / Dart 构建产物与本地配置 # Flutter / Dart 构建产物与本地配置
client/android/local.properties client/android/local.properties
+4
View File
@@ -0,0 +1,4 @@
{
"account_id": "e585821c881c4cd23bc2530986edea9e",
"project_name": "pangolin-site"
}
+4
View File
@@ -48,6 +48,10 @@ sudo VPS_IP=<公网IP> bash deploy/single-node/deploy.sh
- 二进制:`cmd/{server,agent,nodectl,migrate}`;`go build ./...` 直接编译,免确认。 - 二进制:`cmd/{server,agent,nodectl,migrate}`;`go build ./...` 直接编译,免确认。
- 控制面 HTTP API(`:8080`)+ gRPC agent 服务(`:9443`, mTLS);agent 自 enroll → 渲染 - 控制面 HTTP API(`:8080`)+ gRPC agent 服务(`:9443`, mTLS);agent 自 enroll → 渲染
sing-box 配置 → `systemctl restart sing-box`。客户端连节点真实出网。 sing-box 配置 → `systemctl restart sing-box`。客户端连节点真实出网。
- **控制面 API 对外经 Cloudflare Tunnel**:`https://api.yanmeiai.com`(cloudflared 出站隧道,
不监听入站端口)→ 源站 `pangolin-server` 只绑 `127.0.0.1:8080`,不外露、不放行防火墙。
数据面 sing-box REALITY 仍独占入站 `:443`(未改动);gRPC agent mTLS 仍 `:9443`。详见
`docs/control-plane-tls-tunnel.html`
### 数据层:多数据库(一个环境变量切换) ### 数据层:多数据库(一个环境变量切换)
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# scan-cleartext.sh — 禁止 Android manifest 重新开启全局明文(控制面已 https/CF Tunnel)。
# usesCleartextTraffic="true" 会让全 app 允许明文 HTTP,退回 #25 之前的不安全态。
set -euo pipefail
MANIFEST="client/android/app/src/main/AndroidManifest.xml"
if grep -q 'usesCleartextTraffic="true"' "$MANIFEST"; then
echo "$MANIFEST 含 usesCleartextTraffic=\"true\":控制面已 https,禁止全局明文。" >&2
echo " 如个别调试域名确需明文,请用 res/xml/network_security_config.xml 按域白名单,勿开全局。" >&2
exit 1
fi
echo "✅ Android manifest 未开启全局明文"
@@ -26,8 +26,7 @@
<application <application
android:label="穿山甲" android:label="穿山甲"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher">
android:usesCleartextTraffic="true"><!-- 控制面 API 当前为 http(联调),Android 9+ 默认禁明文,需开;生产改 https 后可去掉 -->
<!-- 主 Activity --> <!-- 主 Activity -->
<activity <activity
+3 -2
View File
@@ -2,8 +2,9 @@
// //
// 历史上各 service/provider 各自重复声明 _kApiUrl;统一收敛到这里, // 历史上各 service/provider 各自重复声明 _kApiUrl;统一收敛到这里,
// 由 --dart-define=PANGOLIN_API_URL 注入。 // 由 --dart-define=PANGOLIN_API_URL 注入。
// TODO(联调临时): 默认值改成测试节点,避免 release 构建漏传 dart-define;发版前改回 localhost 或正式控制面域名 // 控制面 API 基址(单源,全端 providers 共用)。默认走 CF Tunnel 的 https 域名;
// 本地联调可 --dart-define=PANGOLIN_API_URL=http://127.0.0.1:8080 覆盖。
const String kApiBaseUrl = String.fromEnvironment( const String kApiBaseUrl = String.fromEnvironment(
'PANGOLIN_API_URL', 'PANGOLIN_API_URL',
defaultValue: 'http://103.119.13.48:8080', defaultValue: 'https://api.yanmeiai.com',
); );
+11
View File
@@ -0,0 +1,11 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pangolin_vpn/services/api_config.dart';
void main() {
test('控制面基址默认走 https(禁止回退明文 http)', () {
expect(kApiBaseUrl, startsWith('https://'),
reason: '控制面已迁 CF Tunnel(api.yanmeiai.com);默认值不得是明文 http');
expect(kApiBaseUrl, isNot(contains('103.119.13.48')),
reason: '不得再硬编码节点 IP 作控制面基址');
});
}
+30 -7
View File
@@ -164,7 +164,7 @@ cat > "$ETC/server.env" <<EOF
DB_DRIVER=sqlite DB_DRIVER=sqlite
DB_DSN=$DB_FILE DB_DSN=$DB_FILE
REDIS_ADDR=127.0.0.1:6379 REDIS_ADDR=127.0.0.1:6379
ADDR=:$HTTP_PORT ADDR=127.0.0.1:$HTTP_PORT
JWT_PRIVATE_KEY_PATH=$ETC/jwt_private.pem JWT_PRIVATE_KEY_PATH=$ETC/jwt_private.pem
JWT_KEY_ID=node-key-1 JWT_KEY_ID=node-key-1
JWT_PUBLIC_KEYS=node-key-1:$ETC/jwt_public.pem JWT_PUBLIC_KEYS=node-key-1:$ETC/jwt_public.pem
@@ -175,7 +175,7 @@ CA_KEY_PATH=$ETC/ca.key
CA_CERT_PATH=$ETC/ca.crt 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
PANGOLIN_PUBLIC_URL=http://$VPS_IP:$HTTP_PORT PANGOLIN_PUBLIC_URL=https://api.yanmeiai.com
PANGOLIN_RULES_DIR=$DATA_DIR/rules PANGOLIN_RULES_DIR=$DATA_DIR/rules
EOF EOF
if [ -n "${SMTP_HOST:-}" ]; then if [ -n "${SMTP_HOST:-}" ]; then
@@ -257,6 +257,24 @@ sed "s#/usr/local/bin/sing-box#${SB_BIN}#g" \
chmod 644 /etc/systemd/system/sing-box.service chmod 644 /etc/systemd/system/sing-box.service
install -d -m 755 /etc/polkit-1/rules.d install -d -m 755 /etc/polkit-1/rules.d
install -m 644 "$HERE/polkit/49-pangolin-singbox.rules" /etc/polkit-1/rules.d/ install -m 644 "$HERE/polkit/49-pangolin-singbox.rules" /etc/polkit-1/rules.d/
# ── cloudflared(控制面 API 出站隧道,Cloudflare apt 源)─────────────────────────
if ! command -v cloudflared >/dev/null 2>&1; then
log "安装 cloudflared(Cloudflare apt 源)..."
install -m 0755 -d /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
-o /usr/share/keyrings/cloudflare-main.gpg
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bookworm main' \
> /etc/apt/sources.list.d/cloudflared.list
apt-get update -qq
apt-get install -y -qq cloudflared
fi
# apt 固定装到 /usr/bin;软链到 unit 期望的 /usr/local/bin(与其他 pangolin 二进制一致)。
CFD_BIN=/usr/bin/cloudflared
[ -x "$CFD_BIN" ] || die "cloudflared 安装失败:$CFD_BIN 不存在。"
ln -sf "$CFD_BIN" /usr/local/bin/cloudflared
install -m 644 "$HERE/systemd/cloudflared.service" /etc/systemd/system/
systemctl daemon-reload systemctl daemon-reload
log "启动控制面(pangolin-server,以 $PUSER 运行)..." log "启动控制面(pangolin-server,以 $PUSER 运行)..."
@@ -269,17 +287,22 @@ log "启用 sing-box + 启动 agent ..."
systemctl enable sing-box.service systemctl enable sing-box.service
systemctl enable --now pangolin-agent.service systemctl enable --now pangolin-agent.service
# 放行控制面 API 端口(若 ufw 启用)。⚠️ 明文,生产应前置 TLS # 控制面 API 已绑 127.0.0.1(经 cloudflared 隧道对外),不放行 8080/tcp
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 # ── cloudflared:按需启用(需 /etc/pangolin/cloudflared.env 提供 TUNNEL_TOKEN)───
log "ufw 放行 ${HTTP_PORT}/tcp(控制面 API,明文)" if [ -f /etc/pangolin/cloudflared.env ]; then
log "启用 cloudflared(检测到 /etc/pangolin/cloudflared.env)..."
systemctl enable --now cloudflared.service
else
log "cloudflared 已安装但未启动:等 /etc/pangolin/cloudflared.env(TUNNEL_TOKEN)就绪后执行:"
log " systemctl enable --now cloudflared.service"
fi fi
# ── 11. 摘要 ────────────────────────────────────────────────────────────────── # ── 11. 摘要 ──────────────────────────────────────────────────────────────────
log "完成。单机栈已起(SQLite + pangolin 用户)。" log "完成。单机栈已起(SQLite + pangolin 用户)。"
cat <<EOF cat <<EOF
控制面 API : http://$VPS_IP:$HTTP_PORT (⚠️ 明文;防火墙需放行 ${HTTP_PORT}/tcp) 控制面 API : https://api.yanmeiai.com (CF Tunnel;源站 127.0.0.1:$HTTP_PORT,不外露/不放行)
Node uuid : $NODE_UUID Node uuid : $NODE_UUID
Endpoint : $ENDPOINT SNI=$REALITY_SNI (REALITY ${REALITY_PORT}/tcp) Endpoint : $ENDPOINT SNI=$REALITY_SNI (REALITY ${REALITY_PORT}/tcp)
REALITY pbk : $REALITY_PBK REALITY pbk : $REALITY_PBK
@@ -0,0 +1,19 @@
[Unit]
Description=Pangolin cloudflared (control-plane API tunnel → 127.0.0.1:8080)
Documentation=https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
After=network-online.target pangolin-server.service
Wants=network-online.target
[Service]
Type=notify
# TUNNEL_TOKEN 从此文件注入(不入 unit 本体、不进 ps);cloudflared 自动读取 env TUNNEL_TOKEN。
EnvironmentFile=/etc/pangolin/cloudflared.env
ExecStart=/usr/local/bin/cloudflared --no-autoupdate tunnel run
Restart=on-failure
RestartSec=5
# 出站隧道,无需 root:用非特权用户即可(与 pangolin-server 同用户)。
User=pangolin
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
+5 -3
View File
@@ -111,10 +111,12 @@ pangolin.yanmeiai.com)与多端产物。现状:仅 <code>ci.yml</code> 校
</ol> </ol>
</div> </div>
<h2>4. 官网部署</h2> <h2>4. 官网部署 —— Cloudflare Pages</h2>
<div class="card" style="border-left:3px solid var(--warn)">
<b>架构变更(2026-07-06 实施):</b>原计划 rsync 到 pangolin1 的 nginx。但节点 <code>:443</code> 被 sing-box(VPN 数据面)占用,而 CF 免费套餐 proxied 回源只能打 :80/:443、改回源端口需 Enterprise —— 同机同 IP 上官网 HTTPS 与 VPN 无法共存。<b>故官网改由 Cloudflare Pages 托管</b>:纯静态、全程 HTTPS、<code>_headers</code>/CSP 原生生效、不落 VPS,从根上无 :443 冲突,也不拖累 VPN 机器。<b>已上线</b> <code>https://pangolin.yanmeiai.com</code>
</div>
<p>Astro <code>npm ci && npm run build</code><code>SITE_URL=https://pangolin.yanmeiai.com</code>)→ <code>dist/</code> <p>Astro <code>npm ci && npm run build</code><code>SITE_URL=https://pangolin.yanmeiai.com</code>)→ <code>dist/</code>
rsync 到服务器 web 根 → nginx/Caddy 静态托管。<b>DNS</b>CF 加记录 <code>npx wrangler pages deploy</code> 发布到 Pages 项目 <b>pangolin-site</b>(自定义域 <code>pangolin.yanmeiai.com</code>,CNAME → <code>pangolin-site.pages.dev</code>,proxied)。需 secret <code>CLOUDFLARE_API_TOKEN</code>(带 Account&gt;Pages&gt;Edit+ <code>CLOUDFLARE_ACCOUNT_ID</code>deploy 步骤在 <code>node:20</code> 容器内跑 wrangler。灾备:产物仍纯静态,可另 rsync 到镜像。</p>
<code>pangolin.yanmeiai.com</code> → 服务器(首次部署时做,记 baize)。</p>
<h2>5. 下载链接闭环(30A</h2> <h2>5. 下载链接闭环(30A</h2>
<p><code>web/website/src/config/site.ts</code><code>downloads:{ android, macos, windows }</code>,值为 Gitea release <p><code>web/website/src/config/site.ts</code><code>downloads:{ android, macos, windows }</code>,值为 Gitea release
+136
View File
@@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pangolin 控制面 TLS(Cloudflare Tunnel 前置)实现计划</title>
<style>
:root{
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
.wrap{max-width:960px;margin:0 auto;padding:48px 24px 96px}
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
h3{font-size:16px;margin:24px 0 8px;color:var(--fg)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.86em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:12.5px;line-height:1.55;color:#cdd3df}
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:16px 20px;margin:14px 0}
.card h3{margin-top:0;color:var(--accent2)}
.files{font-family:var(--mono);font-size:12px;color:var(--fg2);margin:6px 0 10px}
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
th{color:var(--fg2);font-weight:600;font-size:13px}
ul,ol{padding-left:22px;margin:8px 0}
li{margin:5px 0}
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
.small{color:var(--fg2);font-size:13px}
a{color:var(--accent2)}
.back{display:inline-block;margin-bottom:24px;font-size:13px}
b{color:#fff}
.phase{font-size:19px;margin:40px 0 6px;color:var(--accent);font-weight:700}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 文档索引</a>
<h1>Pangolin 控制面 TLS(Cloudflare Tunnel 前置)实现计划</h1>
<p class="sub">2026-07-06 · <span class="tag info">阅读版</span> · 执行真相源 <code>docs/superpowers/plans/2026-07-06-control-plane-tls-tunnel.md</code>(带 checkbox)</p>
<div class="lead">
<b>目标:</b>把 pangolin-server 控制面 API 从明文 <code>http://103.119.13.48:8080</code> 迁到
<code>https://api.yanmeiai.com</code>,经 Cloudflare Tunnel 前置(隐藏源站 IP、白嫖标准 443 + 免证书)。
数据面 sing-box REALITY <code>:443</code> 全程不动。
</div>
<h2>架构</h2>
<p>
pangolin1 上跑 <code>cloudflared</code> <b>出站</b>隧道(不监听任何入站端口 → 与 sing-box 独占的
<code>:443</code> 零冲突),CF 边缘把 <code>api.yanmeiai.com</code> 的请求经隧道回送到
<code>127.0.0.1:8080</code>。客户端(Flutter 四端共享 <code>kApiBaseUrl</code>)默认改 https 域名;
控制面下发给客户端 sing-box 的 <code>.srs</code> 规则集下载基址(<code>PANGOLIN_PUBLIC_URL</code>)
同步改 https。最后一步把 <code>:8080</code> 收回 loopback 并关防火墙,彻底退役明文口——该步有
<b>上线顺序闸</b>(须待现网客户端更新后再做)。
</p>
<h2>端口 / URL 布局</h2>
<table>
<tr><th>用途</th><th>对外</th><th>源站/绑定</th><th>本轮变更</th></tr>
<tr><td>控制面 HTTP API</td><td><code>https://api.yanmeiai.com</code>(CF Tunnel)</td><td><code>127.0.0.1:8080</code></td><td>新增 CF Tunnel 前置 + 收 loopback</td></tr>
<tr><td>数据面 sing-box REALITY</td><td><code>:443</code>(节点公网 IP)</td><td>同端口</td><td>不动</td></tr>
<tr><td>gRPC agent(mTLS)</td><td>—(仅节点内)</td><td><code>:9443</code></td><td>不动</td></tr>
</table>
<h2>全局约束</h2>
<ul>
<li>Bash 禁 <code>$()</code> 命令替换、禁 <code>set -a</code>/<code>set +a</code>;需捕获输出拆多步或用管道。</li>
<li>凭证走 Bitwarden/rbw,不写 <code>~/.env</code>/明文配置/git。Cloudflare 用 <code>cf-api</code> 封装(token 内部从 Bitwarden 取)。隧道 token 等密钥一律不入 git,只落 <code>/etc/pangolin/*</code>(已 gitignore)+ Bitwarden。</li>
<li>改机器(装包/改配置/重启服务)前必须先问用户(只读操作除外)。</li>
<li>pangolin1 = <code>103.119.13.48</code>,ssh 别名 <code>pangolin1</code>。数据面 sing-box REALITY 独占入站 <code>:443</code>,<b>不得触碰</b>;gRPC agent mTLS <code>:9443</code> 不动。</li>
<li><b>上线顺序铁律:</b>现网客户端硬编码 <code>http://103.119.13.48:8080</code>。隧道与 https 端点必须<b>加法上线</b>(与旧口并存),客户端切 https 发版后,收 loopback 才能做,否则旧客户端全挂。</li>
</ul>
<h2>6 个任务</h2>
<div class="card">
<h3>Task 1 · Cloudflare Tunnel 供给</h3>
<div class="files">Create: deploy/single-node/systemd/cloudflared.service · Modify: deploy/single-node/deploy.sh</div>
CF 账户侧(cf-api)建 remotely-managed 隧道 + ingress(<code>api.yanmeiai.com</code><code>http://localhost:8080</code>)+ 代理 CNAME;pangolin1 装 cloudflared(Debian apt 源)+ committed systemd unit(token 经 <code>EnvironmentFile</code> 注入,不入 unit 本体)。验证:<code>https://api.yanmeiai.com/healthz</code> 与旧的 <code>http://103.119.13.48:8080/healthz</code> <b>并存可用</b>(加法,不破坏现网)。
</div>
<div class="card">
<h3>Task 2 · 客户端控制面基址切 https + Android 去明文</h3>
<div class="files">Modify: client/lib/services/api_config.dart · client/android/.../AndroidManifest.xml · Create: client/test/unit/api_config_test.dart</div>
先写守护测试(断言 <code>kApiBaseUrl</code> 必须 <code>https://</code> 且不含节点 IP)→ 确认失败 → 把 <code>kApiBaseUrl</code> 默认值改为 <code>https://api.yanmeiai.com</code>(仍保留 <code>String.fromEnvironment</code> 可本地覆盖)→ 测试转绿。同步移除 Android manifest 的 <code>android:usesCleartextTraffic="true"</code>(控制面已 https,不再需要明文豁免;iOS/macOS 无 ATS 配置,无需改动)。跑 <code>flutter analyze</code> + 全量单测。
</div>
<div class="card">
<h3>Task 3 · CI 守护:Android release manifest 禁明文</h3>
<div class="files">Create: ci/scan-cleartext.sh · Modify: .gitea/workflows/ci.yml</div>
新增扫描脚本:manifest 一旦重新出现 <code>usesCleartextTraffic="true"</code> 就 CI 失败(防止将来有人把明文开关加回来,退回到 #25 之前的不安全态)。接入 <code>ci.yml</code> 新 job + shellcheck 列表。
</div>
<div class="card">
<h3>Task 4 · <code>PANGOLIN_PUBLIC_URL</code> 切 https</h3>
<div class="files">Modify: deploy/single-node/deploy.sh</div>
该变量被嵌进客户端 sing-box 配置当 <code>.srs</code> 分流规则集下载基址(<code>clientconfig.go</code>)。不改的话新客户端仍去明文 IP 拉。改为 <code>https://api.yanmeiai.com</code>;与 Task 1 隧道并存,对新旧客户端都安全(URL 由服务端下发,客户端只是照着 GET)。pangolin1 上应用 + 重启 server,验证规则集经隧道可 200 下载。
</div>
<div class="card">
<h3>Task 5 · 退役明文口(收 loopback + 关防火墙 + 修健康检查)<span class="tag warn">上线顺序闸</span></h3>
<div class="files">Modify: deploy/single-node/deploy.sh · scripts/ci/deploy-server.sh</div>
<b>此 Task 会切断外部 <code>http://103.119.13.48:8080</code>,只有当现网客户端都已更新到 Task 2 的 https 版本后才能执行</b>,执行前需与用户确认「旧客户端可弃」。内容:<code>ADDR</code><code>127.0.0.1:8080</code>;不再 ufw 放行 8080;<code>deploy-server.sh</code> 健康检查从「runner 远程 curl 公网 IP」改为「ssh 内本地 curl loopback」+「经隧道 curl https 域名」双路验证。验证:明文口不可达、隧道仍活、<code>ss</code> 显示 8080 仅监听 <code>127.0.0.1</code>
</div>
<div class="card">
<h3>Task 6 · 文档更新(本任务)</h3>
<div class="files">Modify: CLAUDE.md · docs/index.html · docs/control-plane-tls-tunnel.html(本页)</div>
CLAUDE.md 补充端口/URL 布局说明;生成本 HTML 阅读版并登记 <code>docs/index.html</code>「实现计划」分类;顺带修正 <code>deploy/single-node/deploy.sh</code> 摘要 echo 里残留的旧明文口描述(Task 4/5 落地后的措辞漂移)。
</div>
<h2>上线顺序</h2>
<p>
Task 1(隧道加法)→ Task 4(<code>PANGOLIN_PUBLIC_URL</code>,新旧客户端皆安全)→ Task 2(客户端切 https,发版)→
<b>待客户端更新</b> → Task 5(收口)。Task 3(CI 守护)、Task 6(文档)无顺序耦合,可随时并行推进。
</p>
<h2>不在本轮</h2>
<ul class="small">
<li>#32 控制面 fallback(CF 域名被 SNI 封 → 客户端退回直连节点 IP 的 https 控制口)。</li>
<li>控制面 API 的 CF WAF/rate-limit 规则精调。</li>
<li>usercenter(web/usercenter)也接入同域名 API(其部署属 #30 30A)。</li>
</ul>
</div>
</body>
</html>
+5
View File
@@ -86,6 +86,11 @@
</a> </a>
<h2>实现计划 / Plans</h2> <h2>实现计划 / Plans</h2>
<a class="doc" href="control-plane-tls-tunnel.html">
<div class="t">控制面 TLS(Cloudflare Tunnel 前置)实现计划 <span class="tag html">HTML</span></div>
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-06-control-plane-tls-tunnel.md</code>(含 checkbox)。把控制面 API 从明文 <code>http://103.119.13.48:8080</code> 迁到 <code>https://api.yanmeiai.com</code>cloudflared 出站隧道前置,源站仅绑 127.0.0.1,数据面 sing-box REALITY :443 全程不动)。6 任务:CF Tunnel 供给 → 客户端切 https/Android 去明文 → CI 守护禁明文 → PANGOLIN_PUBLIC_URL 切 https → 退役明文口(收 loopback,带上线顺序闸)→ 文档。</div>
<div class="path">docs/control-plane-tls-tunnel.html · 真相源 docs/superpowers/plans/2026-07-06-control-plane-tls-tunnel.md</div>
</a>
<a class="doc" href="cicd-plan.html"> <a class="doc" href="cicd-plan.html">
<div class="t">CI/CD 全流程 实现计划(#30<span class="tag html">HTML</span></div> <div class="t">CI/CD 全流程 实现计划(#30<span class="tag html">HTML</span></div>
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-05-cicd.md</code>(含 checkbox)。三期 11 任务:Phase1 基座+官网+服务端(无签名可立即上线,服务端固化 F3/F4 备份/迁移/回滚) → Phase2 Android(接 release keystore 签名,解锁下载链接) → Phase3 macOS 公证 dmg + Windows 安装包。runner 混合 nas/mac/windows;密钥已建(对齐 jiu)。设计见 cicd-design.html。</div> <div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-05-cicd.md</code>(含 checkbox)。三期 11 任务:Phase1 基座+官网+服务端(无签名可立即上线,服务端固化 F3/F4 备份/迁移/回滚) → Phase2 Android(接 release keystore 签名,解锁下载链接) → Phase3 macOS 公证 dmg + Windows 安装包。runner 混合 nas/mac/windows;密钥已建(对齐 jiu)。设计见 cicd-design.html。</div>
@@ -0,0 +1,499 @@
# 控制面 TLS(Cloudflare Tunnel 前置)Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把 pangolin-server 控制面 API 从明文 `http://103.119.13.48:8080` 迁到 `https://api.yanmeiai.com`,经 Cloudflare Tunnel 前置(隐藏源站 IP、白嫖标准 443 + 免证书),数据面 sing-box REALITY:443 完全不动。
**Architecture:** pangolin1 上跑 `cloudflared` **出站**隧道(不监听任何入站端口 → 与 sing-box 独占的 :443 零冲突),CF 边缘把 `api.yanmeiai.com` 的请求经隧道回送到 `127.0.0.1:8080`。客户端(Flutter 四端共享 `kApiBaseUrl`)默认改 https 域名;控制面下发给客户端 sing-box 的 `.srs` 规则集下载基址(`PANGOLIN_PUBLIC_URL`)同步改 https。最后一步把 `:8080` 收回 loopback 并关防火墙,彻底退役明文口——该步有上线顺序闸(须待现网客户端更新后再做)。
**Tech Stack:** Cloudflare Tunnel(remotely-managed / token 模式)、cloudflared(Debian 12 apt)、systemd、Go(pangolin-server,仅 env 变更零代码)、Flutter/Dart(`api_config.dart`)、Android manifest、Gitea Actions(`deploy-server.sh` 健康检查)、cf-api 封装(Bitwarden token)。
## Global Constraints
- **Bash 禁 `$()` 命令替换**;禁 `set -a`/`set +a`。需捕获输出拆多步或用管道。
- **凭证走 Bitwarden/rbw**,不写 `~/.env`/明文配置/git。Cloudflare 用 `cf-api` 封装(token 脚本内部从 Bitwarden 取,禁引用 `$CF_API_TOKEN`)。**隧道 token、私钥等 PII/密钥一律不入 git**,只落 `/etc/pangolin/*`(已 gitignore)+ Bitwarden。
- **改机器(装包/改配置/重启服务)前必须先问用户**(只读操作除外)。本方案 Task 1B/2/5 会 ssh 改 pangolin1 与 CF 账户配置,执行到那几步先征得确认。
- 回复中文件路径**一律绝对路径**。
- git commit 结尾附:`Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>``Claude-Session:` 行;**不 force-push、不推 main**。
- CF 账户 id `e585821c881c4cd23bc2530986edea9e`;zone `yanmeiai.com` id `2325730de45276d87180a8b66bd4cca0`
- pangolin1 = `103.119.13.48`,ssh 别名 `pangolin1`(root 免密)。数据面 sing-box REALITY 独占入站 `:443`,**不得触碰**。gRPC agent mTLS `:9443` 不动。
- **上线顺序铁律**:现网客户端硬编码 `http://103.119.13.48:8080`。隧道与 https 端点必须**加法上线**(与旧口并存),客户端切 https 发版后,**Task 5(收 loopback + 关防火墙)才能做**,否则旧客户端全挂。
---
## File Structure
**新建:**
- `deploy/single-node/systemd/cloudflared.service` — cloudflared 的 systemd unit(committed,`install -m 644` 到位,token 从 `/etc/pangolin/cloudflared.env``EnvironmentFile` 注入,不入 unit 本体)。
- `client/test/unit/api_config_test.dart` — 守护测试:控制面基址必须 https(防回退明文)。
- `ci/scan-cleartext.sh` — CI 守护:Android release manifest 不得含 `usesCleartextTraffic="true"`
**修改:**
- `deploy/single-node/deploy.sh` — server.env 里 `PANGOLIN_PUBLIC_URL` 改 https(Task 4);`ADDR` 改 loopback + 去掉 ufw 放行 8080(Task 5);新增 cloudflared 安装/enable(Task 1B)。
- `client/lib/services/api_config.dart:6-9``kApiBaseUrl` 默认值改 `https://api.yanmeiai.com`(Task 3)。
- `client/android/app/src/main/AndroidManifest.xml:30` — 移除 `android:usesCleartextTraffic="true"`(Task 3)。
- `scripts/ci/deploy-server.sh:57` — 健康检查从「runner 远程 curl `http://IP:8080`」改为 ssh 内本地 `curl http://127.0.0.1:8080/healthz`(Task 5)。
- `.gitea/workflows/ci.yml` — shellcheck 列表加 `ci/scan-cleartext.sh`;新增 cleartext-scan job(Task 3)。
- `CLAUDE.md` + `docs/` — 端口/URL 布局更新(Task 6)。
---
## Task 1: Cloudflare Tunnel 供给(CF 账户侧 + pangolin1 装 cloudflared)
把隧道建起来、DNS 指过去、cloudflared 在 pangolin1 上连通,`https://api.yanmeiai.com/healthz` 与旧的 `http://103.119.13.48:8080/healthz` **并存可用**(加法,不破坏现网)。
**Files:**
- Create: `deploy/single-node/systemd/cloudflared.service`
- Modify: `deploy/single-node/deploy.sh`(安装/enable cloudflared)
**Interfaces:**
- Produces: 隧道域名 `https://api.yanmeiai.com``127.0.0.1:8080`;隧道 token 存于 Bitwarden item `pangolin-cloudflared-tunnel` 字段 `TUNNEL_TOKEN` + pangolin1 `/etc/pangolin/cloudflared.env`。后续 Task 3/4 依赖此域名可达。
### 1A — CF 侧:创建隧道 + ingress + DNS(cf-api,只读账户外均属改配置,先确认)
- [ ] **Step 1: 建 remotely-managed 隧道,取 token**
先确认 rbw 已解锁(`rbw unlock`)。运行:
```bash
cf-api -X POST "/accounts/e585821c881c4cd23bc2530986edea9e/cfd_tunnel" \
--data '{"name":"pangolin-api","config_src":"cloudflare"}'
```
Expected: JSON `success:true`,`result.id`(隧道 UUID)、`result.token`(base64 长串)。**记下 `result.id``TUNNEL_ID`,`result.token``TUNNEL_TOKEN`。token 是密钥,不要落 git/明文文档。**
- [ ] **Step 2: 配 ingress(hostname → 本机 8080,兜底 404)**
```bash
cf-api -X PUT "/accounts/e585821c881c4cd23bc2530986edea9e/cfd_tunnel/<TUNNEL_ID>/configurations" \
--data '{"config":{"ingress":[{"hostname":"api.yanmeiai.com","service":"http://localhost:8080"},{"service":"http_status:404"}]}}'
```
Expected: `success:true`,`result.config.ingress` 含上面两条。
- [ ] **Step 3: 建代理 CNAME `api` → 隧道**
```bash
cf-api -X POST "/zones/2325730de45276d87180a8b66bd4cca0/dns_records" \
--data '{"type":"CNAME","name":"api","content":"<TUNNEL_ID>.cfargotunnel.com","proxied":true,"comment":"pangolin 控制面 API(CF Tunnel → pangolin1:8080)"}'
```
Expected: `success:true`,`result.name` = `api.yanmeiai.com`,`result.proxied` = true。
- [ ] **Step 4: token 存入 Bitwarden(留档)**
`TUNNEL_TOKEN` 存进 Bitwarden item `pangolin-cloudflared-tunnel`(字段 `TUNNEL_TOKEN`)。验证:
```bash
rbw get pangolin-cloudflared-tunnel --field TUNNEL_TOKEN | head -c 12
```
Expected: 打印 token 前 12 字符(证明可取回)。
### 1B — pangolin1:装 cloudflared + systemd 常驻(改机器,先确认)
- [ ] **Step 5: 写 committed systemd unit**
创建 `deploy/single-node/systemd/cloudflared.service`:
```ini
[Unit]
Description=Pangolin cloudflared (control-plane API tunnel → 127.0.0.1:8080)
Documentation=https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
After=network-online.target pangolin-server.service
Wants=network-online.target
[Service]
Type=notify
# TUNNEL_TOKEN 从此文件注入(不入 unit 本体、不进 ps);cloudflared 自动读取 env TUNNEL_TOKEN。
EnvironmentFile=/etc/pangolin/cloudflared.env
ExecStart=/usr/local/bin/cloudflared --no-autoupdate tunnel run
Restart=on-failure
RestartSec=5
# 出站隧道,无需 root:用非特权用户即可(与 pangolin-server 同用户)。
User=pangolin
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
```
- [ ] **Step 6: deploy.sh 里安装 cloudflared 二进制 + unit + enable**
`deploy/single-node/deploy.sh` 的 systemd 安装段(现有 `install -m 644 .../pangolin-server.service` 一带,约 251-252 行)后追加。先加安装函数(Debian apt,无 `$()`):
```bash
# ── cloudflared(控制面 API 出站隧道)──────────────────────────────
if ! command -v cloudflared >/dev/null 2>&1; then
log "安装 cloudflared(Cloudflare apt 源)"
install -m 0755 -d /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
-o /usr/share/keyrings/cloudflare-main.gpg
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bookworm main' \
> /etc/apt/sources.list.d/cloudflared.list
apt-get update -qq && apt-get install -y -qq cloudflared
# apt 装到 /usr/bin;软链到 unit 期望的 /usr/local/bin(与其他 pangolin 二进制一致)。
[ -x /usr/local/bin/cloudflared ] || ln -sf "$(command -v cloudflared)" /usr/local/bin/cloudflared
fi
install -m 644 "$HERE/systemd/cloudflared.service" /etc/systemd/system/
```
> 注:上面为示意锚点;`$(command -v cloudflared)` 违反禁 `$()` 规则——落地时改为:`CFD_BIN=/usr/bin/cloudflared` 后 `ln -sf "$CFD_BIN" /usr/local/bin/cloudflared`(apt 固定装到 `/usr/bin`)。
- [ ] **Step 7: 在 pangolin1 落 token env 文件 + 起服务**(ssh,改机器,先确认)
token 经用户剪贴板落地(不经过我、不入 git):
```bash
# 本机把 token 通过 ssh 写到远端受限权限文件(避免出现在 ps/history):
rbw get pangolin-cloudflared-tunnel --field TUNNEL_TOKEN | \
ssh pangolin1 'install -m 600 -o pangolin -g pangolin /dev/stdin /etc/pangolin/cloudflared.env.tmp && \
printf "TUNNEL_TOKEN=" | cat - /etc/pangolin/cloudflared.env.tmp > /etc/pangolin/cloudflared.env && \
rm -f /etc/pangolin/cloudflared.env.tmp && chmod 600 /etc/pangolin/cloudflared.env'
```
> 落地时若上面拼接别扭,改为本机 `printf 'TUNNEL_TOKEN=%s\n' "<token>"` 结果 ssh 管道写入;核心要求:`/etc/pangolin/cloudflared.env` 内容为单行 `TUNNEL_TOKEN=<token>`,mode 600,owner pangolin。
装 unit 并启动:
```bash
scp deploy/single-node/systemd/cloudflared.service pangolin1:/etc/systemd/system/
ssh pangolin1 'systemctl daemon-reload && systemctl enable --now cloudflared.service && sleep 3 && systemctl is-active cloudflared'
```
Expected: `active`
- [ ] **Step 8: 验证隧道连通(加法上线,不破坏旧口)**
```bash
curl -fsS -m 10 https://api.yanmeiai.com/healthz && echo " <= 隧道 OK"
curl -fsS -m 10 http://103.119.13.48:8080/healthz && echo " <= 旧口仍在(预期)"
```
Expected: 两条都返回 `/healthz` 成功体。证明 https 端点上线、旧明文口并存(现网客户端不受影响)。
- [ ] **Step 9: Commit**
```bash
git add deploy/single-node/systemd/cloudflared.service deploy/single-node/deploy.sh
git commit -m "feat(deploy): cloudflared 出站隧道前置控制面 API(api.yanmeiai.com→127.0.0.1:8080)"
```
---
## Task 2: 客户端控制面基址切 https + Android 去明文(含守护测试)
**Files:**
- Modify: `client/lib/services/api_config.dart:6-9`
- Modify: `client/android/app/src/main/AndroidManifest.xml:30`
- Create: `client/test/unit/api_config_test.dart`
**Interfaces:**
- Consumes: Task 1 产出的 `https://api.yanmeiai.com`(须已可达)。
- Produces: 全 Flutter 端(auth/nodes/account/connection providers 共享的)`kApiBaseUrl` 默认 = `https://api.yanmeiai.com`
- [ ] **Step 1: 写守护测试(先失败)**
创建 `client/test/unit/api_config_test.dart`:
```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:pangolin/services/api_config.dart';
void main() {
test('控制面基址默认走 https(禁止回退明文 http)', () {
expect(kApiBaseUrl, startsWith('https://'),
reason: '控制面已迁 CF Tunnel(api.yanmeiai.com);默认值不得是明文 http');
expect(kApiBaseUrl, isNot(contains('103.119.13.48')),
reason: '不得再硬编码节点 IP 作控制面基址');
});
}
```
- [ ] **Step 2: 跑测试确认失败**
Run: `cd client && flutter test test/unit/api_config_test.dart`
Expected: FAIL —— 当前默认 `http://103.119.13.48:8080` 两条断言都不满足。
- [ ] **Step 3: 改默认值为 https 域名**
`client/lib/services/api_config.dart:6-9`,把:
```dart
const String kApiBaseUrl = String.fromEnvironment(
'PANGOLIN_API_URL',
defaultValue: 'http://103.119.13.48:8080',
);
```
改为(保留 `String.fromEnvironment` 让本地联调仍可 `--dart-define` 覆盖,只换默认值并更新注释):
```dart
// 控制面 API 基址(单源,全端 providers 共用)。默认走 CF Tunnel 的 https 域名;
// 本地联调可 --dart-define=PANGOLIN_API_URL=http://127.0.0.1:8080 覆盖。
const String kApiBaseUrl = String.fromEnvironment(
'PANGOLIN_API_URL',
defaultValue: 'https://api.yanmeiai.com',
);
```
同时删掉第 5 行「TODO(联调临时)…发版前改回」那条注释(已落地)。
- [ ] **Step 4: 跑测试确认通过**
Run: `cd client && flutter test test/unit/api_config_test.dart`
Expected: PASS。
- [ ] **Step 5: 移除 Android 全局明文开关**
`client/android/app/src/main/AndroidManifest.xml:30`,把 `<application>` 上的:
```
android:usesCleartextTraffic="true"><!-- 控制面 API 当前为 http(联调),Android 9+ 默认禁明文,需开;生产改 https 后可去掉 -->
```
改为(去掉该属性,闭合标签接到上一属性行;控制面已 https,不再需要明文豁免):
```
android:icon="@mipmap/ic_launcher">
```
> iOS/macOS 无 ATS 配置(已确认),https 天然满足 ATS,**无需改任何 plist**。
- [ ] **Step 6: analyze + 全量单测**
Run: `cd client && flutter analyze --no-fatal-infos && flutter test test/unit test/widget test/contract`
Expected: analyze 无 error;测试全绿(含新 `api_config_test`)。
- [ ] **Step 7: Commit**
```bash
git add client/lib/services/api_config.dart client/android/app/src/main/AndroidManifest.xml client/test/unit/api_config_test.dart
git commit -m "feat(client): 控制面基址默认 https://api.yanmeiai.com + 移除 Android 明文开关"
```
---
## Task 3: CI 守护 —— Android release manifest 禁明文
防止将来有人把 `usesCleartextTraffic="true"` 加回来(回退明文)。
**Files:**
- Create: `ci/scan-cleartext.sh`
- Modify: `.gitea/workflows/ci.yml`(新增 job + shellcheck 列表)
- [ ] **Step 1: 写扫描脚本**
创建 `ci/scan-cleartext.sh`:
```bash
#!/usr/bin/env bash
# scan-cleartext.sh — 禁止 Android manifest 重新开启全局明文(控制面已 https/CF Tunnel)。
# usesCleartextTraffic="true" 会让全 app 允许明文 HTTP,退回 #25 之前的不安全态。
set -euo pipefail
MANIFEST="client/android/app/src/main/AndroidManifest.xml"
if grep -q 'usesCleartextTraffic="true"' "$MANIFEST"; then
echo "$MANIFEST 含 usesCleartextTraffic=\"true\":控制面已 https,禁止全局明文。" >&2
echo " 如个别调试域名确需明文,请用 res/xml/network_security_config.xml 按域白名单,勿开全局。" >&2
exit 1
fi
echo "✅ Android manifest 未开启全局明文"
```
- [ ] **Step 2: 本地跑一遍(应通过,因 Task 2 已移除)**
Run: `bash ci/scan-cleartext.sh`
Expected: `✅ Android manifest 未开启全局明文`
- [ ] **Step 3: 反向自测(临时加回应失败)**
Run:
```bash
sed -i.bak 's#android:icon="@mipmap/ic_launcher">#android:icon="@mipmap/ic_launcher" android:usesCleartextTraffic="true">#' client/android/app/src/main/AndroidManifest.xml
bash ci/scan-cleartext.sh; echo "exit=$?"
mv client/android/app/src/main/AndroidManifest.xml.bak client/android/app/src/main/AndroidManifest.xml
```
Expected: 打印 ❌ 且 `exit=1`;还原后文件复原。
- [ ] **Step 4: 接入 CI**
`.gitea/workflows/ci.yml`:(a)在 lint job 的「shellcheck CI 脚本」列表(约 42-58 行)加 `/mnt/... ` 对应项前,先把 `ci/scan-cleartext.sh` 纳入 shellcheck——注意该文件在 `ci/``scripts/ci/`,复用已有的 redline-scan 挂载方式即可;(b)新增 job:
```yaml
cleartext-scan:
name: Cleartext Scan — Android 禁明文
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: scan Android manifest for global cleartext
run: bash ci/scan-cleartext.sh
```
- [ ] **Step 5: Commit**
```bash
git add ci/scan-cleartext.sh .gitea/workflows/ci.yml
git commit -m "ci: 守护 Android manifest 禁全局明文(#25 控制面已 https)"
```
---
## Task 4: 服务端 `PANGOLIN_PUBLIC_URL` 切 https(规则集下载基址)
`PANGOLIN_PUBLIC_URL` 被嵌进**客户端 sing-box 配置**当 `.srs` 分流规则集下载基址(`clientconfig.go:150-161`,`download_detour:"direct"`)。不改的话新客户端仍去 `http://103.119.13.48:8080` 拉。此步与 Task 1 隧道并存,对新旧客户端都安全(URL 由服务端下发,客户端只是照着 GET)。
**Files:**
- Modify: `deploy/single-node/deploy.sh:178`
- [ ] **Step 1: 改 deploy.sh 的 server.env 默认**
`deploy/single-node/deploy.sh:178`,把:
```
PANGOLIN_PUBLIC_URL=http://$VPS_IP:$HTTP_PORT
```
改为:
```
PANGOLIN_PUBLIC_URL=https://api.yanmeiai.com
```
- [ ] **Step 2: 在 pangolin1 应用 + 重启 server**(ssh,改机器,先确认)
```bash
ssh pangolin1 "sed -i 's#^PANGOLIN_PUBLIC_URL=.*#PANGOLIN_PUBLIC_URL=https://api.yanmeiai.com#' /etc/pangolin/server.env && systemctl restart pangolin-server && sleep 2 && systemctl is-active pangolin-server"
```
Expected: `active`
- [ ] **Step 3: 验证下发配置里规则集基址已是 https**
用一个测试账号取一份客户端配置(经隧道),断言规则集 URL 走 https:
```bash
curl -fsS -m 10 https://api.yanmeiai.com/v1/rules/geoip-cn.srs -o /dev/null -w '%{http_code}\n'
```
Expected: `200`(规则集经隧道可下载)。并在有测试 token 时抓一份 `/v1/...` 客户端配置,确认内嵌 `route.rule_set[].url` 前缀为 `https://api.yanmeiai.com`
- [ ] **Step 4: Commit**
```bash
git add deploy/single-node/deploy.sh
git commit -m "feat(deploy): PANGOLIN_PUBLIC_URL 改 https://api.yanmeiai.com(客户端规则集走隧道)"
```
---
## Task 5: 退役明文口 —— 8080 收 loopback + 关防火墙 + 修健康检查
> **⚠️ 上线顺序闸:此 Task 会切断外部 `http://103.119.13.48:8080`,只有当现网客户端都已更新到 Task 2 的 https 版本后才能执行。** 执行前与用户确认「旧客户端可弃」。做完后一切经隧道/loopback,数据面 :443 不受影响。
**Files:**
- Modify: `deploy/single-node/deploy.sh:167`(ADDR 收 loopback)、`:272-275`(去掉 ufw 放行 8080)
- Modify: `scripts/ci/deploy-server.sh:57`(健康检查改本地)
- [ ] **Step 1: deploy.sh — ADDR 绑 loopback**
`deploy/single-node/deploy.sh:167`,把 `ADDR=:$HTTP_PORT` 改为:
```
ADDR=127.0.0.1:$HTTP_PORT
```
- [ ] **Step 2: deploy.sh — 不再放行 8080(loopback 后无需外开)**
`deploy/single-node/deploy.sh:272-275` 的 ufw 放行段删除或改注释(8080 已 loopback,外部本就不可达):
```bash
# 控制面 API 已绑 127.0.0.1(经 cloudflared 隧道对外),不放行 8080/tcp。
```
- [ ] **Step 3: deploy-server.sh — 健康检查改 ssh 内本地 curl**
`scripts/ci/deploy-server.sh:57`,把 runner 远程:
```
curl -fsS -m 10 --retry 5 --retry-connrefused "http://${DEPLOY_HOST}:8080/healthz" >/dev/null && echo "healthz OK"
```
改为经隧道校验对外可达 + ssh 内本地兜底(二选一或都留,推荐经隧道最贴近真实客户端路径):
```bash
$SSH "root@${DEPLOY_HOST}" 'curl -fsS -m 10 --retry 5 --retry-connrefused http://127.0.0.1:8080/healthz >/dev/null && echo "healthz(local) OK"'
curl -fsS -m 10 --retry 5 "https://api.yanmeiai.com/healthz" >/dev/null && echo "healthz(tunnel) OK"
```
- [ ] **Step 4: 在 pangolin1 应用 loopback 绑定**(ssh,改机器,先确认客户端已迁移)
```bash
ssh pangolin1 "sed -i 's#^ADDR=.*#ADDR=127.0.0.1:8080#' /etc/pangolin/server.env && systemctl restart pangolin-server && sleep 2 && systemctl is-active pangolin-server"
```
Expected: `active`
- [ ] **Step 5: 验证明文口已死、隧道仍活**
```bash
curl -fsS -m 8 http://103.119.13.48:8080/healthz && echo "!! 不该还通" || echo "旧明文口已不可达(预期)"
curl -fsS -m 10 https://api.yanmeiai.com/healthz && echo " <= 隧道仍 OK"
ssh pangolin1 'ss -ltnp | grep ":8080" | grep 127.0.0.1 && echo "8080 已仅 loopback"'
```
Expected: 明文口失败;隧道成功;`ss` 显示 8080 仅监听 `127.0.0.1`
- [ ] **Step 6: Commit**
```bash
git add deploy/single-node/deploy.sh scripts/ci/deploy-server.sh
git commit -m "feat(deploy): 8080 收 loopback + 关 8080 防火墙 + 健康检查改本地/隧道(退役明文控制口)"
```
---
## Task 6: 文档更新(端口/URL 布局)
**Files:**
- Modify: `CLAUDE.md`(项目根,worktree 内那份)— 端口布局说明
- Modify: `docs/index.html` — 登记本方案 HTML 阅读版
- [ ] **Step 1: 更新 CLAUDE.md 端口/URL 描述**
`deploy/ 结构` 或 server 段补一句:控制面 API 对外经 **CF Tunnel** `https://api.yanmeiai.com`(源站 `127.0.0.1:8080`,不外露);数据面 sing-box REALITY 仍独占 `:443`;gRPC agent mTLS `:9443`
- [ ] **Step 2: 生成本方案 HTML 阅读版并登记 index**
按既有深色 HTML 家族样式,把本 plan 同内容生成 `docs/control-plane-tls-tunnel.html`,登记进 `docs/index.html` 的「实现计划」分类。
- [ ] **Step 3: Commit**
```bash
git add CLAUDE.md docs/control-plane-tls-tunnel.html docs/index.html
git commit -m "docs: 控制面 CF Tunnel/端口布局说明 + 方案 HTML 登记 index"
```
---
## Self-Review
**Spec coverage:**
- ✅ CF Tunnel 前置控制面 → Task 1。
- ✅ 客户端默认 http→https → Task 2。
- ✅ Android 移除 usesCleartextTraffic → Task 2(iOS/macOS 无 ATS 需改,已核实)。
- ✅ server 8080 收 loopback → Task 5(带上线顺序闸)。
-`PANGOLIN_PUBLIC_URL` 同步 https(Explore 发现的隐藏依赖)→ Task 4。
- ✅ 健康检查随 loopback 调整 → Task 5。
- ✅ 数据面 :443 不动 → 全程未触碰 sing-box(约束显式声明)。
- ✅ fallback(域名被封退直连 IP)→ 明确拆到 #32,不在本轮。
**上线顺序验证:** Task 1(隧道加法)→ Task 4(PUBLIC_URL,新旧客户端皆安全)→ Task 2(客户端切 https,发版)→ **待客户端更新** → Task 5(收口)。Task 3(CI 守护)、Task 6(文档)无顺序耦合。
**Placeholder / 一致性:** Task 1B Step 6 的 `$(command -v cloudflared)` 已在注释显式提示落地时改为无 `$()` 写法(禁 `$()` 全局约束);token 全程不落 git;`kApiBaseUrl` 名称跨 Task 2/守护测试一致。
## 不在本轮
- #32 控制面 fallback(CF 域名被 SNI 封 → 客户端退回直连节点 IP 的 https 控制口)。
- 控制面 API 的 CF WAF/rate-limit 规则精调。
- usercenter(web/usercenter)也接入同域名 API(其部署属 #30 30A)。
@@ -43,7 +43,7 @@ pangolin 现有 CI 仅 `.gitea/workflows/ci.yml`(nas,只校验无部署)+ `web/w
- `test.sh <server|client>` —— `go test` / `flutter test` - `test.sh <server|client>` —— `go test` / `flutter test`
- `notify.sh` —— 成功/失败 Telegram 通知(可选,复用节点监控 bot) - `notify.sh` —— 成功/失败 Telegram 通知(可选,复用节点监控 bot)
- `compile-site.sh` / `compile-backend.sh` / `compile-android.sh` / `compile-macos.sh` / `compile-windows.sh` - `compile-site.sh` / `compile-backend.sh` / `compile-android.sh` / `compile-macos.sh` / `compile-windows.sh`
- `deploy-site.sh` / `deploy-server.sh`(ssh pangolin1) - `deploy-site.sh`(wrangler → CF Pages)/ `deploy-server.sh`(ssh pangolin1,复用 lib-ssh)
- `release-<x>.sh` —— 建 release + 挂产物 - `release-<x>.sh` —— 建 release + 挂产物
> 每个 `compile-*` 内部封装该端已验证的构建命令(如 Android 走 > 每个 `compile-*` 内部封装该端已验证的构建命令(如 Android 走
@@ -74,11 +74,18 @@ pangolin 现有 CI 仅 `.gitea/workflows/ci.yml`(nas,只校验无部署)+ `web/w
5. `install` 新二进制到 `/usr/local/bin`(旧的备份为 `.bak-<tag>`) 5. `install` 新二进制到 `/usr/local/bin`(旧的备份为 `.bak-<tag>`)
6. `systemctl start pangolin-server` + `/healthz` 健康检查;agent 随连接自恢复 6. `systemctl start pangolin-server` + `/healthz` 健康检查;agent 随连接自恢复
### 4.4 官网部署(deploy-site.sh) ### 4.4 官网部署(deploy-site.sh)—— Cloudflare Pages
Astro `npm ci && npm run build`(`SITE_URL=https://pangolin.yanmeiai.com`)→ `dist/` rsync 到 > **架构变更(2026-07-06 实施):** 原计划 rsync 到 pangolin1 的 nginx。但节点 :443 被 sing-box
服务器 web 根 → 由 nginx/Caddy 静态托管 `pangolin.yanmeiai.com`。**DNS**:CF 加 A/CNAME > (VPN 数据面)占用,而 CF 免费套餐 proxied 回源只能打 :80/:443、改回源端口需 Enterprise ——
`pangolin.yanmeiai.com` → 服务器(首次部署时做,记 baize)。 > 无法在同机同 IP 上让官网 HTTPS 与 VPN 共存。**故官网改由 Cloudflare Pages 托管**:纯静态、
> 全程 HTTPS、`_headers`/CSP 原生生效、不落 VPS,从根上无 :443 冲突,也不拖累 VPN 机器。
Astro `npm ci && npm run build`(`SITE_URL=https://pangolin.yanmeiai.com`)→ `dist/`
`npx wrangler pages deploy` 发布到 CF Pages 项目 **`pangolin-site`**(自定义域
`pangolin.yanmeiai.com`,CNAME → `pangolin-site.pages.dev`,proxied)。
需 secret:`CLOUDFLARE_API_TOKEN`(带 Account>Pages>Edit)+ `CLOUDFLARE_ACCOUNT_ID`(账户级)。
deploy 步骤在 `node:20` 容器内跑 wrangler。**灾备**:构建产物仍是纯静态,可另 rsync 到任意镜像。
## 5. 下载链接闭环(30A) ## 5. 下载链接闭环(30A)
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# backup-db.sh — pangolin1 控制面 SQLite 每日备份到 NAS(异地容灾,#26 / F2)。
#
# 经 ssh 在 pangolin1 上用 sqlite3 online `.backup` 取 WAL 一致快照(不阻塞服务、
# 不像裸 cp 那样可能拿到半写状态),远端先跑 PRAGMA integrity_check 确认没坏,再把
# 快照 gzip 流回 runner,落到 NAS 备份盘(默认 /volume1/docker/backups/pangolin,
# 该盘已挂进 runner 容器)。保留 30 天。异地 = 家里 NAS ≠ VPS 机房,构成容灾。
#
# 不在 pangolin1 上安装/常驻任何东西:调度在 Gitea Actions(backup.yml),每次经
# ssh 临时跑一次 sqlite3。需环境:DEPLOY_SSH_KEY(secret)。从 repo 根调用。
set -euo pipefail
# shellcheck source=scripts/ci/lib-ssh.sh
. scripts/ci/lib-ssh.sh
BACKUP_DIR="${BACKUP_DIR:-/volume1/docker/backups/pangolin}"
mkdir -p "$BACKUP_DIR"
# 时间戳文件名(bash 内建 strftime,免 $() 命令替换)。
printf -v TS '%(%Y%m%d_%H%M%S)T' -1
DEST="${BACKUP_DIR}/pangolin_${TS}.db.gz"
# setup_ssh 内部先注册 EXIT trap 清理临时私钥,再写 key/known_hosts,导出 $SSH。
setup_ssh
echo "==> backup-db: sqlite3 .backup pangolin1(${DEPLOY_HOST})-> ${DEST}"
# 远端单引号 heredoc:$BK/$$ 在 pangolin1 上展开。以 pangolin 用户(DB 属主)跑
# online .backup + integrity_check;校验通过才 cat 回流;临时文件用完即删。
# integrity_check 失败 → 远端非零退出 → 本地 pipefail 令整条命令失败 → set -e 中止。
$SSH "root@${DEPLOY_HOST}" 'bash -s' <<'ENDSSH' | gzip > "$DEST"
set -euo pipefail
BK="/tmp/pangolin-bk-$$.db"
trap 'rm -f "$BK"' EXIT
runuser -u pangolin -- sqlite3 "/var/lib/pangolin/pangolin.db" ".backup ${BK}"
if ! runuser -u pangolin -- sqlite3 "$BK" "PRAGMA integrity_check;" | grep -q '^ok$'; then
echo "backup-db(remote): integrity_check 失败" >&2
exit 1
fi
cat "$BK"
ENDSSH
# 本地二次校验:gzip 完好 + 非空 sanity。空 gzip 仅 ~20 字节;真备份(即便很小的库)
# gzip 后也远大于 1KB,故用 >1k 区分「空/断流」与「有效备份」(不用 50k:小库压缩后可能 <50k)。
gzip -t "$DEST"
if ! find "$DEST" -size +1k -print -quit | grep -q .; then
echo "==> backup-db: 备份异常小(<1k,疑似空/断流),中止" >&2
rm -f "$DEST"
exit 1
fi
# 保留最近 30 天。
find "$BACKUP_DIR" -name 'pangolin_*.db.gz' -mtime +30 -delete
echo "==> backup-db: 备份完成 ${DEST}"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# compile-backend.sh — cross-compile the pangolin Go control-plane binaries
# (server / agent / migrate) for the pangolin1 deploy target. CGO disabled:
# modernc.org/sqlite is pure Go, no cgo toolchain needed on the runner.
# Output: server/out/{pangolin-server,pangolin-agent,pangolin-migrate}.
#
# Run inside a golang:1.25 container by .gitea/workflows/deploy-server.yml;
# this script itself just runs `go build` and assumes it is invoked from the
# repo root.
set -euo pipefail
# shellcheck source=scripts/ci/_env.sh
. scripts/ci/_env.sh
cd server
mkdir -p out
echo "==> compile-backend: building pangolin-server"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o out/pangolin-server ./cmd/server
echo "==> compile-backend: building pangolin-agent"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o out/pangolin-agent ./cmd/agent
echo "==> compile-backend: building pangolin-migrate"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o out/pangolin-migrate ./cmd/migrate
echo "==> compile-backend: done — out/ contents:"
ls -lh out/
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# compile-site.sh — build the Astro 官网 (web/website) as a static site.
# Output: web/website/dist/. Canonical domain is injected via SITE_URL (see
# web/website/astro.config.mjs) — must match the deploy target host name so
# canonical URLs / sitemap resolve correctly.
#
# Run inside a node:20 container by .gitea/workflows/deploy-site.yml; this
# script itself just runs npm and assumes it is invoked from the repo root.
set -euo pipefail
SITE_URL="${SITE_URL:-https://pangolin.yanmeiai.com}"
export SITE_URL
echo "==> compile-site: SITE_URL=${SITE_URL}"
cd web/website
npm ci
npm run build
echo "==> compile-site: done — dist/ contents:"
ls -lh dist/
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# deploy-server.sh <tag> — deploy the pangolin control-plane binaries
# (pangolin-server / pangolin-agent / pangolin-migrate) to pangolin1, in the
# exact manual sequence used for F3/F4: stop → wal checkpoint → backup db →
# migrate (rollback db + restart old binary on failure) → swap binaries →
# start → healthcheck. Assumes compile-backend.sh has already produced
# server/out/{pangolin-server,pangolin-agent,pangolin-migrate}.
#
# Usage: scripts/ci/deploy-server.sh <tag> (e.g. server-v1.2.3)
# Requires env: DEPLOY_SSH_KEY (see lib-ssh.sh).
set -euo pipefail
# shellcheck source=scripts/ci/lib-ssh.sh
. scripts/ci/lib-ssh.sh
DB=/var/lib/pangolin/pangolin.db
BIN=/usr/local/bin
TAG="${1:?usage: deploy-server.sh <tag>}"
# Refuse anything that isn't a strict server-vX.Y.Z[-suffix] tag before it can
# reach the remote heredoc / backup paths below (command-injection guard).
# An anchored regex is used instead of a `case` glob: a trailing `*` in a
# case pattern matches ANY trailing characters (including shell metachars
# like `; rm -rf /`), which would defeat the point of this check.
if ! [[ "$TAG" =~ ^server-v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]]; then
echo "deploy-server: refusing unexpected tag '$TAG'" >&2
exit 1
fi
# setup_ssh registers the EXIT cleanup trap itself (before writing the key),
# so a mid-setup failure still cleans up — see lib-ssh.sh. It exports
# SSH_KEY_FILE / DEPLOY_PORT / SSH_KNOWN_HOSTS_FILE / DEPLOY_HOST used below
# to build SCP (mirroring the SSH/RSYNC_SSH command-string convention).
setup_ssh
SCP="scp -i ${SSH_KEY_FILE} -P ${DEPLOY_PORT} -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=${SSH_KNOWN_HOSTS_FILE}"
echo "==> deploy-server: tag=${TAG} host=${DEPLOY_HOST}"
echo "==> deploy-server: uploading binaries to ${DEPLOY_HOST}:/tmp/"
$SCP server/out/pangolin-server server/out/pangolin-agent server/out/pangolin-migrate "root@${DEPLOY_HOST}:/tmp/"
$SSH "root@${DEPLOY_HOST}" "bash -s" <<REMOTE
set -euo pipefail
systemctl stop pangolin-server
runuser -u pangolin -- sqlite3 "$DB" 'PRAGMA wal_checkpoint(TRUNCATE);'
cp -p "$DB" "$DB.bak-pre-$TAG"
if ! runuser -u pangolin -- env DB_DRIVER=sqlite DB_DSN=$DB /tmp/pangolin-migrate up; then
echo "!! migrate 失败,回滚"; cp -p "$DB.bak-pre-$TAG" "$DB"; systemctl start pangolin-server; exit 1
fi
cp -p "$BIN/pangolin-server" "$BIN/pangolin-server.bak-$TAG" || true
install -m755 /tmp/pangolin-server "$BIN/pangolin-server"
install -m755 /tmp/pangolin-agent "$BIN/pangolin-agent"
install -m755 /tmp/pangolin-migrate "$BIN/pangolin-migrate"
systemctl start pangolin-server
systemctl is-active pangolin-server
REMOTE
# 8080 现仅 loopback(经 cloudflared 隧道对外)。
# 本地 /healthz 是本次二进制部署成败的**权威闸**:新 server 起来即通过。
$SSH "root@${DEPLOY_HOST}" 'curl -fsS -m 10 --retry 5 --retry-connrefused http://127.0.0.1:8080/healthz >/dev/null && echo "healthz(local) OK"'
# 隧道 /healthz 是端到端冒烟(最贴近真实客户端路径),但**非致命**:它依赖 cloudflared/CF 边缘,
# 与「本次二进制是否健康」是两回事——CF 边缘抖动或隧道尚未 provision 不应判整次部署失败
# (本地闸已证明 server 健康)。失败只告警,不 exit。
if curl -fsS -m 10 --retry 3 "https://api.yanmeiai.com/healthz" >/dev/null; then
echo "healthz(tunnel) OK"
else
echo "==> deploy-server: 警告 —— 隧道 https://api.yanmeiai.com/healthz 不通(CF 边缘抖动/隧道未就绪?);本地 healthz 已通过,不阻断部署。" >&2
fi
echo "==> deploy-server: done"
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# deploy-site.sh — 部署构建好的 Astro 官网 (web/website/dist/) 到 Cloudflare Pages。
#
# 官网托管在 CF Pages(项目 pangolin-site,自定义域 pangolin.yanmeiai.com),纯静态、
# 全程 HTTPS、CSP(_headers)自动生效,不落在 VPS 上 —— 故与节点 :443(sing-box)无冲突。
#
# 需环境变量:
# CLOUDFLARE_API_TOKEN 带 Account > Cloudflare Pages > Edit 权限的 CF token
# CLOUDFLARE_ACCOUNT_ID CF 账户 ID
# 由 compile-site.sh 先产出 web/website/dist/;从 repo 根调用。
set -euo pipefail
if [ ! -d web/website/dist ]; then
echo "==> deploy-site: web/website/dist/ 不存在 — 拒绝部署" >&2
exit 1
fi
if ! find web/website/dist -mindepth 1 -print -quit | grep -q .; then
echo "==> deploy-site: web/website/dist/ 为空 — 拒绝部署" >&2
exit 1
fi
: "${CLOUDFLARE_API_TOKEN:?deploy-site: CLOUDFLARE_API_TOKEN 未设(需带 Pages:Edit)}"
: "${CLOUDFLARE_ACCOUNT_ID:?deploy-site: CLOUDFLARE_ACCOUNT_ID 未设}"
echo "==> deploy-site: wrangler pages deploy → project pangolin-site (branch main)"
npx --yes wrangler@4 pages deploy web/website/dist \
--project-name=pangolin-site --branch=main --commit-dirty=true
echo "==> deploy-site: done"
+30 -10
View File
@@ -43,11 +43,13 @@ forgejo_release_ensure() {
get_code_file="/tmp/forgejo_get_code.$$" get_code_file="/tmp/forgejo_get_code.$$"
get_body_file="/tmp/forgejo_get_body.$$.json" get_body_file="/tmp/forgejo_get_body.$$.json"
curl "${FORGEJO_CURL_TLS[@]}" -s -o "$get_body_file" -w '%{http_code}' \ curl ${FORGEJO_CURL_TLS[@]+"${FORGEJO_CURL_TLS[@]}"} -s --max-time 60 --retry 2 --retry-connrefused -o "$get_body_file" -w '%{http_code}' \
-H "Authorization: token ${FORGEJO_TOKEN}" \ -H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_URL}/api/v1/repos/${FORGEJO_REPO}/releases/tags/${tag}" \ "${FORGEJO_URL}/api/v1/repos/${FORGEJO_REPO}/releases/tags/${tag}" \
> "$get_code_file" > "$get_code_file"
read -r get_code < "$get_code_file" # `|| true`: curl -w '%{http_code}' 写入的值无结尾换行,read 到无换行 EOF 返回 1
# (值已赋)→ set -e 会静默中止。容错该退出码;文件恒由上面的 curl 创建,不掩盖真错。
read -r get_code < "$get_code_file" || true
rm -f "$get_code_file" rm -f "$get_code_file"
if [ "$get_code" = "200" ]; then if [ "$get_code" = "200" ]; then
@@ -59,18 +61,36 @@ forgejo_release_ensure() {
rm -f "$get_body_file" rm -f "$get_body_file"
echo "==> forgejo: creating release ${tag}" echo "==> forgejo: creating release ${tag}"
local create_code_file create_body_file create_code local create_code_file create_body_file create_code create_req_file
create_code_file="/tmp/forgejo_create_code.$$" create_code_file="/tmp/forgejo_create_code.$$"
create_body_file="/tmp/forgejo_create_body.$$.json" create_body_file="/tmp/forgejo_create_body.$$.json"
create_req_file="/tmp/forgejo_create_req.$$.json"
curl "${FORGEJO_CURL_TLS[@]}" -s -o "$create_body_file" -w '%{http_code}' \ # Build the JSON request body via python3's json.dumps rather than raw
# string interpolation, so a tag/title containing `"` / `\` / control
# characters can't break out of the JSON structure (json-injection guard).
# Values are piped in NUL-separated on stdin — never interpolated into the
# python source — and no $() command substitution is used.
printf '%s\0%s\0' "$tag" "$title" | python3 -c '
import json
import sys
raw = sys.stdin.buffer.read()
tag, title = (part.decode() for part in raw.split(b"\0")[:2])
json.dump(
{"tag_name": tag, "name": title, "draft": False, "prerelease": False},
sys.stdout,
)
' > "$create_req_file"
curl ${FORGEJO_CURL_TLS[@]+"${FORGEJO_CURL_TLS[@]}"} -s --max-time 60 -o "$create_body_file" -w '%{http_code}' \
-X POST "${FORGEJO_URL}/api/v1/repos/${FORGEJO_REPO}/releases" \ -X POST "${FORGEJO_URL}/api/v1/repos/${FORGEJO_REPO}/releases" \
-H "Authorization: token ${FORGEJO_TOKEN}" \ -H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${tag}\",\"name\":\"${title}\",\"draft\":false,\"prerelease\":false}" \ --data @"$create_req_file" \
> "$create_code_file" > "$create_code_file"
read -r create_code < "$create_code_file" read -r create_code < "$create_code_file" || true # 无结尾换行,见 forgejo_release_ensure 注释
rm -f "$create_code_file" rm -f "$create_code_file" "$create_req_file"
if [ "$create_code" -lt 200 ] || [ "$create_code" -ge 300 ]; then if [ "$create_code" -lt 200 ] || [ "$create_code" -ge 300 ]; then
echo "==> forgejo: release create FAILED (HTTP ${create_code})" >&2 echo "==> forgejo: release create FAILED (HTTP ${create_code})" >&2
@@ -90,7 +110,7 @@ _forgejo_read_release_id() {
id_file="/tmp/forgejo_release_id.$$" id_file="/tmp/forgejo_release_id.$$"
python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['id'])" \ python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['id'])" \
"$json_file" > "$id_file" "$json_file" > "$id_file"
read -r RELEASE_ID < "$id_file" read -r RELEASE_ID < "$id_file" || true # python 写入可能无结尾换行,见上注释
rm -f "$id_file" rm -f "$id_file"
export RELEASE_ID export RELEASE_ID
} }
@@ -107,12 +127,12 @@ forgejo_upload_asset() {
code_file="/tmp/forgejo_upload_code.$$" code_file="/tmp/forgejo_upload_code.$$"
body_file="/tmp/forgejo_upload_body.$$.json" body_file="/tmp/forgejo_upload_body.$$.json"
curl "${FORGEJO_CURL_TLS[@]}" -s -o "$body_file" -w '%{http_code}' \ curl ${FORGEJO_CURL_TLS[@]+"${FORGEJO_CURL_TLS[@]}"} -s --max-time 300 -o "$body_file" -w '%{http_code}' \
-X POST "${FORGEJO_URL}/api/v1/repos/${FORGEJO_REPO}/releases/${RELEASE_ID}/assets" \ -X POST "${FORGEJO_URL}/api/v1/repos/${FORGEJO_REPO}/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${FORGEJO_TOKEN}" \ -H "Authorization: token ${FORGEJO_TOKEN}" \
-F "attachment=@${file}" \ -F "attachment=@${file}" \
> "$code_file" > "$code_file"
read -r code < "$code_file" read -r code < "$code_file" || true # curl http_code 无结尾换行,见上注释
rm -f "$code_file" rm -f "$code_file"
echo "==> forgejo: uploaded ${file} (HTTP ${code})" echo "==> forgejo: uploaded ${file} (HTTP ${code})"
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# lib-ssh.sh — shared SSH deploy-key helpers for the pangolin deploy pipelines
# (site / server). `source` this from deploy-*.sh.
#
# Provides:
# setup_ssh -> registers the EXIT cleanup trap first, then writes
# $DEPLOY_SSH_KEY to a temp private key (mode 600),
# registers the deploy host in a dedicated known_hosts
# file, and exports SSH / RSYNC_SSH (ssh command strings)
# + SSH_KEY_FILE.
# teardown_ssh -> removes the temp private key + known_hosts file.
#
# Requires env: DEPLOY_SSH_KEY (PEM content of the deploy private key,
# authorized for root on the target host).
#
# Target host is hardcoded to the pangolin1 VPS IP (103.119.13.48): the CI
# runner has no access to the user's local ~/.ssh/config, so the `pangolin1`
# alias cannot be resolved there — the bare IP is used instead. Override with
# DEPLOY_HOST / DEPLOY_PORT if a caller needs to retarget.
#
# No command substitution ($()) is used anywhere, per repo bash conventions.
DEPLOY_HOST="${DEPLOY_HOST:-103.119.13.48}"
DEPLOY_PORT="${DEPLOY_PORT:-22}"
SSH_KEY_FILE="${SSH_KEY_FILE:-/tmp/pangolin_deploy_key.$$}"
SSH_KNOWN_HOSTS_FILE="${SSH_KNOWN_HOSTS_FILE:-/tmp/pangolin_deploy_known_hosts.$$}"
# setup_ssh — write the deploy key, register known_hosts, export SSH/RSYNC_SSH.
setup_ssh() {
# Register cleanup FIRST: if anything below fails mid-setup (e.g. a
# transient ssh-keyscan error under `set -e`), the private key file must
# still be removed on exit rather than leaking.
trap teardown_ssh EXIT
if [ -z "${DEPLOY_SSH_KEY:-}" ]; then
echo "==> setup_ssh: DEPLOY_SSH_KEY is empty" >&2
return 1
fi
mkdir -p ~/.ssh
chmod 700 ~/.ssh
# Pre-create the key file with restrictive perms *before* writing any key
# material into it, so there is no window at the default umask between
# file creation and chmod.
install -m 600 /dev/null "${SSH_KEY_FILE}"
# `printf '%s\n'` 末尾补一个换行:Forgejo/Gitea 存 secret 会去掉结尾换行,
# 而缺结尾换行的 OpenSSH 格式私钥会被判为 "invalid format" 拒绝加载,
# 退化成无密钥 → Permission denied。多补的换行对已含结尾换行的 PEM 无害。
printf '%s\n' "${DEPLOY_SSH_KEY}" > "${SSH_KEY_FILE}"
# Populate a dedicated known_hosts file via TOFU keyscan. This is
# belt-and-suspenders: `accept-new` below will pin the host key on first
# real connection regardless, so a transient keyscan failure must not
# abort the deploy.
ssh-keyscan -p "${DEPLOY_PORT}" -H "${DEPLOY_HOST}" >> "${SSH_KNOWN_HOSTS_FILE}" 2>/dev/null || true
# IdentitiesOnly=yes:只用上面 -i 指定的 key,不把 agent/默认 key 也递上去
# (否则会触发服务器 MaxAuthTries「Too many authentication failures」)。
# BatchMode=yes:纯非交互,认证失败即退出,不回落到密码提示卡住。
_ssh_opts="-i ${SSH_KEY_FILE} -p ${DEPLOY_PORT} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=${SSH_KNOWN_HOSTS_FILE}"
SSH="ssh ${_ssh_opts}"
RSYNC_SSH="ssh ${_ssh_opts}"
export SSH RSYNC_SSH SSH_KEY_FILE SSH_KNOWN_HOSTS_FILE DEPLOY_HOST DEPLOY_PORT
echo "==> setup_ssh: key written to ${SSH_KEY_FILE}, known_hosts pinned (accept-new) for ${DEPLOY_HOST}:${DEPLOY_PORT}"
}
# teardown_ssh — remove the temp private key + known_hosts file.
teardown_ssh() {
rm -f "${SSH_KEY_FILE}" "${SSH_KNOWN_HOSTS_FILE}"
echo "==> teardown_ssh: removed ${SSH_KEY_FILE}"
}
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# release-server.sh — ensure the Forgejo release for a server-v* tag exists
# and upload the three compiled binaries as release assets. Assumes
# compile-backend.sh has already produced
# server/out/{pangolin-server,pangolin-agent,pangolin-migrate}.
#
# Usage: scripts/ci/release-server.sh <tag> (e.g. server-v1.2.3)
# Requires env: FORGEJO_URL, FORGEJO_TOKEN (see lib-forgejo.sh).
set -euo pipefail
TAG="${1:?usage: release-server.sh <tag>}"
# shellcheck source=scripts/ci/_env.sh
. scripts/ci/_env.sh
# shellcheck source=scripts/ci/lib-forgejo.sh
. scripts/ci/lib-forgejo.sh
# No command substitution ($()): ver_from_tag prints to stdout, captured via
# a temp file + `read`, same no-substitution pattern as lib-forgejo.sh.
ver_file="/tmp/release_server_ver.$$"
ver_from_tag server "$TAG" > "$ver_file"
VER=""
# `|| true`: ver_from_tag 用 printf '%s'(无结尾换行),read 到无换行的 EOF 会返回 1
# (但 VER 已正确赋值)——set -e 下会静默退出。容错 read 的这个退出码,不掩盖真错。
read -r VER < "$ver_file" || true
rm -f "$ver_file"
echo "==> release-server: tag=${TAG} ver=${VER}"
forgejo_release_ensure "$TAG" "server ${VER}"
forgejo_upload_asset "$TAG" server/out/pangolin-server
forgejo_upload_asset "$TAG" server/out/pangolin-agent
forgejo_upload_asset "$TAG" server/out/pangolin-migrate
echo "==> release-server: done"
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# test.sh <server|client> — run the test suite for one side of the repo.
# Invoked directly on the nas runner (see deploy-server.yml's "Test" step),
# so this script itself owns the docker invocation — Go/Flutter are not on
# the host.
#
# server: `go test ./...` inside golang:1.25, mirroring the go-server job's
# docker invocation in .gitea/workflows/ci.yml (mounts + gomod/gobuild
# caches), plus GOPROXY from _env.sh so it doesn't hit proxy.golang.org.
# client: `flutter test` inside ghcr.io/cirruslabs/flutter:stable, mirroring
# the flutter-client job's docker invocation in .gitea/workflows/ci.yml
# (pub-cache volume, same test dirs); analyze/coverage stay in that job.
#
# Run from the repo root (relative paths below assume this).
set -euo pipefail
# shellcheck source=scripts/ci/_env.sh
. scripts/ci/_env.sh
TARGET="${1:-}"
case "$TARGET" in
server)
# 直接在 runner 跑(不嵌套 docker,避免 DinD 挂载失败)。go.mod 要求的
# go 1.25.x 若高于 runner 自带版本,Go 工具链会经 GOPROXY(_env.sh)自动下载。
echo "==> test: go test ./..."
( cd server && go test ./... )
;;
client)
mkdir -p "$HOME/.cache/pangolin-ci/pubcache"
echo "==> test: flutter test (ghcr.io/cirruslabs/flutter:stable 容器)"
docker run --rm \
-v "$PWD/client:/app" -w /app \
-v "$HOME/.cache/pangolin-ci/pubcache:/root/.pub-cache" \
ghcr.io/cirruslabs/flutter:stable \
bash -c "flutter pub get && flutter test test/unit test/widget test/contract"
;;
*)
echo "usage: scripts/ci/test.sh <server|client>" >&2
exit 1
;;
esac
echo "==> test: ${TARGET} 通过"
+34 -5
View File
@@ -253,12 +253,12 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<header> <header>
<div class="wrap"> <div class="wrap">
<h1>feature+windows — 项目 TODO</h1> <h1>feature+windows — 项目 TODO</h1>
<div class="header-meta">生成于 2026-06-30 · 真相源 todo/todo.json</div> <div class="header-meta">生成于 2026-07-01 · 真相源 todo/todo.json</div>
<div class="stats"> <div class="stats">
<div class="stat-pill"><strong>16</strong>全部</div> <div class="stat-pill"><strong>17</strong>全部</div>
<div class="stat-pill"><strong>8</strong>待开始</div> <div class="stat-pill"><strong>8</strong>待开始</div>
<div class="stat-pill"><strong>0</strong>开发中</div> <div class="stat-pill"><strong>0</strong>开发中</div>
<div class="stat-pill"><strong>0</strong>待验收</div> <div class="stat-pill"><strong>1</strong>待验收</div>
<div class="stat-pill"><strong>8</strong>已验收</div> <div class="stat-pill"><strong>8</strong>已验收</div>
</div> </div>
@@ -563,12 +563,41 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
</div> </div>
<div class="section-block" id="section-done"> <div class="section-block" id="section-done">
<div class="section-title st-done" data-toggle="done"> <div class="section-title st-done" data-toggle="done">
🔍 待验收 <span class="s-count">0</span> 🔍 待验收 <span class="s-count">1</span>
<span class="s-arrow">▴ 收起</span> <span class="s-arrow">▴ 收起</span>
</div> </div>
<div class="section-list-wrap " id="list-wrap-done"> <div class="section-list-wrap " id="list-wrap-done">
<ul class="todo-list" id="list-done"> <ul class="todo-list" id="list-done">
<p class="empty-tip">暂无条目</p>
<li class="todo-card s-done"
data-id="18"
data-level="mid"
data-status="done"
data-tier="3"
data-tags="mac,前端">
<div class="card-header">
<span class="item-id">#18</span>
<span class="item-title">看门狗后台唤醒误判节点死→假重连</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-high">重要</span>
<span class="tag tier-3">三级</span>
<button class="reject-btn" data-id="18" data-title="看门狗后台唤醒误判节点死→假重连">拒绝验收</button>
</div>
</div>
<div class="item-desc">app/Mac 后台或睡眠一段时间再打开,连通看门狗路径A(urltest stale)用墙上时钟算陈旧度,_lastUrltestOk 在挂起期冻住→唤醒时误判&gt;45s→_onNodeUnhealthy 触发 disconnect+connect 假重连(智能模式还自动切节点),但隧道没掉。修:_onStats 记 _lastStatsAt,路径A 仅在 stats 仍在流但 urltest 停更时才判死;stats 整体停(挂起)则跳过。connection_provider.dart 一处。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="前端">前端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-07-01</span>
</div>
</div>
</li>
</ul> </ul>
</div> </div>
</div> </div>
+18 -2
View File
@@ -1,9 +1,9 @@
{ {
"meta": { "meta": {
"title": "feature+windows — 项目 TODO", "title": "feature+windows — 项目 TODO",
"updated_at": "2026-06-30T14:47:39.344Z" "updated_at": "2026-06-30T23:10:43.458Z"
}, },
"seq": 17, "seq": 18,
"items": [ "items": [
{ {
"id": 1, "id": 1,
@@ -254,6 +254,22 @@
"done": false, "done": false,
"completed_at": null, "completed_at": null,
"version": null "version": null
},
{
"id": 18,
"title": "看门狗后台唤醒误判节点死→假重连",
"desc": "app/Mac 后台或睡眠一段时间再打开,连通看门狗路径A(urltest stale)用墙上时钟算陈旧度,_lastUrltestOk 在挂起期冻住→唤醒时误判>45s→_onNodeUnhealthy 触发 disconnect+connect 假重连(智能模式还自动切节点),但隧道没掉。修:_onStats 记 _lastStatsAt,路径A 仅在 stats 仍在流但 urltest 停更时才判死;stats 整体停(挂起)则跳过。connection_provider.dart 一处。",
"level": "mid",
"tier": 3,
"tags": [
"mac",
"前端"
],
"status": "done",
"created_at": "2026-06-30T23:00:25.193Z",
"done": false,
"completed_at": null,
"version": null
} }
] ]
} }