Compare commits

...

48 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
wangjia ff8fc36726 fix(ci): lib-forgejo TLS 校验默认开启,-k 改显式 opt-in(安全)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 23:55:56 +08:00
wangjia b8343a8788 feat(ci): scripts/ci 基座(_env/lib-forgejo/notify)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 23:49:59 +08:00
wangjia 6be777dff5 docs(plan): CI/CD 实现计划(#30) —— .md 真相源 + HTML 阅读版 + 索引
三期 11 任务:Phase1 基座+官网+服务端(无签名) → Phase2 Android → Phase3 macOS/Windows。
服务端部署固化 F3/F4 备份/迁移/回滚;下载链接接 release 资产;密钥对齐 jiu。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 23:40:59 +08:00
wangjia 0ab31b4c58 docs(spec): CI/CD 密钥命名对齐 jiu(MACOS_*/APPSTORE_*/ANDROID_*)
Apple 那套账户级与 jiu 共用一份(同名);新增 FORGEJO_URL、macOS 两个描述文件
入表。Android keystore 同命名规范但各 app 独立不共享。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 23:19:29 +08:00
wangjia fe9a3a6cdb docs: CI/CD 方案 HTML 阅读版 + 登记 docs/index.html(#30)
深色家族风 HTML(cicd-design.html),真相源仍是 superpowers/specs 的 .md。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:06:25 +08:00
wangjia f30097172a docs(spec): CI/CD 全流程设计方案(#30)
范围 A~F(官网+服务端+Android/macOS/Windows,排除 iOS/#26/#25);runner 混合
(nas/mac/windows);tag 触发;镜像 jiu 的 scripts/ci+workflows 结构;密钥作用域
(Apple/token 账户级、部署key/keystore 仓库级);服务端部署固化 F3/F4 手动次序带回滚。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:02:40 +08:00
wangjia 70d3293251 refactor(website): 域名/邮箱/渠道收口到 src/config/site.ts 单一真相源
新增 SITE 配置(url/email/telegram/line/store),Footer、Pricing、astro.config
(canonical 域名)均改为引用它;改联系方式/域名只动一处。
astro.config.mjs 直接 import 该 TS(Astro 配置加载支持)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:23:19 +08:00
wangjia a454c63c4e fix(website): 落定真实域名/邮箱/Telegram
- canonical 域名 → https://pangolin.yanmeiai.com(astro.config SITE_URL 默认值)
- 联系邮箱 support@/buy@pangolin.vpnpangolin@yanmeiai.com(可点 mailto)
- Telegram @PangolinVPN_bot → @pangolin_app(可点 https://t.me/pangolin_app)
邮件转发已在 CF 配好:pangolin@yanmeiai.comchenxin880812@gmail.com(目标已验证)。
未动(待 #24):shop 自助商店 URL、LINE handle(TG 群为私有,不放公开页)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:19:58 +08:00
wangjia c1c6b7ccf5 fix(website): Docs 卡片暂改不可点(去掉「阅读」死链)
4 张文档卡原为 <a class=doc> 无 href + 「阅读」死链;真实文档页未写。
改为信息卡(<div>),保留标题/描述与导航·页脚入口,不误导。
撰写真实文档(快速开始/FAQ/协议安全/隐私政策)并接回链接 → #31。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:09:07 +08:00
wangjia 75ed293151 fix(website): 移除无效的「登录」按钮(死 span,无链接)
Web 用户中心(web/usercenter)尚未部署、无 URL,登录入口暂无处可去;
产品主流程为 App 内登录。先删死按钮避免公开页点了没反应,待 #30 部署
usercenter 后接上登录地址恢复(已挂 #30 子任务)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:56:49 +08:00
wangjia 95abb74db2 fix(website): 价格按语言分币种 —— 英文版显示美元(P0)
PRICES 原为中英共用一份人民币值 → 英文页显示 ¥ 不专业。改为 Record<Lang,...>:
- zh: ¥0 / ¥25(年¥20) / ¥99(年¥79)
- en: $0 / $3.99(年$3.19) / $13.99(年$11.19)  年付沿用 8 折
Pricing.astro 接收 lang 按语言取价;Site.astro 传入 lang。
构建产物核实:EN 页 0 处 ¥、ZH 页 0 处 $;红线扫描 0 命中。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:43:44 +08:00
wangjia 1c8f6cb56b fix(website): 去除夸大宣传 + 隐藏占位 Blog 区(官宣页上线前 P0)
- 「80+ 全球加速线路」→ 中性「全球加速线路 / Global routes」(hero 徽章/功能卡/Pro 套餐/对比表 4 处);
  现实仅 1 个节点(pangolin1),80+ 属夸大宣传(国内 VPN 有法律风险)。
- 功能卡③描述去掉「港日新美欧韩」具体地区罗列 → 「覆盖多地优质线路」。
- 隐藏整个 Blog 区:3 篇均占位假帖,含虚构的「首尔/法兰克福节点」「v2.4 版本」。
  移除 <Blog/> + 顶栏导航 Blog 项 + 页脚 Blog 链接;有真实文章后恢复。

未动(待用户输入):英文版价格货币(等美元定价);下载链接归入 #30 CI/CD。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:35:49 +08:00
wangjia 08fb915b5e test(devices): #27 同机换账号登录注册 E2E(SQLite,服务层)
走 login 完全相同的路径 devices.Service.RegisterIfAbsent{MaxDevices:0}:
同一 device uuid 两账号各得一行、A 重登幂等刷新、共享 uuid 恰 2 行。
补 SQLite(生产同引擎)服务层覆盖,与既有 store 层 TestSQLite_DevicesUserScopedUUID
及 MySQL 集成 TestSameDeviceUUIDTwoAccounts 三层互证 F3 修复。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:11:25 +08:00
wangjia eb7c3c1062 feat(agent): WARP 域名分流 —— 命中域名走 Cloudflare 干净出口(#29)
节点 sing-box 渲染新增可选 WARP 分流:节点本地 warp.json(默认
<StateDir>/warp.json)配 WARP 凭证 + 域名清单 → 渲染时注入一个 userspace
WireGuard(WARP)endpoint + route(sniff 取 SNI/Host → domain_suffix 命中走
warp,其余 final=direct)。sing-box 1.11+ endpoints 语法,system=false 用户态
不依赖内核 wg 模块。

- 运营改域名只需编辑 warp.json + 重启 agent(sing-box 无热重载),即「配置的方式」。
- warp.json 不存在/enabled=false/域名空/凭证缺/坏 JSON → 一律按未启用,配置与旧
  节点逐字节一致,坏配置绝不产出无法启动的 sing-box config(渲染读失败仅记日志)。
- WARP 凭证节点私有(wgcf 注册免费匿名账号),不入 git、不经控制面。
- 测试:注入 endpoint+route/无配置无 route/禁用或残缺不注入/坏 JSON 优雅退化。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:40:00 +08:00
wangjia f035552ff5 fix: disconnect 吊销每设备凭证 + 客户端断开时真正调用(F4,#28)
审查 F4 两层问题:①DisconnectNode 只撤账户级 ent.DpUUID,而 connect 下发的是
每设备 devDp——吊销从未对准目标;②客户端从未调用过 disconnect 端点(只有
fetchConfig),端点是死代码,凭证一律活到 TTL(付费 24h)。

- server:disconnect 收 optional body {device_id},吊销该设备 dp_uuid(优先)+
  账户级遗留兜底;旧客户端无 body 走兜底,行为不回归。nil hub 守卫(测试友好,
  与 ListNodes 一致)。
- client:ConnectApi.disconnect(best-effort,5s 超时吞错);_disconnect 加
  revokeCredential 参数,仅在「不会紧接重连同节点」的路径置 true(用户主动断开/
  额度耗尽/登出)——看门狗断开→重连若也吊销,revoke 可能晚于新 connect 推送、
  误杀新会话。
- test:httpapi disconnect 三态(带 device_id 双吊销/无 body 仅兜底/设备已移除
  不炸);client 功能套件 182 过(golden 为已知 macOS 本地漂移,不相关)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:29:01 +08:00
wangjia 63d1baeb01 fix(server): devices 唯一键改 (user_id,uuid) —— 同机换账号不再 403 死结(F3,#27)
device_id 按安装持久、跨账号复用;旧全局 UNIQUE(uuid) 使同机第二账号注册永远
403 → ConnectNode 判 DEVICE_NOT_REGISTERED,提示的「重新登录」无法自救。

- migration 21(sqlite/mysql):UNIQUE(uuid)→UNIQUE(user_id,uuid);platform 放行
  linux(normalizePlatform 早已接受,旧 CHECK/ENUM 会拒)。SQLite 表重建用
  rename→重建→复制→drop 次序,单事务内不触发 sessions 的级联清空(FK ON)。
- 查找全部收口为按 (user,uuid) 作用域(重复 uuid 跨用户后全局查询歧义):
  findDeviceByUserUUIDTx / FindByUserUUID;Register 删跨用户 Forbidden 分支;
  Delete/ForceLogout/Rename 对他人设备返回 404(不可见);SessionActive 删
  「非本人 fail-safe」分支,dev==nil→false 语义不变。
- 测试:SQLite 真迁移库 F3 回归(两账号同 uuid 各自成行/同用户重复拒/linux 入库/
  sessions 重建后级联仍成立)+ MySQL 集成测试 schema 同步与双账号用例。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:16:40 +08:00
wangjia bbe149fd41 docs: 全栈设计审查 2026-07(前端/后端/数据库,13 项发现分级)
P0:控制面明文 HTTP、SQLite 零备份。P1:同机换账号设备注册 403 死结(migration16
注释里推迟的 UNIQUE(user_id,uuid) 未落地)、disconnect 撤账户级凭证而 connect 发
每设备凭证、agent 用量 Reset 后上报失败即丢(at-most-once)、Redis 白名单单点全员
掉线、argon2id 64MiB 并发 OOM。P2:sessions/audit 无留存、免费额度 UTC 日界、
三时钟口径、hub 内存态、单实例端口无握手、ReportUsage 无事务。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:52:08 +08:00
wangjia 14bcdc2e79 docs(contact): 落定渠道二级页设计 —— 统一单表 + LINE 灰置
拍板:不展示成员数;群组=「加入」其余=「打开」;独立 GET /v1/contact;
全渠道(Telegram/LINE/邮箱/发卡)统一一张 contact_link 表,靠 url 协议区分
(mailto:/https:/tg://),邮箱=kind=link+mailto。L1 平台图标/名/强调色由前端
platform 注册表内置(唯一不入库的皮)。LINE 也做二级、同构,但先灰置「即将开放」
(registry comingSoon=[line],配好去灰即用)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:30:24 +08:00
wangjia 91a890428f docs: 联系我们渠道二级页交互设计(Telegram/LINE 多频道群组,DB 配置)
点渠道进二级页,按 kind 分组(频道/群组/Bot)列多条链接,内容 DB 配置。
含可点原型(App 真实暗色 token)+ 单表 contact_link 数据模型 + GET /v1/contact
接口 + 交互规则(平台 >1 条链接才进二级,否则点击直达)。#24,待统一后开发。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:23:02 +08:00
wangjia 2c804d4ebf fix(scripts): local_test.sh 对齐当前 macOS 架构(sysext 标识符名 + 静态 libbox)
发版脚本停留在旧「嵌入 Libbox.framework」架构,与现工程漂移两处、导致 sign 失败:
- sysext bundle 名 = 标识符 com.pangolin.pangolin.PacketTunnel.systemextension
  (PRODUCT_NAME,见 CLAUDE.md),非短名 PacketTunnel.systemextension。
- libbox 现「只 Link 不 Embed」静态进 sysext 二进制,sysext 内已无独立
  Libbox.framework;删掉对不存在 framework 的签名,签 sysext bundle 即覆盖。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:11:05 +08:00
63 changed files with 3742 additions and 130 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/**'
- 'server/**'
- 'ci/**'
- 'scripts/ci/**'
- '.gitea/workflows/ci.yml'
pull_request:
branches: [main]
@@ -20,7 +21,7 @@ jobs:
# ── Job 1: Lint (shellcheck) ─────────────────────────────────────────────
lint:
name: Lint — shellcheck
runs-on: nas
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -38,10 +39,36 @@ jobs:
/mnt/deploy/bootstrap/monitor/deadman-watch.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 ────────────────────────────────────────────
openapi-check:
name: OpenAPI Sync Check
runs-on: nas
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -58,7 +85,7 @@ jobs:
# ── Job 3: Redline Word Scan (脱敏) ──────────────────────────────────────
redline-scan:
name: Redline Scan — 脱敏 (UI 文案)
runs-on: nas
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -66,10 +93,21 @@ jobs:
- name: scan UI text resources for prohibited words
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 客户端(分析 + 单测/组件测试)────────────────────────
flutter-client:
name: Flutter — analyze + test
runs-on: nas
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -92,7 +130,7 @@ jobs:
# 规则与豁免见 ci/scan-portable-sql.sh 头注 + docs/dev-conventions.html 支柱 3。
portable-sql-scan:
name: Portable SQL — 可移植性 (mysql/sqlite)
runs-on: nas
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -105,7 +143,7 @@ jobs:
# (改了 design/colors_and_type.css 没重生成,或手改了生成物)。
codegen-drift:
name: Codegen Drift — token 生成物未漂移
runs-on: nas
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -122,7 +160,7 @@ jobs:
# -tags integration(需 docker 起 mysql/redis),见 go-integration job。
go-server:
name: Go — build + test
runs-on: nas
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -148,7 +186,7 @@ jobs:
# 详见 scripts/e2e-smoke.sh + server/test/e2e/。
e2e-smoke:
name: E2E Smoke — L4 进程级端到端
runs-on: nas
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -172,7 +210,7 @@ jobs:
# · -p 1 串行:一次只起一个 mysql 容器,避免并发把 Docker Desktop 压垮/端口资源争用。
go-integration:
name: Go — integration (mysql/redis testcontainers)
runs-on: nas
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -187,7 +225,7 @@ jobs:
# tablet/desktop-stats golden 与 stats-overhaul 工作区耦合,待其合并后并入本 job。
golden:
name: Golden — 视觉回归 (components + auth)
runs-on: nas
runs-on: ubuntu-latest
steps:
- name: Checkout
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 等)
server/mockserver
server/pangolin-server
server/out/
# Flutter / Dart 构建产物与本地配置
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 ./...` 直接编译,免确认。
- 控制面 HTTP API(`:8080`)+ gRPC agent 服务(`:9443`, mTLS);agent 自 enroll → 渲染
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
android:label="穿山甲"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"><!-- 控制面 API 当前为 http(联调),Android 9+ 默认禁明文,需开;生产改 https 后可去掉 -->
android:icon="@mipmap/ic_launcher">
<!-- 主 Activity -->
<activity
+3 -2
View File
@@ -2,8 +2,9 @@
//
// 历史上各 service/provider 各自重复声明 _kApiUrl;统一收敛到这里,
// 由 --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(
'PANGOLIN_API_URL',
defaultValue: 'http://103.119.13.48:8080',
defaultValue: 'https://api.yanmeiai.com',
);
+22
View File
@@ -121,5 +121,27 @@ class ConnectApi {
return response.body;
}
/// 通知控制面吊销本设备在 [nodeId] 上的数据面凭证(F4)。
///
/// best-effort:断开的本地拆隧道不依赖它,任何失败(网络/401/超时)都吞掉——
/// 凭证最迟到 TTL 也会过期,这里只是让「断开」在服务端即刻生效。
Future<void> disconnect({
required String nodeId,
required String deviceId,
}) async {
try {
await _client
.post(
Uri.parse('$baseUrl/v1/nodes/$nodeId/disconnect'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $authToken',
},
body: jsonEncode({'device_id': deviceId}),
)
.timeout(const Duration(seconds: 5));
} catch (_) {/* best-effort */}
}
void dispose() => _client.close();
}
+21 -4
View File
@@ -110,7 +110,8 @@ class ConnectionController extends StateNotifier<ConnectionState> {
_authSub = _ref.listen<AuthState>(authProvider, (prev, next) {
if ((prev?.isLoggedIn ?? false) && !next.isLoggedIn) {
_userDisconnect = true; // 视为「非节点异常」的主动断开,不弹「节点异常」
unawaited(_disconnect());
// 登出也尝试吊销凭证(F4);token 可能已失效,best-effort 吞错。
unawaited(_disconnect(revokeCredential: true));
}
});
// 生命周期闸:切后台停看门狗,回前台再开。原因:后台(尤其 Android Doze)会把 urltest
@@ -175,7 +176,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
_connect();
case VpnPhase.on:
_userDisconnect = true; // 用户主动断开:其 kernel off 不当作节点异常
_disconnect();
_disconnect(revokeCredential: true); // 服务端同步吊销本设备凭证(F4)
case VpnPhase.connecting:
break; // 握手进行中,不响应
}
@@ -277,7 +278,23 @@ class ConnectionController extends StateNotifier<ConnectionState> {
}
}
Future<void> _disconnect() async {
/// [revokeCredential]:同时通知控制面吊销本设备在该节点的数据面凭证(F4)。
/// 仅在「不会紧接着重连同一节点」的路径置 true(用户主动断开/额度耗尽/登出)——
/// 看门狗「断开→立刻重连」若也吊销,revoke 可能在新 connect 推完凭证后才到达、
/// 把新会话杀掉。fire-and-forget:不阻塞本地拆隧道与 UI 回 off。
Future<void> _disconnect({bool revokeCredential = false}) async {
if (revokeCredential) {
final api = _api;
final node = _connectedNode;
if (api != null && node != null && node.uuid.isNotEmpty) {
unawaited(() async {
try {
final deviceId = await _ref.read(deviceIdentityProvider).deviceId();
await api.disconnect(nodeId: node.uuid, deviceId: deviceId);
} catch (_) {/* best-effort */}
}());
}
}
_stopElapsed();
_stopWatchdog();
try {
@@ -505,7 +522,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
_offNotice = _ref.read(appTextProvider).quotaExhaustedNotice;
_ref.read(quotaProvider.notifier).markExhausted();
logLine('Quota', 'free daily minutes used up → auto disconnect');
await _disconnect();
await _disconnect(revokeCredential: true); // 额度耗尽:服务端即刻吊销(F4)
}
void _stopElapsed() {
+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_DSN=$DB_FILE
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_KEY_ID=node-key-1
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
GRPC_CERT_PATH=$ETC/grpc.crt
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
EOF
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
install -d -m 755 /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
log "启动控制面(pangolin-server,以 $PUSER 运行)..."
@@ -269,17 +287,22 @@ log "启用 sing-box + 启动 agent ..."
systemctl enable sing-box.service
systemctl enable --now pangolin-agent.service
# 放行控制面 API 端口(若 ufw 启用)。⚠️ 明文,生产应前置 TLS
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then
ufw allow "${HTTP_PORT}/tcp" >/dev/null 2>&1 || true
log "ufw 放行 ${HTTP_PORT}/tcp(控制面 API,明文)"
# 控制面 API 已绑 127.0.0.1(经 cloudflared 隧道对外),不放行 8080/tcp
# ── cloudflared:按需启用(需 /etc/pangolin/cloudflared.env 提供 TUNNEL_TOKEN)───
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
# ── 11. 摘要 ──────────────────────────────────────────────────────────────────
log "完成。单机栈已起(SQLite + pangolin 用户)。"
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
Endpoint : $ENDPOINT SNI=$REALITY_SNI (REALITY ${REALITY_PORT}/tcp)
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
+173
View File
@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pangolin CI/CD 全流程 · 设计方案(#30)</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:26px 0 8px;color:var(--accent2)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.88em;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:13px;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.bad{background:rgba(224,106,106,.16);color:var(--bad)}
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
.card h3{margin-top:0;color:var(--fg)}
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}
td code{font-size:.85em}
ul,ol{padding-left:22px;margin:10px 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}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 文档索引</a>
<h1>Pangolin CI/CD 全流程 · 设计方案</h1>
<p class="sub">#30 · 2026-07-05 · <span class="tag warn">设计定稿待审</span> · 范围 A~F(排除 iOS、备份#26、TLS#25)· 真相源 <code>docs/superpowers/specs/2026-07-05-cicd-design.md</code></p>
<div class="lead">
<b>目标:</b>tag 触发的 <b>编译 → 测试 → 发版(Gitea release)→ 部署</b> 全自动。参考 jiu 的
<code>.gitea/workflows</code> + <code>scripts/ci/*.sh</code> 结构,适配 pangolin 的部署目标(pangolin1 /
pangolin.yanmeiai.com)与多端产物。现状:仅 <code>ci.yml</code> 校验无部署,服务端手动部署、官网未部署、下载死链。
</div>
<h2>1. 范围</h2>
<table>
<tr><th>子块</th><th>内容</th></tr>
<tr><td><b>A 基座</b></td><td><code>scripts/ci/*</code>env/provision/test/release/notify/lib-forgejo+ checks 保留</td></tr>
<tr><td><b>B 官网</b></td><td>Astro 构建 → 部署 <code>pangolin.yanmeiai.com</code></td></tr>
<tr><td><b>C 服务端</b></td><td>交叉编译 server/agent/migrate → release → ssh pangolin1(备份→migrate→换二进制→重启→健康检查)</td></tr>
<tr><td><b>D Android</b></td><td>apkarm64release keystore 签名)→ release 资产</td></tr>
<tr><td><b>E macOS</b></td><td>公证 dmgDeveloper ID + notarytool)→ release 资产</td></tr>
<tr><td><b>F Windows</b></td><td>exe/installerInno Setup)→ release 资产</td></tr>
</table>
<p class="small"><b>排除:</b>iOS(G,未来)、SQLite 备份/容灾(#26)、控制面 TLS(#25)。</p>
<h2>2. 已锁定决策</h2>
<table>
<tr><th>维度</th><th>决定</th><th>理由</th></tr>
<tr><td>runner</td><td>nas=官网+服务端(容器化)· mac=Android+macOS · windows=Windows</td><td>nas 常在线且 Astro/Go 轻量(非 Flutter Web);mac/windows 做必须它们的活</td></tr>
<tr><td>触发</td><td>tag <code>site-v*</code> / <code>server-v*</code> / <code>client-v*</code> + <code>manual.yml</code> 手动派发</td><td>同 jiu,发版即部署,可手动重放</td></tr>
<tr><td>macOS 签名</td><td>mac 自动 Developer ID 签名 + notarytool 公证 + staple</td><td>凭据入 secret(见 §6</td></tr>
<tr><td>Android 签名</td><td>正式 release keystore</td><td>app 级专属签名身份</td></tr>
<tr><td>下载链接</td><td>官网 href 指向 Gitea release 稳定资产 URL</td><td>发版即更新,见 §5</td></tr>
<tr><td>镜像</td><td>goproxy.cn / flutter-io.cn</td><td>国内网络</td></tr>
</table>
<h2>3. 架构</h2>
<h3>3.1 共享基座 <code>scripts/ci/</code>(镜像 jiu</h3>
<ul>
<li><code>_env.sh</code> —— 公共环境(镜像源、路径、版本号解析 <code>${tag#prefix-v}</code></li>
<li><code>lib-forgejo.sh</code> —— release 建/查 + 资产上传(用 <code>FORGEJO_TOKEN</code></li>
<li><code>provision-mac.sh</code> —— mac 幂等装 flutter / xcode-select / gomobile / NDK+JDK17</li>
<li><code>test.sh</code><code>notify.sh</code><code>compile-*.sh</code><code>deploy-*.sh</code><code>release-*.sh</code></li>
</ul>
<p class="small">每个 <code>compile-*</code> 封装该端已验证的构建命令(Android 走 <code>build-libbox.sh android</code> +
<code>flutter build apk --split-per-abi</code>macOS 走 Developer ID 签名 + <code>notarytool submit --wait</code> +
<code>stapler</code>)。工作流只调脚本,逻辑在脚本里、便于本地复现。</p>
<h3>3.2 工作流 <code>.gitea/workflows/</code></h3>
<table>
<tr><th>工作流</th><th>触发</th><th>runner</th><th>步骤</th></tr>
<tr><td><code>checks.yml</code>(现 ci.yml</td><td>push 分支</td><td>nas</td><td>保留:shellcheck / openapi / redline / flutter analyze+test / go test</td></tr>
<tr><td><code>deploy-site.yml</code></td><td><code>site-v*</code></td><td>nas</td><td><code>node:20</code> 构建 Astro(注入 SITE_URL)→ <code>deploy-site.sh</code></td></tr>
<tr><td><code>deploy-server.yml</code></td><td><code>server-v*</code></td><td>nas</td><td><code>golang:1.25</code> 交叉编译 → test → release → <code>deploy-server.sh</code></td></tr>
<tr><td><code>build-android.yml</code></td><td><code>client-v*</code></td><td>mac</td><td>provision → <code>compile-android.sh</code>(签名 apk)→ release</td></tr>
<tr><td><code>build-macos.yml</code></td><td><code>client-v*</code></td><td>mac</td><td>provision → <code>compile-macos.sh</code>(签名+公证 dmg)→ release</td></tr>
<tr><td><code>build-windows.yml</code></td><td><code>client-v*</code></td><td>windows</td><td><code>compile-windows.sh</code>exe/installer)→ release</td></tr>
</table>
<h3>3.3 服务端部署(固化 F3/F4 手动那套,带回滚)</h3>
<div class="card">
<ol>
<li>scp <code>pangolin-{server,agent,migrate}</code> 到 pangolin1 <code>/tmp</code></li>
<li><code>systemctl stop pangolin-server</code></li>
<li><code>wal_checkpoint(TRUNCATE)</code><code>cp</code> 备份 <code>pangolin.db.bak-pre-&lt;tag&gt;</code></li>
<li><code>pangolin-migrate up</code>pangolin 用户);<b>失败即恢复备份 + 重启旧 server + 退出非零</b></li>
<li><code>install</code> 新二进制到 <code>/usr/local/bin</code>(旧的备份为 <code>.bak-&lt;tag&gt;</code></li>
<li><code>systemctl start</code> + <code>/healthz</code> 健康检查;agent 随连接自恢复</li>
</ol>
</div>
<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>
<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>
<h2>5. 下载链接闭环(30A</h2>
<p><code>web/website/src/config/site.ts</code><code>downloads:{ android, macos, windows }</code>,值为 Gitea release
稳定 latest 资产 URLForgejo 支持 <code>…/releases/latest/download/&lt;asset&gt;</code> 则直用;否则构建期用
<code>FORGEJO_TOKEN</code> 查最新 <code>client-v*</code> 版本烘焙进 href)。<code>Download.astro</code> 各平台按钮读
<code>SITE.downloads.&lt;platform&gt;</code>。客户端发版后官网重部署即刷新(或 build-* 完成触发 deploy-site)。</p>
<h2>6. 密钥与作用域(solo / wangjia,命名对齐 jiu 以共用)</h2>
<table>
<tr><th>Secret</th><th>作用域</th><th>说明</th></tr>
<tr><td><code>FORGEJO_TOKEN</code> / <code>FORGEJO_URL</code></td><td>账户级</td><td>建 release + 传产物,jiu 复用</td></tr>
<tr><td><code>MACOS_DEVELOPER_ID_CERT_P12_BASE64</code> / <code>MACOS_DEVELOPER_ID_CERT_PASSWORD</code></td><td>账户级</td><td>Developer ID 证书(账号级),与 jiu 共用;续期改一处</td></tr>
<tr><td><code>APPSTORE_API_KEY_P8_BASE64</code> / <code>APPSTORE_API_KEY_ID</code> / <code>APPSTORE_API_ISSUER_ID</code></td><td>账户级</td><td>公证 API keyKEY_ID=<code>3PZTHR8YMJ</code>),与 jiu 同一把</td></tr>
<tr><td><code>DEPLOY_SSH_KEY</code></td><td>pangolin 仓库级</td><td>授权到 pangolin1,最小权限</td></tr>
<tr><td><code>ANDROID_KEYSTORE_BASE64</code> / <code>ANDROID_KEYSTORE_PASSWORD</code> / <code>ANDROID_KEY_ALIAS</code> / <code>ANDROID_KEY_PASSWORD</code></td><td>pangolin 仓库级</td><td>Android app 级专属签名(<b>pangolin 自己的 keystore,不复用 jiu</b></td></tr>
<tr><td><code>MACOS_APP_PROVISION_PROFILE_BASE64</code> / <code>MACOS_SYSEXT_PROVISION_PROFILE_BASE64</code></td><td>pangolin 仓库级</td><td>主 app + PacketTunnel sysext 描述文件(pangolin bundle 专属)</td></tr>
</table>
<p class="small">命名对齐 jiu<code>MACOS_*</code>/<code>APPSTORE_*</code>/<code>ANDROID_*</code>):<b>Apple 那套放账户级 → jiu/pangolin 共用一份</b>compile-macos 脚本可复用 jiu 的;Android keystore 虽同命名规范但<b>各 app 独立、不共享</b>。工作流用 <code>secrets.XXX</code> 引用,作用域对写法透明。</p>
<h2>7. 实现顺序</h2>
<p>范围虽 A~F,按风险/依赖递增落地,每阶段独立可发、独立验收:</p>
<ol>
<li><b>A 基座</b> + <code>checks</code> 迁移(抽 <code>scripts/ci</code> 骨架)</li>
<li><b>B 官网</b>(最简,验证 release/deploy 骨架)</li>
<li><b>C 服务端</b>(固化手动部署)</li>
<li><b>D Android</b>(解锁下载链接;需 keystore 就绪 + gradle 接签名)</li>
<li><b>E macOS</b>(最复杂:证书 + 2 描述文件 + 公证)</li>
<li><b>F Windows</b>windows runner + Inno Setup</li>
</ol>
<h2>8. 验证</h2>
<ul>
<li>每条流水线先 <code>workflow_dispatch</code> 手动跑通、核对产物/部署,再依赖 tag。</li>
<li>服务端:<code>server-v*</code> → migrate 版本 + <code>/healthz</code> + 行数守恒(同 F3 核对)。</li>
<li>官网:<code>site-v*</code> → 站点可访问 + canonical 正确 + redline 扫描。</li>
<li>客户端:release 资产可下载安装(Android 侧载 / macOS <code>spctl</code> / Windows 安装)。</li>
<li>下载链接:官网按钮落到最新 release 资产。</li>
</ul>
<h2>9. 风险与缓解</h2>
<table>
<tr><th>风险</th><th>缓解</th></tr>
<tr><td>nas 内存(3.8G)构建 OOM</td><td>容器化单 job、Astro/Go 轻量;必要时该端移 mac</td></tr>
<tr><td>migrate 在生产出错</td><td>部署前备份 + 失败自动回滚(§3.3),已在 F3/F4 手动验证</td></tr>
<tr><td>Android keystore 丢失</td><td>存 Bitwarden(文件+密码);终身签名身份</td></tr>
<tr><td>macOS 公证凭据泄露</td><td>账户级 secret,不落盘;<code>.p8</code>/<code>.p12</code> 用完即删临时文件</td></tr>
<tr><td>发版后下载链接不刷新</td><td>build-* 成功触发 deploy-site 重烘焙,或用 latest-download 稳定 URL</td></tr>
</table>
<h2>10. 不在本方案</h2>
<p class="small">iOS 流水线(G)、SQLite 备份/容灾(#26)、TLS#25)、上架 Play、Windows 代码签名(先不签)。</p>
</div>
</body>
</html>
+173
View File
@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pangolin CI/CD 实现计划(#30</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 CI/CD 实现计划</h1>
<p class="sub">#30 · 2026-07-05 · <span class="tag info">阅读版</span> · 执行真相源 <code>docs/superpowers/plans/2026-07-05-cicd.md</code>(带 checkbox)· 设计 <a href="cicd-design.html">cicd-design.html</a></p>
<div class="lead">
<b>目标:</b>tag 触发的「编译 → 测试 → 发版(Gitea release)→ 部署」全自动。镜像 jiu 的
<code>scripts/ci/*.sh</code>(逻辑)+ <code>.gitea/workflows/*.yml</code>(编排)。runner:nas(官网+服务端,容器化
<code>node:20</code>/<code>golang:1.25</code>)、macAndroid+macOS)、windowsWindows)。
<b>CI「测试」= <code>workflow_dispatch</code> 手动触发跑一遍 + 观察产物/部署</b>(非经典单元 TDD)。
</div>
<h2>全局约束</h2>
<ul>
<li>参考:jiu 的 <code>~/code/jiu/.gitea/workflows/*</code> + <code>~/code/jiu/scripts/ci/*</code>provencopy+adapt)。</li>
<li>nas 每 job <code>docker run</code> 官方镜像(<code>node:20</code>/<code>golang:1.25</code>),不装宿主工具链。</li>
<li>国内镜像:<code>GOPROXY=goproxy.cn,direct</code><code>PUB_HOSTED_URL/FLUTTER_STORAGE_BASE_URL=flutter-io.cn</code></li>
<li>Secrets(已建,对齐 jiu):账户级 <code>FORGEJO_TOKEN/URL</code><code>MACOS_DEVELOPER_ID_CERT_P12_BASE64/PASSWORD</code><code>APPSTORE_API_KEY_P8_BASE64/APPSTORE_API_KEY_ID/APPSTORE_API_ISSUER_ID</code>;仓库级 <code>DEPLOY_SSH_KEY</code><code>ANDROID_KEYSTORE_BASE64/PASSWORD</code><code>ANDROID_KEY_ALIAS/PASSWORD</code><code>MACOS_APP_PROVISION_PROFILE_BASE64</code><code>MACOS_SYSEXT_PROVISION_PROFILE_BASE64</code></li>
<li>客户端铁律:macOS 递增 <code>CURRENT_PROJECT_VERSION</code>Android NDK≥28、gomobile JDK17、libbox 包名 <code>io.nekohasekai.libbox</code></li>
<li>Bash 禁 <code>$()</code>;提交带 Co-Authored-By footer。部署机 <code>pangolin1</code>103.119.13.48),官网 <code>pangolin.yanmeiai.com</code></li>
</ul>
<div class="phase">Phase 1 —— 基座 + 官网 + 服务端(无签名,可立即上线)</div>
<div class="card">
<h3>Task 1 · scripts/ci 基座</h3>
<div class="files">Create: scripts/ci/_env.sh · lib-forgejo.sh · notify.sh(抄 jiu 同名改 pangolin 专属值)</div>
<b>产出:</b><code>_env.sh</code>(镜像源 + <code>ver_from_tag</code><code>${ref#refs/tags/prefix-v}</code> 参数展开);<code>lib-forgejo.sh</code><code>forgejo_release_ensure</code> / <code>forgejo_upload_asset</code>curl+API);<code>notify.sh</code>
验证:<code>bash -n</code> + <code>shellcheck</code> 0 告警 → commit。
</div>
<div class="card">
<h3>Task 2 · checks 工作流(保留)</h3>
<div class="files">Modify: .gitea/workflows/ci.yml</div>
现有 ci.ymlnasshellcheck/openapi/redline/flutter/go test)保留;把 <code>scripts/ci/*.sh</code> 纳入 shellcheck 扫描。push 观察全绿。
</div>
<div class="card">
<h3>Task 3 · 官网发布</h3>
<div class="files">Create: scripts/ci/compile-site.sh · deploy-site.sh · .gitea/workflows/deploy-site.yml</div>
<b>前置(基础设施,改机器前问用户):</b>pangolin1 装 nginx/caddy 配 <code>pangolin.yanmeiai.com</code> vhostweb 根 <code>/var/www/pangolin-site</code>);CF DNS 指向 103.119.13.48<code>cf-api</code>,记 baize);TLS 先 CF 橙云或并入 #25。
<ul>
<li><code>compile-site.sh</code><code>node:20</code> 容器 <code>npm ci && SITE_URL=https://pangolin.yanmeiai.com npm run build</code></li>
<li><code>deploy-site.sh</code><code>DEPLOY_SSH_KEY</code><code>rsync -az --delete dist/ pangolin1:/var/www/pangolin-site/</code></li>
<li><code>deploy-site.yml</code>tag <code>site-v*</code> + dispatch<code>runs-on: nas</code></li>
</ul>
验证:dispatch → <code>curl -I https://pangolin.yanmeiai.com/</code> 200。
</div>
<div class="card">
<h3>Task 4 · 服务端发布(固化 F3/F4,带回滚)</h3>
<div class="files">Create: scripts/ci/compile-backend.sh · release-server.sh · deploy-server.sh · .gitea/workflows/deploy-server.yml</div>
<ul>
<li><code>compile-backend.sh</code><code>golang:1.25</code> 容器,<code>CGO_ENABLED=0 GOOS=linux GOARCH=amd64</code> 编 server/agent/migrate。</li>
<li><code>release-server.sh</code><code>forgejo_release_ensure</code> + 上传三个二进制。</li>
<li><code>deploy-server.sh</code>(核心,复刻手动次序):</li>
</ul>
<pre>#!/usr/bin/env bash
set -euo pipefail
DB=/var/lib/pangolin/pangolin.db; BIN=/usr/local/bin; TAG="$1"
scp server/out/pangolin-{server,agent,migrate} pangolin1:/tmp/
ssh pangolin1 "bash -s" &lt;&lt;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
curl -fsS -m10 --retry 5 --retry-connrefused http://103.119.13.48:8080/healthz &gt;/dev/null &amp;&amp; echo healthz OK</pre>
<code>deploy-server.yml</code>tag <code>server-v*</code> + dispatchnascompile → test → release → deploy。
验证:dispatch → migrate 版本前进 + <code>/healthz</code> 200 + 行数守恒。
</div>
<div class="card">
<h3>Task 5 · scripts/ci/test.sh</h3>
<code>test.sh server</code><code>golang:1.25</code> <code>go test ./...</code><code>test.sh client</code><code>flutter test</code>。接入 deploy-server 的 test 步骤。
</div>
<div class="phase">Phase 2 —— Android(解锁官网下载链接)</div>
<div class="card">
<h3>Task 6 · Android gradle 接 release 签名</h3>
<div class="files">Modify: client/android/app/build.gradle · Create: keystore.propertiesgitignore</div>
<code>signingConfigs.release</code>,从 env/<code>keystore.properties</code> 读 keystore + 三密码(<code>ANDROID_*</code>);<code>buildTypes.release.signingConfig</code> 指向它。本机验证 <code>apksigner verify --print-certs</code> 显示 CN=Pangolin(非 debug)。
</div>
<div class="card">
<h3>Task 7 · Android CI</h3>
<div class="files">Create: scripts/ci/compile-android.sh · release-client.sh · .gitea/workflows/build-android.yml</div>
<code>compile-android.sh</code><code>build-libbox.sh android</code>JDK17/NDK≥28)→ secrets 落 keystore → <code>flutter build apk --release --split-per-abi --dart-define=PANGOLIN_API_URL=…</code><code>build-android.yml</code>tag <code>client-v*</code><code>runs-on: mac</code>provision → compile → release。验证:真机 <code>adb install -r</code> 可用。
</div>
<div class="card">
<h3>Task 8 · 官网下载链接接 Android</h3>
<div class="files">Modify: web/website/src/config/site.tsdownloads.android)· Download.astro</div>
<code>site.ts.downloads.android</code> = Forgejo <code>/releases/latest/download/&lt;asset&gt;</code> 稳定 URL(不支持则构建期烘焙)。重部署官网,点击落到最新 apk。关 todo 30AAndroid 部分)。
</div>
<div class="phase">Phase 3 —— macOS + Windows</div>
<div class="card">
<h3>Task 9 · macOS 签名+公证 dmg</h3>
<div class="files">Create: scripts/ci/compile-macos.sh · .gitea/workflows/build-macos.yml(证书导入+notarytool 可复用 jiu compile-macos.sh</div>
建临时 keychain → <code>MACOS_DEVELOPER_ID_CERT_P12_BASE64</code> 导入 → 两个描述文件解码装入 → 递增 <code>CURRENT_PROJECT_VERSION</code> → Xcode Developer ID 构建 app+sysext → <code>notarytool submit --key-id $APPSTORE_API_KEY_ID --issuer $APPSTORE_API_ISSUER_ID --wait</code><code>stapler</code> → dmg。<code>runs-on: mac</code>。验证:另一台 mac <code>spctl -a -vv</code> + <code>stapler validate</code> 通过。
</div>
<div class="card">
<h3>Task 10 · Windows 安装包</h3>
<div class="files">Create: scripts/ci/compile-windows.sh · .gitea/workflows/build-windows.yml(参照 jiu install-innosetup.ps1</div>
<code>flutter build windows --release</code> → Inno Setup 打包(<b>先不代码签名</b>,首装有 SmartScreen 提示可接受)。tag <code>client-v*</code>/<code>winbuild*</code><code>runs-on: windows</code>。验证:windows 机装上能连。
</div>
<div class="card">
<h3>Task 11 · 下载链接全端闭环</h3>
<code>site.ts.downloads</code> 补齐 macos/windows;三端按钮全接 release。客户端发版触发官网重部署刷新。关 todo 30A/30B。
</div>
<h2>不在本计划</h2>
<p class="small">iOSG)、备份/容灾(#26)、TLS#25)、Windows 代码签名、上架商店。</p>
</div>
</body>
</html>
+216
View File
@@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>全栈设计审查 2026-07(前端 / 后端 / 数据库)</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:26px 0 8px;color:var(--accent2)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.88em;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:13px;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.bad{background:rgba(224,106,106,.16);color:var(--bad)}
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
.card.p0{border-left:3px solid var(--bad)}
.card.p1{border-left:3px solid var(--warn)}
.card.p2{border-left:3px solid var(--accent2)}
.card h3{margin-top:0;color:var(--fg)}
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}
td code{font-size:.85em}
ul,ol{padding-left:22px;margin:10px 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}
.loc{font-family:var(--mono);font-size:12px;color:var(--fg2);margin-top:8px}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 返回文档索引</a>
<h1>全栈设计审查 · 2026-07</h1>
<p class="sub">范围:<code>server/</code>Go 控制面 + agent)· <code>client/</code>Flutter)· 数据库 schemamigrations 120)· 部署脚本。方法:核心链路逐文件精读(认证 / 会话 / 连接下发 / 用量记账 / 配额卡控 / 设备管理),非全量逐行。<span class="tag info">13 项发现</span> <span class="tag bad">P0 ×2</span> <span class="tag warn">P1 ×5</span></p>
<div class="lead">
总体评价:<b>架构底子是好的</b>——方言层数据库解耦、argon2id 密码、refresh 单次轮换 + Redis 白名单、gRPC mTLS、
per-device dp_uuid 归因、Lua 滑窗限流、节点三态判活(DB×agent在线×数据面健康),都属同规模项目少见的干净设计。
问题集中在<b>两条主线</b>:① 传输与数据安全的「最后一公里」没封口(明文 API、零备份);
② 多处「一次写对、后续演进没跟上」的<b>接缝漂移</b>connect 发每设备凭证 / disconnect 撤账户凭证;
设备唯一键改造写进注释却没落地;agent 用量取走即焚)。
</div>
<h2>发现汇总</h2>
<table>
<tr><th>#</th><th>严重度</th><th></th><th>一句话</th></tr>
<tr><td>F1</td><td><span class="tag bad">P0 安全</span></td><td>全栈</td><td>控制面全程明文 HTTP:密码 / JWT / 会话轮询裸奔公网</td></tr>
<tr><td>F2</td><td><span class="tag bad">P0 运维</span></td><td>数据库</td><td>SQLite 生产库零备份——单盘单机,丢了就是全部</td></tr>
<tr><td>F3</td><td><span class="tag warn">P1 正确性</span></td><td>后端+DB</td><td>同一台机器换账号登录 → 设备注册永远 403 → 无法连接,且提示的自救方法无效</td></tr>
<tr><td>F4</td><td><span class="tag warn">P1 正确性</span></td><td>后端</td><td>connect 下发每设备凭证,disconnect 却撤账户级凭证——断开从未真正吊销</td></tr>
<tr><td>F5</td><td><span class="tag warn">P1 可靠性</span></td><td>agent</td><td>用量「取走即焚」:计数器已清零,上报失败数据永久丢(注释写 at-least-once,实为 at-most-once</td></tr>
<tr><td>F6</td><td><span class="tag warn">P1 可靠性</span></td><td>后端</td><td>Redis 重启 ≈ 全员被登出;sessions 表自称权威却不被 refresh 路径参考</td></tr>
<tr><td>F7</td><td><span class="tag warn">P1 容量</span></td><td>后端</td><td>argon2id 64 MiB/次登录,1 GB 小机上一波并发登录即可 OOM</td></tr>
<tr><td>F8</td><td><span class="tag info">P2 数据库</span></td><td>数据库</td><td>sessions / audit_log 无限增长,无留存策略;按登录史全量扫描</td></tr>
<tr><td>F9</td><td><span class="tag info">P2 产品</span></td><td>后端</td><td>免费额度按 UTC 日重置 = 北京时间早上 8 点,「今日」口径与用户认知不符</td></tr>
<tr><td>F10</td><td><span class="tag info">P2 一致性</span></td><td>全栈</td><td>免费时长三个时钟各说各话:服务端分钟(有流量才计)、凭证 TTL、客户端倒计时</td></tr>
<tr><td>F11</td><td><span class="tag info">P2 可靠性</span></td><td>后端</td><td>agent 在线状态纯内存:server 重启后短窗内全体拒连</td></tr>
<tr><td>F12</td><td><span class="tag info">P2 客户端</span></td><td>客户端</td><td>单实例探测固定端口 47654 无握手:被占则 App 无法启动,任意本地进程可唤窗</td></tr>
<tr><td>F13</td><td><span class="tag info">P2 后端</span></td><td>后端</td><td>ReportUsage 多条 SQL 无事务,崩溃可留部分记账</td></tr>
</table>
<h2>P0 — 必须尽快处理</h2>
<div class="card p0">
<h3>F1 · 控制面全程明文 HTTP <span class="tag bad">安全</span></h3>
<p><b>现象</b>:客户端默认 API 基址是 <code>http://103.119.13.48:8080</code>(裸 IP + 明文)。登录密码、
access/refresh token、会话轮询、设备列表——所有控制面流量在公网明文传输。服务端 argon2id 只保护「存储」,保护不了「传输」。</p>
<p><b>影响</b>:任何链路中间者(ISP、Wi-Fi、GFW 探测设备)可截获密码与 token 直接接管账户。对一个「主打隐私」的产品,这是与定位直接矛盾的短板;且中国链路上明文 HTTP + 可疑 payload 更易被主动探测/干扰。</p>
<p><b>修法</b>:域名 + 反代 TLSCaddy 一行配置自动 Let's Encrypt,或 nginx+certbot),客户端默认改
<code>https://api.&lt;domain&gt;</code>Android 移除 <code>usesCleartextTraffic</code>;服务端 8080 收回 loopback。
无域名过渡期可先自签 + 客户端证书 pinning(次优)。</p>
<div class="loc">client/lib/services/api_config.dart:8 · scripts/local_test.sh:20API_URL)· server :8080 直挂公网</div>
</div>
<div class="card p0">
<h3>F2 · SQLite 生产库零备份 <span class="tag bad">运维</span></h3>
<p><b>现象</b><code>deploy/</code> 全目录无任何 backup / dump / litestream 痕迹。用户、订阅、激活码、用量全部在
pangolin1 单机单盘的一个 SQLite 文件里。</p>
<p><b>影响</b>:磁盘损坏 / 误操作 / VPS 商跑路 = 用户资产全灭,无法恢复付费用户订阅关系(直接经济损失 + 信誉损失)。这是当前全项目期望损失最大的单点。</p>
<p><b>修法</b>(一晚可落地):① 最简:cron 每日 <code>sqlite3 .backup</code> + <code>rclone</code> 推 Cloudflare R2/S3 异地,保留 30 天;
② 更优:Litestream 持续复制到对象存储(秒级 RPO,内存开销可忽略,适合 1GB 小机)。恢复流程写进 runbook 并演练一次。</p>
<div class="loc">deploy/bootstrap/ · deploy/single-node/deploy.sh(均无备份任务)</div>
</div>
<h2>P1 — 设计缺陷,建议排期修</h2>
<div class="card p1">
<h3>F3 · 同机换账号 → 设备注册永远 403,连接被卡死 <span class="tag warn">正确性</span></h3>
<p><b>链路</b>:客户端 <code>device_id</code> 一次生成、安全存储持久、<b>跨账号复用</b>(登出不清)。
<code>devices.uuid</code><b>全局 UNIQUE</b>migration 000001),<code>RegisterIfAbsent</code> 遇到「uuid 已属他人」直接
<code>ErrForbidden</code>;登录侧注册是 best-effort → <b>登录成功但设备永远注册不上</b>;随后
<code>ConnectNode</code> 因设备未注册拒发凭证,提示「请退出后重新登录以重新注册设备」——<b>而重新登录永远解不了这个死结</b></p>
<p><b>影响</b>:一台机器先后登两个账号(家人共用电脑、用户换号、测试机)→ 第二个账号完全无法连接,且用户按提示操作也无效。migration 16 头注释已写明「UNIQUE(uuid)→UNIQUE(user_id,uuid) 需表重建,风险隔离到单独迁移」——<b>该迁移至今未落地</b>,是典型的「注释里的 TODO 变成生产 bug」。</p>
<p><b>修法</b>:① 落地推迟的迁移:<code>UNIQUE(user_id, uuid)</code>(设备身份按用户隔离,语义即「此用户的此设备」);
dp_uuid 归因按 (user,device) 查本就成立;② 或语义改「重绑」:新登录抢走设备行(转移 owner 并吊销旧主会话)——更贴近「一台设备此刻只属一个账号」的现实;③ 客户端兜底:登出时按账号命名空间存 device_id。推荐 ①+③。</p>
<div class="loc">server/internal/devices/service.go:209ErrForbidden)· server/migrations/sqlite/000016_*.up.sql 头注释 · server/internal/httpapi/nodes.go:245DEVICE_NOT_REGISTERED)· client/lib/services/device_identity.dart:66</div>
</div>
<div class="card p1">
<h3>F4 · disconnect 撤销的不是 connect 发出的凭证 <span class="tag warn">正确性</span></h3>
<p><b>现象</b><code>ConnectNode</code> 走每设备凭证 <code>EnsureDeviceDpUUID → devDp</code>nodes.go:244);
<code>DisconnectNode</code> 却吊销<b>账户级</b> <code>ent.DpUUID</code> 并删账户凭证行(nodes.go:371-377),且接口没有
<code>device_id</code> 入参。<b>用户主动断开从未真正吊销数据面凭证</b>——每设备凭证在节点上一直活到 TTL(付费 24h)。</p>
<p><b>影响</b>:「断开」的服务端语义失效;被移除/被强退的设备若本地还留着 sing-box 配置,断开后的
TTL 窗口内仍可直连数据面(绕过控制面判定)。DeleteDevice 路径有自己的 revoker 是对的,但普通 disconnect 是空转。</p>
<p><b>修法</b>disconnect 请求体加 <code>device_id</code>,查 <code>devDp</code> 后吊销之;账户级 dp_uuid 作为遗留兜底再撤一次亦可。顺手给 revoke 失败加告警(现在 <code>_ =</code> 吞掉)。</p>
<div class="loc">server/internal/httpapi/nodes.go:244 vs 336-380</div>
</div>
<div class="card p1">
<h3>F5 · 用量「取走即焚」:上报失败 = 数据永久丢 <span class="tag warn">可靠性</span></h3>
<p><b>现象</b>v2ray 用量源 <code>QueryStats(Reset_: true)</code> <b>先清零内核计数器</b>拿到 delta
<code>runUsage</code><code>ReportUsage</code> 一旦失败直接 <code>return err</code> 拆会话重连——<b>刚取走的这窗口数据没有任何缓冲,永久丢失</b>。注释声称 at-least-once,实际是 at-most-once。</p>
<p><b>影响</b>:控制面-agent 之间任何 gRPC 抖动(server 重启、网络闪断——每分钟一窗,天天发生)都在漏记:
免费用户少计分钟 = 变相多送时长;统计页字节数偏低。计费相关数据不该按「尽力而为」设计。</p>
<p><b>修法</b>Collect 后先并入内存 pending 缓冲,ReportUsage 成功才清;失败保留、下窗口合并重发(按 dp_uuid 累加,幂等安全);再给报文加 <code>window_id</code>,控制面按 (node,window_id) 去重防重发双计。缓冲上限封顶(如 1h)防内存膨胀。</p>
<div class="loc">server/internal/agentd/usage_v2ray.go:66Reset_)· server/internal/agentd/usage.go:42-51</div>
</div>
<div class="card p1">
<h3>F6 · Redis 重启 ≈ 全员被登出;「权威」sessions 表不参与 refresh 判定 <span class="tag warn">可靠性</span></h3>
<p><b>现象</b>refresh token 白名单只活在 Redis<code>jwt:refresh:*</code>)。single-node 部署用发行版默认 RedisRDB 快照,非 AOF)——crash/重启丢最近几分钟到全部白名单 → 存量 refresh 全被拒 → <b>全体用户被迫重新登录</b>。而 sessions 表注释自称「可查询的权威记录」,refresh 路径却从不回查它——两边脑裂:DB 说会话有效,Redis 说无效,以 Redis 为准。</p>
<p><b>影响</b>1GB 小机上 Redis 恰是 OOM-killer 高危对象;一次意外重启= 一次全量掉线事故 + 客服风暴。</p>
<p><b>修法</b>refresh 白名单 miss 时<b>回查 sessions 表</b>jti 存在且未 revoke → 放行并回填 Redis),Redis 降级为缓存而非唯一真相;同时 single-node 部署给 Redis 开 AOF (<code>appendonly yes</code>) + <code>maxmemory</code> 上限。这也顺手消除了「强退后 Redis 删失败仍可刷新」的反向缝隙。</p>
<div class="loc">server/internal/auth/token.go:228-234 · server/internal/sessions/store.go:1-5(“authoritative”)· deploy/single-node/deploy.sh:104</div>
</div>
<div class="card p1">
<h3>F7 · argon2id 64 MiB/次登录,1 GB 机可被打 OOM <span class="tag warn">容量/安全</span></h3>
<p><b>现象</b>argon2id 参数 64 MiB × 4 线程。登录是公开端点:~10 个并发登录请求 ≈ 640 MB 瞬时内存——机器总共 1 GB,还要跑 sing-box + agent + Redis。滑窗限流按 scope(邮箱/IP) 计,攻击者换 IP/邮箱可绕。</p>
<p><b>修法</b>:给密码哈希加<b>全局并发闸</b>semaphore 1–2 个并发,其余排队),几行代码把内存上限钉死在 128 MiB;
或按 OWASP 备选参数降到 19 MiB×2。限流再加全局维度(每秒总登录数)兜底。</p>
<div class="loc">server/internal/auth/password.go:19-21 · server/internal/auth/ratelimit.go</div>
</div>
<h2>P2 — 结构性小患 / 口径问题</h2>
<div class="card p2">
<h3>F8 · sessions / audit_log 无限增长,无留存策略 <span class="tag info">数据库</span></h3>
<p>每次登录一行 sessions、永不清理;audit_log 纯追加。<code>LastLoginByDevice</code> 按用户<b>全史扫描</b>ORDER BY created_at ASC 无 LIMIT),<code>HasActiveSession</code>(15s 轮询热路径)只有 user_id 单列索引可用。年级尺度上小机的磁盘与查询都会被拖住。<b></b>:留存任务(revoked 会话 &gt;90 天、audit &gt;180 天定期删)+ 复合索引 <code>(user_id, device_id, revoked_at)</code>LastLogin 改每设备 MAX 子查询或维护 devices.last_login 列。</p>
<div class="loc">server/internal/sessions/store.go:54-62,134-152 · migrations 000016(仅两个单列索引)</div>
</div>
<div class="card p2">
<h3>F9 · 免费额度按 UTC 日重置(北京时间 08:00)<span class="tag info">产品</span></h3>
<p><code>utcToday()</code> / <code>windowEnd.UTC().Truncate(24h)</code>:主力用户在国内,「今日剩余」却在早上 8 点跳变,倒计时/额度体验诡异且难解释。<b></b>:额度日界定死 <code>Asia/Shanghai</code>(产品定位明确,不必 per-user 时区),服务端集中改 <code>utcToday</code> 与记账日期两处即可,客户端展示自动跟随 /me。</p>
<div class="loc">server/internal/usage/quota.go:40,55 · server/internal/nodes/handler_grpc.go:291</div>
</div>
<div class="card p2">
<h3>F10 · 免费时长三个时钟不一致 <span class="tag info">一致性</span></h3>
<p>同一「10 分钟」有三种度量:① 服务端 minutes_used——<b>有流量的窗口才 +1</b>(挂着不动不扣);② 凭证 TTL——发放时定死墙钟;③ 客户端倒计时——连接起墙钟递减。后果:闲置用户被客户端切断但服务端几乎没扣分 → 重连又是满额倒计时(免费时长实际无上限,只要愿意重连);反之轻流量用户每窗口整分扣。<b></b>:先定口径——推荐「连接在线即计时」(agent 按凭证存活窗口计 1 分钟,不看流量),三个时钟自然对齐;或接受现状但把客户端倒计时以 /me 剩余为准动态校正(已部分做)。</p>
<div class="loc">server/internal/agentd/usage_v2ray.go:96-101(有流量才计)· httpapi/nodes.go:235TTL)· client connection_provider 倒计时</div>
</div>
<div class="card p2">
<h3>F11 · agent 在线状态纯内存,server 重启短窗全体拒连 <span class="tag info">可靠性</span></h3>
<p><code>hub.IsOnline</code> 是进程内 mapserver 重启后到 agent 重连前,ListNodes 全灰、ConnectNode 全拒(503)。当前单节点影响秒级,可接受;但多节点后放大。<b></b>:启动后给一个宽限窗(如 60s 内 unknown 视为 up),或 agent 心跳落 Redis 带 TTL。与 todo #8(掉线告警)同一片改。</p>
</div>
<div class="card p2">
<h3>F12 · 单实例探测:固定端口 47654、无握手 <span class="tag info">客户端</span></h3>
<p>任何本地进程先占住该端口 → 真 App 启动时 bind 失败误判「已有实例」直接退出(<b>App 无法启动且无提示</b>);反之任意本地进程连一下就能唤起主窗(无害但脏)。<b></b>:连接后交换 magic 字节验明正身,验不过改用文件锁兜底再启动;唤窗同样验 magic。</p>
<div class="loc">client/lib/system_tray.dart:15-38</div>
</div>
<div class="card p2">
<h3>F13 · ReportUsage 多条 SQL 无事务 <span class="tag info">后端</span></h3>
<p>每设备 Accumulate + 每用户 Accumulate 是多条独立语句,中途崩溃留部分记账(设备有、账户无)。量级小、图表级偏差,配合 F5 的 window_id 幂等一起收进单事务即可。</p>
<div class="loc">server/internal/nodes/handler_grpc.go:300-359</div>
</div>
<h2>做得好的(保持)</h2>
<ul>
<li><b>方言层</b><code>internal/db/dialect.go</code>):裸 SQL + 中性 Upsert/锁语义,时间 Go 端算——MySQL/SQLite 真正可切换,测试免 docker。</li>
<li><b>认证栈</b>argon2id + RS256 双 kid 轮换 + refresh 单次使用轮换 + typ 声明防混用,教科书级。</li>
<li><b>节点判活</b>DB 状态 × agent gRPC 在线 × 数据面健康三合一(<code>effectiveNodeStatus</code>),并拒绝向离线 agent「假装下发成功」——正是修过 6 天静默事故后的正确形态。</li>
<li><b>per-device dp_uuid + v2ray per-user 计数</b>:归因链路是准的(老 clash 均摊源已弃用、仅遗留代码)。</li>
<li><b>免费额度账户级共享 + 凭证 TTL 硬切断</b>:卡控在服务端成立,客户端绕过也兜得住。</li>
<li><b>迁移成对成套</b>mysql/sqlite 各一份 up/down),审查期未见漂移。</li>
</ul>
<h2>建议处理顺序</h2>
<table>
<tr><th>批次</th><th></th><th>理由</th></tr>
<tr><td><b>立刻</b></td><td>F2(备份)→ F1TLS</td><td>F2 一晚落地、消掉最大期望损失;F1 需要域名决策,动客户端默认值要随发版</td></tr>
<tr><td><b>下一迭代</b></td><td>F3 + F4(一起动 devices/凭证接缝);F5 + F13(一起动用量链路);F6 + F7(一起动 auth 可靠性)</td><td>三组各自同一片代码,一组一 PR</td></tr>
<tr><td><b>排队</b></td><td>F8F12</td><td>口径决策(F9/F10)先拍板再动手;F11 并入 todo #8</td></tr>
</table>
<p class="small">备注:web/usercenter/website)本轮未深审(改动频率与暴露面低于 server/client 核心链路);
近期已修复且验证过的不再列出:用量多设备超计(#22)、被移除设备判活(dev==nil)、弱网看门狗误伤(#18)、统计流广播订阅。
本报告基于 worktree-macos-killswitch @ 2026-07-02。</p>
</div>
</body>
</html>
+337
View File
@@ -0,0 +1,337 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>联系我们:渠道二级页(Telegram 频道/群组,DB 配置)· 交互设计</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:26px 0 8px;color:var(--accent2)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.88em;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:13px;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:18px 20px;margin:16px 0}
.card.root{border-left:3px solid var(--accent)}
.card h3{margin-top:0}
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}
td code{font-size:.85em}
ul,ol{padding-left:22px;margin:10px 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}
/* ── 交互原型:用真实 App 暗色 token(design/colors_and_type.css [data-theme=dark])──*/
.proto{
--p-bg:#14110E; --p-bg-subtle:#1F1C18; --p-surface:#221E19; --p-surface2:#2A251F;
--p-fg1:#F4EFE8; --p-fg2:#B6AC9C; --p-fg3:#897F6F; --p-fg-on-accent:#1F1C18;
--p-accent:#CC8B5C; --p-accent-hover:#D9A982; --p-accent-subtle:rgba(204,139,92,.14);
--p-accent-border:rgba(204,139,92,.30);
--p-border:rgba(242,238,231,.10); --p-border-strong:rgba(242,238,231,.18);
--p-green:#7FB07A;
background:var(--p-bg);border:1px solid var(--border);border-radius:14px;overflow:hidden;
display:flex;height:520px;margin:18px 0;font-size:14px;
}
.proto *{box-sizing:border-box}
/* sidebar */
.p-side{width:190px;flex:none;background:var(--p-bg);border-right:1px solid var(--p-border);padding:18px 14px;display:flex;flex-direction:column;gap:4px}
.p-brand{display:flex;align-items:center;gap:10px;padding:4px 8px 18px}
.p-logo{width:30px;height:30px;border-radius:8px;background:var(--p-accent-subtle);display:flex;align-items:center;justify-content:center;color:var(--p-accent);font-size:16px}
.p-brand b{color:var(--p-fg1);font-size:16px;line-height:1.1}
.p-brand span{display:block;color:var(--p-fg3);font-size:9px;letter-spacing:2px}
.p-nav{display:flex;align-items:center;gap:11px;padding:9px 12px;border-radius:9px;color:var(--p-fg2);cursor:default}
.p-nav .ic{width:17px;text-align:center;opacity:.85}
.p-nav.active{background:var(--p-accent-subtle);color:var(--p-accent);font-weight:600}
/* main */
.p-main{flex:1;display:flex;flex-direction:column;min-width:0}
.p-top{display:flex;align-items:center;gap:12px;padding:16px 22px;border-bottom:1px solid var(--p-border)}
.p-top .ttl{font-size:19px;font-weight:700;color:var(--p-fg1)}
.p-top .rt{margin-left:auto;display:flex;align-items:center;gap:14px;color:var(--p-fg2);font-size:12px}
.p-dot{width:7px;height:7px;border-radius:50%;background:var(--p-green);display:inline-block;margin-right:5px}
.p-body{padding:20px 22px;overflow-y:auto}
.p-intro{color:var(--p-fg2);font-size:13.5px;margin:0 0 14px}
/* channel / item cards */
.p-card{display:flex;align-items:center;gap:13px;padding:13px 15px;background:var(--p-surface);border:1px solid var(--p-border);border-radius:13px;margin-bottom:10px;cursor:pointer;transition:border-color .12s,background .12s}
.p-card:hover{border-color:var(--p-accent-border);background:var(--p-surface2)}
.p-ic{width:38px;height:38px;flex:none;border-radius:10px;background:var(--p-bg-subtle);display:flex;align-items:center;justify-content:center;font-size:18px;color:var(--p-fg2)}
.p-ic.accent{background:var(--p-accent-subtle);color:var(--p-accent)}
.p-tx{flex:1;min-width:0}
.p-tx .nm{color:var(--p-fg1);font-weight:600;font-size:14.5px;display:flex;align-items:center;gap:6px}
.p-tx .sb{color:var(--p-fg3);font-size:12.5px;margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.p-chev{color:var(--p-fg3);font-size:17px;flex:none}
.p-verify{color:var(--p-accent);font-size:12px}
.p-count{color:var(--p-fg3);font-size:11.5px;flex:none;display:flex;align-items:center;gap:4px}
.p-btn{flex:none;font-size:12.5px;font-weight:600;padding:6px 15px;border-radius:8px;border:1px solid var(--p-accent-border);color:var(--p-accent);background:transparent}
.p-btn.solid{background:var(--p-accent);color:var(--p-fg-on-accent);border-color:transparent}
.p-sechd{color:var(--p-fg3);font-size:11px;font-weight:700;letter-spacing:1.4px;margin:18px 2px 9px}
.p-back{display:inline-flex;align-items:center;gap:8px;color:var(--p-fg2);font-size:13px;cursor:pointer;padding:2px 0;margin-bottom:2px}
.p-back:hover{color:var(--p-fg1)}
.p-detail-hd{display:flex;align-items:center;gap:12px;margin:6px 0 4px}
.p-detail-hd .p-ic{width:44px;height:44px;font-size:21px}
.p-detail-hd .nm{font-size:18px;font-weight:700;color:var(--p-fg1)}
.p-detail-hd .sb{color:var(--p-fg3);font-size:12.5px}
.hide{display:none!important}
.p-card.disabled{opacity:.5;cursor:not-allowed}
.p-card.disabled:hover{border-color:var(--p-border);background:var(--p-surface)}
.p-soon{font-size:10px;font-weight:600;padding:1px 7px;border-radius:999px;background:var(--p-bg-subtle);color:var(--p-fg3);border:1px solid var(--p-border)}
.flag{margin-left:auto;color:var(--p-fg3);font-size:12px}
.toggles{display:flex;gap:8px;margin:10px 0 2px}
.toggles button{background:var(--panel2);color:var(--fg2);border:1px solid var(--border);border-radius:8px;padding:6px 12px;font-size:13px;cursor:pointer}
.toggles button:hover{color:var(--fg)}
.capt{color:var(--fg2);font-size:12.5px;text-align:center;margin-top:-4px}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 返回文档索引</a>
<h1>联系我们 · 渠道二级页</h1>
<p class="sub">交互设计 · Telegram/LINE 多频道 · 群组 · 内容 DB 配置 · <span class="tag info">前端 + 后端 + 数据库</span></p>
<div class="lead">
现「联系我们」把每个渠道当<b>单一入口</b>Telegram 只挂一个 <code>@PangolinVPN_bot</code>)。
需求:点 Telegram 进入<b>二级页</b>,列出该平台下的<b>多个频道 / 群组 / Bot</b>
每项含名称、@handle、一句说明、可选成员数、认证标记与「打开 / 加入」动作,
<b>全部由数据库配置</b>(运营可随时增删改,客户端不写死)。本页给出交互原型 + 数据模型 + 接口,待你统一后再开发。
</div>
<h2>可点原型(点 Telegram 卡片进二级,← 返回)</h2>
<p class="small">下方为内嵌可交互原型,配色用 App 真实暗色 tokenclay/espresso)。点 <b>Telegram</b><b>LINE</b> 卡进详情;「邮箱客服 / 自助发卡商店」为单链接,点即直接打开(原型里仅提示)。</p>
<div class="proto" id="proto">
<!-- sidebar -->
<div class="p-side">
<div class="p-brand"><div class="p-logo">🐾</div><div><b>穿山甲</b><span>PANGOLIN</span></div></div>
<div class="p-nav"><span class="ic"></span>连接</div>
<div class="p-nav"><span class="ic">🌐</span>节点</div>
<div class="p-nav"><span class="ic">📊</span>统计</div>
<div class="p-nav"><span class="ic"></span>设置</div>
<div class="p-nav active"><span class="ic">💬</span>联系我们</div>
</div>
<!-- main -->
<div class="p-main">
<div class="p-top">
<span class="ttl" id="p-title">联系我们</span>
<span class="rt"><span><span class="p-dot"></span>US</span><span>🌙</span></span>
</div>
<!-- L1: channel list -->
<div class="p-body" id="view-l1">
<p class="p-intro">遇到问题?通过以下任一渠道联系我们,通常数分钟内回复。</p>
<div class="p-card" onclick="showTg()">
<div class="p-ic accent"></div>
<div class="p-tx"><div class="nm">Telegram</div><div class="sb">官方频道 · 交流群 · 客服 Bot</div></div>
<span class="p-chev"></span>
</div>
<div class="p-card disabled" title="即将开放,先灰置">
<div class="p-ic">💬</div>
<div class="p-tx"><div class="nm">LINE <span class="p-soon">即将开放</span></div><div class="sb">官方账号 · 中文/日文群</div></div>
<span class="p-chev"></span>
</div>
<div class="p-card" title="单链接:直接打开邮件">
<div class="p-ic"></div>
<div class="p-tx"><div class="nm">邮箱客服</div><div class="sb">support@pangolin.vpn</div></div>
<span class="p-chev"></span>
</div>
<div class="p-card" title="单链接:直接打开商店">
<div class="p-ic">🛍</div>
<div class="p-tx"><div class="nm">自助发卡商店</div><div class="sb">shop.pangolin.vpn</div></div>
<span class="p-chev"></span>
</div>
</div>
<!-- L2: Telegram detail -->
<div class="p-body hide" id="view-tg">
<div class="p-back" onclick="showList()"> 返回</div>
<div class="p-detail-hd">
<div class="p-ic accent"></div>
<div><div class="nm">Telegram</div><div class="sb">加入官方频道获取更新,进群与用户互助</div></div>
</div>
<div class="p-sechd">频道 · CHANNELS</div>
<div class="p-card">
<div class="p-ic accent">📣</div>
<div class="p-tx"><div class="nm">穿山甲 · 官方频道 <span class="p-verify"></span></div><div class="sb">产品更新与公告 · @PangolinVPN</div></div>
<button class="p-btn">打开</button>
</div>
<div class="p-card">
<div class="p-ic accent">📶</div>
<div class="p-tx"><div class="nm">穿山甲 · 节点状态</div><div class="sb">节点/故障实时播报 · @PangolinStatus</div></div>
<button class="p-btn">打开</button>
</div>
<div class="p-sechd">群组 · GROUPS</div>
<div class="p-card">
<div class="p-ic">👥</div>
<div class="p-tx"><div class="nm">穿山甲 · 用户交流群</div><div class="sb">使用问题互助交流 · @PangolinChat</div></div>
<button class="p-btn solid">加入</button>
</div>
<div class="p-card">
<div class="p-ic">🌏</div>
<div class="p-tx"><div class="nm">Pangolin · English Group</div><div class="sb">English support &amp; chat · @PangolinEN</div></div>
<button class="p-btn solid">加入</button>
</div>
<div class="p-sechd">客服机器人 · BOT</div>
<div class="p-card">
<div class="p-ic accent">🤖</div>
<div class="p-tx"><div class="nm">客服机器人</div><div class="sb">自动答疑 / 提交工单 · @PangolinVPN_bot</div></div>
<button class="p-btn">打开</button>
</div>
</div>
<!-- L2: LINE detail (示意同构) -->
<div class="p-body hide" id="view-line">
<div class="p-back" onclick="showList()"> 返回</div>
<div class="p-detail-hd">
<div class="p-ic">💬</div>
<div><div class="nm">LINE</div><div class="sb">官方账号与交流群</div></div>
</div>
<div class="p-sechd">官方账号 · OFFICIAL</div>
<div class="p-card">
<div class="p-ic">💬</div>
<div class="p-tx"><div class="nm">Pangolin 官方账号 <span class="p-verify"></span></div><div class="sb">公告与客服 · @pangolinvpn</div></div>
<button class="p-btn">打开</button>
</div>
<div class="p-sechd">群组 · GROUPS</div>
<div class="p-card">
<div class="p-ic">👥</div>
<div class="p-tx"><div class="nm">中文交流群</div><div class="sb">使用互助 · openchat</div></div>
<button class="p-btn solid">加入</button>
</div>
</div>
</div>
</div>
<div class="toggles">
<button onclick="showList()">L1 渠道列表</button>
<button onclick="showTg()">L2 · Telegram</button>
<button onclick="showLine()">L2 · LINE(预留结构预览)</button>
</div>
<p class="capt">同一原型内切换三态 · 真机为路由 push(移动端整页)/ 内容区替换(桌面)。
L1 上 LINE 为<b>灰置「即将开放」不可点</b>;「L2 · LINE」按钮仅用于预览其<b>预留的二级结构</b>(去灰后即此样式)。</p>
<h2>交互规则</h2>
<ul>
<li><b>进入二级的条件</b>:某平台配置了 <b>&gt;1 条</b>链接 → 卡片带 <code></code>,点击进二级页(Telegram / LINE)。
<b>1 条</b>链接的平台(邮箱、发卡商店)→ 点击<b>直接执行该链接动作</b>(打开邮件 / 浏览器),不进二级。
规则统一由「该平台 link 条数」驱动,无需前端写死哪个进二级。</li>
<li><b>二级页结构</b>:顶部 <code> 返回</code> + 平台头(图标 + 名 + 一句副标题);下方按 <b>kind 分组</b>展示
——<code>频道 CHANNELS</code> / <code>群组 GROUPS</code> / <code>客服机器人 BOT</code>(分组标题仅在该组有内容时出现,顺序固定)。</li>
<li><b>每一项</b>:图标/头像 · 标题(可带 <span class="p-verify" style="color:var(--accent)"></span> 认证)· 一句说明 + <code>@handle</code> ·
可选成员数(<b>展示用缓存文本</b>,不实时拉 Telegram)· 动作按钮。</li>
<li><b>动作</b>:频道/Bot → 「打开」(描边按钮);群组 → 「加入」(实心强调按钮)。点按钮或点整行都触发。
优先 <code>tg://resolve?domain=…</code> 唤起已装 Telegram,失败回退 <code>https://t.me/…</code>(浏览器)。</li>
<li><b>桌面 vs 移动</b>:桌面在右侧内容区做<b>视图替换</b>(标题栏文案随之切到「Telegram」,← 返回回列表);
移动端为<b>整页 push 路由</b>,系统返回手势/返回键回列表。两端同一份数据与卡片组件。</li>
<li><b>空/禁用</b>:某平台所有 link <code>enabled=0</code>(或库里无该平台行)→ 默认 L1 <b>不展示</b>该平台;二级页某分组为空 → 不渲染该分组标题。</li>
<li><b>「即将开放」灰置</b>:前端 platform 注册表有一个 <code>comingSoon</code> 列表(当前 = <code>[line]</code>)。列在其中且<b>暂无 enabled 链接</b>的平台,L1 <b>置灰 + 「即将开放」角标、不可点进</b>(占位预告,不隐藏)。
运营在库里配好该平台链接后,从 <code>comingSoon</code> 列表移除该项(一行代码)→ 自动变可点二级页。LINE 即走此路:结构与 Telegram 同构,先灰、内容就绪即上。</li>
</ul>
<h2>数据模型(DB 配置)· 全渠道统一一张表</h2>
<p><b>一张表覆盖所有联系方式</b> —— Telegram 频道/群组/Bot、LINE、邮箱、发卡商店都是 <code>contact_link</code> 里的一行,
差别只在 <code>platform</code>(分到哪个 L1 卡)+ <code>kind</code>(二级分到哪组 &amp; 动作样式)+ <code>url</code> 协议。
一行 = 一条可点链接;L1 按 <code>platform</code> 聚合,L2 按 <code>kind</code> 分组。</p>
<pre>contact_link
─────────────────────────────────────────────────────────────
id INTEGER PK
platform TEXT -- telegram | line | email | store | whatsapp | ...(L1 分组键)
kind TEXT -- channel | group | bot | link(L2 分组键 & 动作样式)
title TEXT -- "穿山甲 · 官方频道" / "邮箱客服" / "自助发卡商店"
handle TEXT -- "@PangolinVPN"(展示;可空,邮箱/商店留空)
url TEXT -- 点击目标(见下表);App 侧对 telegram 优先转 tg://
description TEXT -- 一句说明(可空)
verified INTEGER -- 0/1 认证勾
sort_order INTEGER -- 组内排序
enabled INTEGER -- 0/1 下线开关
locale TEXT -- "zh"|"en"|NULL(全部) 可选按语言过滤
</pre>
<p><b>邮箱之类怎么进这张表 —— 就是把 <code>url</code> 换个协议、<code>kind=link</code></b></p>
<table>
<tr><th>渠道</th><th>platform</th><th>kind</th><th>url 示例</th><th>动作</th></tr>
<tr><td>Telegram 频道</td><td><code>telegram</code></td><td><code>channel</code></td><td><code>https://t.me/PangolinVPN</code>App 转 <code>tg://</code></td><td>打开</td></tr>
<tr><td>Telegram 群组</td><td><code>telegram</code></td><td><code>group</code></td><td><code>https://t.me/PangolinChat</code></td><td>加入</td></tr>
<tr><td>Telegram Bot</td><td><code>telegram</code></td><td><code>bot</code></td><td><code>https://t.me/PangolinVPN_bot</code></td><td>打开</td></tr>
<tr><td>LINE 账号</td><td><code>line</code></td><td><code>link</code></td><td><code>https://line.me/R/ti/p/@pangolinvpn</code></td><td>打开</td></tr>
<tr><td>邮箱客服</td><td><code>email</code></td><td><code>link</code></td><td><code>mailto:support@pangolin.vpn</code></td><td>打开(拉起邮件)</td></tr>
<tr><td>自助发卡商店</td><td><code>store</code></td><td><code>link</code></td><td><code>https://shop.pangolin.vpn</code></td><td>打开(浏览器)</td></tr>
<tr><td>WhatsApp(将来)</td><td><code>whatsapp</code></td><td><code>link</code></td><td><code>https://wa.me/…</code></td><td>打开</td></tr>
</table>
<p class="small">动作按钮文案由 <code>kind</code> 决定:<code>group</code><b>「加入」</b>(实心强调);其余(<code>channel/bot/link</code>)→ <b>「打开」</b>(描边)。
点击一律「用系统方式打开 <code>url</code>」,App 侧仅对 <code>telegram</code> 平台做 <code>https://t.me/x → tg://resolve?domain=x</code> 的唤起优化,失败回退原 url。</p>
<p class="small"><b>唯一不入库的是「皮」</b>L1 平台卡的<b>图标 / 强调色 / 默认显示名</b>由前端一个小 <b>platform 注册表</b><code>platform</code> 键内置
<code>telegram</code>=✈+强调色、<code>line</code>=💬、<code>email</code>=✉、<code>store</code>=🛍…),避免把图标资源塞进数据库;未知 platform 用通用图标兜底。
库里只配<b>内容</b>。加已有平台的新频道/群 = 纯 DB,无需发版;加一个全新平台类型(新图标)= 注册表加一行 + 发版。</p>
<h2>接口</h2>
<pre>GET /v1/contact # 无需登录亦可(客户端普通请求)
→ 200
{
"platforms": [
{ "platform":"telegram",
"links":[
{"kind":"channel","title":"穿山甲 · 官方频道","handle":"@PangolinVPN",
"url":"https://t.me/PangolinVPN","description":"产品更新与公告","verified":true},
{"kind":"group","title":"穿山甲 · 用户交流群","handle":"@PangolinChat",
"url":"https://t.me/PangolinChat","description":"使用问题互助"},
{"kind":"bot","title":"客服机器人","handle":"@PangolinVPN_bot",
"url":"https://t.me/PangolinVPN_bot","description":"自动答疑/提交工单"}
]},
{ "platform":"line", "links":[ ... ] },
{ "platform":"email", "links":[{"kind":"link","title":"邮箱客服","url":"mailto:support@pangolin.vpn"}] },
{ "platform":"store", "links":[{"kind":"link","title":"自助发卡商店","url":"https://shop.pangolin.vpn"}] }
]
}</pre>
<ul>
<li>后端按 <code>enabled=1</code> 过滤、按 <code>sort_order</code> 排序、按 <code>platform</code><code>kind</code> 分组返回;客户端只渲染。</li>
<li>客户端<b>缓存</b>上次结果(离线/弱网仍可展示),启动或进联系页时后台刷新。</li>
<li>迁移:<code>server/migrations/{mysql,sqlite}/</code> 各加建表 + 种子数据(把现有 4 渠道灌入,Telegram 先补真实频道/群)。</li>
</ul>
<h2>已定(本轮拍板)</h2>
<ul>
<li><span class="tag ok"></span> <b>不展示成员数</b> —— 已从模型与 UI 移除 <code>member_hint</code></li>
<li><span class="tag ok"></span> 群组动作叫 <b>「加入」</b><code>kind=group</code>,实心强调);频道/Bot/普通链接 <b>「打开」</b>(描边)。</li>
<li><span class="tag ok"></span> 接口走 <b>独立 <code>GET /v1/contact</code></b>(不并进 <code>/me</code> 引导)。</li>
<li><span class="tag ok"></span> <b>全渠道统一一张 <code>contact_link</code></b>:邮箱/发卡/LINE 与 Telegram 同表,靠 <code>url</code> 协议区分(<code>mailto:</code> / <code>https:</code> / <code>tg://</code>)。</li>
<li><span class="tag ok"></span> <b>LINE 也做二级</b>(与 Telegram 同构),但<b>先灰置</b>——L1 上 LINE 卡置灰 + 「即将开放」,不可点进;二级页结构预留,等运营在库里配好 LINE 链接、去灰即用。</li>
</ul>
<h2>仍可再定(不阻塞,先给默认)</h2>
<ul>
<li><b>认证勾 <span class="p-verify" style="color:var(--accent)"></span> 与分组标题</b>:默认<b>保留</b>(频道/群组/Bot 分组 + 官方项带勾)。若想更简可拍平成单列表 —— 说一声即可。</li>
<li><b>成员数字段是否保留在库里(仅不展示)</b>:默认<b>删列</b>,需要时再加回不迟。</li>
</ul>
</div>
</body>
<script>
function _hideAll(){ ['view-l1','view-tg','view-line'].forEach(id=>document.getElementById(id).classList.add('hide')); }
function showList(){ _hideAll(); document.getElementById('view-l1').classList.remove('hide'); document.getElementById('p-title').textContent='联系我们'; }
function showTg(){ _hideAll(); document.getElementById('view-tg').classList.remove('hide'); document.getElementById('p-title').textContent='Telegram'; }
function showLine(){ _hideAll(); document.getElementById('view-line').classList.remove('hide'); document.getElementById('p-title').textContent='LINE'; }
</script>
</html>
+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>
+25
View File
@@ -44,6 +44,16 @@
</div>
<h2>设计方案 / Specs</h2>
<a class="doc" href="cicd-design.html">
<div class="t">CI/CD 全流程(tag 触发编译/发版/部署)<span class="tag html">HTML</span></div>
<div class="d">#30。参考 jiu 的 scripts/ci + .gitea/workflowstag 触发(site-v*/server-v*/client-v*)→ 编译 → 测试 → Gitea release → 部署。runner 混合(nas=官网+服务端容器化 / mac=Android+macOS / windows=Windows)。服务端部署固化 F3/F4「备份→migrate→换二进制→重启→健康检查+回滚」;官网部署 pangolin.yanmeiai.com;客户端 apk/dmg/exe 挂 release 喂官网下载链接。密钥作用域:Apple/token 账户级、部署 key/Android keystore 仓库级。范围 A~F(排除 iOS/#26/#25)。</div>
<div class="path">docs/cicd-design.html · 真相源 docs/superpowers/specs/2026-07-05-cicd-design.md</div>
</a>
<a class="doc" href="contact-telegram-channels-design.html">
<div class="t">联系我们 · 渠道二级页(Telegram 频道/群组,DB 配置)<span class="tag html">HTML</span></div>
<div class="d">点 Telegram 进二级页,按 kind 分组(频道/群组/Bot)列多条链接,含 @handle/说明/认证/「打开·加入」动作,全渠道(含邮箱/发卡)统一一张 contact_link 表(靠 url 协议区分 mailto/https/tg)。独立 GET /v1/contact。规则:平台 >1 条链接才进二级,否则点击直达。LINE 同构但先灰置「即将开放」(registry comingSoon 开关,配好即去灰)。不展示成员数、群组=加入。含可点原型。</div>
<div class="path">docs/contact-telegram-channels-design.html</div>
</a>
<a class="doc" href="free-quota-ad.html">
<div class="t">免费版 10 分钟卡控 + 累加式看广告加时 <span class="tag html">HTML</span></div>
<div class="d">免费版真卡控(账户级/全设备共享):连接期倒计时 + 到点自动切断 + 耗尽按钮灰化 + 点击弹广告看完 +10 分钟(累加,每日封顶 120)。桌面硬 10 分钟不可延。服务端 ad_bonus_minutes 累加模型 + ConnectNode 按 remaining 卡控 + TTL 硬切断;占位 DevVerifier。#21。</div>
@@ -76,6 +86,16 @@
</a>
<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">
<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="path">docs/cicd-plan.html · 真相源 docs/superpowers/plans/2026-07-05-cicd.md</div>
</a>
<a class="doc" href="device-session-management-plan.html">
<div class="t">设备 &amp; 会话管理 + 每设备流量归因 实现计划(P1–P6)<span class="tag html">HTML</span></div>
<div class="d">阅读版;执行真相源为 <code>docs/superpowers/plans/2026-06-29-device-session-management.md</code>(含 checkbox)。P1 设备注册打通 → P2 sessions表+在线/最后登录 → P3 强制退出/清除 → P4 每设备流量 → P5 2FA信任(future) → P6 UI重做。</div>
@@ -103,6 +123,11 @@
</a>
<h2>知识库 / 调研</h2>
<a class="doc" href="code-review-2026-07.html">
<div class="t">全栈设计审查 2026-07(前端/后端/数据库)<span class="tag html">HTML</span></div>
<div class="d">核心链路精读式审查,13 项发现分 P0/P1/P2:明文 HTTP、SQLite 零备份(P0);同机换账号 403 死结、disconnect 撤错凭证、用量取走即焚、Redis 重启全员掉线、argon2id OOM(P1);留存/UTC 日界/三时钟口径等(P2)。附「做得好的」与处理顺序建议。</div>
<div class="path">docs/code-review-2026-07.html</div>
</a>
<a class="doc" href="dev-conventions.html">
<div class="t">开发规范 · 可测试性五支柱 <span class="tag html">HTML</span></div>
<div class="d">「怎么写才好测」——开发规范作为可测试性前置条件。五支柱(接缝即接口/契约单源/纯逻辑分离/错误是值/可观测)+ 支柱↔测试层咬合矩阵图 + 反例→真实bug→对应支柱对照表。与测试框架文档咬合。</div>
+193
View File
@@ -0,0 +1,193 @@
# Pangolin CI/CD 全流程 Implementation Plan#30
> **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:** tag 触发的「编译 → 测试 → 发版(Gitea release)→ 部署」全自动流水线,覆盖官网 + 服务端 + Android/macOS/Windows 客户端。
**Architecture:** 镜像 jiu 的 `scripts/ci/*.sh`(逻辑)+ `.gitea/workflows/*.yml`(编排)。逻辑放脚本便于本地复现,工作流只调脚本。runner 混合:nas(官网+服务端,容器化 `node:20`/`golang:1.25`)、mac(Android+macOS)、windows(Windows)。
**Tech Stack:** Gitea Actions(act_runner,host-mode)、Bash、Astro/Node、Go 交叉编译、Flutter、gomobile libbox、Xcode notarytool、Inno Setup、Forgejo release API。
## Global Constraints
- 参考真相源:`docs/superpowers/specs/2026-07-05-cicd-design.md`;jiu 的 `~/code/jiu/.gitea/workflows/*` + `~/code/jiu/scripts/ci/*`(proven,copy+adapt)。
- runner label:`nas` / `mac` / `windows`;nas 上每 job `docker run` 官方镜像(`node:20``golang:1.25`),不装宿主工具链。
- 国内镜像:`GOPROXY=https://goproxy.cn,direct`;`PUB_HOSTED_URL=https://pub.flutter-io.cn`;`FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn`
- Secrets(已建,命名对齐 jiu):账户级 `FORGEJO_TOKEN`/`FORGEJO_URL`/`MACOS_DEVELOPER_ID_CERT_P12_BASE64`/`MACOS_DEVELOPER_ID_CERT_PASSWORD`/`APPSTORE_API_KEY_P8_BASE64`/`APPSTORE_API_KEY_ID`/`APPSTORE_API_ISSUER_ID`;pangolin 仓库级 `DEPLOY_SSH_KEY`/`ANDROID_KEYSTORE_BASE64`/`ANDROID_KEYSTORE_PASSWORD`/`ANDROID_KEY_ALIAS`/`ANDROID_KEY_PASSWORD`/`MACOS_APP_PROVISION_PROFILE_BASE64`/`MACOS_SYSEXT_PROVISION_PROFILE_BASE64`
- 客户端铁律:macOS 每次构建递增 `CURRENT_PROJECT_VERSION`;Android NDK≥28、gomobile JDK17、libbox 包名 `io.nekohasekai.libbox`
- Bash:禁 `$()` 命令替换(拆分/管道);提交 footer 带 `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`
- **CI 脚本的「测试」= `workflow_dispatch` 手动触发跑一遍 + 观察产物/部署结果**(非经典单元 TDD);每条流水线先手动 dispatch 跑通再依赖 tag。
- 部署机 pangolin1 别名 `pangolin1`(103.119.13.48);官网域名 `pangolin.yanmeiai.com`
---
# Phase 1 —— 基座 + 官网 + 服务端(无签名,可立即上线)
### Task 1: `scripts/ci/` 基座(env + forgejo 库 + 通知)
**Files:**
- Create: `scripts/ci/_env.sh``scripts/ci/lib-forgejo.sh``scripts/ci/notify.sh`
- 参照:`~/code/jiu/scripts/ci/_env.sh``~/code/jiu/scripts/ci/lib-forgejo.sh``~/code/jiu/scripts/ci/notify.sh`
**Interfaces:**
- Produces:`_env.sh` 导出 `GOPROXY`/`PUB_HOSTED_URL`/`FLUTTER_STORAGE_BASE_URL` + `ver_from_tag <prefix> <ref>`(解析 `server-v1.2.3``1.2.3`);`lib-forgejo.sh` 提供 `forgejo_release_ensure <tag> <title>``forgejo_upload_asset <tag> <file>`(用 `FORGEJO_TOKEN`/`FORGEJO_URL`,curl+API);`notify.sh` 提供 `notify_ok`/`notify_fail`
- [ ] Step 1:抄 jiu 三个脚本到 `scripts/ci/`,把 ali/jiu 专属值替换为 pangolin(仓库名、域名);`ver_from_tag``${ref#refs/tags/${prefix}-v}` 参数展开(不 `$()`)。
- [ ] Step 2:`chmod +x scripts/ci/*.sh`;本地 `bash -n` 语法检查每个脚本。Expected:无输出(语法 OK)。
- [ ] Step 3:`shellcheck scripts/ci/*.sh`。Expected:0 告警(或仅可接受的 info)。
- [ ] Step 4:Commit `feat(ci): scripts/ci 基座(_env/lib-forgejo/notify)`
### Task 2: checks 工作流(保留现有)
**Files:** Modify(可选 rename): `.gitea/workflows/ci.yml`
- [ ] Step 1:确认现有 `ci.yml`(nas,shellcheck/openapi/redline/flutter analyze+test/go test)仍覆盖需求;把新增的 `scripts/ci/*.sh` 纳入 shellcheck job 的扫描路径。
- [ ] Step 2:push 一个无关小改到分支,观察 checks 全绿。Expected:所有 job pass。
- [ ] Step 3:Commit(若有改动)`ci(checks): shellcheck 覆盖 scripts/ci`
### Task 3: 官网发布(compile + deploy + workflow)
**Files:**
- Create: `scripts/ci/compile-site.sh``scripts/ci/deploy-site.sh``.gitea/workflows/deploy-site.yml`
- 参照:`~/code/jiu/scripts/ci/compile-site.sh``deploy-site.sh``.gitea/workflows/deploy-site.yml`
**Interfaces:**
- Consumes:`_env.sh`;secret `DEPLOY_SSH_KEY`
- Produces:`pangolin.yanmeiai.com` 静态站上线。
**前置(基础设施,需先做 / 确认——改机器前问用户):**
- pangolin1 上装 nginx(或 caddy),配 `pangolin.yanmeiai.com` vhost,web 根如 `/var/www/pangolin-site`
- CF DNS:`pangolin.yanmeiai.com` A/CNAME → 103.119.13.48(用 `cf-api`,记 baize)。
- TLS:先 HTTP 起,证书并入 #25 或用 CF proxy 橙云。
- [ ] Step 1:写 `compile-site.sh` —— 在 `node:20` 容器内 `cd web/website && npm ci && SITE_URL=https://pangolin.yanmeiai.com npm run build`,产物 `web/website/dist/`
- [ ] Step 2:写 `deploy-site.sh` —— 用 `DEPLOY_SSH_KEY` 起 ssh agent,`rsync -az --delete web/website/dist/ pangolin1:/var/www/pangolin-site/`;远端 `nginx -s reload` 非必需(静态文件即时生效)。
- [ ] Step 3:写 `deploy-site.yml` —— `on.push.tags: ['site-v[0-9]*.[0-9]*.[0-9]*']` + `workflow_dispatch`;`runs-on: nas`;并发组 `deploy-site`;steps: checkout → `docker run --rm -v $PWD:/w -w /w node:20 bash scripts/ci/compile-site.sh``bash scripts/ci/deploy-site.sh`
- [ ] Step 4:**手动验证** —— `workflow_dispatch` 触发 deploy-site;`curl -I https://pangolin.yanmeiai.com/` 返回 200,页面 canonical 正确。Expected:站点可访问。
- [ ] Step 5:Commit `feat(ci): 官网 site-v* 构建+部署到 pangolin.yanmeiai.com`
### Task 4: 服务端发布(compile + release + deploy + workflow)
**Files:**
- Create: `scripts/ci/compile-backend.sh``scripts/ci/release-server.sh``scripts/ci/deploy-server.sh``.gitea/workflows/deploy-server.yml`
**Interfaces:**
- Consumes:`_env.sh``lib-forgejo.sh`;secret `DEPLOY_SSH_KEY``FORGEJO_TOKEN`/`FORGEJO_URL`
- Produces:pangolin1 上 `pangolin-server`/`pangolin-agent`/`pangolin-migrate` 更新到 tag 版本,migrate 已应用。
- [ ] Step 1:写 `compile-backend.sh` —— `golang:1.25` 容器内 `cd server && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o out/pangolin-server ./cmd/server`(同样出 agent、migrate);产物 `server/out/`
- [ ] Step 2:写 `release-server.sh` —— `forgejo_release_ensure "$TAG" "server $TAG"` + 逐个 `forgejo_upload_asset`
- [ ] Step 3:写 `deploy-server.sh`(固化 F3/F4 手动次序,**带回滚**):
```bash
#!/usr/bin/env bash
set -euo pipefail
DB=/var/lib/pangolin/pangolin.db; BIN=/usr/local/bin; TAG="$1"
scp server/out/pangolin-server server/out/pangolin-agent server/out/pangolin-migrate pangolin1:/tmp/
ssh pangolin1 "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
curl -fsS -m 10 --retry 5 --retry-connrefused http://103.119.13.48:8080/healthz >/dev/null && echo "healthz OK"
```
- [ ] Step 4:写 `deploy-server.yml` —— tag `server-v*` + dispatch;`runs-on: nas`;并发组 `deploy-server`;steps: checkout → compile(golang 容器)→ `test.sh server`(见 Task 5)→ `release-server.sh``deploy-server.sh $VER`
- [ ] Step 5:**手动验证** —— 打 tag `server-v0.0.1-ci`(或 dispatch)→ 观察:migrate 版本前进、`/healthz` 200、`sqlite3 nodes` 行数守恒(同 F3 核对法)。Expected:部署成功、无数据丢失。
- [ ] Step 6:Commit `feat(ci): 服务端 server-v* 编译+release+部署(备份/迁移/回滚)`
### Task 5: `scripts/ci/test.sh`
**Files:** Create `scripts/ci/test.sh`
- [ ] Step 1:`test.sh server``golang:1.25` 容器 `cd server && go test ./...`;`test.sh client``flutter test`(容器或 nas flutter)。
- [ ] Step 2:接入 deploy-server.yml 的 test 步骤;dispatch 跑通。Expected:go test 全绿才继续部署。
- [ ] Step 3:Commit `ci: test.sh(go test / flutter test)`
---
# Phase 2 —— D Android(解锁官网下载链接)
### Task 6: Android gradle 接 release 签名
**Files:** Modify `client/android/app/build.gradle(.kts)`、Create `client/android/keystore.properties`(gitignore,占位)
**Interfaces:** Consumes secrets `ANDROID_KEYSTORE_BASE64`/`ANDROID_KEYSTORE_PASSWORD`/`ANDROID_KEY_ALIAS`/`ANDROID_KEY_PASSWORD`
- [ ] Step 1:`build.gradle``signingConfigs.release`,从环境变量/`keystore.properties` 读 keystore 路径与三密码;`buildTypes.release.signingConfig = signingConfigs.release`。参照 jiu 的 android 签名接法。
- [ ] Step 2:本机用真 keystore(你已建)`flutter build apk --release` 验证签名生效:`apksigner verify --print-certs build/app/outputs/flutter-apk/app-release.apk` 显示 CN=Pangolin。Expected:release 签名(非 debug)。
- [ ] Step 3:Commit `build(android): release keystore 签名接线`
### Task 7: Android CI(compile + release + workflow)
**Files:** Create `scripts/ci/compile-android.sh``scripts/ci/release-client.sh``.gitea/workflows/build-android.yml`;参照 jiu `compile-android.sh`/`release-client.sh`/`deploy-client.yml`
- [ ] Step 1:`compile-android.sh` —— `bash scripts/build-libbox.sh android`(JDK17/NDK≥28)→ 把 secrets 落成 keystore 文件 + `keystore.properties``flutter build apk --release --split-per-abi --dart-define=PANGOLIN_API_URL=...` → 产物 `app-arm64-v8a-release.apk`
- [ ] Step 2:`release-client.sh` —— `forgejo_release_ensure client-$VER` + 上传该端资产(多端共用一个 `client-v*` release,各自 upload)。
- [ ] Step 3:`build-android.yml` —— tag `client-v*` + dispatch;`runs-on: mac`;steps:provision-mac → compile-android → release-client。
- [ ] Step 4:**手动验证** —— dispatch → release 出现 arm64 apk → 真机 `adb install -r` 成功、能连。Expected:签名 apk 可装可用。
- [ ] Step 5:Commit `feat(ci): Android client-v* 构建+release`
### Task 8: 官网下载链接接 Android release
**Files:** Modify `web/website/src/config/site.ts`(加 `downloads.android`)、`web/website/src/components/Download.astro`
- [ ] Step 1:`site.ts``downloads: { android: '<Forgejo latest-download 稳定 URL 或构建期烘焙>' }`;`Download.astro` android 按钮 `href={SITE.downloads.android}`。先探 Forgejo 是否支持 `/releases/latest/download/<asset>`;不支持则 `deploy-site.sh` 构建期用 `FORGEJO_TOKEN` 查最新 `client-v*` 版本注入。
- [ ] Step 2:重部署官网,点 Android 下载按钮落到最新 apk。Expected:下载可用。
- [ ] Step 3:Commit `feat(website): Android 下载按钮接 release 资产`;更新 todo 子任务 30A(Android 部分)。
---
# Phase 3 —— E macOS + F Windows
### Task 9: macOS CI(签名+公证 dmg)
**Files:** Create `scripts/ci/compile-macos.sh``.gitea/workflows/build-macos.yml`;参照 jiu `compile-macos.sh`(证书导入 + notarytool 部分可几乎直接复用)。
**Interfaces:** Consumes 账户级 Apple secrets + 仓库级 `MACOS_APP_PROVISION_PROFILE_BASE64`/`MACOS_SYSEXT_PROVISION_PROFILE_BASE64`
- [ ] Step 1:`compile-macos.sh` —— 建临时 keychain,`MACOS_DEVELOPER_ID_CERT_P12_BASE64` 解码导入(复用 jiu)→ 两个 provisioning profile 解码装入 `~/Library/MobileDevice/Provisioning Profiles/` → 递增 `CURRENT_PROJECT_VERSION` → Xcode Developer ID 构建 app+sysext(`scripts/local_test.sh build` 逻辑)→ `notarytool submit --key <p8> --key-id $APPSTORE_API_KEY_ID --issuer $APPSTORE_API_ISSUER_ID --wait``stapler` → 打 dmg。
- [ ] Step 2:`build-macos.yml` —— tag `client-v*` + dispatch;`runs-on: mac`;provision → compile-macos → release-client(上传 dmg)。
- [ ] Step 3:**手动验证** —— dispatch → dmg 出现 → 另一台 mac `spctl -a -vv` 通过、`stapler validate` OK、装上能连。Expected:公证 dmg 可分发。
- [ ] Step 4:Commit `feat(ci): macOS client-v* 签名+公证 dmg`;下载链接接 macOS。
### Task 10: Windows CI(exe/installer)
**Files:** Create `scripts/ci/compile-windows.sh`(或 .ps1)、`.gitea/workflows/build-windows.yml`;参照 jiu `compile-windows.sh`/`install-innosetup.ps1`/`build-windows.yml`
- [ ] Step 1:`compile-windows.sh` —— `flutter build windows --release --dart-define=PANGOLIN_API_URL=...` → Inno Setup 打安装包(`install-innosetup.ps1` 装 ISCC)。**先不做代码签名**(用户首装 SmartScreen 提示,可接受)。
- [ ] Step 2:`build-windows.yml` —— tag `client-v*` / `winbuild*` + dispatch;`runs-on: windows`;compile → release-client(上传 exe)。参考记忆:Windows 出包复制到桌面 pangolin 目录。
- [ ] Step 3:**手动验证** —— dispatch → installer 出现 → windows 机装上能连。Expected:安装包可用。
- [ ] Step 4:Commit `feat(ci): Windows client-v* 安装包`;下载链接接 Windows。
### Task 11: 下载链接全端闭环 + 文档
**Files:** Modify `web/website/src/config/site.ts`(downloads.macos/windows)、`docs/index.html`(若有)。
- [ ] Step 1:`site.ts.downloads` 补齐 macos/windows;Download.astro 三端按钮全部接 release 资产。
- [ ] Step 2:客户端发版触发官网重部署(`build-*` 成功后 dispatch `deploy-site`,或用 latest-download URL 免重建)。
- [ ] Step 3:**验证** —— 官网三端下载按钮均落到最新 release。Commit;关掉 todo 子任务 30A/30B。
---
## 验证(整体)
- 每条流水线先 `workflow_dispatch` 跑通再依赖 tag。
- 服务端:migrate 版本 + `/healthz` + 行数守恒。官网:站点可访问 + canonical + redline。客户端:各端资产可装可连。
- 下载链接:官网按钮落到最新 release 资产。
## 风险(见 spec §9)
nas 内存(容器化单 job 缓解)、migrate 生产出错(备份+回滚)、keystore/公证凭据(Bitwarden+不落盘)、下载链接刷新(build-* 触发 deploy-site 或 latest-download)。
## 不在本计划
iOS(G)、备份/容灾(#26)、TLS(#25,官网 TLS 先 CF 橙云或并入 #25)、Windows 代码签名、上架商店。
@@ -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)。
@@ -0,0 +1,145 @@
# Pangolin CI/CD 全流程 —— 设计方案(#30)
> 状态:设计定稿待审 · 日期 2026-07-05 · 范围 A~F(排除 iOS、备份#26、TLS#25)
## 1. 背景与目标
pangolin 现有 CI 仅 `.gitea/workflows/ci.yml`(nas,只校验无部署)+ `web/website/.gitea/workflows/website.yml`
服务端部署靠手动(F3/F4 那次我手动 scp+ssh+migrate),客户端出包靠本地脚本,官网未部署,
下载链接是死链。目标:**tag 触发的编译 → 测试 → 发版(Gitea release)→ 部署** 全自动,
参考 jiu 的 `.gitea/workflows` + `scripts/ci/*.sh` 结构,适配 pangolin 的部署目标与产物。
## 2. 范围
| 子块 | 内容 |
|---|---|
| A 基座 | `scripts/ci/*`(env/provision/test/release/notify/lib-forgejo)+ checks 保留 |
| B 官网 | Astro 构建 → 部署 `pangolin.yanmeiai.com` |
| C 服务端 | 交叉编译 server/agent/migrate → release → ssh pangolin1(备份→migrate→换二进制→重启→健康检查) |
| D Android | apk(arm64,release keystore 签名)→ release 资产 |
| E macOS | 公证 dmg(Developer ID + notarytool)→ release 资产 |
| F Windows | exe/installer(Inno Setup)→ release 资产 |
**排除**:iOS(G,未来)、SQLite 备份/容灾(#26)、控制面 TLS(#25)。
## 3. 已锁定决策
| 维度 | 决定 | 理由 |
|---|---|---|
| runner | nas=官网+服务端(容器化)· mac=Android+macOS · windows=Windows | nas 常在线且 Astro/Go 轻量(非 Flutter Web);mac/windows 做必须它们的活 |
| 触发 | tag `site-v*` / `server-v*` / `client-v*` + `manual.yml` 手动派发 | 同 jiu,发版即部署,可手动重放 |
| macOS 签名 | mac runner 自动 Developer ID 签名 + notarytool 公证 + staple | 凭据入 Gitea secret(见 §7) |
| Android 签名 | 正式 release keystore | app 级专属签名身份 |
| 下载链接 | 官网 href 指向 Gitea release 资产的稳定 URL | 发版即更新,见 §6 |
| 镜像 | GOPROXY=goproxy.cn、PUB_HOSTED_URL/FLUTTER_STORAGE_BASE_URL=flutter-io.cn | 国内网络 |
## 4. 架构
### 4.1 共享基座 `scripts/ci/`(镜像 jiu)
- `_env.sh` —— 公共环境(镜像源、路径、版本号解析 `${tag#prefix-v}`)
- `lib-forgejo.sh` —— Gitea/Forgejo release 建/查 + 资产上传(用 `FORGEJO_TOKEN`)
- `provision-mac.sh` —— mac 幂等装 flutter / xcode-select / gomobile / Android NDK+JDK17
- `test.sh <server|client>` —— `go test` / `flutter test`
- `notify.sh` —— 成功/失败 Telegram 通知(可选,复用节点监控 bot)
- `compile-site.sh` / `compile-backend.sh` / `compile-android.sh` / `compile-macos.sh` / `compile-windows.sh`
- `deploy-site.sh`(wrangler → CF Pages)/ `deploy-server.sh`(ssh pangolin1,复用 lib-ssh)
- `release-<x>.sh` —— 建 release + 挂产物
> 每个 `compile-*` 内部封装该端已验证的构建命令(如 Android 走
> `scripts/build-libbox.sh android` + `flutter build apk --split-per-abi`;macOS 走
> Xcode Developer ID 签名 + `notarytool submit --wait` + `stapler`)。工作流只调脚本,
> 逻辑在脚本里,便于本地复现。
### 4.2 工作流 `.gitea/workflows/`
| 工作流 | 触发 | runner | 步骤 |
|---|---|---|---|
| `checks.yml`(现 ci.yml) | push 分支 | nas | 保留:shellcheck / openapi / redline / flutter analyze+test / go test |
| `deploy-site.yml` | `site-v*` | nas | `node:20` 容器构建 Astro(`SITE_URL` 注入)→ `deploy-site.sh` |
| `deploy-server.yml` | `server-v*` | nas | `golang:1.25` 交叉编译 → `test.sh server``release-server.sh``deploy-server.sh` |
| `build-android.yml` | `client-v*` | mac | provision → `compile-android.sh`(签名 apk)→ `release-client.sh` |
| `build-macos.yml` | `client-v*` | mac | provision → `compile-macos.sh`(签名+公证 dmg)→ `release-client.sh` |
| `build-windows.yml` | `client-v*` / `winbuild*` | windows | `compile-windows.sh`(exe/installer)→ `release-client.sh` |
并发组按 jiu:`deploy-site` / `deploy-server` / `deploy-client` 各自 `cancel-in-progress: false`
### 4.3 服务端部署(deploy-server.sh)—— 把手动那套固化
复刻 F3/F4 手动部署的安全次序(带回滚):
1. scp `pangolin-server` / `pangolin-agent` / `pangolin-migrate` 到 pangolin1 `/tmp`
2. `systemctl stop pangolin-server`
3. `sqlite3 wal_checkpoint(TRUNCATE)``cp` 备份 `pangolin.db.bak-pre-<tag>`
4. `pangolin-migrate up`(以 pangolin 用户);**失败即恢复备份 + 重启旧 server + 退出非零**
5. `install` 新二进制到 `/usr/local/bin`(旧的备份为 `.bak-<tag>`)
6. `systemctl start pangolin-server` + `/healthz` 健康检查;agent 随连接自恢复
### 4.4 官网部署(deploy-site.sh)—— Cloudflare Pages
> **架构变更(2026-07-06 实施):** 原计划 rsync 到 pangolin1 的 nginx。但节点 :443 被 sing-box
> (VPN 数据面)占用,而 CF 免费套餐 proxied 回源只能打 :80/:443、改回源端口需 Enterprise ——
> 无法在同机同 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)
`web/website/src/config/site.ts``downloads: { android, macos, windows }`,值为 Gitea release 的
**稳定 latest 资产 URL**(Forgejo 支持 `…/releases/latest/download/<asset>` 则直接用;
不支持则 `deploy-site.sh` 构建期用 `FORGEJO_TOKEN` 查最新 `client-v*` release 版本、烘焙进 href)。
`Download.astro` 各平台按钮读 `SITE.downloads.<platform>`。客户端发版后官网重部署即刷新
(或 `build-*` 完成触发 `deploy-site`)。
## 6. 密钥与作用域(solo / wangjia,命名对齐 jiu 以共用)
| Secret | 作用域 | 说明 |
|---|---|---|
| `FORGEJO_TOKEN` / `FORGEJO_URL` | 账户级(wangjia) | 建 release + 传产物,jiu 复用 |
| `MACOS_DEVELOPER_ID_CERT_P12_BASE64` / `MACOS_DEVELOPER_ID_CERT_PASSWORD` | 账户级 | Developer ID 证书(账号级),与 jiu 共用;续期改一处。证书在钥匙串,导出一次 .p12 |
| `APPSTORE_API_KEY_P8_BASE64` / `APPSTORE_API_KEY_ID` / `APPSTORE_API_ISSUER_ID` | 账户级 | 公证 API key(KEY_ID=`3PZTHR8YMJ`),与 jiu 同一把,`.p8` 现成 |
| `DEPLOY_SSH_KEY` | pangolin 仓库级 | 授权到 pangolin1,最小权限 |
| `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD` | pangolin 仓库级 | Android app 级专属签名(**pangolin 自己的 keystore,不复用 jiu**) |
| `MACOS_APP_PROVISION_PROFILE_BASE64` / `MACOS_SYSEXT_PROVISION_PROFILE_BASE64` | pangolin 仓库级 | 主 app + PacketTunnel sysext 描述文件(pangolin bundle 专属,签名期落盘嵌入) |
命名对齐 jiu(`MACOS_*`/`APPSTORE_*`/`ANDROID_*`):**Apple 那套放账户级 → jiu/pangolin 共用一份**,
compile-macos 脚本可复用 jiu 的;Android keystore 虽同命名规范但**各 app 独立、不共享**。
工作流用 `secrets.XXX` 引用,作用域对写法透明。
## 7. 实现顺序(单仓库内分阶段落地)
范围虽是 A~F,实现按风险/依赖递增:
1. **A 基座** + `checks` 迁移(`ci.yml``checks.yml` 复用现有,抽 `scripts/ci` 骨架)
2. **B 官网**(最简,验证 release/deploy 骨架跑通)
3. **C 服务端**(固化手动部署,告别手动)
4. **D Android**(解锁下载链接;需 keystore 就绪 + gradle 接签名)
5. **E macOS**(最复杂:证书+2 描述文件+公证)
6. **F Windows**(windows runner + Inno Setup)
每阶段独立可发、独立验收。
## 8. 验证
- 每条流水线先 `workflow_dispatch` 手动跑通、产物/部署核对,再依赖 tag。
- 服务端:`server-v*` → 看 pangolin1 migrate 版本 + `/healthz` + 行数守恒(同 F3 部署核对)。
- 官网:`site-v*``pangolin.yanmeiai.com` 可访问 + canonical 正确 + redline 扫描。
- 客户端:release 资产可下载安装(Android 侧载 / macOS 公证校验 `spctl` / Windows 安装)。
- 下载链接:官网按钮点击落到最新 release 资产。
## 9. 风险与缓解
| 风险 | 缓解 |
|---|---|
| nas 内存(3.8G)构建 OOM | 容器化单 job、Astro/Go 轻量;必要时该端移 mac |
| migrate 在生产出错 | 部署前备份 + 失败自动回滚(§4.3),已在 F3/F4 手动验证 |
| Android keystore 丢失 | 存 Bitwarden(文件+密码);终身签名身份 |
| macOS 公证凭据泄露 | 账户级 secret,不落盘;`.p8`/`.p12` 用完即删临时文件 |
| 客户端发版后下载链接不刷新 | `build-*` 成功触发 `deploy-site` 重烘焙,或用 latest-download 稳定 URL |
## 10. 不在本方案
iOS 流水线(G)、SQLite 备份/容灾(#26)、TLS(#25)、Android/上架 Play、Windows 代码签名(先不签)。
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# _env.sh — shared CI mirror env + tag-version helper. `source` this from other
# scripts; do not execute directly. Idempotent: only sets vars if not already
# provided by the environment (e.g. workflow-level overrides).
export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
export PUB_HOSTED_URL="${PUB_HOSTED_URL:-https://pub.flutter-io.cn}"
export FLUTTER_STORAGE_BASE_URL="${FLUTTER_STORAGE_BASE_URL:-https://storage.flutter-io.cn}"
# Forgejo/Gitea repo coordinates (used by lib-forgejo.sh). Secrets
# FORGEJO_TOKEN / FORGEJO_URL are injected by the CI runner, not set here.
export FORGEJO_REPO="${FORGEJO_REPO:-wangjia/pangolin}"
# Ensure Homebrew tools are on PATH (macOS runners); harmless elsewhere.
case ":${PATH}:" in
*":/opt/homebrew/bin:"*) ;;
*) export PATH="/opt/homebrew/bin:${PATH}" ;;
esac
# ver_from_tag <prefix> <ref>
# Strips an optional "refs/tags/" prefix, then the "<prefix>-v" prefix,
# leaving a bare semver. Uses parameter expansion only (no command
# substitution). Examples:
# ver_from_tag server server-v1.2.3 -> 1.2.3
# ver_from_tag server refs/tags/server-v1.2.3 -> 1.2.3
ver_from_tag() {
local prefix="$1" ref="$2"
ref="${ref#refs/tags/}"
ref="${ref#"${prefix}"-v}"
printf '%s' "$ref"
}
+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"
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bash
# lib-forgejo.sh — Forgejo/Gitea release API helpers shared by the pangolin
# release pipelines (website / server / client). `source` this after _env.sh.
#
# Provides:
# forgejo_release_ensure <tag> <title> -> looks up or creates the release
# for <tag>; sets/exports RELEASE_ID
# forgejo_upload_asset <tag> <file> -> uploads one asset to that release
#
# Requires env: FORGEJO_URL, FORGEJO_TOKEN, FORGEJO_REPO (see _env.sh for the
# default FORGEJO_REPO=wangjia/pangolin).
#
# No command substitution ($()) is used anywhere: HTTP status codes and JSON
# fields are written to temp files by curl/python3 and read back with `read`.
#
# TLS verification is ON by default. Opt-outs (env-driven, resolved once below
# into the FORGEJO_CURL_TLS array and spliced into every curl call):
# FORGEJO_CA_BUNDLE=<path> -> verify against this CA bundle (--cacert)
# FORGEJO_INSECURE=1 -> disable verification (-k), for self-signed
# internal CAs only; explicit opt-in required.
: "${FORGEJO_REPO:=wangjia/pangolin}"
FORGEJO_CURL_TLS=()
if [ -n "${FORGEJO_CA_BUNDLE:-}" ]; then
FORGEJO_CURL_TLS=(--cacert "$FORGEJO_CA_BUNDLE")
else
case "${FORGEJO_INSECURE:-}" in
1 | true | yes)
FORGEJO_CURL_TLS=(-k)
;;
*)
FORGEJO_CURL_TLS=()
;;
esac
fi
# forgejo_release_ensure <tag> <title>
forgejo_release_ensure() {
local tag="$1" title="$2"
local get_code_file get_body_file get_code
get_code_file="/tmp/forgejo_get_code.$$"
get_body_file="/tmp/forgejo_get_body.$$.json"
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}" \
"${FORGEJO_URL}/api/v1/repos/${FORGEJO_REPO}/releases/tags/${tag}" \
> "$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"
if [ "$get_code" = "200" ]; then
echo "==> forgejo: release ${tag} already exists"
_forgejo_read_release_id "$get_body_file"
rm -f "$get_body_file"
return 0
fi
rm -f "$get_body_file"
echo "==> forgejo: creating release ${tag}"
local create_code_file create_body_file create_code create_req_file
create_code_file="/tmp/forgejo_create_code.$$"
create_body_file="/tmp/forgejo_create_body.$$.json"
create_req_file="/tmp/forgejo_create_req.$$.json"
# 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" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
--data @"$create_req_file" \
> "$create_code_file"
read -r create_code < "$create_code_file" || true # 无结尾换行,见 forgejo_release_ensure 注释
rm -f "$create_code_file" "$create_req_file"
if [ "$create_code" -lt 200 ] || [ "$create_code" -ge 300 ]; then
echo "==> forgejo: release create FAILED (HTTP ${create_code})" >&2
cat "$create_body_file" >&2
rm -f "$create_body_file"
return 1
fi
_forgejo_read_release_id "$create_body_file"
rm -f "$create_body_file"
echo "==> forgejo: release_id=${RELEASE_ID}"
}
# _forgejo_read_release_id <json_file> — internal: sets/exports RELEASE_ID.
_forgejo_read_release_id() {
local json_file="$1" id_file
id_file="/tmp/forgejo_release_id.$$"
python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['id'])" \
"$json_file" > "$id_file"
read -r RELEASE_ID < "$id_file" || true # python 写入可能无结尾换行,见上注释
rm -f "$id_file"
export RELEASE_ID
}
# forgejo_upload_asset <tag> <file>
forgejo_upload_asset() {
local tag="$1" file="$2"
if [ -z "${RELEASE_ID:-}" ]; then
forgejo_release_ensure "$tag" "$tag" || return 1
fi
local code_file body_file code
code_file="/tmp/forgejo_upload_code.$$"
body_file="/tmp/forgejo_upload_body.$$.json"
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" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-F "attachment=@${file}" \
> "$code_file"
read -r code < "$code_file" || true # curl http_code 无结尾换行,见上注释
rm -f "$code_file"
echo "==> forgejo: uploaded ${file} (HTTP ${code})"
if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then
cat "$body_file" >&2
rm -f "$body_file"
return 1
fi
rm -f "$body_file"
}
+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
# notify.sh — Telegram CI notifications. `source` this from other scripts.
#
# Provides:
# notify_ok <msg> -> send a "success" notification
# notify_fail <msg> -> send a "failure" notification
#
# Uses the same TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID convention as
# deploy/bootstrap/monitor/pangolin-monitor.sh. Safe no-op (never fails the
# pipeline) when those are unset, or when the Telegram API call itself fails.
_notify_send() {
local text="$1"
if [ -z "${TELEGRAM_BOT_TOKEN:-}" ] || [ -z "${TELEGRAM_CHAT_ID:-}" ]; then
echo "==> notify: (skipped, no TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID) ${text}"
return 0
fi
curl -fsS --max-time 15 \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \
--data-urlencode "text=${text}" \
--data "disable_web_page_preview=true" >/dev/null 2>&1 \
|| echo "==> notify: Telegram 发送失败(已忽略,不影响流水线)" >&2
}
# notify_ok <msg>
notify_ok() {
local msg="$1"
_notify_send "✅ Pangolin CI: ${msg}"
}
# notify_fail <msg>
notify_fail() {
local msg="$1"
_notify_send "❌ Pangolin CI: ${msg}"
}
+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} 通过"
+6 -4
View File
@@ -35,7 +35,9 @@ DIR="${SRC%/*}"; [ "$DIR" = "$SRC" ] && DIR="."
cd "$DIR/.."; REPO_ROOT="$PWD"
CLIENT="$REPO_ROOT/client"
APP="$CLIENT/build/macos/Build/Products/Release/pangolin_vpn.app"
SE="$APP/Contents/Library/SystemExtensions/PacketTunnel.systemextension"
# sysext bundle 名 = 标识符(PRODUCT_NAME=com.pangolin.pangolin.PacketTunnel,见 CLAUDE.md),
# 不是短名 PacketTunnel.systemextension。
SE="$APP/Contents/Library/SystemExtensions/com.pangolin.pangolin.PacketTunnel.systemextension"
LIBFW="$SE/Contents/Frameworks/Libbox.framework"
PROF_DIR="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
WORK="${TMPDIR:-/tmp}/pangolin_local_test"; mkdir -p "$WORK"
@@ -112,9 +114,9 @@ cmd_sign(){
cp "$app_prof" "$APP/Contents/embedded.provisionprofile"
cp "$se_prof" "$SE/Contents/embedded.provisionprofile"
# 内向外:Libbox 真二进制 → Libbox.framework → sysext → app 各 framework → app 主体
cs -s "$SIGN_ID" "$LIBFW/Versions/A/Libbox"
cs -s "$SIGN_ID" "$LIBFW"
# 内向外:sysext → app 各 framework → app 主体
# libbox 现为「只 Link 不 Embed」静态进 sysext 二进制(见 CLAUDE.md),sysext 内已无
# 独立 Libbox.framework;签 sysext bundle 即覆盖其静态链接的 libbox,无需单独签 framework。
cs --entitlements "$WORK/sysext.entitlements" -s "$SIGN_ID" "$SE"
local item
for item in "$APP/Contents/Frameworks/"*; do
+7
View File
@@ -54,6 +54,10 @@ type Config struct {
// SingboxConfigPath is where the rendered sing-box config is written.
SingboxConfigPath string
// WarpConfigPath 指向节点本地的 WARP 分流配置(默认 <StateDir>/warp.json)。
// 文件不存在 = WARP 未启用。渲染时读取,支持编辑后重启 agent 生效(#29)。
WarpConfigPath string
// DeriveKey keys the Hy2 password derivation (see DeriveHy2Password).
DeriveKey string
@@ -79,6 +83,9 @@ func (c Config) withDefaults() Config {
if c.SingboxConfigPath == "" {
c.SingboxConfigPath = DefaultSingboxCfg
}
if c.WarpConfigPath == "" {
c.WarpConfigPath = filepath.Join(c.StateDir, "warp.json")
}
if c.HeartbeatInterval == 0 {
c.HeartbeatInterval = DefaultHeartbeatInterval
}
+14 -2
View File
@@ -21,13 +21,17 @@ const (
clashAPIAddr = "127.0.0.1:19090"
clashAPISecret = "pangolin-local-stats"
v2rayAPIAddr = "127.0.0.1:19091"
// sing-box outbound/endpoint tags used in route rules.
directOutboundTag = "direct"
warpOutboundTag = "warp"
)
func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string) ([]byte, error) {
func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string, warp *WarpConfig) ([]byte, error) {
cfg := map[string]any{
"log": map[string]any{"level": "warn", "timestamp": true},
"inbounds": buildInbounds(creds, reality, hy2, deriveKey),
"outbounds": []any{map[string]any{"type": "direct", "tag": "direct"}},
"outbounds": []any{map[string]any{"type": "direct", "tag": directOutboundTag}},
"experimental": map[string]any{
"clash_api": map[string]any{
"external_controller": clashAPIAddr,
@@ -42,6 +46,14 @@ func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *age
},
},
}
// WARP 分流(#29):命中配置域名的流量走 Cloudflare WARP 干净出口,其余直连。
// warp 为 nil 或未 active 时完全不加 endpoints/route → 与旧配置逐字节一致(向后兼容)。
if warp.active() {
cfg["endpoints"] = []any{warp.warpEndpoint()}
cfg["route"] = warp.warpRoute()
}
return json.MarshalIndent(cfg, "", " ")
}
+9 -1
View File
@@ -315,7 +315,15 @@ func (s *SingBox) RenderConfig() ([]byte, error) {
hy2 := s.hy2
s.mu.Unlock()
sort.Slice(creds, func(i, j int) bool { return creds[i].DpUUID < creds[j].DpUUID })
return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey)
// WARP 分流配置每次渲染读一次:编辑 warp.json 后任一渲染(或 agent 重启)即生效(#29)。
// 读失败(坏 JSON)仅记日志、按未启用处理,绝不因坏配置产出无法启动的 sing-box 配置。
warp, err := LoadWarpConfig(s.cfg.WarpConfigPath)
if err != nil {
logf("[warp] load %s failed, WARP routing disabled: %v", s.cfg.WarpConfigPath, err)
warp = nil
}
return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp)
}
// writeAndRestart renders, writes the config file and restarts sing-box.
+121
View File
@@ -0,0 +1,121 @@
package agentd
import (
"encoding/json"
"fmt"
"net"
"os"
"strconv"
"strings"
)
// WarpConfig 描述节点上「部分域名走 Cloudflare WARP 干净出口」的分流配置(#29)。
// 由节点本地文件(默认 <StateDir>/warp.json)提供,agent 渲染 sing-box 配置时读取:
// 存在且 enabled 且有域名 → 注入一个 WireGuard(WARP) endpoint + 域名分流 route 规则,
// 命中域名走 WARP、其余直连。运营改域名清单只需编辑该文件并重启 agent(sing-box 无热重载)。
//
// WARP 凭证(private_key / peer_public_key / endpoint / address / reserved)由 wgcf
// 注册免费匿名 WARP 账号得到,是节点私有的,不入 git、不经控制面。
type WarpConfig struct {
Enabled bool `json:"enabled"`
PrivateKey string `json:"private_key"`
PeerPublicKey string `json:"peer_public_key"`
Endpoint string `json:"endpoint"` // host:port,如 162.159.192.1:2408
Address []string `json:"address"` // 本端 WARP 分配地址,如 ["172.16.0.2/32","2606:4700:110:...::/128"]
Reserved []int `json:"reserved"` // WARP client reserved 三字节(可空)
MTU int `json:"mtu"` // 缺省 1280
Domains []string `json:"domains"` // 走 WARP 的域名后缀,如 ["reddit.com","redd.it"]
}
// LoadWarpConfig 读取并解析 warp.json。文件不存在 → 返回 (nil, nil)(WARP 未启用,
// 不是错误)。解析失败或字段缺失才返回 error,避免坏配置静默退化。
func LoadWarpConfig(path string) (*WarpConfig, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("agentd: read warp config %q: %w", path, err)
}
var wc WarpConfig
if err := json.Unmarshal(data, &wc); err != nil {
return nil, fmt.Errorf("agentd: parse warp config %q: %w", path, err)
}
return &wc, nil
}
// active 报告本配置是否应真正注入分流(启用、凭证齐全、至少一个域名)。
// 任一必需字段缺失都返回 false —— 宁可不分流(全直连)也不产出坏 sing-box 配置。
func (wc *WarpConfig) active() bool {
if wc == nil || !wc.Enabled || len(wc.Domains) == 0 {
return false
}
if wc.PrivateKey == "" || wc.PeerPublicKey == "" || wc.Endpoint == "" || len(wc.Address) == 0 {
return false
}
host, _, err := net.SplitHostPort(wc.Endpoint)
return err == nil && host != ""
}
// mtu 返回配置的 MTU 或缺省 1280(WARP 常用值)。
func (wc *WarpConfig) mtu() int {
if wc.MTU > 0 {
return wc.MTU
}
return 1280
}
// cleanDomains 去空白/空项后返回域名清单(用于 domain_suffix)。
func (wc *WarpConfig) cleanDomains() []string {
out := make([]string, 0, len(wc.Domains))
for _, d := range wc.Domains {
d = strings.TrimSpace(strings.ToLower(d))
if d != "" {
out = append(out, d)
}
}
return out
}
// endpointHostPort 拆 Endpoint 为 host + port(active() 已校验可拆)。
func (wc *WarpConfig) endpointHostPort() (string, int) {
host, portStr, _ := net.SplitHostPort(wc.Endpoint)
port, _ := strconv.Atoi(portStr)
return host, port
}
// warpEndpoint 构造 sing-box 1.11+ 的 WireGuard endpoint(userspace,无需内核 wg 模块)。
// tag = "warp",route 规则以此 tag 作 outbound。
func (wc *WarpConfig) warpEndpoint() map[string]any {
host, port := wc.endpointHostPort()
peer := map[string]any{
"address": host,
"port": port,
"public_key": wc.PeerPublicKey,
"allowed_ips": []string{"0.0.0.0/0", "::/0"},
}
if len(wc.Reserved) == 3 {
peer["reserved"] = wc.Reserved
}
return map[string]any{
"type": "wireguard",
"tag": warpOutboundTag,
"system": false, // gVisor 用户态,不依赖内核 wireguard
"mtu": wc.mtu(),
"address": wc.Address,
"private_key": wc.PrivateKey,
"peers": []any{peer},
}
}
// warpRoute 构造分流 route:先 sniff 取出 SNI/Host(客户端多半发的是已解析 IP,
// 不 sniff 域名规则无从命中),命中域名后缀走 warp,其余 final=direct。
func (wc *WarpConfig) warpRoute() map[string]any {
return map[string]any{
"rules": []any{
map[string]any{"action": "sniff"},
map[string]any{"domain_suffix": wc.cleanDomains(), "outbound": warpOutboundTag},
},
"final": directOutboundTag,
}
}
+152
View File
@@ -0,0 +1,152 @@
package agentd
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"
)
// writeWarp 把 warp.json 写到 cfg 的 WarpConfigPath。
func writeWarp(t *testing.T, path, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
const validWarp = `{
"enabled": true,
"private_key": "aW52YWxpZC1rZXk=",
"peer_public_key": "bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=",
"endpoint": "162.159.192.1:2408",
"address": ["172.16.0.2/32", "2606:4700:110:8abc::/128"],
"reserved": [1, 2, 3],
"mtu": 1280,
"domains": ["reddit.com", "redd.it"]
}`
// 无 warp.json → 配置里既无 endpoints 也无 route(向后兼容,与旧节点逐字节一致)。
func TestRender_NoWarp_NoRouteSection(t *testing.T) {
sb := NewSingBox(testConfig(t), nil)
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
data, err := sb.RenderConfig()
if err != nil {
t.Fatal(err)
}
var cfg map[string]any
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatal(err)
}
if _, ok := cfg["endpoints"]; ok {
t.Error("no warp.json but endpoints present")
}
if _, ok := cfg["route"]; ok {
t.Error("no warp.json but route present")
}
}
// 有效 warp.json → 注入 WireGuard endpoint(tag=warp,userspace)+ 域名分流 route。
func TestRender_Warp_InjectsEndpointAndRoute(t *testing.T) {
cfg := testConfig(t)
writeWarp(t, cfg.WarpConfigPath, validWarp)
sb := NewSingBox(cfg, nil)
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
data, err := sb.RenderConfig()
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("rendered config invalid JSON: %v", err)
}
eps, ok := m["endpoints"].([]any)
if !ok || len(eps) != 1 {
t.Fatalf("want 1 endpoint, got %v", m["endpoints"])
}
ep := eps[0].(map[string]any)
if ep["type"] != "wireguard" || ep["tag"] != "warp" {
t.Errorf("endpoint type/tag wrong: %v", ep)
}
if ep["system"] != false {
t.Errorf("WARP endpoint must be userspace (system=false), got %v", ep["system"])
}
peers := ep["peers"].([]any)
peer := peers[0].(map[string]any)
if peer["public_key"] != "bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=" {
t.Errorf("peer public_key wrong: %v", peer["public_key"])
}
if peer["address"] != "162.159.192.1" {
t.Errorf("peer address wrong: %v", peer["address"])
}
route := m["route"].(map[string]any)
if route["final"] != "direct" {
t.Errorf("route.final = %v, want direct", route["final"])
}
rules := route["rules"].([]any)
// 首条必须是 sniff(否则客户端发来的已解析 IP 无域名可匹配)。
if rules[0].(map[string]any)["action"] != "sniff" {
t.Errorf("first route rule must be sniff, got %v", rules[0])
}
last := rules[len(rules)-1].(map[string]any)
if last["outbound"] != "warp" {
t.Errorf("domain rule must route to warp, got %v", last)
}
if !strings.Contains(string(data), "reddit.com") {
t.Error("configured domain reddit.com not in route")
}
}
// enabled=false 或域名为空 → 视为未启用,不注入(坏配置宁可全直连)。
func TestRender_Warp_DisabledOrIncomplete(t *testing.T) {
cases := map[string]string{
"disabled": strings.Replace(validWarp, `"enabled": true`, `"enabled": false`, 1),
"no-domains": strings.Replace(validWarp, `["reddit.com", "redd.it"]`, `[]`, 1),
"no-key": strings.Replace(validWarp, `"private_key": "aW52YWxpZC1rZXk=",`, `"private_key": "",`, 1),
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
cfg := testConfig(t)
writeWarp(t, cfg.WarpConfigPath, body)
sb := NewSingBox(cfg, nil)
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
data, err := sb.RenderConfig()
if err != nil {
t.Fatal(err)
}
var m map[string]any
_ = json.Unmarshal(data, &m)
if _, ok := m["route"]; ok {
t.Errorf("%s: route must be absent", name)
}
})
}
}
// 坏 JSON → 渲染不报错、按未启用处理(不产出无法启动的配置)。
func TestRender_Warp_BadJSONDegradesGracefully(t *testing.T) {
cfg := testConfig(t)
writeWarp(t, cfg.WarpConfigPath, `{ this is not json `)
sb := NewSingBox(cfg, nil)
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
data, err := sb.RenderConfig()
if err != nil {
t.Fatalf("bad warp.json must not fail render: %v", err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatal(err)
}
if _, ok := m["route"]; ok {
t.Error("bad warp.json must degrade to no route")
}
}
@@ -70,13 +70,18 @@ func applySchema(db *sql.DB) error {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS devices (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE,
user_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(64) NOT NULL,
platform ENUM('ios','android','windows','macos') NOT NULL,
last_seen DATETIME(6) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(64) NOT NULL,
platform ENUM('ios','android','windows','macos','linux') NOT NULL,
last_seen DATETIME(6) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
client_version VARCHAR(32) NULL,
totp_trusted_until DATETIME(6) NULL,
dp_uuid CHAR(36) NULL,
UNIQUE KEY uniq_devices_user_uuid (user_id, uuid),
UNIQUE KEY idx_devices_dp_uuid (dp_uuid),
FOREIGN KEY (user_id) REFERENCES users(id),
INDEX idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
@@ -330,9 +335,10 @@ func TestDeleteOthersDevice(t *testing.T) {
t.Fatalf("register: %v", apiErr)
}
// Other user cannot delete it → 403 FORBIDDEN.
if apiErr := svc.DeleteDevice(ctx, other, devUUID); apiErr == nil || apiErr.Code != "FORBIDDEN" {
t.Errorf("want FORBIDDEN, got %v", apiErr)
// Other user cannot delete it → 404 NOT_FOUND(查找按 (user,uuid) 作用域,
// 他人名下的行不可见,migration 21 起不再是 403)。
if apiErr := svc.DeleteDevice(ctx, other, devUUID); apiErr == nil || apiErr.Code != "NOT_FOUND" {
t.Errorf("want NOT_FOUND, got %v", apiErr)
}
// Non-existent device → 404 NOT_FOUND.
if apiErr := svc.DeleteDevice(ctx, owner, newUUID(t, db)); apiErr == nil || apiErr.Code != "NOT_FOUND" {
@@ -340,6 +346,65 @@ func TestDeleteOthersDevice(t *testing.T) {
}
}
// TestSameDeviceUUIDTwoAccounts:F3 回归——同一物理设备(同 device uuid)先后登录
// 两个账号,双方都能注册成功、各自成行,互不 403;各自的删除只影响自己名下的行。
func TestSameDeviceUUIDTwoAccounts(t *testing.T) {
db := setupMySQL(t)
svc := devices.NewService(devices.NewStore(db), nil)
ctx := context.Background()
userA := createUser(t, db, "a-shared@example.com", "active")
userB := createUser(t, db, "b-shared@example.com", "active")
devUUID := newUUID(t, db) // 同一台机器的持久 device_id
if _, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{
UserID: userA, DeviceUUID: devUUID, Name: "Shared Mac", Platform: "macos", MaxDevices: 5,
}); apiErr != nil {
t.Fatalf("register user A: %v", apiErr)
}
// 换账号:同 uuid 注册到 user B —— 旧全局 UNIQUE(uuid) 下这里是 403 死结。
if _, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{
UserID: userB, DeviceUUID: devUUID, Name: "Shared Mac", Platform: "macos", MaxDevices: 5,
}); apiErr != nil {
t.Fatalf("register user B (same device uuid): %v", apiErr)
}
// 各自名下都各有一行。
for _, uid := range []int64{userA, userB} {
list, apiErr := svc.ListDevices(ctx, uid)
if apiErr != nil {
t.Fatalf("list %d: %v", uid, apiErr)
}
n := 0
for _, d := range list {
if d.UUID == devUUID {
n++
}
}
if n != 1 {
t.Errorf("user %d: want 1 row for shared uuid, got %d", uid, n)
}
}
// A 删除自己的行,不影响 B 的行。
if apiErr := svc.DeleteDevice(ctx, userA, devUUID); apiErr != nil {
t.Fatalf("delete A: %v", apiErr)
}
listB, apiErr := svc.ListDevices(ctx, userB)
if apiErr != nil {
t.Fatalf("list B after A delete: %v", apiErr)
}
found := false
for _, d := range listB {
if d.UUID == devUUID {
found = true
}
}
if !found {
t.Errorf("user B's row must survive user A's delete")
}
}
// TestBannedUserRejected verifies the resolver/middleware path rejects banned users.
func TestBannedUserRejected(t *testing.T) {
db := setupMySQL(t)
+20 -31
View File
@@ -201,16 +201,13 @@ func (svc *Service) RegisterIfAbsent(ctx context.Context, in RegisterInput) (int
return 0, nil, apierr.ErrAccountBanned
}
existing, err := svc.store.findDeviceByUUIDTx(ctx, tx, uuid)
// 按 (user,uuid) 查:唯一键是 UNIQUE(user_id,uuid)(migration 21),同一物理设备
// 在别的账号名下的行与本次注册无关 —— 同机换账号各自成行,不再互相 403(F3)。
existing, err := svc.store.findDeviceByUserUUIDTx(ctx, tx, in.UserID, uuid)
if err != nil {
return 0, nil, apierr.ErrInternal
}
if existing != nil {
if existing.UserID != in.UserID {
// UUID is client-generated; a collision across users is treated as
// a conflict rather than silently rebinding the device.
return 0, nil, apierr.ErrForbidden
}
if err := svc.store.touchLastSeenTx(ctx, tx, existing.ID, in.ClientVersion); err != nil {
return 0, nil, apierr.ErrInternal
}
@@ -253,26 +250,25 @@ func (svc *Service) RegisterIfAbsent(ctx context.Context, in RegisterInput) (int
// and then triggers per-user credential recall on the node side.
//
// - device not found → 404 NOT_FOUND
// - device owned by another user → 403 FORBIDDEN (does not delete)
// - device owned by another user → 404 NOT_FOUND (user-scoped lookup; invisible)
func (svc *Service) DeleteDevice(ctx context.Context, userID int64, deviceUUID string) *apierr.Error {
uuid := strings.TrimSpace(deviceUUID)
if uuid == "" {
return apierr.ErrBadRequest
}
// Resolve + ownership check (non-tx) so sessions can be revoked BEFORE the
// delete tx: SQLite (_txlock=immediate) holds a write lock for the tx, so a
// session write on another pool connection would deadlock against it.
dev, err := svc.store.FindByUUID(ctx, uuid)
// Resolve (non-tx) so sessions can be revoked BEFORE the delete tx: SQLite
// (_txlock=immediate) holds a write lock for the tx, so a session write on
// another pool connection would deadlock against it. Lookup is user-scoped
// (UNIQUE(user_id,uuid)) — other users' rows with the same uuid are invisible,
// so "not mine" and "not found" are both 404.
dev, err := svc.store.FindByUserUUID(ctx, userID, uuid)
if err != nil {
return apierr.ErrInternal
}
if dev == nil {
return apierr.ErrNotFound
}
if dev.UserID != userID {
return apierr.ErrForbidden
}
// Revoke the device's sessions (drop their refresh JTIs from Redis) while the
// rows still exist; the device delete then cascades them away.
@@ -319,22 +315,19 @@ func (svc *Service) DeleteDevice(ctx context.Context, userID int64, deviceUUID s
// user can simply log in again.
//
// - device not found → 404 NOT_FOUND
// - device owned by another user → 403 FORBIDDEN
// - device owned by another user → 404 NOT_FOUND (user-scoped lookup; invisible)
func (svc *Service) ForceLogout(ctx context.Context, userID int64, deviceUUID string) *apierr.Error {
uuid := strings.TrimSpace(deviceUUID)
if uuid == "" {
return apierr.ErrBadRequest
}
dev, err := svc.store.FindByUUID(ctx, uuid)
dev, err := svc.store.FindByUserUUID(ctx, userID, uuid)
if err != nil {
return apierr.ErrInternal
}
if dev == nil {
return apierr.ErrNotFound
}
if dev.UserID != userID {
return apierr.ErrForbidden
}
svc.revokeDeviceSessions(ctx, userID, dev.ID)
return nil
}
@@ -343,7 +336,7 @@ func (svc *Service) ForceLogout(ctx context.Context, userID int64, deviceUUID st
// 400; name is trimmed + truncated to 64 runes.
//
// - device not found → 404
// - device owned by another user → 403
// - device owned by another user → 404 (user-scoped lookup; invisible)
func (svc *Service) RenameDevice(ctx context.Context, userID int64, deviceUUID, rawName string) *apierr.Error {
uuid := strings.TrimSpace(deviceUUID)
name := strings.TrimSpace(rawName)
@@ -353,16 +346,13 @@ func (svc *Service) RenameDevice(ctx context.Context, userID int64, deviceUUID,
if r := []rune(name); len(r) > 64 {
name = string(r[:64])
}
dev, err := svc.store.FindByUUID(ctx, uuid)
dev, err := svc.store.FindByUserUUID(ctx, userID, uuid)
if err != nil {
return apierr.ErrInternal
}
if dev == nil {
return apierr.ErrNotFound
}
if dev.UserID != userID {
return apierr.ErrForbidden
}
if err := svc.store.UpdateName(ctx, dev.ID, name); err != nil {
return apierr.ErrInternal
}
@@ -378,19 +368,18 @@ func (svc *Service) SessionActive(ctx context.Context, userID int64, deviceUUID
if svc.sessions == nil || strings.TrimSpace(deviceUUID) == "" {
return true, nil
}
dev, err := svc.store.FindByUUID(ctx, deviceUUID)
dev, err := svc.store.FindByUserUUID(ctx, userID, deviceUUID)
if err != nil {
return false, apierr.ErrInternal
}
if dev == nil {
// 设备行已不存在 = 本设备被「移除」(DeleteDevice 删行)。已登录的客户端在登录时
// 必然注册过自己的设备,轮询自身 device_id 却查无此行,只能是被移除 → 视为会话失效,
// 让其登出(否则被移除的设备永远收到 active=true,不退出,只表现为数据面被断→「节点异常」)。
// 本用户名下无此设备行 = 本设备被「移除」(DeleteDevice 删行)。已登录的客户端在
// 登录时必然注册过自己的设备,轮询自身 device_id 却查无此行,只能是被移除 → 视为
// 会话失效,让其登出(否则被移除的设备永远收到 active=true,不退出,只表现为数据面
// 被断→「节点异常」)。查找按 (user,uuid) 作用域,他人账号下的同 uuid 行不可见,
// 不存在旧「非本人设备 fail-safe」分支。
return false, nil
}
if dev.UserID != userID {
return true, nil // 非本人设备 uuid:不据此登出(fail-safe)
}
active, err := svc.sessions.HasActiveSession(ctx, userID, dev.ID)
if err != nil {
return false, apierr.ErrInternal
+11 -8
View File
@@ -77,21 +77,24 @@ func (s *Store) ListByUser(ctx context.Context, userID int64) ([]DeviceRow, erro
return out, rows.Err()
}
// findDeviceByUUIDTx looks up a device by UUID with FOR UPDATE inside tx.
// Returns (nil, nil) when the device does not exist.
func (s *Store) findDeviceByUUIDTx(ctx context.Context, tx *sql.Tx, uuid string) (*DeviceRow, error) {
// findDeviceByUserUUIDTx looks up the user's device by UUID with FOR UPDATE
// inside tx. Returns (nil, nil) when the device does not exist for this user.
// 必须带 user_id:唯一键是 UNIQUE(user_id,uuid)(migration 21),同一物理设备的
// uuid 可在多个账号下各有一行,全局按 uuid 查会歧义。
func (s *Store) findDeviceByUserUUIDTx(ctx context.Context, tx *sql.Tx, userID int64, uuid string) (*DeviceRow, error) {
row := tx.QueryRowContext(ctx,
`SELECT id, uuid, user_id, name, platform, last_seen, created_at, client_version, dp_uuid
FROM devices WHERE uuid=? `+s.dialect.LockForUpdate(), uuid)
FROM devices WHERE user_id=? AND uuid=? `+s.dialect.LockForUpdate(), userID, uuid)
return scanDeviceRow(row)
}
// FindByUUID looks up a device by UUID (non-tx). Returns (nil, nil) if absent.
// Used by force-logout/delete to resolve ownership + dp_uuid.
func (s *Store) FindByUUID(ctx context.Context, uuid string) (*DeviceRow, error) {
// FindByUserUUID looks up the user's device by UUID (non-tx). Returns (nil, nil)
// if absent for this user. Used by force-logout/delete/rename/session-poll to
// resolve the device row; other users' rows with the same uuid are invisible.
func (s *Store) FindByUserUUID(ctx context.Context, userID int64, uuid string) (*DeviceRow, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, uuid, user_id, name, platform, last_seen, created_at, client_version, dp_uuid
FROM devices WHERE uuid=?`, uuid)
FROM devices WHERE user_id=? AND uuid=?`, userID, uuid)
return scanDeviceRow(row)
}
+40 -7
View File
@@ -332,7 +332,17 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) {
// ─── POST /v1/nodes/{id}/disconnect ──────────────────────────────────────────
// disconnectRequest is the optional JSON body: device_id 指认要吊销凭证的设备。
// 旧客户端不带 body(或不带 device_id)→ 仅吊销遗留账户级凭证(历史行为)。
type disconnectRequest struct {
DeviceID string `json:"device_id"`
}
// DisconnectNode handles POST /v1/nodes/{id}/disconnect.
//
// F4:connect 下发的是每设备凭证(EnsureDeviceDpUUID),吊销也必须对准它——
// 此前这里只撤账户级 ent.DpUUID,设备凭证一直活到 TTL(付费 24h),「断开」在
// 服务端形同空转。现按 device_id 吊销该设备的 dp_uuid,账户级作为遗留兜底仍撤。
func (a *NodeAPI) DisconnectNode(w http.ResponseWriter, r *http.Request) {
uid, ok := auth.UserIDFromContext(r.Context())
if !ok {
@@ -346,6 +356,10 @@ func (a *NodeAPI) DisconnectNode(w http.ResponseWriter, r *http.Request) {
return
}
// Optional body(旧客户端无 body → device_id 为空,走兜底路径)。
var req disconnectRequest
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024)).Decode(&req)
// Load dp_uuid.
ent, err := a.store.EntitlementForUser(r.Context(), uid)
if err != nil {
@@ -367,14 +381,33 @@ func (a *NodeAPI) DisconnectNode(w http.ResponseWriter, r *http.Request) {
return
}
// Push revoke command.
_ = a.hub.Push(r.Context(), node.UUID, &agentv1.Command{
Type: agentv1.CommandTypeRevoke,
Revoke: &agentv1.RevokePayload{DpUUID: ent.DpUUID},
})
// 收集待吊销凭证:每设备(connect 真正下发的)+ 账户级(遗留兜底)。
dpUUIDs := make([]string, 0, 2)
if devID := strings.TrimSpace(req.DeviceID); devID != "" {
devDp, _, derr := a.store.EnsureDeviceDpUUID(r.Context(), uid, devID)
if derr != nil && !errors.Is(derr, nodes.ErrDeviceNotFound) {
slog.Warn("disconnect: device dp_uuid lookup failed", "user", uid, "device", devID, "err", derr)
}
if devDp != "" {
dpUUIDs = append(dpUUIDs, devDp)
}
}
if ent.DpUUID != "" {
dpUUIDs = append(dpUUIDs, ent.DpUUID)
}
// Delete persisted credential.
_ = a.store.DeleteCredential(r.Context(), node.ID, ent.DpUUID)
for _, dp := range dpUUIDs {
// Push revoke command(best-effort:agent 离线时命令进 Redis 队列,重连即达;
// 删除持久化凭证后 resync 也不会再下发)。nil hub = 测试环境,跳过推送。
if a.hub != nil {
_ = a.hub.Push(r.Context(), node.UUID, &agentv1.Command{
Type: agentv1.CommandTypeRevoke,
Revoke: &agentv1.RevokePayload{DpUUID: dp},
})
}
// Delete persisted credential.
_ = a.store.DeleteCredential(r.Context(), node.ID, dp)
}
w.WriteHeader(http.StatusNoContent)
}
@@ -0,0 +1,113 @@
package httpapi
import (
"context"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/wangjia/pangolin/server/internal/codes"
"github.com/wangjia/pangolin/server/internal/nodes"
agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"
)
// fakeDisconnectStore implements the NodeStore methods DisconnectNode touches;
// everything else panics via the embedded nil interface (未用到即安全)。
type fakeDisconnectStore struct {
nodes.NodeStore // embed:未实现的方法调用即 panic,测试只走下面四个
node *nodes.NodeRow
ent *nodes.Entitlement
devDp string // EnsureDeviceDpUUID 返回值;"" = 设备不存在
deleted []string
}
func (f *fakeDisconnectStore) EntitlementForUser(context.Context, int64) (*nodes.Entitlement, error) {
return f.ent, nil
}
func (f *fakeDisconnectStore) NodeByUUID(context.Context, string) (*nodes.NodeRow, error) {
return f.node, nil
}
func (f *fakeDisconnectStore) EnsureDeviceDpUUID(context.Context, int64, string) (string, int64, error) {
if f.devDp == "" {
return "", 0, nodes.ErrDeviceNotFound
}
return f.devDp, 7, nil
}
func (f *fakeDisconnectStore) DeleteCredential(_ context.Context, _ int64, dpUUID string) error {
f.deleted = append(f.deleted, dpUUID)
return nil
}
func (f *fakeDisconnectStore) PersistCredential(context.Context, int64, *agentv1.Credential, time.Time) error {
return nil
}
func doDisconnect(t *testing.T, store *fakeDisconnectStore, body string) int {
t.Helper()
api := NewNodeAPI(store, nil, nil, "", "") // nil hub:跳过 Push,只验证凭证删除
req := httptest.NewRequest("POST", "/v1/nodes/node-1/disconnect", strings.NewReader(body))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "node-1")
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, codes.CtxKeyUserID, int64(42))
rec := httptest.NewRecorder()
api.DisconnectNode(rec, req.WithContext(ctx))
return rec.Code
}
// F4 回归:带 device_id 的 disconnect 必须吊销**每设备** dp_uuid(connect 真正
// 下发的那个),账户级作为遗留兜底也一并吊销。
func TestDisconnectNode_RevokesDeviceCredential(t *testing.T) {
store := &fakeDisconnectStore{
node: &nodes.NodeRow{ID: 1, UUID: "node-1", Status: "up"},
ent: &nodes.Entitlement{DpUUID: "acct-dp"},
devDp: "device-dp",
}
if code := doDisconnect(t, store, `{"device_id":"dev-uuid-1"}`); code != 204 {
t.Fatalf("status = %d, want 204", code)
}
want := map[string]bool{"device-dp": true, "acct-dp": true}
if len(store.deleted) != 2 || !want[store.deleted[0]] || !want[store.deleted[1]] {
t.Errorf("deleted = %v, want both device-dp and acct-dp", store.deleted)
}
// 每设备凭证在前(connect 真正下发的),账户级兜底在后。
if store.deleted[0] != "device-dp" {
t.Errorf("device credential should be revoked first, got %v", store.deleted)
}
}
// 旧客户端无 body → 仅账户级兜底(历史行为不回归)。
func TestDisconnectNode_LegacyNoBody(t *testing.T) {
store := &fakeDisconnectStore{
node: &nodes.NodeRow{ID: 1, UUID: "node-1", Status: "up"},
ent: &nodes.Entitlement{DpUUID: "acct-dp"},
}
if code := doDisconnect(t, store, ""); code != 204 {
t.Fatalf("status = %d, want 204", code)
}
if len(store.deleted) != 1 || store.deleted[0] != "acct-dp" {
t.Errorf("deleted = %v, want only acct-dp", store.deleted)
}
}
// 设备不存在(已被移除)→ 不炸,仍撤账户级。
func TestDisconnectNode_DeviceGone(t *testing.T) {
store := &fakeDisconnectStore{
node: &nodes.NodeRow{ID: 1, UUID: "node-1", Status: "up"},
ent: &nodes.Entitlement{DpUUID: "acct-dp"},
devDp: "", // ErrDeviceNotFound
}
if code := doDisconnect(t, store, `{"device_id":"gone"}`); code != 204 {
t.Fatalf("status = %d, want 204", code)
}
if len(store.deleted) != 1 || store.deleted[0] != "acct-dp" {
t.Errorf("deleted = %v, want only acct-dp", store.deleted)
}
}
@@ -0,0 +1,75 @@
package store_test
import (
"context"
"testing"
"github.com/wangjia/pangolin/server/internal/devices"
)
// TestSQLite_LoginRegistersSameDeviceForTwoAccounts reproduces the #27 (F3) fix
// at the exact code path login drives: recordLogin → devReg.RegisterDevice →
// devices.Service.RegisterIfAbsent{MaxDevices:0}. Two accounts on the SAME physical
// device (same device uuid) must each get their own devices row.
//
// Under the old global UNIQUE(uuid) the second account's registration failed
// (silently on login, best-effort) → the device row for account B never existed →
// ConnectNode later reported DEVICE_NOT_REGISTERED (the 403 deadlock, F3).
func TestSQLite_LoginRegistersSameDeviceForTwoAccounts(t *testing.T) {
ctx := context.Background()
db := openSQLite(t)
mkUser := func(u, email string) int64 {
res, err := db.ExecContext(ctx,
`INSERT INTO users (uuid, email, pw_hash, dp_uuid) VALUES (?, ?, 'h', ?)`,
u, email, "dp-"+u)
if err != nil {
t.Fatalf("user %s: %v", u, err)
}
id, _ := res.LastInsertId()
return id
}
userA := mkUser("u-a", "a@x.c")
userB := mkUser("u-b", "b@x.c")
svc := devices.NewService(devices.NewStore(db), nil)
const sharedUUID = "shared-install-uuid"
// Account A "logs in" on the device.
idA, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{
UserID: userA, DeviceUUID: sharedUUID, Platform: "macos", MaxDevices: 0,
})
if apiErr != nil || idA == 0 {
t.Fatalf("A register: id=%d err=%v", idA, apiErr)
}
// Account B "logs in" on the SAME physical device (same uuid) — the F3 case.
idB, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{
UserID: userB, DeviceUUID: sharedUUID, Platform: "macos", MaxDevices: 0,
})
if apiErr != nil {
t.Fatalf("B register on same device uuid must succeed (F3 fix), got %v", apiErr)
}
if idB == 0 || idB == idA {
t.Fatalf("B must get its own device row, idA=%d idB=%d", idA, idB)
}
// A logs in again → idempotent refresh of A's own row (not a new row).
idA2, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{
UserID: userA, DeviceUUID: sharedUUID, Platform: "macos", MaxDevices: 0,
})
if apiErr != nil || idA2 != idA {
t.Fatalf("A re-register must refresh same row: id=%d (want %d) err=%v", idA2, idA, apiErr)
}
// Both rows coexist for the shared uuid, one per account.
var n int
if err := db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM devices WHERE uuid=?`, sharedUUID).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("shared uuid must have 2 rows (one per account), got %d", n)
}
t.Logf("OK: device uuid %q shared by account A (row #%d) + account B (row #%d)", sharedUUID, idA, idB)
}
+2 -2
View File
@@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) {
if dirty {
t.Fatalf("schema dirty after MigrateUp")
}
if v != 20 {
t.Errorf("version = %d, want 20", v)
if v != 21 {
t.Errorf("version = %d, want 21", v)
}
// 2. Core tables exist.
@@ -146,6 +146,69 @@ func TestSQLite_SessionHasActiveSession(t *testing.T) {
}
}
// TestSQLite_DevicesUserScopedUUID:F3 回归(migration 21)——同一物理设备的
// device uuid 在两个账号下各自成行(UNIQUE(user_id,uuid)),同用户重复注册仍被
// 唯一键拒绝;linux 平台可入库(CHECK 已放行);sessions 表在重建后 FK 仍指向新
// devices(级联删除成立)。
func TestSQLite_DevicesUserScopedUUID(t *testing.T) {
ctx := context.Background()
db := openSQLite(t)
mkUser := func(u, email string) int64 {
res, err := db.ExecContext(ctx,
`INSERT INTO users (uuid, email, pw_hash, dp_uuid) VALUES (?, ?, 'h', ?)`,
u, email, "dp-"+u)
if err != nil {
t.Fatalf("user %s: %v", u, err)
}
id, _ := res.LastInsertId()
return id
}
userA := mkUser("u-a", "a@x.c")
userB := mkUser("u-b", "b@x.c")
// 同一 device uuid,两个账号各自成行(旧全局 UNIQUE(uuid) 下第二条会失败)。
if _, err := db.ExecContext(ctx,
`INSERT INTO devices (uuid, user_id, name, platform) VALUES ('shared-dev', ?, 'Mac', 'macos')`, userA); err != nil {
t.Fatalf("register A: %v", err)
}
res, err := db.ExecContext(ctx,
`INSERT INTO devices (uuid, user_id, name, platform) VALUES ('shared-dev', ?, 'Mac', 'macos')`, userB)
if err != nil {
t.Fatalf("register B (same uuid, other user) must succeed: %v", err)
}
devB, _ := res.LastInsertId()
// 同用户重复注册仍被 UNIQUE(user_id,uuid) 拒绝。
if _, err := db.ExecContext(ctx,
`INSERT INTO devices (uuid, user_id, name, platform) VALUES ('shared-dev', ?, 'Mac2', 'macos')`, userA); err == nil {
t.Fatalf("duplicate (user,uuid) must be rejected")
}
// linux 平台可入库(migration 21 顺手放行,normalizePlatform 早已接受)。
if _, err := db.ExecContext(ctx,
`INSERT INTO devices (uuid, user_id, name, platform) VALUES ('linux-dev', ?, 'NUC', 'linux')`, userA); err != nil {
t.Fatalf("linux platform must be accepted: %v", err)
}
// sessions FK 重建后仍指向新 devices:删 B 的设备,B 的会话级联消失。
ss := sessions.NewStore(db)
if err := ss.Create(ctx, userB, devB, "jti-b", "", ""); err != nil {
t.Fatalf("session B: %v", err)
}
if _, err := db.ExecContext(ctx, `DELETE FROM devices WHERE id=?`, devB); err != nil {
t.Fatalf("delete devB: %v", err)
}
var n int
if err := db.QueryRowContext(ctx,
`SELECT COUNT(1) FROM sessions WHERE device_id=?`, devB).Scan(&n); err != nil {
t.Fatalf("count sessions: %v", err)
}
if n != 0 {
t.Errorf("sessions must cascade on device delete after rebuild, got %d rows", n)
}
}
func TestSQLite_NodeAccumulateUsage(t *testing.T) {
ctx := context.Background()
db := openSQLite(t)
@@ -0,0 +1,12 @@
-- 回退:UNIQUE(user_id,uuid) → 全局 UNIQUE(uuid),platform ENUM 去掉 linux。
-- 有损:同一 uuid 跨用户的重复行只保留最早一行(id 最小),linux 平台行删除。
DELETE d1 FROM devices d1
JOIN devices d2 ON d1.uuid = d2.uuid AND d1.id > d2.id;
DELETE FROM devices WHERE platform = 'linux';
ALTER TABLE devices
DROP INDEX uniq_devices_user_uuid,
ADD UNIQUE KEY uuid (uuid),
MODIFY platform ENUM('ios','android','windows','macos') NOT NULL;
@@ -0,0 +1,11 @@
-- 设备唯一键 UNIQUE(uuid) → UNIQUE(user_id, uuid)F3:同机换账号 403 死结)
--
-- device_id 是客户端按「安装」生成并持久的,跨账号复用;全局 UNIQUE(uuid) 使同一台
-- 机器登第二个账号时 RegisterIfAbsent 永远 403。改为按用户隔离:同一物理设备在每个
-- 账号下各有一行。顺手给 platform ENUM 加 linux(normalizePlatform 已接受,列会拒)。
-- MySQL 可原地 ALTER,无需重建(列级 UNIQUE 的隐式索引名 = 列名 uuid)。
ALTER TABLE devices
DROP INDEX uuid,
ADD UNIQUE KEY uniq_devices_user_uuid (user_id, uuid),
MODIFY platform ENUM('ios','android','windows','macos','linux') NOT NULL;
@@ -0,0 +1,59 @@
-- 回退:UNIQUE(user_id,uuid) → 全局 UNIQUE(uuid),platform CHECK 去掉 linux。
-- 有损:同一 uuid 跨用户的重复行只保留最早一行(id 最小),linux 平台行删除
-- (旧 CHECK 不接受)。次序与 up 相同(rename → 重建 → 复制 → drop)。
ALTER TABLE devices RENAME TO devices_old;
CREATE TABLE devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
platform TEXT NOT NULL CHECK (platform IN ('ios','android','windows','macos')),
last_seen DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
client_version TEXT NULL,
totp_trusted_until DATETIME NULL,
dp_uuid TEXT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
INSERT INTO devices (id, uuid, user_id, name, platform, last_seen, created_at,
client_version, totp_trusted_until, dp_uuid)
SELECT id, uuid, user_id, name, platform, last_seen, created_at,
client_version, totp_trusted_until, dp_uuid
FROM devices_old o
WHERE o.platform IN ('ios','android','windows','macos')
AND o.id = (SELECT MIN(i.id) FROM devices_old i WHERE i.uuid = o.uuid);
ALTER TABLE sessions RENAME TO sessions_old;
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
device_id INTEGER NOT NULL,
refresh_jti TEXT NOT NULL UNIQUE,
client_ip TEXT NULL,
client_version TEXT NULL,
created_at DATETIME NOT NULL,
last_active DATETIME NULL,
revoked_at DATETIME NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
);
-- 只回填仍存在对应设备行的会话(被去重删掉的设备,其会话一并丢弃)。
INSERT INTO sessions (id, user_id, device_id, refresh_jti, client_ip, client_version,
created_at, last_active, revoked_at)
SELECT s.id, s.user_id, s.device_id, s.refresh_jti, s.client_ip, s.client_version,
s.created_at, s.last_active, s.revoked_at
FROM sessions_old s
WHERE EXISTS (SELECT 1 FROM devices d WHERE d.id = s.device_id);
DROP TABLE sessions_old;
DROP TABLE devices_old;
CREATE INDEX idx_devices_user ON devices (user_id);
CREATE UNIQUE INDEX idx_devices_dp_uuid ON devices (dp_uuid);
CREATE INDEX idx_sessions_user ON sessions (user_id);
CREATE INDEX idx_sessions_device ON sessions (device_id);
@@ -0,0 +1,68 @@
-- 设备唯一键 UNIQUE(uuid) → UNIQUE(user_id, uuid)F3:同机换账号 403 死结)
--
-- device_id 是客户端按「安装」生成并持久的,跨账号复用;全局 UNIQUE(uuid) 使同一台
-- 机器登第二个账号时 RegisterIfAbsent 永远 403 → ConnectNode 判 DEVICE_NOT_REGISTERED,
-- 且提示的「重新登录」无法自救。改为按用户隔离:同一物理设备在每个账号下各有一行。
-- 顺手落地 migration 16 头注释里推迟的两件事:本唯一键改造 + platform CHECK 加 linux
-- (normalizePlatform 已接受 linux,但旧 CHECK 会拒 INSERT)。
--
-- SQLite 无法删除列级 UNIQUE,须重建表。sessions 对 devices 有 ON DELETE CASCADE 外键
-- 且连接开启 foreign_keys=ON:直接 DROP devices 会级联清空全部会话(=全员被登出)。
-- 安全次序(单事务内成立,不依赖 PRAGMA foreign_keys=OFF):
-- ① RENAME devices → devices_old(sessions 的 FK 定义随 rename 跟走到 devices_old);
-- ② 建新 devices(复合唯一键),按原 id 复制数据;
-- ③ RENAME sessions → sessions_old,建新 sessions(FK 指向新 devices),复制数据;
-- ④ 先 DROP 子表 sessions_old,再 DROP 已无引用的 devices_old;重建全部索引。
ALTER TABLE devices RENAME TO devices_old;
CREATE TABLE devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
platform TEXT NOT NULL CHECK (platform IN ('ios','android','windows','macos','linux')),
last_seen DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
client_version TEXT NULL,
totp_trusted_until DATETIME NULL,
dp_uuid TEXT NULL,
UNIQUE (user_id, uuid),
FOREIGN KEY (user_id) REFERENCES users(id)
);
INSERT INTO devices (id, uuid, user_id, name, platform, last_seen, created_at,
client_version, totp_trusted_until, dp_uuid)
SELECT id, uuid, user_id, name, platform, last_seen, created_at,
client_version, totp_trusted_until, dp_uuid
FROM devices_old;
ALTER TABLE sessions RENAME TO sessions_old;
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
device_id INTEGER NOT NULL,
refresh_jti TEXT NOT NULL UNIQUE,
client_ip TEXT NULL,
client_version TEXT NULL,
created_at DATETIME NOT NULL,
last_active DATETIME NULL,
revoked_at DATETIME NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
);
INSERT INTO sessions (id, user_id, device_id, refresh_jti, client_ip, client_version,
created_at, last_active, revoked_at)
SELECT id, user_id, device_id, refresh_jti, client_ip, client_version,
created_at, last_active, revoked_at
FROM sessions_old;
DROP TABLE sessions_old;
DROP TABLE devices_old;
CREATE INDEX idx_devices_user ON devices (user_id);
CREATE UNIQUE INDEX idx_devices_dp_uuid ON devices (dp_uuid);
CREATE INDEX idx_sessions_user ON sessions (user_id);
CREATE INDEX idx_sessions_device ON sessions (device_id);
+34 -5
View File
@@ -253,12 +253,12 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<header>
<div class="wrap">
<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="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>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>
@@ -563,12 +563,41 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
</div>
<div class="section-block" id="section-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>
</div>
<div class="section-list-wrap " id="list-wrap-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>
</div>
</div>
+18 -2
View File
@@ -1,9 +1,9 @@
{
"meta": {
"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": [
{
"id": 1,
@@ -254,6 +254,22 @@
"done": false,
"completed_at": 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
}
]
}
+2 -1
View File
@@ -1,13 +1,14 @@
// @ts-check
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import { SITE } from './src/config/site.ts';
// 纯静态站点 (SSG),零 SSR。
// `site` 仅用于生成绝对 URLsitemap / canonical),镜像部署时可被任意域名覆盖,
// 不影响「整站可秒级复制到任意备用域名」:产物为纯相对路径静态文件。
// 主站 canonical 域名。镜像构建务必用「同一个」SITE_URL,使 canonical 始终指向主站
// (避免镜像与主站 SEO 竞争),且主站/镜像产物 hash 完全一致(验收:内容一致性)。
const SITE_URL = process.env.SITE_URL || 'https://pangolin.example';
const SITE_URL = process.env.SITE_URL || SITE.url;
export default defineConfig({
site: SITE_URL,
+3 -3
View File
@@ -20,13 +20,13 @@ const docs = [
</div>
<div class="wrap">
<div class="docs-grid">
{/* 文档页未就绪:卡片暂作信息展示(非链接),写好真实文档后改回 <a href> + 恢复「阅读」。 */}
{docs.map((d) => (
<a class="doc">
<div class="doc">
<div class="ico"><Icon name={d.icon} /></div>
<h3>{t(d.t)}</h3>
<p>{t(d.d)}</p>
<span class="ln">{t('docs.read')}</span>
</a>
</div>
))}
</div>
</div>
+5 -5
View File
@@ -1,6 +1,7 @@
---
import Brand from './Brand.astro';
import type { T } from '../i18n/strings';
import { SITE } from '../config/site';
interface Props { t: T }
const { t } = Astro.props;
@@ -27,7 +28,6 @@ const { t } = Astro.props;
<h4>{t('ft.resources')}</h4>
<ul>
<li><a href="#docs">{t('ft.docs')}</a></li>
<li><a href="#blog">{t('ft.blog')}</a></li>
<li><a href="#docs">{t('ft.faq')}</a></li>
<li><a href="#docs">{t('ft.privacy')}</a></li>
</ul>
@@ -35,10 +35,10 @@ const { t } = Astro.props;
<div>
<h4>{t('ft.contact')}</h4>
<ul>
<li><a class="mono">shop.pangolin.vpn</a></li>
<li><a class="mono">Telegram @PangolinVPN_bot</a></li>
<li><a class="mono">LINE @pangolinvpn</a></li>
<li><a class="mono">support@pangolin.vpn</a></li>
<li><a class="mono">{SITE.store.label}</a></li>
<li><a class="mono" href={SITE.telegram.url} target="_blank" rel="noopener">Telegram {SITE.telegram.handle}</a></li>
<li><a class="mono">LINE {SITE.line.handle}</a></li>
<li><a class="mono" href={`mailto:${SITE.email}`}>{SITE.email}</a></li>
</ul>
</div>
</div>
+2 -2
View File
@@ -41,7 +41,6 @@ export default function Header({ lang = 'zh', t = {} }) {
['#pricing', t.pricing],
['#download', t.download],
['#docs', t.docs],
['#blog', t.blog],
];
return (
@@ -61,7 +60,8 @@ export default function Header({ lang = 'zh', t = {} }) {
<a data-lang="zh" class={lang === 'zh' ? 'on' : undefined} href="/">中文</a>
<a data-lang="en" class={lang === 'en' ? 'on' : undefined} href="/en/">EN</a>
</div>
<span class="linklogin">{t.login}</span>
{/* 「登录」暂移除:Web 用户中心(web/usercenter)未部署、无 URL;待 #30 CI/CD
部署后接上 usercenter 登录地址再恢复。 */}
<a class="btn btn-primary" href="#download">
<Download />
<span>{t.get}</span>
+12 -10
View File
@@ -1,10 +1,12 @@
---
import Icon from './Icon.astro';
import PricingPlans from './PricingPlans.jsx';
import { PRICES, type T } from '../i18n/strings';
import { PRICES, type T, type Lang } from '../i18n/strings';
import { SITE } from '../config/site';
interface Props { t: T }
const { t } = Astro.props;
interface Props { t: T; lang: Lang }
const { t, lang } = Astro.props;
const P = PRICES[lang];
const plansData = {
monthly: t('price.monthly'),
@@ -16,7 +18,7 @@ const plansData = {
{
key: 'free',
name: t('price.free'),
price: PRICES.free,
price: P.free,
desc: t('price.freedesc'),
feats: [t('pf.free1'), t('pf.free2'), t('pf.free3'), t('pf.free4')],
cta: t('price.cta_free'),
@@ -26,7 +28,7 @@ const plansData = {
{
key: 'pro',
name: t('price.pro'),
price: PRICES.pro,
price: P.pro,
desc: t('price.prodesc'),
feats: [t('pf.pro1'), t('pf.pro2'), t('pf.pro3'), t('pf.pro4'), t('pf.pro5')],
cta: t('price.cta_pro'),
@@ -36,7 +38,7 @@ const plansData = {
{
key: 'team',
name: t('price.team'),
price: PRICES.team,
price: P.team,
desc: t('price.teamdesc'),
feats: [t('pf.team1'), t('pf.team2'), t('pf.team3'), t('pf.team4')],
cta: t('price.cta_team'),
@@ -48,7 +50,7 @@ const plansData = {
// 功能对比表行:[名称键, free, pro, team];值可为字符串或 'check' / 'dash'。
const rows = [
{ label: 'cmp.locations', free: '1', pro: '80+', team: '80+' },
{ label: 'cmp.locations', free: '1', pro: t('cmp.loc_all'), team: t('cmp.loc_all') },
{ label: 'cmp.data', free: t('cmp.daily'), pro: t('cmp.unlimited'), team: t('cmp.unlimited') },
{ label: 'cmp.time', free: t('cmp.time_free'), pro: t('cmp.unlimited'), team: t('cmp.unlimited') },
{ label: 'cmp.devices', free: '1', pro: '5', team: '10' },
@@ -81,9 +83,9 @@ function cell(v: string) {
<div class="chips">
<span class="chip"><Icon name="shopping-bag" /><span>{t('pay.store')}</span></span>
<span class="chip"><Icon name="credit-card" /><span>{t('pay.usdt')}</span></span>
<span class="chip"><Icon name="send" />Telegram @PangolinVPN_bot</span>
<span class="chip"><Icon name="message-circle" />LINE @pangolinvpn</span>
<span class="chip"><Icon name="mail" />buy@pangolin.vpn</span>
<a class="chip" href={SITE.telegram.url} target="_blank" rel="noopener"><Icon name="send" />Telegram {SITE.telegram.handle}</a>
<span class="chip"><Icon name="message-circle" />LINE {SITE.line.handle}</span>
<a class="chip" href={`mailto:${SITE.email}`}><Icon name="mail" />{SITE.email}</a>
</div>
</div>
</div>
+22
View File
@@ -0,0 +1,22 @@
/**
* site.ts — 站点级配置「单一真相源」:域名、联系方式、对外渠道。
*
* 改这一处即可:组件(Footer / Pricing)与 astro.config 的 canonical 域名都从这里读。
* 此处只承载「事实值」(URL / 邮箱 / handle);可翻译的展示文案仍走 i18n/strings.ts。
*/
export const SITE = {
/** canonical 主域名。镜像部署可用 SITE_URL 环境变量覆盖(见 astro.config.mjs)。 */
url: 'https://pangolin.yanmeiai.com',
/** 联系邮箱。Cloudflare Email Routing 转发到个人 Gmail。 */
email: 'pangolin@yanmeiai.com',
/** Telegram 公开频道(群为私有,公开页不放)。 */
telegram: { handle: '@pangolin_app', url: 'https://t.me/pangolin_app' },
/** LINE 官方账号 —— 待确认(#24)。 */
line: { handle: '@pangolinvpn' },
/** 自助发卡商店 —— 占位待定(#24)。 */
store: { label: 'shop.pangolin.vpn' },
} as const;
+18 -9
View File
@@ -44,7 +44,7 @@ export const STRINGS: Record<string, [string, string]> = {
'hero.lede': ['轻盈、亲和、即开即用的跨平台网络加速应用。一键连接,智能选线,稳定不掉线。', 'A lightweight, friendly cross-platform network accelerator. One tap, smart routing, rock-solid.'],
'hero.cta1': ['免费下载', 'Download free'],
'hero.cta2': ['查看定价', 'See pricing'],
'hero.t1': ['80+ 全球加速线路', '80+ global routes'],
'hero.t1': ['全球加速线路', 'Global routes'],
'hero.t2': ['严格无日志', 'Strict no-logs'],
'hero.t3': ['端到端加密', 'End-to-end encrypted'],
'orb.cap': ['已连接', 'CONNECTED'],
@@ -62,8 +62,8 @@ export const STRINGS: Record<string, [string, string]> = {
'feat.1d': ['一颗按钮,智能挑选最快线路。无需手动配置,也能秒连。', 'One button picks the fastest route. No manual setup, instant connect.'],
'feat.2t': ['智能分流', 'Smart routing'],
'feat.2d': ['按规则智能分流,该加速的加速,本地服务直连,互不打扰。', 'Smart rule-based routing — accelerate what needs it, keep local traffic direct.'],
'feat.3t': ['80+ 全球加速线路', '80+ global routes'],
'feat.3d': ['覆盖港日新美欧韩等地,视频与游戏优化线路随心选。', 'Routes across HK / JP / SG / US / EU and more, tuned for video & gaming.'],
'feat.3t': ['全球加速线路', 'Global routes'],
'feat.3d': ['覆盖多地优质线路,视频与游戏优化随心选。', 'Premium routes across regions, tuned for video & gaming.'],
'feat.4t': ['严格无日志', 'Strict no-logs'],
'feat.4d': ['我们不记录你的浏览数据。隐私是底线,不是卖点。', 'We never log your browsing. Privacy is the baseline, not a feature.'],
'feat.5t': ['Kill Switch', 'Kill Switch'],
@@ -92,7 +92,7 @@ export const STRINGS: Record<string, [string, string]> = {
'pf.free2': ['仅 1 个基础节点', '1 basic node only'],
'pf.free3': ['使用前观看广告', 'Watch an ad to start'],
'pf.free4': ['核心加密 · 无日志', 'Core encryption · no-logs'],
'pf.pro1': ['80+ 全球加速线路', '80+ global routes'],
'pf.pro1': ['全球加速线路', 'Global routes'],
'pf.pro2': ['无限流量 · 极速', 'Unlimited · top speed'],
'pf.pro3': ['5 台设备同时在线', '5 devices at once'],
'pf.pro4': ['流媒体 & P2P 优化', 'Streaming & P2P routes'],
@@ -119,6 +119,7 @@ export const STRINGS: Record<string, [string, string]> = {
'cmp.kill': ['Kill Switch', 'Kill Switch'],
'cmp.support': ['客服', 'Support'],
'cmp.unlimited': ['无限', 'Unlimited'],
'cmp.loc_all': ['全球', 'Global'],
'cmp.daily': ['不限', 'Unlimited'],
'cmp.basic': ['标准', 'Standard'],
'cmp.priority': ['优先', 'Priority'],
@@ -192,9 +193,17 @@ export function createT(lang: Lang): T {
};
}
/** 价格表(月 / 年),与 ui_kits/website/site.js 的 PRICES 同步,对齐 design/CLAUDE.md §7。 */
export const PRICES: Record<'free' | 'pro' | 'team', [string, string]> = {
free: ['¥0', '¥0'],
pro: ['¥25', '¥20'],
team: [99', 79'],
/** 价格表(月 / 年·按月),按语言分币种:zh=人民币、en=美元。年付约 8 折。
* 对齐 design/CLAUDE.md §7;收款走客户端内兑换激活码,网页价仅为展示。 */
export const PRICES: Record<Lang, Record<'free' | 'pro' | 'team', [string, string]>> = {
zh: {
free: [0', 0'],
pro: ['¥25', '¥20'],
team: ['¥99', '¥79'],
},
en: {
free: ['$0', '$0'],
pro: ['$3.99', '$3.19'],
team: ['$13.99', '$11.19'],
},
};
+2 -3
View File
@@ -35,7 +35,7 @@ import WhySignup from '../components/WhySignup.astro';
import Pricing from '../components/Pricing.astro';
import Download from '../components/Download.astro';
import Docs from '../components/Docs.astro';
import Blog from '../components/Blog.astro';
// Blog 区暂隐藏(占位内容未就绪);有真实文章后恢复 import + <Blog/> + 导航/页脚链接。
import CtaBand from '../components/CtaBand.astro';
import Footer from '../components/Footer.astro';
@@ -81,10 +81,9 @@ const headerT = {
<Hero t={t} />
<Features t={t} />
<WhySignup t={t} />
<Pricing t={t} />
<Pricing t={t} lang={lang} />
<Download t={t} />
<Docs t={t} />
<Blog t={t} />
<CtaBand t={t} />
<Footer t={t} />
</body>