Files
pangolin/CLAUDE.md
T
wangjia 3311430db6 docs(ds-flow): Phase 0 — CLAUDE.md 增前端设计系统治理章节
新增「## 前端设计系统治理(ds-flow)」:心智模型(原型单源·代码镜像·静态闸
拦漂移·golden/fidelity 双级验收)、原型三件套(design/prototype/ tokens/atoms/
icons/index.html)、L1/L2/L3 三层治理、codegen(Flutter 生成 + Web 同源不重复
生成)、四道静态闸「违规谁拦」对照表、双级像素验收、pre-commit 启用说明。

 标注部件为 Phase 1-5 建设中,诚实反映当前落地状态,指向计划 #19。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:56:15 +08:00

15 KiB
Raw Blame History

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/ 结构

# ① 新机初始化(幂等,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.envdeploy/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 专属构造(已全部清除,加回会破坏可移植性)。

CI/CD

.gitea/workflows/ci.yml(runner label nas):shellcheck(bootstrap/single-node 脚本)、 OpenAPI 结构校验、UI 文案脱敏扫描、Flutter analyze+test。仅校验,无部署动作。

  • 节点部署是手动/按需的(scp+ssh 跑 bootstrap / single-node),不走 CI 自动推送。

设计 Token 单源模型

design/colors_and_type.css唯一 token 真相源。改颜色/间距/圆角只改这一个文件,然后:

# 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

心智模型:设计只有一个出生地(原型单源),代码永远是镜像;跨端副本是否走样由 静态闸在提交/CI 前拦截,像素是否还原由 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

  • Flutternode design/codegen/gen_flutter_tokens.mjspangolin_tokens.gen.dart勿手改)。
  • Webbuild-tokens.mjswebsite→src/styles/tokens.gen.css / usercenter→public/colors_and_type.css), 只同源不重复生成设计决策;靠同源闸逐值校验(不建跨端共享组件包,两端各自实现)。

四道静态闸 —「违规谁拦」(规则没上闸 = 没有规则):

拦什么 何时
原型校验 check-ds.mjs Phase 5 硬编码色/未定义 token/未登记组件/魔法数断点… 12 道 pre-commit(动了原型)+ CI
跨端同源 check-l1-sync.mjs Phase 2 tokens 逐值/icons 同集/Web hex 白名单 CI
代码色单源 check_ds_code.mjs Phase 5 Flutter 裸 Color(0x)/具名 Colors.x// ds-ignore: 理由 豁免) pre-commit--changed+ CI
codegen 零 diff ci/check-codegen-drift.sh已生效 重生成 token 后 git diff 非空即 fail pre-commit + CI

双级像素验收

  • 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):sysextdno 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.xcframeworkclient/{macos,ios}/Frameworks/
  • Android:bash scripts/build-libbox.sh androidLibbox.aar,重命名小写 libbox.aarapp/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 用前者。

跨端实时统计 + 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(仅登录那刻)。