Files
pangolin/CLAUDE.md
T
wangjia b255fdfb4e chore(ci): CI 校验迁本地(ci/check-local.sh),删 pipeline ci.yml
ci.yml 跑在自建 gitea 的 mac-pangolin-2 runner 上,该 runner 不稳:重 job
(go/flutter/golden/integration/e2e)频繁整体超时/失败(run 233、236 复现:所有
runs-on=mac 的 job 全挂、runs-on=ubuntu 的 nas 快扫描全过,证明是 mac runner
掉线而非代码)。既然开发就在 mac 上、原生工具齐全,把校验迁回本地。

- 新增 ci/check-local.sh:与 ci.yml 各 job 一一对应,dev mac 原生跑(仅 golden
  因 Linux 权威基线走 docker)。分档:默认=静态闸+go build/test+flutter analyze/test
  (各带覆盖率闸 Go30%/Flutter28%);--full 加 golden/go-integration/e2e;
  --only <名> 单跑;--list 列项。缺工具标 SKIP 不算失败。shellcheck-clean。
- 删 .gitea/workflows/ci.yml(部署流水线 deploy-server/client/site 保留,发版
  仍过 go test)。
- .githooks/pre-commit:秒级快闸不变,指引改指 ci/check-local.sh(原指 CI)。
- CLAUDE.md CI/CD 段重写 + ds-flow 闸表「CI」触发点改「check-local」。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A79VtQA1BwTuQN1ThpvYpo
2026-08-01 10:20:18 +08:00

