- CLAUDE.md:加「移动端(iOS/iPad+Android)原生隧道」「跨端实时统计+urltest 延迟」两节—— libbox 构建前置(JDK 17/包名/gitignore)、clash_api 注入正解、libbox 单 client Group 坑、 Dart 共享广播流、连接态跳直连实测;附「最后在线取 last_seen」一句。 - 新增 docs/connect-latency-urltest.html(排障/Runbook):四端 urltest 实现 + 根因链 + 打点法, 登记进 docs/index.html。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
11 KiB
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.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。客户端连节点真实出网。
数据层:多数据库(一个环境变量切换)
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 组件代码副本(会漂移)。
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 / 静默拒)
- 自包含:
PacketTunneltarget 设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_ctliOS SDK 不发,靠PacketTunnel-Bridging-Header.h手写声明。 - Android:
PangolinVpnService(client/android/.../PangolinVpnService.kt),VpnService + libbox 同进程;明文联调 API 需 manifestusesCleartextTraffic(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 用前者。
跨端实时统计 + 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(仅登录那刻)。