# 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 节点

当前节点:**RackNerd VPS `107.172.55.251`**(Debian 12 bookworm,root,1 核 / 512MB / 2G swap)。

- **SSH 免密**:本机 `~/.ssh/config` 有别名 `racknerd`(密钥登录,密码登录已禁)→ 直接 `ssh racknerd`。
- 已用 `deploy/bootstrap/init.sh` 加固(ufw + fail2ban + BBR + swap + pangolin 用户)。
- **512MB 内存是硬约束**:控制面用 **SQLite**(见下),不要在这上面跑 MySQL 8。
- 改任何机器系统(装包/改配置/重装)前**先问用户**(只读操作除外)。

## deploy/ 结构

```bash
# ① 新机初始化(幂等,root 跑一次;后续每台新机复用)
#    远程:scp -r deploy/bootstrap racknerd:/root/ && ssh racknerd '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 真相源**。改颜色/间距/圆角只改这一个文件，然后：

```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 组件代码副本（会漂移）。

## 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` 预取**,避免启动期下载。