243 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 这个仓库是什么
Pangolin 是面向中国大陆用户的终端 VPN(科学上网)项目,**自研全栈**:
- `server/` —— Go 控制面 + 节点 agent + sing-box 数据面(HTTP API + gRPC mTLS)
- `client/` —— Flutter 客户端(内嵌 sing-box + TUN 全局代理)
- `web/` —— 用户中心(usercenter)+ 官网(website)
- `deploy/` —— 节点部署:`bootstrap/`(新机初始化)+ `single-node/`(整套自建栈)
- `design/` —— 设计 token 单源 + 规格;`docs/` / `plan/` —— 文档与计划
> 历史:早期借 EC2 上的 Marzban 临时顶现网。**本仓库已不再管理那套 EC2/marzban 部署**
> (相关 `deploy/edge|singbox|scripts|docker-compose.yml` 与 Gitea 自动部署 CD 已删除),
> 全面转向在自有 VPS 上跑自研栈。
## 部署目标:自有 VPS 节点
当前节点:**`pangolin1` = `103.119.13.48`**(Debian 12 bookworm,root,2 核 / ~1GB / 2G swap)。
单机部署:控制面 `pangolin-server` + 节点 `pangolin-agent` + 数据面 `sing-box` 三者同机,均 systemd 常驻。
- **SSH 免密**:本机 `~/.ssh/config` 有别名 `pangolin1`(密钥登录,密码登录已禁)→ 直接 `ssh pangolin1`
- 已用 `deploy/bootstrap/init.sh` 加固(ufw + fail2ban + BBR + swap + pangolin 用户)。
- **内存仍紧(~1GB)**:控制面用 **SQLite**(见下),不要在这上面跑 MySQL 8。
- 改任何机器系统(装包/改配置/重装)前**先问用户**(只读操作除外)。
> 历史:早期节点为 RackNerd VPS `107.172.55.251`(别名 `racknerd`),已迁离;该别名/IP 作废,勿再用。
## deploy/ 结构
```bash
# ① 新机初始化(幂等,root 跑一次;后续每台新机复用)
# 远程:scp -r deploy/bootstrap pangolin1:/root/ && ssh pangolin1 'cd /root/bootstrap && bash init.sh'
deploy/bootstrap/init.sh # 加固/swap/BBR/pangolin用户/ufw/fail2ban/Telegram监控
deploy/bootstrap/monitor/ # pangolin-monitor(节点自报)+ deadman-watch(常在线主机探活)
# ② 整套自建栈(一台 VPS 跑 MySQL/Redis + 控制面 + agent + sing-box,端到端真连)
sudo VPS_IP=<公网IP> bash deploy/single-node/deploy.sh
```
`deploy/bootstrap/bootstrap.env``/etc/pangolin-monitor.env``deploy/single-node`
`/etc/pangolin*` 密钥均 **不入 git**
## server/ 后端(Go)
- 二进制:`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`
### 数据层:多数据库(一个环境变量切换)
server 已与具体 DB 解绑(裸 SQL + 薄方言层,`internal/db/dialect.go`):
- `DB_DRIVER=mysql`(默认)| `sqlite`;`DB_DSN`(mysql DSN / sqlite 文件路径或 `:memory:`)。
- 迁移分 `server/migrations/{mysql,sqlite}/` 两套,`migrate` 按驱动选源(`golang-migrate`)。
- SQLite 用 `modernc.org/sqlite`(纯 Go 免 CGO)+ `_txlock=immediate`(等价 MySQL 行锁的悲观语义)。
- 测试:`go test ./...`(含 SQLite 实库测试,免 docker)+ `./server/run_sqlite_test.sh`;
MySQL 集成测试 `./server/run_mysql_test.sh`(需 docker)。
- **改 schema/查询**:upsert 用 `dialect.Upsert(...)`(中性 `EXCLUDED.col` 记法),行锁用
`dialect.LockForUpdate()`;时间等一律 Go 端算好传 `?`,**不要**用 `UTC_TIMESTAMP()`/
`NOW()`/`FIELD()` 等 MySQL 专属构造(已全部清除,加回会破坏可移植性)。
### 可配置分流(routing profile,类 Shadowrocket)
用户自定义路由规则(域名/IP/GeoIP/GeoSite × 直连/走隧道/拒绝),有序首命中,存服务端 per-user 档案:
-`routing_profiles`(迁移 000028)· `internal/routing`(Profile/校验/Store)· `GET/POST /v1/me/routing`
(`httpapi/routing.go`)。写操作走 **POST**(仓库无 PUT 先例);校验非法整体 400 逐条错误、不半保存。
- **客户端不拼配置(铁律 ARCHITECTURE.md §3.1)**:客户端只编辑/存取档案,`BuildClientConfig`
(`httpapi/clientconfig.go`)在 connect 时读档案翻译进 `route.rules`——层级 **系统强制层**(hijack-dns/
LAN 直连/私有服务走隧道,恒在、用户不可越)→ **用户规则** → geoip-cn 国内分流 → FINAL;三模式
rule/global/direct(global/direct 忽略用户规则只改 FINAL)。
- **直连真生效**:IP 直连 value 并入 `route_exclude_address`(在 **TUN inbound** `tunIn` 非顶层 `route`)、
域名直连开 `dns.reverse_mapping`。**fail-safe**:档案空/坏/取失败 → 回退默认,`Profile==nil` 逐字节
等价旧行为(有测试钉)。GET 附只读 `system_locked_domains`(私有域名清单,PUT 不持久化)供 UI 标「系统强制不生效」。
- 客户端:设置页「分流规则」下钻(`routing_screen.dart` + `routing_provider`,Riverpod);改档案后连接态
自动重连使新规则生效。设计/计划见 `docs/configurable-proxy-{spec,plan}.html`
## CI/CD
**校验(CI)已迁本地,不再上 pipeline**:原 `ci.yml`(push/PR)跑在 mac-pangolin-2 runner 上,
该 runner 不稳(重 job 频繁整体超时/失败),已**删除**。校验改为本地手动跑
**`bash ci/check-local.sh`**(dev mac 原生工具齐全):
- 默认档(快):shellcheck + 脱敏红线 + Android 明文 + 可移植SQL + OpenAPI + codegen漂移 +
ds-flow 三闸 + `go build/vet/test ./...` + `flutter analyze+test`(各带覆盖率闸)。
- `--full`:追加 golden(**docker Linux flutter**,mac 原生像素对不上基线)+ go integration
(testcontainers,docker)+ e2e-smoke。`--only <名>` 单跑,`--list` 列项。
- 秒级子集仍由 `.githooks/pre-commit` 提交时自动跑(`bash ci/install-hooks.sh` 启用一次);
完整校验靠 `ci/check-local.sh`。**改 CI 检查逻辑改 `ci/*.sh` 与本脚本,不要再加 workflow。**
`.gitea/workflows/` 现**只剩发版流水线**(deploy-server/client/site,tag 触发;runner 同为家里
NAS act_runner):
- **发版走 tag 触发**(cicd-design 已落地,取代旧「无部署」):`server-vX.Y.Z``deploy-server.yml`
= compile-backend → `test.sh server`(**`go test ./...`**,全绿才继续)→ Forgejo release →
`deploy-server.sh` 到 pangolin1(**备份DB→migrate up→换二进制→重启→本地 `/healthz` 权威闸**,
migrate 失败自动回滚DB+重启旧二进制)。同理 `client-v*`(deploy-client.yml)、`site-v*`
- **⚠️ 发版必踩的私有依赖坑**:go.mod 依赖 **私有** `github.com/wangjia/codes`(真源在自建 gitea
`git.51yanmei.com/wangjia/codes`)。冷缓存 runner 上 `go build` 会走 direct git 到 github → 无凭证
挂(`could not read Username, terminal prompts disabled`)。`deploy-server.yml` 已加「私有依赖鉴权」步:
`GOPRIVATE=github.com/wangjia/codes` + `git config url."<FORGEJO_URL 带 oauth2:FORGEJO_TOKEN>/wangjia/codes.git".insteadOf https://github.com/wangjia/codes`。**新增任何私有 Go 依赖,编译前照此配鉴权。**
- **发版排障**:gitea `https://git.51yanmei.com/wangjia/pangolin/actions`;日志用 rbw「gitea 读写key」查
`/api/v1/repos/wangjia/pangolin/actions/runs/<url_id>/jobs`(step 结论)+
`/wangjia/pangolin/actions/runs/<url_id>/jobs/<job_db_id>/logs`(原始日志)。
- 节点**新机初始化**仍手动(scp+ssh 跑 bootstrap / single-node),不走 CI。
## 设计 Token 单源模型
`design/colors_and_type.css` 是**唯一 token 真相源**。改颜色/间距/圆角只改这一个文件,然后:
```bash
# Flutter token(生成 client/lib/pangolin_tokens.gen.dart
node design/codegen/gen_flutter_tokens.mjs
# usercenter / website CSS token(由 prebuild/predev 钩子自动触发,也可手动)
cd web/usercenter && npm run gen:tokens
cd web/website && npm run gen:tokens
```
- `client/lib/pangolin_tokens.gen.dart`**勿手改**,由生成器覆盖。
- `client/lib/pangolin_theme.dart` — 只含实现层(`PangolinScheme`/`PangolinText`/`PangolinTheme`),不含 token 数值。
- `design/flutter/` 已删除;Flutter 组件 canonical 实现在 `client/lib/widgets/`,规格在 `design/preview/`
- **禁止**再向 `design/` 提交 Dart/TS 组件代码副本(会漂移)。
## 前端设计系统治理(ds-flow)
> **落地中**(分阶段收口,见计划 `docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md`
> / 阅读版 `docs/frontend-ds-refactor-plan.html` / todo #19)。以下是**目标模型与硬规则**;
> 标注「⏳」的部件正在建,未标注的已生效。参考样板 `~/code/jiu`。
**心智模型**:设计只有一个出生地(**原型单源**),代码永远是镜像;跨端副本是否走样由
**静态闸**在提交/本地校验(check-local)前拦截,像素是否还原由 **golden/fidelity 双级验收**兜底。主题:**light / dark 双主题**
**原型单源** `design/prototype/`(⏳ 建设中,Phase 1):
- `tokens.css` — 令牌真源:基础 `:root`(主题无关标量:间距/圆角/字号/字体/阴影/动效)+ `[data-theme=dark]` 颜色覆盖块。
(当前真源仍是 `design/colors_and_type.css`,Phase 1 迁移后数值不变、结构规整)
- `atoms.css` — 公用组件原子(按钮/卡片/输入/语言下拉/徽章/状态药丸),**只引 `var(--token)`,禁硬编码**。
- `icons.js` — SVG sprite 单源(`<symbol id="i-*">`);website/usercenter/Flutter 三处图标集须 ⊆ 此集。
- `index.html` — 活登记页:主题切换 + 声明式色板 + 全组件/图标展示卡。**每个 atom 必须在此登记**。
- `serve.mjs` — 零依赖热重载预览;评审给 URL,**不截图**。
**三层治理**
- **L1 设计系统**:新增颜色/组件/图标——**先登记原型,再同步代码**,无例外。跨端映射(状态词→图标)两端同集要有闸。
- **L2 屏级三态**(台账记 `design/CONTRACT.md`):`同步`=入 fidelity`快照`=原型退役、golden+契约为准;`代码先行`=无原型屏、golden 唯一基准。
- **L3 新屏/改版**design-first——原型 → serve 评审 → 契约 → 实现 → 验收 → 入同步态。
**codegen**
- Flutter`node design/codegen/gen_flutter_tokens.mjs``pangolin_tokens.gen.dart`**勿手改**)。
- Web`build-tokens.mjs`website→`src/styles/tokens.gen.css` / usercenter→`public/colors_and_type.css`),
**只同源不重复生成设计决策**;靠同源闸逐值校验(不建跨端共享组件包,两端各自实现)。
**四道静态闸 —「违规谁拦」**(规则没上闸 = 没有规则):
| 闸 | 拦什么 | 何时 |
|---|---|---|
| 原型校验 `check-ds.mjs`(⏳ Phase 5 | 硬编码色/未定义 token/未登记组件/魔法数断点… 12 道 | pre-commit(动了原型)+ check-local |
| 跨端同源 `check-l1-sync.mjs`(⏳ Phase 2 | tokens 逐值/icons 同集/Web hex 白名单 | check-local |
| 代码色单源 `check_ds_code.mjs`(⏳ Phase 5 | Flutter 裸 `Color(0x)`/具名 `Colors.x``// ds-ignore: 理由` 豁免) | pre-commit`--changed`+ check-local |
| codegen 零 diff `ci/check-codegen-drift.sh`**已生效** | 重生成 token 后 `git diff` 非空即 fail | pre-commit + check-local |
**双级像素验收**
- **golden****已有**`client/test/golden/`):多主题回归自比(同渲染器),抓串色/漏 token;真字体加载防豆腐块、钉死 viewport/dpr/动态值。重录 `flutter test --update-goldens`,随功能 commit 入库。
- **fidelity**(⏳ Phase 5,本地体检**不进 CI**):原型 Chromium 截图 vs Flutter golden pixelmatch,逐屏阈值=实测残差+2pp;跨渲染器噪声大,故不入 CI。
**pre-commit**`.githooks/pre-commit` 已写,**每台机需 `bash ci/install-hooks.sh` 启用一次**(设 `core.hooksPath=.githooks`)。
**发现硬编码色**:换 token;确属例外(`#fff/#000`/品牌 logo 固定色)加 `// ds-ignore: 理由` 或列白名单。
## client/ macOS 原生隧道(PacketTunnel 系统扩展 + 内嵌 libbox)
内嵌 sing-box(`Libbox.xcframework`)的 `NEPacketTunnelProvider` **系统扩展**(站外 Developer ID
分发)。让它能被 `sysextd` 加载并真正连通踩了一长串坑,**改这块前必读**
`docs/macos-sysext-realize-troubleshooting.html`。以下是铁律:
**构建 / 发版**
- 一律走 `scripts/local_test.sh`(`build`/`notarize`/`copy`/`run`):Developer ID 签名 + 公证 + staple。
- **每次构建必递增 `CFBundleVersion`**(`CURRENT_PROJECT_VERSION`)——否则 `sysextd` 视为同版本**不更新**,装上去跑的还是旧扩展。
- SIP 开启的机器只接受**已公证**的 sysext;`client/macos/sign_libbox.sh` 在构建期以 Developer ID 重签内嵌 Libbox。
**系统扩展能被 realize 的硬性要求**(缺一即 `code=4` / 静默拒)
- **自包含**:`PacketTunnel` target 设 `OTHER_LDFLAGS = ""`(切断继承项目级 CocoaPods 链接标志,否则会把 `flutter_secure_storage` 链进扩展);`Libbox.xcframework` **只 Link 不 Embed**(它是静态库)。验证:`otool -L` 扩展二进制应**零 `@rpath` 外部依赖**。
- **bundle 名 = 标识符**:`PRODUCT_NAME = com.pangolin.pangolin.PacketTunnel`
- 扩展 `Info.plist` 必须有 **`NSSystemExtensionUsageDescription`**(网络扩展类别强制,主 app 的不顶用)。
- **App Group 用 macOS 原生格式 `<TeamID>.<name>`**(`BYL4KQHMTN.com.pangolin.pangolin`,非 iOS 的 `group.` 前缀);`NEMachServiceName` 以其为前缀。
- 沙箱扩展补 `network.client` / `network.server`;`get-task-allow=false` + 签名加 `--timestamp`
**libbox / NetworkExtension 集成铁律**(改 `PacketTunnelProvider.swift` 注意)
- `startTunnel` **必须在后台队列**执行 libbox 启动(`DispatchQueue.global().async`)——否则 `startOrReloadService` 在 provider 队列同步阻塞,与 `openTun → setTunnelNetworkSettings` 回调**三方死锁**(隧道卡 connecting 永不完成)。
- `startOrReloadService(options:)` **传非空** `LibboxOverrideOptions()`(传 `nil` → libbox 解引用空指针 SIGSEGV,扩展进程崩溃)。
- `startDefaultInterfaceMonitor` 要**阻塞到首个 path 更新再返回**(否则 sing-box 启动期拿不到默认接口,报 `no available network interface`)。
**配置由服务端渲染,客户端不拼**
- sing-box 客户端配置由 `server/internal/httpapi/clientconfig.go::BuildClientConfig` 渲染、原样下发。
- **TUN 模式必须有 DNS 劫持**:`route.rules` 首条 `{"action":"hijack-dns","port":[53]}`(排在 LAN 直连规则前)——否则发往隧道 DNS(172.19.0.2:53)的查询被 `172.16.0.0/12` 吞去直连,域名解析失败,**隧道连上也打不开网站**。
- REALITY 数据口走 **节点 `endpoint` 的端口**(当前 443;受限网络常封高位端口如 11443,优先 443)。
**已知坑**
- 开发机若是 **macOS 26 (Tahoe)**:`sysextd``no policy, cannot allow apps outside /Applications`(app 在 /Applications 也报)是 **Apple 回归**,本机调试需关 SIP 后 `systemextensionsctl developer on`;真实用户(macOS 14/15)不受影响。
- #5 国内分流:客户端 `smartRoute``?split_cn=1` 下发远程 rule-set;**TODO 改本地 `.srs` 预取**,避免启动期下载。
## client/ 移动端(iOS/iPad + Android)原生隧道
同样内嵌 libbox,与 macOS 同「CommandServer 模型」,但形态/打包不同:
- **iOS/iPad**:`NEPacketTunnelProvider` 扩展(`client/ios/PacketTunnel/`),经 `NETunnelProviderManager` 管理(非 sysext);App Group 用 **iOS 的 `group.` 前缀**(`group.com.pangolin.pangolinVpn`)。`ctl_info`/`sockaddr_ctl` iOS SDK 不发,靠 `PacketTunnel-Bridging-Header.h` 手写声明。
- **Android**:`PangolinVpnService`(`client/android/.../PangolinVpnService.kt`),VpnService + libbox **同进程**;明文联调 API 需 manifest `usesCleartextTraffic`(Android 9+ 默认禁 http)。
- iOS/Android 与 macOS 同走 Dart `VpnNativeBridge`,所以 Dart 侧修复(状态/统计流)四端共享。
**libbox 构建(产物 gitignore,需手动生成)**——`scripts/build-libbox.sh`:
- Apple:`bash scripts/build-libbox.sh apple macos`(只 macOS,快)/ `apple ios`(iOS+sim)→ `Libbox.xcframework``client/{macos,ios}/Frameworks/`
- Android:`bash scripts/build-libbox.sh android``Libbox.aar`,**重命名小写** `libbox.aar``app/kernel/dist/android/`。⚠️ gomobile 编 Android **强制 JDK 17**(JDK 21 直接拒);`export JAVA_HOME=/opt/homebrew/opt/openjdk@17/...` 再编。
- ⚠️ **Android libbox Java 包名是 `io.nekohasekai.libbox`**(不是 `libbox`),Kotlin import 用前者。
**真机装机(老忘——有现成脚本,别手搓 xcodebuild/签名)**:
- **iOS**:`API_URL=https://api.yanmeiai.com bash scripts/local_test.sh ipad "<设备名/id>"`(`local_test.sh ios-devices` 列已连设备)。脚本自动:flutter build ipa(**公司分发证书** Apple Distribution: Yanmei / Team `BYL4KQHMTN`,ad-hoc)→ 核验签名主体(防无声退回个人证书)→ `xcrun devicectl` 装机。`API_URL` 决定客户端连哪个控制面(prod = `https://api.yanmeiai.com`)。
- iOS libbox 是 **gitignore 产物**,新 worktree 常缺 → 从主仓拷免重建:`cp -R /Users/wangjia/code/pangolin/client/ios/Frameworks/Libbox.xcframework client/ios/Frameworks/`(routing 等功能不碰 libbox 接口,主仓那份兼容)。
- 新设备首次装报 `0xe8008012`(描述文件不含 UDID)→ 需 xcodebuild `-allowProvisioningUpdates -allowProvisioningDeviceRegistration`(脚本 die 里给了兜底命令);chen 的设备(chen-macbook/chen-iphone)已注册。装机走**公司**分发证书,禁个人开发证书(记忆 `ios-install-team-cert`)。
## 跨端实时统计 + urltest 延迟(连接页"延迟"的唯一正解)
连接页"延迟"= 内核 urltest(经 REALITY 真实出站测 RTT);**坑很深,改前必读**
`docs/connect-latency-urltest.html`。铁律:
- **服务端下发的 sing-box 配置没有 `clash_api`** → 内核不暴露出站组/urltest 历史 → libbox 的
Group 命令 / `/proxies` 全空。**每个原生端必须在起内核前给配置注入 `experimental.clash_api`
(127.0.0.1 本地监听)+ `cache_file`**,再经本地 HTTP 查 `/proxies`(读 history)+
`/group/<name>/delay`(刷新)取真实延迟(与 Windows desktop bridge 同法)。
- **libbox CommandClient 一连接只订一种命令**:`addCommand(Status)+addCommand(Group)` 同一 client
只第一个生效(Group 永不回调)。速率走 Status,urltest 走上面的 clash HTTP,别指望单 client 的 Group。
- **macOS 扩展是 root、容器与无 root 主 app 不同路径** → 主 app 连不上扩展的 command.sock;macOS/iOS
统计走 `NETunnelProviderSession.sendProviderMessage`(扩展内 StatsCollector 采集、app 拉取)。Android
同进程,直接 socket。
- **Dart 侧**:`VpnNativeBridge.statsStream/statusStream` 必须缓存成**共享广播流**(`asBroadcastStream`)——
否则多订阅者(连接页速度 + ConnectionController 回写延迟)抢 EventChannel 的单一原生 sink,后订阅者赢、
前者变哑(表现:速度正常但延迟一直 —)。`_onStats` 在自动连到已运行隧道(`_connectedNode==null`)时回退
`effectiveNode`;连接态 `_measure` 跳过直连实测(全局 TUN 会本地接住、返回假的几 ms)。
> 设备列表"最后在线"取 `last_seen`(连接/用量/~15s 会话轮询刷新),不是 `last_login`(仅登录那刻)。