Compare commits

..

3 Commits

Author SHA1 Message Date
wangjia e157b16c11 fix(server): 走隧道/拒绝的域名规则也开 reverse_mapping
用户规则(可配置分流)里 action==proxy/reject 的域名规则,此前不触发
dns.reverse_mapping(只有 direct 域名规则触发)。而应用自行解析域名后按 IP
发起连接,路由层只剩 IP,无反向映射则 domain 规则永不命中——走隧道/拒绝的
域名规则会静默失效。

translateUserRules 把 hasDomainDirect 扩成 hasDomainRule:任意 action 的
域名类规则(domain/domain_suffix/domain_keyword)都置真、都开 reverse_mapping。
direct 专属的 ip_cidr→route_exclude_address(extraExclude)那条线不变。

这也是把私有服务分流(PANGOLIN_PRIVATE_SPLIT_DOMAINS)改用用户规则表达的
前置修复——否则一条"走隧道"用户规则替代 private-split 会连不上。

回归测试 TestBuildConfigProxyDomainEnablesReverseMapping:唯一一条走隧道/拒绝
域名规则(privateSplit 关、无 direct 域名规则)必须开 reverse_mapping。go test ./... 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A79VtQA1BwTuQN1ThpvYpo
2026-07-31 01:15:34 +08:00
wangjia ce485a1b62 fix(ui): SegSwitch 用 FittedBox(scaleDown) 防窄容器截断文字
添加规则弹层的动作段选(Direct/Tunnel/Reject)在窄弹层里被 icon+文字挤到 ellipsis 截断
成「Dire…/Tun…/Reje…」。改:段内容包 FittedBox(scaleDown)——放得下原样(宽段选/goldens
不变),放不下整体等比缩放而非截断;padding 16→12 留余量。flutter test 265/265 无 golden 回归。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A79VtQA1BwTuQN1ThpvYpo
2026-07-29 08:42:27 +08:00
wangjia 49d0c5d2df docs(claude): 记 iOS 真机装机(local_test.sh ipad,老忘)+ CI 发版(tag触发+私有依赖鉴权)+ 可配置分流功能
- 移动端段加真机装机:API_URL=... local_test.sh ipad <设备>(公司分发证书/签名核验/xcurl 装机);
  iOS libbox gitignore 产物从主仓拷免重建;新设备 -allowProvisioningDeviceRegistration。
- CI/CD 段更新:tag 触发发版(server-v*/client-v*/site-v* → compile→test→release→deploy pangolin1
  备份+migrate+回滚+healthz),取代过时的「仅校验无部署」;私有依赖 github.com/wangjia/codes→gitea
  鉴权(GOPRIVATE+insteadOf,冷缓存 runner 必踩);发版排障(gitea actions API + rbw gitea 读写key)。
- server/ 加可配置分流小节:routing_profiles/GET-POST me/routing/BuildClientConfig 翻译(层级/三模式/
  IP直连 route_exclude 在 tunIn/域名直连 reverse_mapping/nil 逐字节不变)/system_locked_domains。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A79VtQA1BwTuQN1ThpvYpo
2026-07-29 08:23:42 +08:00
4 changed files with 89 additions and 25 deletions
+36 -3
View File
@@ -66,11 +66,39 @@ server 已与具体 DB 解绑(裸 SQL + 薄方言层,`internal/db/dialect.go`):
`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
`.gitea/workflows/ci.yml`(runner label `nas`):shellcheck(bootstrap/single-node 脚本)、
OpenAPI 结构校验、UI 文案脱敏扫描、Flutter analyze+test。**仅校验,无部署动作。**
- 节点部署是手动/按需的(scp+ssh 跑 bootstrap / single-node),不走 CI 自动推送。
`.gitea/workflows/`(runner = 家里 NAS 上的 act_runner;job `runs-on: ubuntu-latest` 由它在
`catthehacker/ubuntu:act-latest` 容器承接):
- **`ci.yml`**(push/PR,**仅校验**):shellcheck、OpenAPI 结构校验、UI 文案脱敏扫描、Flutter
analyze+test、codegen 漂移闸(`ci/check-codegen-drift.sh`:token/l10n/原型 i18n)。
- **发版走 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 单源模型
@@ -179,6 +207,11 @@ cd web/website && npm run gen:tokens
- 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);**坑很深,改前必读**
+13 -11
View File
@@ -49,30 +49,32 @@ class SegSwitch extends StatelessWidget {
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOut,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
// .segswitch-opt.is-active:surface 底 + shadow-sm;未选透明
color: active ? c.surface : Colors.transparent,
borderRadius: BorderRadius.circular(PangolinRadius.full),
boxShadow: active ? PangolinShadow.sm : null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(o.icon, size: 16, color: active ? c.fg1 : c.fg2),
const SizedBox(width: 8),
Flexible(
child: Text(
// FittedBox(scaleDown):放得下则原样(宽段选不变),放不下则图标+文字整体
// 等比缩放而非把文字 ellipsis 截断(窄弹层里 Direct/Tunnel/Reject 曾被截成 Dire…)。
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(o.icon, size: 16, color: active ? c.fg1 : c.fg2),
const SizedBox(width: 8),
Text(
o.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: PangolinText.sm.copyWith(
color: active ? c.fg1 : c.fg2,
fontWeight: FontWeight.w600,
),
),
),
],
],
),
),
),
);
+14 -11
View File
@@ -43,11 +43,14 @@ type ClientConfigOpts struct {
// - extraExclude: type==ip_cidr && action==direct 的 value,供调用方并入
// TUN 入站的 route_exclude_address(auto_route 层直连才真正生效,
// 见 tunIn 构造处注释)。
// - hasDomainDirect: 是否存在 action==direct 的域名类规则(domain/
// - hasDomainRule: 是否存在**任意** action 的域名类规则(domain/
// domain_suffix/domain_keyword),供调用方决定是否开 dns.reverse_mapping。
// 不限 direct:走隧道/拒绝的域名规则同样需要反向映射——应用自行解析域名后
// 按 IP 发起连接,路由层只剩 IP,没有 reverse_mapping 则 domain 规则永不命中、
// 该规则(不论直连/隧道/拒绝)静默失效。
// - geoSets: 规则引用到的 geoip-<v>/geosite-<v> rule_set tag(去重),供
// 调用方在国内分流(splitActive)之外也补上 rule_set 定义。
func translateUserRules(p *routing.Profile) (rules []any, extraExclude []string, hasDomainDirect bool, geoSets []string) {
func translateUserRules(p *routing.Profile) (rules []any, extraExclude []string, hasDomainRule bool, geoSets []string) {
if p == nil || p.Mode != "rule" {
return nil, nil, false, nil
}
@@ -71,9 +74,8 @@ func translateUserRules(p *routing.Profile) (rules []any, extraExclude []string,
switch r.Type {
case "domain", "domain_suffix", "domain_keyword":
rules = append(rules, map[string]any{r.Type: []string{r.Value}, "outbound": outbound})
if outbound == "direct" {
hasDomainDirect = true
}
// 任意 action 的域名规则都要 reverse_mapping(见返回值注释),不止 direct。
hasDomainRule = true
case "ip_cidr":
rules = append(rules, map[string]any{"ip_cidr": []string{r.Value}, "outbound": outbound})
if outbound == "direct" {
@@ -88,7 +90,7 @@ func translateUserRules(p *routing.Profile) (rules []any, extraExclude []string,
}
}
}
return rules, extraExclude, hasDomainDirect, geoSets
return rules, extraExclude, hasDomainRule, geoSets
}
// ruleSetDef 渲染一个自托管 remote rule_set 定义(与既有 geoip-cn/geosite-cn
@@ -243,10 +245,10 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien
// 直接返回全零值,以下每一步都随之短路,保证 nil-profile 渲染逐字节不变。
var userRules []any
var extraExclude []string
var hasDomainDirect bool
var hasDomainRule bool
var geoSets []string
if opts.Profile != nil {
userRules, extraExclude, hasDomainDirect, geoSets = translateUserRules(opts.Profile)
userRules, extraExclude, hasDomainRule, geoSets = translateUserRules(opts.Profile)
if opts.RulesBaseURL == "" && len(geoSets) > 0 {
// 没有 base 就没法渲染 remote rule_set 的下载 URL,引用它的用户规则
// 会指向未定义的 tag(sing-box FATAL)。与 splitActive 缺 base 时静默
@@ -380,10 +382,11 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien
"domain": opts.PrivateSplitDomains, "server": "dns-system",
})
}
if privateSplit || hasDomainDirect {
if privateSplit || hasDomainRule {
// 回映射:记住"哪个 IP 是哪个域名解析出来的",给后续按 IP 发起的连接补回
// 域名元数据——路由层的 domain 规则(私有域名→隧道 / 用户域名直连)靠它
// 才会命中。hasDomainDirect:用户规则含 action==direct 的域名类规则时同样需要。
// 域名元数据——路由层的 domain 规则(私有域名→隧道 / 用户域名规则)靠它
// 才会命中。hasDomainRule:用户规则含**任意** action 的域名类规则(直连/
// 走隧道/拒绝)时都需要——否则该域名规则永不命中、静默失效。
dns["reverse_mapping"] = true
}
// 国内分流的 DNS 面(补 #5 数据面之外的 DNS 面):开分流时,命中 geosite-cn 的
@@ -358,6 +358,32 @@ func TestBuildConfigUserRules(t *testing.T) {
}
}
// 回归钉:走隧道/拒绝的域名规则也必须开 reverse_mapping。应用自行解析域名后按 IP
// 发起连接,路由层只剩 IP,无反向映射则 domain 规则永不命中、该规则静默失效。
// 旧实现只为 action==direct 的域名规则开 reverse_mapping,走隧道/拒绝会漏 → 本测试
// 用**唯一一条走隧道域名规则**(无任何 direct 域名规则、privateSplit 关)钉死修复。
func TestBuildConfigProxyDomainEnablesReverseMapping(t *testing.T) {
for _, action := range []string{"proxy", "reject"} {
node := testNode()
p := routing.Default()
p.Rules = []routing.Rule{
{Type: "domain_suffix", Value: "example.com", Action: action, Enabled: true},
}
// SplitCN 关 + 不给 PrivateSplitDomains → reverse_mapping 只可能由该域名规则触发。
raw, err := BuildClientConfig(node, "dp", "k", ClientConfigOpts{Profile: p})
if err != nil {
t.Fatal(err)
}
var cfg map[string]any
if err := json.Unmarshal(raw, &cfg); err != nil {
t.Fatal(err)
}
if cfg["dns"].(map[string]any)["reverse_mapping"] != true {
t.Fatalf("action=%s domain rule must enable reverse_mapping", action)
}
}
}
func TestBuildConfigGlobalMode(t *testing.T) {
p := routing.Default()
p.Mode = "global"