Commit Graph

626 Commits

Author SHA1 Message Date
wangjia fc1160ecb3 ci(client): sing-box 源码统一走公网镜像 git.51yanmei.com(android/ios/macos)
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 24s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 20s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 18s
ci-pangolin / Lint — shellcheck (pull_request) Successful in 3m17s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 9m23s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 1m20s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 5m29s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 54s
ci-pangolin / Go — build + test (pull_request) Failing after 1m20s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 17m49s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 5m24s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Successful in 1m16s
android/ios/macos 三端都内嵌 libbox,编译期都要 git clone sing-box 源码。
此前只有 build-android 注入了 SINGBOX_GIT(且指向本地 relay 127.0.0.1:13000,
依赖 tailscale/LAN,Mac runner 出外网即失效),ios/macos 完全没注入 → 直接
clone github.com → 被 GFW/DNS 污染打挂(SSL_ERROR_SYSCALL,exit 128)。

统一改用稳定公网域名 git.51yanmei.com(ali frps ← 家里 NAS frpc,nginx 443
反代 gitea)——外网/内网都通,不依赖本地 relay/tailscale。已用真实 clone 命令
从外网验证 v1.13.13 可拉。ios/macos 同时补 GOPROXY(gomobile go install)。
build-windows 保持 LAN 镜像不动(Windows 台式机固定在家,不移动)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A79VtQA1BwTuQN1ThpvYpo
2026-07-26 10:36:08 +08:00
wangjia ee16cd3266 ci(android): libbox 从 NAS gitea 镜像取 sing-box 源码,绕开 github 不可达
Deploy Client / build-windows (push) Successful in 1m46s
Deploy Client / build-android (push) Failing after 22m36s
Deploy Client / release-deploy (push) Has been skipped
Deploy Client / build-macos (push) Failing after 3m40s
Deploy Client / build-ios (push) Failing after 1m16s
build-android 失败根因:build-libbox.sh 第一步 git clone github.com/SagerNet/
sing-box,而 mac runner(经 pangolin 隧道)连不上 github(SSL_ERROR_SYSCALL,
exit 128)。windows 成功是因为它从 NAS 镜像取 sing-box.exe、不碰 github。

修:build-libbox.sh clone 改用 $SINGBOX_GIT(默认仍 github,本地开发不受影响);
deploy-client.yml 的 build-android 设 SINGBOX_GIT=http://127.0.0.1:13000/wangjia/
sing-box.git,经 runner 的 gitea relay 取 NAS 上的 sing-box 镜像(已用 gitea
migrate 从 github 拉好,含 v1.13.13)。与 windows 的 DESKTOP_BIN_MIRROR 同思路,
CI 不再依赖 github。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
client-v1.0.74
2026-07-26 08:24:03 +08:00
wangjia e1e43adc80 fix(client-tun): macOS/iOS 应用 excludedRoutes,私有 LAN 真直连
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 26s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 20s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 21s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 2m5s
ci-pangolin / Go — build + test (push) Failing after 2m37s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 3m47s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Failing after 11m31s
ci-pangolin / Flutter — analyze + test (push) Failing after 11m39s
ci-pangolin / OpenAPI Sync Check (push) Failing after 11m50s
ci-pangolin / Lint — shellcheck (push) Failing after 12m1s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 6m57s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Failing after 10m29s
Deploy Client / build-windows (push) Successful in 1m51s
Deploy Client / build-android (push) Failing after 3m7s
Deploy Client / release-deploy (push) Has been skipped
Deploy Client / build-macos (push) Failing after 27s
Deploy Client / build-ios (push) Failing after 5m17s
上一版加了服务端 route_exclude_address,但 macOS/iOS 路由由 Swift 的
NEPacketTunnelNetworkSettings 控制,而 openTun 只设了 includedRoutes、
从不设 excludedRoutes → libbox 解析出的排除网段被丢弃,strict_route 仍把
LAN(192.168/16、10/8)抓进隧道,VPN 开着连不上局域网/NAS/家里机器/内网 gitea。

修:openTun 里 ipv4/ipv6 各补 excludedRoutes = excludedRoutes4/6(options),
读 libbox 的 getInet4/6RouteExcludeAddress(sing-box config 的 route_exclude_address
经此暴露)。镜像已有的 includedRoutes4/6 写法。

生效:客户端重新构建发版后,私有 LAN 直连恢复 → CI runner 不再需要经 ali 的
中继隧道(relay),从 Mac 也能直连家里所有内网机器。
Android(VpnService.excludeRoute,API33+ 分版本)作后续。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 07:17:09 +08:00
wangjia a3ad50aa75 fix(client-config): TUN 加 route_exclude_address 让私有 LAN 直连
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 27s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 20s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 20s
ci-pangolin / Flutter — analyze + test (push) Failing after 10m20s
ci-pangolin / OpenAPI Sync Check (push) Failing after 10m29s
ci-pangolin / Lint — shellcheck (push) Failing after 10m41s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 14m28s
ci-pangolin / Go — build + test (push) Failing after 14m37s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Failing after 14m49s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Failing after 14m57s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 19m19s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Failing after 12m37s
隧道开着时连不上局域网/NAS/家里机器(SSH/gitea 全 reset)。根因:TUN 入站
auto_route+strict_route 在 OS 层把所有流量(含 LAN)强抓进隧道,而 route.rules
里的 ip_cidr(192.168/16…)→direct 在 macOS 被 strict_route 抵消(direct 出站的
包又被捕回隧道)。

修法:TUN 入站加 route_exclude_address=[192.168.0.0/16, 10.0.0.0/8],在 auto_route
层就把这些网段排除出隧道,LAN 走系统直连。**刻意不含 172.16.0.0/12**——隧道自身
地址与 DNS(172.19.x)在此段,排除会断 DNS。route_exclude_address 为 sing-box
v1.13 合法 TUN 字段(option/tun.go)。

影响:VPN 不再黑洞局域网——直连 NAS/家里机器/内网 gitea 恢复,CI runner 也不再
需要经 ali 的中继隧道(relay)。客户端需完整重连拉新配置生效。

测试:TestBuildClientConfigLANExclude 断言 tun 含 192.168/16+10/8、不含 172.16/12;
go test ./internal/httpapi 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:53:35 +08:00
wangjia 5bc5ed1e62 ci: pangolin 的 runs-on 从 nas 统一改为 mac
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 29s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 21s
ci-pangolin / Lint — shellcheck (push) Successful in 49s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 22s
ci-pangolin / OpenAPI Sync Check (push) Successful in 10m53s
ci-pangolin / Flutter — analyze + test (push) Failing after 12s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 28s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 24s
ci-pangolin / Go — build + test (push) Failing after 46s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 36s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 5m15s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Successful in 1m3s
runner 命名/标签统一:mac-pangolin-2 是跑在 Mac 上的个人级 runner,原来挂
`nas` 标签(历史上按"理想归宿是 NAS Linux runner"预留),命名与实体不符、
易混。现把 runner 标签改为 `mac`(个人级不变,仍服务名下所有仓库),并把
pangolin 全部 workflow 的 `runs-on: nas` → `runs-on: mac` 对齐(ci.yml 9 个
job + 解释注释、deploy-client.yml 4 个 job)。与 jiu 的 `runs-on: mac` 统一,
一个个人级 mac runner 即可服务两边。ci-runner.md 标签引用同步更新。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 00:55:26 +08:00
wangjia 398e6e61cb Revert "ci(site): web 发版从 ubuntu-latest 迁到 nas runner"
This reverts commit 7e357ce.

原判断有误:以为 ubuntu-latest 无对应 runner。管理后台确认 gitea 有一个
**全局**在线 runner `nas-runner`(id 2, label ubuntu-latest),它服务所有仓库
(含 pangolin)——web 发版本来就有 runner、并未卡住。恢复 runs-on: ubuntu-latest,
用回这个专供 web/node 的全局 runner,而非把 web 挪到 Mac host runner。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 02:48:11 +08:00
wangjia 7e357ced0a ci(site): web 发版从 ubuntu-latest 迁到 nas runner
pangolin 只注册了一个 runner(mac-pangolin-2, label nas, host 模式);
deploy-site.yml 原 runs-on: ubuntu-latest 无对应 runner → site-v* 发版
永久排队,官网/用户中心走 CI 发不出来。

改 runs-on: nas,直接在宿主 Mac 上 host 跑(Mac 自带 node v25/npm)。
内存无忧:Astro 轻,Next.js next build 峰值 ~1-2GB,Mac 足够——刻意不放
内存紧的 pangolin1(960MB)或 NAS-Linux。wrangler pages deploy 需网络 +
CF token(CLOUDFLARE_API_TOKEN/ACCOUNT_ID secret 已配)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-25 00:44:42 +08:00
wangjia ea1a875607 Merge feat/private-dest-acl: 节点侧私有目的地 ACL(按 dp_uuid 放行家庭内网服务)
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 28s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 19s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 18s
ci-pangolin / OpenAPI Sync Check (push) Successful in 1m24s
ci-pangolin / Flutter — analyze + test (push) Successful in 1m0s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 27s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 29s
ci-pangolin / Go — build + test (push) Failing after 49s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 38s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 5m3s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Successful in 43s
ci-pangolin / Lint — shellcheck (push) Failing after 12m23s
pangolin 出口 IP 是家庭内网服务(brain/nas/git/win.51yanmei.com)在 ali 侧的
唯一准入凭据;单用户时=「只有我」,现在 5 个账号退化成「所有 pangolin 用户」。
本功能在节点 sing-box route 上按 dp_uuid 补一道 fail-closed 白名单闸,把语义
拉回「只有我」。改动收敛在 internal/agentd/,零 migration,不动控制面/客户端/DB。

合并动因:该分支代码已在 pangolin1 生产运行(线上 agent 二进制 vcs.revision=
e4b527b + auth_user 修复,昨晚经 SIGHUP 热渲染 auth_user 闸生效),但 main 缺失
——从 main 重建部署 agent 会静默移除这道安全闸。合并后 main 与生产对齐,并把
线上「脏树构建(vcs.modified=true)」规整成干净可追溯来源。

含 21 个提交:设计/计划文档、ACL 配置类型与 fail-closed 加载、渲染放行+拒绝
规则对、last-good 内存+落盘跨重启兜底、SIGHUP 热重载不踢人、ip_cidr/port 校验、
resolve 堵域名绕过、auth_user(VLESS 运行时只认 auth_user)、29 个单测 + 真机验收。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-25 00:20:53 +08:00
wangjia 1d154bd627 feat(admin): 后台登录支持「记住此设备」(免二次验证 + 保持登录)
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 25s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 20s
ci-pangolin / Lint — shellcheck (push) Successful in 51s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 23s
ci-pangolin / OpenAPI Sync Check (push) Successful in 1m10s
ci-pangolin / Flutter — analyze + test (push) Successful in 3m44s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 1m7s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 24s
ci-pangolin / Go — build + test (push) Failing after 1m0s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 40s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 6m9s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Successful in 42s
常用设备(已在 mTLS 白名单内)每次都要输 TOTP + 30 分钟就掉线,体验差。
新增登录页「记住此设备」勾选:

- 勾选并成功登录(需完整 密码+TOTP)后,签发 30 天设备信任令牌(HttpOnly/
  Secure/SameSite=Strict cookie,Redis 存储绑定 admin ID),并把会话延到 30 天
  (持久 cookie + 服务端 TTL,滑动续期按会话自身 TTL)。
- 之后该设备重登只需 用户名+密码,**跳过 TOTP**;会话在有效期内保持登录。

安全不变量(均有测试覆盖):
- 密码永远必验——即便持有效信任令牌,密码错一律拒(只跳过第二因子,不跳过密码);
- 信任令牌绑定 admin,alice 的令牌不能给 bob 免 TOTP;
- 无令牌 + 空 TOTP 一律拒(未记住设备仍强制二次验证);
- 令牌过期/Redis 清空/未知令牌全部 fail-closed 回退到「要 TOTP」;
- TrustedDeviceTTL=0 关闭整功能(勾选无效)。

实现:新增 TrustedStore(Redis, trusted.go);Authenticator.LoginDevice
(旧 Login 保持签名,委托新方法,零行为变化);SessionStore.CreateWithTTL +
Session.TTLSeconds 支持持久会话按自身 TTL 滑动;handler 读 cookie/勾选、
按 Persistent 设长短会话 cookie、下发信任 cookie;登录页加勾选、TOTP 去
required。配置项 ADMIN_TRUSTED_DEVICE_TTL(默认 720h)。

测试:trusted_test(签发/校验/绑定/吊销/过期/禁用)、login_device_test
(跳过TOTP/仍需密码/绑定admin/无令牌需TOTP)、login_device_handler_test
(端到端 勾选→双cookie→凭信任cookie免TOTP、无信任空TOTP 401);
go test ./internal/admin 全绿,go vet 净。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-24 09:52:56 +08:00
wangjia f819a77d83 fix(admin): 登录审计记录真实来源 IP(经本机反代时取 XFF 末跳)
管理后台经 caddy mTLS 网关(同机 loopback 反代 127.0.0.1:9444)对外后,
LoginSubmit 里 hostOnly(r.RemoteAddr) 恒为 127.0.0.1,admin_login_ok/fail
的 ip 字段失去溯源价值。

新增 realIP():仅当 TCP 对端是 loopback(请求确实来自本机可信反代)才信
X-Forwarded-For,且取**最后一跳**(Caddy 把真实 TCP 对端追加在末位,更早的
段可被客户端伪造预置);直连、XFF 缺失或非法值一律回退 TCP 对端。

IPAllow 白名单中间件刻意不变——安全闸继续只看 RemoteAddr,不受任何代理头
影响(维持原注释声明的边界)。

测试:TestRealIP 七例(直连/伪造头/单跳/多跳/缺失/非法/IPv6);
go test ./internal/admin 全绿。(internal/store 迁移彩排 3 例失败为 HEAD
既有——主仓干净树复现,与本次无关。)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-24 08:34:57 +08:00
wangjia 344dfe9a82 docs(agent): 记录 auth_user 血泪教训 + 客户端隧道前置 + 验收须真连接
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 07:39:39 +08:00
wangjia 2abe4d0816 fix(agent): ACL 放行用 auth_user 而非 user(VLESS 运行时只认 auth_user)
生产验证发现:sing-box 1.13 的 route rule 里 user 字段对 VLESS/REALITY
入站运行时不匹配(仅 sing-box check 语法通过),导致放行规则永不命中、
白名单用户也被兜底拒绝。本地真 VLESS 连接实测确认 auth_user 生效
(good→通/bad→block),node 端 git 一进一出白名单验证 per-user 生效。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 10:36:02 +08:00
wangjia e4b527b2c9 docs(agent): 上线验收补「live 生效核实」与「域名+裸IP 双形式测另一账号被拒」
C1 指出 config.json 内容正常不等于线上生效——SIGHUP 走 sing-box 自身校验,若
新配置被拒,它会保留旧实例继续跑,agent 侧仍记"渲染成功"。第 4 步补
systemctl is-active sing-box + journalctl -u sing-box -n5 | grep -v FATAL。

I3 表明 ip_cidr 目的地存在域名形式绕过口子(已修),第 6 步"另一账号被拒"补充
必须分别用域名和裸 IP 两种目的地表达形式各测一遍,只测一种形式验证不到位。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 07:45:38 +08:00
wangjia aaa3384e3b feat(agent): route 加 resolve action,堵住 ip_cidr 目的地的域名形式绕过
sing-box route 的 ip_cidr 匹配的是已解析的连接目的地地址;客户端若发的是域名
形式(如 nas.51yanmei.com:5001)而不先解析,ip_cidr + port 的拒绝规则不命中,
请求会绕过 ACL 直接落到 final:direct——DSM/RDP/SSH 因而对任何持有有效 dp_uuid
的 pangolin 用户可达,不受白名单约束。攻击者只需让客户端连接目的地时使用域名
而非 IP 即可绕过。

用户已拍板方案:在 sniff 之后紧跟加一条 {"action":"resolve"},让节点自己解析
目的地,使域名形式收敛到 ip_cidr 规则上(已用本机 sing-box 1.13.13 确认接受
[{"action":"sniff"},{"action":"resolve"},...] 这个形状,TestRenderedConfig_
PassesSingBoxCheck 也覆盖了这条产物)。route 规则顺序变为:
sniff → resolve → ACL 放行 → ACL 拒绝 → WARP。resolve 与 sniff 同条件——只在
产出 route 块时才出现,唯一且紧跟 sniff。

代价(用户已知情接受):节点从此会对连接目的地做 DNS 解析,是对"节点只见不透明
dp_uuid、不知道目的地"这条隐私姿态的一次主动后退,详见设计文档 §12。

TDD:更新 TestBuildRoute_Matrix 的四态断言(规则数 2/3/4 → 3/4/5,新增 resolve
计数/位置断言),新增 TestBuildRoute_ResolvePositionedRightAfterSniff(RED:此前
resolve 不存在)与 TestBuildRoute_NeverConfigured_NoResolve(未配置态不受影响)。
确认既有 TestRender_Warp_InjectsEndpointAndRoute(只断言首条 sniff、末条 warp,
不断言精确条数)未受影响,原样绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 07:44:58 +08:00
wangjia f147ffbe9e fix(agent): 校验 ip_cidr/port,非法即走 fail-closed;渲染产物加 sing-box check 常驻测试
cleanTargets 只做 trim/lower,从不校验取值——port:[70000]、ip_cidr:"not-a-cidr"
这类语法正确但语义非法的值会被顺利渲染进 sing-box 配置。SIGHUP 热重载路径下
sing-box 会自己校验并拒绝这份新配置、保留旧实例继续跑,但 agent 早已把它当
"渲染成功"写盘并记正常日志——于是 /etc/sing-box/config.json 内容看起来正常
(能被 json.load 解析、rules 都在),但线上的 gate 其实完全没生效,而这在
运维层面几乎不可见。

修复:
- LoadACLConfig 解析成功后跑一遍 validate():每个 target 的 ip_cidr 必须能被
  netip.ParsePrefix 解析,每个 port 必须落在 1-65535,否则返回 error——与解析
  失败走同一条路径(fail-closed last-good 兜底),而不是把坏值一路渲染出去。
- 新增 TestRenderedConfig_PassesSingBoxCheck:把渲染结果喂给真实 sing-box 1.13.13
  二进制的 `check` 子命令断言 exit 0,把设计 §9 "产物合法性" 验收项落成常驻测试
  (exec.LookPath 找不到二进制则 t.Skip,不 fail)。本机 sing-box(homebrew 装,
  无 with_v2ray_api tag)会因实验性 v2ray_api 段落报编译期不支持的 FATAL——与本
  测试要验的 ACL/route 语法合法性无关,故只在这条测试内剥离该段落后再校验。

TDD:acl_test.go 新增 TestLoadACLConfig_RejectsInvalidPort / _RejectsInvalidCIDR
(RED:此前返回 nil error)、TestACL_InvalidPortFallsBackToLastGood(RED:非法端口
被渲染进配置)。三条现全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 07:43:18 +08:00
wangjia 8e24a157e1 fix(agent): last-good 双失败必须告警 + 降级日志分级 + derr 落日志
loadACL 终点此前有两个问题:(1) 读磁盘 last-good 的 derr 从不落日志;(2) ALERT
只在 err != nil 时触发,但 acl.json 单纯缺失(LoadACLConfig 按设计返回 nil,nil,
不是 error)时 err 恰好是 nil——于是 acl.json 缺失 + 磁盘 last-good 同时损坏这种
"gate 实质消失"的最糟场景,反而完全没有日志。

修复:
- derr 非 nil 时打 ERROR。
- ALERT 判断改用文件是否曾经存在(os.Stat)而非 err 是否非 nil:acl.json 与
  last-good 均 not-exist → 判定"从未配置过该功能",安静返回;否则(其一存在但
  读取/解析失败)→ ALERT。
- 两条降级 fallback 日志补 WARN 级别标签,配合 ERROR/ALERT 可用
  journalctl | grep -E 'ERROR|WARN|ALERT' 一并抓到。

TDD:acl_test.go 新增三条——双失败必须 ALERT(RED)、从未配置不误报(基线即绿,
防止告警刷屏回归)、降级日志缺 WARN 标签(RED)。全部现绿。

复现细节:双失败场景没有采用"把 acl.json 和 last-good 都整个删除"来复现——那种
状态在文件系统层面与"这台节点从没配置过 ACL"完全无法区分(两次 os.Stat 皆
not-exist),任何仅凭当前文件状态判断的实现都做不出区分,要区分需要额外的持久
标记,超出本 finding 范围。改用"acl.json 缺失 + 磁盘 last-good 存在但损坏"复现,
这是一个可被 os.Stat 命中的信号,也更贴近 §5 描述的真实故障。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 07:39:30 +08:00
wangjia dbc787ac2e docs(agent): 设计 §12 补 SNI 绕过残留 + brain basic auth 已落地
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 07:38:16 +08:00
wangjia 63f4325e51 fix(agent): 空/手抖 acl.json 不得静默关闸或覆盖 last-good
loadACL 的成功分支此前无条件把解析出的 ACL 存为内存 last-good 并落盘,不管
它是否真的产出任何规则。encoding/json 忽略未知字段(必须如此,样例文件靠
_comment 自文档)——所以 "targetz"(手抖打错 "targets")或 "targets": [] 都能
解析通过,Targets 变 nil,active() 为 false,规则消失(gate 被静默关闭),且同一次
调用还会拿这份空配置覆盖 acl.last-good.json,销毁唯一的恢复快照。

修复:
- enabled=true 但 cleanTargets() 为空 → 视为加载失败,走 last-good 兜底,
  绝不返回、也绝不持久化这份空配置。
- 只有 active() 为真的配置才更新 last-good;active()=false 只可能是显式
  enabled:false(合法关闭),此时原样返回(不产出规则)但不覆盖磁盘上的
  last-good,保留后续误删/损坏时的恢复能力。
- 白名单为空但 target 有效(拒绝所有人的合法配置)不受影响,仍持久化为
  last-good、仍产出拒绝规则。

TDD:acl_test.go 新增三条 —— 手抖字段名(RED:拒绝规则消失且 last-good 被覆盖)、
显式 enabled:false(RED:last-good 被覆盖)、空白名单但有效 target(基线即绿,
防止修复过度矫正)。三条现全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 07:37:47 +08:00
wangjia 60ff7cc586 fix(agent): pangolin-agent.service 补 ExecReload,使 systemctl reload 走 SIGHUP 热重载
[Service] 块只有 ExecStart,没有 ExecReload——systemctl reload pangolin-agent
返回「Job type reload is not applicable」,运维只能退回 restart,而 restart 会让
agent 进程内 s.started 复位为 false,首次渲染走冷启动 systemctl restart sing-box,
瞬断全部在线用户。这正是 Task 5 SIGHUP 热重载路径存在的意义所在,却因为 unit 文件
缺一行而完全走不通。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 07:36:11 +08:00
wangjia 1cb494daa6 docs(agent): 计划回填 cleanHosts 去重的执行后修订
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:28:49 +08:00
wangjia 3324c1d687 docs(agent): 私有目的地 ACL 配置样例与常驻校验测试
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:24:51 +08:00
wangjia 6f183ff0df feat(agent): SIGHUP 重读节点本地配置并热重渲染,不踢在线用户
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:15:27 +08:00
wangjia d216e157c3 test(agent): 覆盖 acl.json 被删时仍走 last-good 兜底
删文件走的是 LoadACLConfig 返回 (nil,nil) 的分支,与坏 JSON 的
err!=nil 分支不同,此前无回归覆盖。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:09:02 +08:00
wangjia 87d5afa703 feat(agent): 渲染时读取 ACL,内存+磁盘 last-good 保证 fail-closed 跨重启
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:01:45 +08:00
wangjia 4155fc8542 refactor(agent): route 块改由 buildRoute 统一产出,合并 ACL 与 WARP
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 01:52:39 +08:00
wangjia 597d6ab68b feat(agent): ACL 渲染放行/拒绝规则对,空白名单仍拒绝
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 01:40:11 +08:00
wangjia d4156b81e2 refactor(agent): cleanDomains 委托 cleanHosts,消除同包重复逻辑
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 01:32:43 +08:00
wangjia 6df7c9de44 docs(agent): 修复计划未闭合代码块 + Task 6 改用常驻测试校验样例
未闭合的 ```go 让 Task 4-6 的标题落进代码块,task-brief 无法提取;
Task 6 原用 go run 校验样例文件,该文件在 module 外无法解析 import,
改为常驻测试 TestACLExampleFileStaysValid,并断言样例 uuid 为占位符
以防真实 dp_uuid 入库。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 01:12:06 +08:00
wangjia e4d014ba99 feat(agent): 私有目的地 ACL 配置类型与 fail-closed 加载语义
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 01:07:15 +08:00
wangjia b71f6038ae chore: gitignore superpowers 执行暂存目录
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 01:02:37 +08:00
wangjia 9cbffbb727 docs(agent): 私有目的地 ACL 设计方案与实现计划
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 01:01:19 +08:00
wangjia dd721fafc8 fix(scripts): local_test.sh ipad 改走公司分发证书(ad-hoc),装机前核验签名主体
原 `ipad` 子命令用 `flutter run --release` 装真机,走的是 Xcode 工程的**开发**
签名。Apple 只把 Development 证书签发给团队里的**个人成员**(不存在组织版),
于是装到机上「设置→通用→VPN 与设备管理」里开发者显示成个人姓名,而不是
Yanmei (beijing) Technology Co., Ltd。

改为 ad-hoc 分发(Xcode 15+ 的 method=release-testing)+ 自动签名:
- 新增 write_ios_export_options() 生成 ExportOptions.plist(teamID=BYL4KQHMTN);
- `flutter build ipa --release --export-options-plist=…` 导出时由 Xcode 用
  **Apple Distribution: Yanmei…** 重签;
- 装机前解包 IPA 逐字比对签名主体(IOS_EXPECT_SIGNER),不符即中止 —— 防止
  以后无声退回个人证书;
- 用 devicectl 安装;仅当覆盖安装失败(旧包签名主体不同)才卸载重装,不无条件
  先卸(卸载会清空 app 数据要重新登录);
- 构建失败时提示新设备注册命令:光给 -allowProvisioningUpdates 不够,必须同时
  给 -allowProvisioningDeviceRegistration。

另外两处顺手修:
- 取 IPA 路径原写 `ls … | head -1`,在 set -o pipefail 下 head 提前退出会让 ls
  吃 SIGPIPE、管道返回 141,被 set -e 当失败终止脚本(实测复现,退出码 141)。
  改用数组 glob。签名主体提取的 `codesign | grep -m1` 同一个坑,改成整段捕获
  后用 awk 读完全部输入。
- API_URL 支持 env 覆盖(默认值不变),便于装生产地址的包做验证。

验证:bash -n 通过;shellcheck 无新增告警;
API_URL=https://api.yanmeiai.com bash scripts/local_test.sh ipad 无字天书
端到端 exit=0,日志确认"签名核验通过: Apple Distribution: Yanmei…"并装机成功。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-22 11:59:08 +08:00
wangjia e38c7e7c1c chore: gitignore 用全局通配兜住签名私钥/证书
签名私钥 pangolin-release.jks 实际躺在**仓库根目录**,而原规则只写了
client/android/*.jks 一层,根目录那份既未被 git 跟踪、也未被忽略 ——
一次 `git add -A` 就会把 release 私钥提交进库。

改为全局通配 *.jks / *.keystore / key.properties,并一并兜住 Apple 侧的
*.p12 / *.mobileprovision / *.provisionprofile(CI 与 local_test.sh 都会
在工作区落盘这些材料)。

已核对:仓库当前没有任何**已跟踪**文件匹配这些模式,不会把在库文件变成
untracked。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-22 10:57:10 +08:00
wangjia d5fc9c1c07 fix(client): 补 es/ja/ko/ru 支付文案 + 去掉 pubspec 重复依赖键
HEAD 上客户端编译不过,两处:

1. pay-v2 给 AppText 加了 28 个支付相关 getter,只补了 zh/en,
   es/ja/ko/ru 四个 Strings* 实现类没跟上 → non_abstract_class_
   inherits_abstract_member,flutter analyze/test/build 全挂。
   本次按 zh/en 原文补齐四语(购买/支付方式/USDT 收款/支付宝跳转/
   订单态等 28 条),用词沿用生命周期中文词的对应译法。

2. pubspec.yaml 里 url_launcher 声明了两次(line 23 与 line 30),
   yaml Duplicate mapping key 直接让 flutter 工具链抛异常。删掉
   后加的那条重复声明,保留带原注释的首条。

验证:flutter analyze 仅剩 3 条既有 info;flutter build ios --release
成功并已装到 iPad Pro 真机。(test/golden 34 个失败是既有 UI 漂移,
与本次改动无关——goldens 不涉及 es/ja/ko/ru。)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-22 10:38:27 +08:00
wangjia 74d8c857be feat(server): 私有服务域名分流(PANGOLIN_PRIVATE_SPLIT_DOMAINS)
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Failing after 11s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Failing after 9s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Failing after 9s
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Has been cancelled
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Has been cancelled
ci-pangolin / Go — build + test (push) Has been cancelled
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Has been cancelled
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Has been cancelled
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Has been cancelled
家庭内网穿透域名(nas/git/win.yanmeiai.com)的客户端配置渲染:
- DNS 面: 系统解析器(type=local,底层网络)+私有域名规则置顶+reverse_mapping
- 路由面: 私有域名→强制走隧道,钉在 LAN 直连之后、国内分流(geoip-cn)之前
在家: 局域网 DNS 覆盖→私网IP→LAN直连零绕行; 在外: 锚点(frps@ali,国内IP)
不再被 smartRoute 分流成直连、避开安全组限源。env 不配置=行为零变化。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 07:22:16 +08:00
wangjia 43c76fb978 Merge remote-tracking branch 'origin/main' into feat/pay-v2-integration
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 25s
ci-pangolin / Lint — shellcheck (push) Successful in 29s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 22s
ci-pangolin / OpenAPI Sync Check (push) Successful in 40s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 19s
ci-pangolin / Flutter — analyze + test (push) Failing after 4m59s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 1m51s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Failing after 1m33s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m59s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Failing after 4s
# Conflicts:
#	docs/index.html
#	server/cmd/server/main.go
2026-07-11 16:44:05 +08:00
wangjia 05922710b2 feat(server): CORS 中间件(白名单跨域,支持用户中心/官网前端)
控制面无 CORS 导致 pangolin.yanmeiai.com 的用户中心跨域打 api.yanmeiai.com 被浏览器拦(Network error)。
加白名单 CORS(env CORS_ALLOWED_ORIGINS,默认 https://pangolin.yanmeiai.com):回显 Origin、应答预检 OPTIONS、
放行 Authorization/Content-Type;不用 '*'、不放行 credentials(走 Bearer token)。
2026-07-11 15:33:07 +08:00
wangjia 1a7b93ed2f feat(server): /buy 自包含购买页(同源,USDT+哪吒,登录→下单→轮询开通)
单文件 HTML(内联 CSS+JS,go:embed)挂在公开 GET /buy,与 /v1/pay 同源免 CORS:
登录 -> 拉取套餐 -> 选 USDT/哪吒下单 -> 按 render_type(crypto_address/redirect/qr)
展示付款信息 -> 轮询订单直到 activated,页面隐藏/关闭时停止轮询。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-11 14:52:21 +08:00
wangjia 1198ecc556 fix(migrations): 恢复线性迁移编号,与生产已应用的对齐
集成分支曾把已上线的 000020_ad_bonus_minutes(#21)/000021_devices_user_scoped_uuid(#27)
两个迁移错误覆盖成 codes_lib_legacy_rename/pay_purchases,与生产(schema_migrations=21)
的实际血统冲突,导致 golang-migrate 在生产上找不到 version 21 文件而报错。
恢复为:020 ad_bonus / 021 devices / 022 codes_lib_legacy_rename / 023 pay_purchases。
生产库副本完整试跑通过:version→23,pay_purchases 建好,subscriptions CHECK 加 'pay',
codes 库表就位,3 用户/3 订阅数据零丢失。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-11 14:25:09 +08:00
wangjia e6897ecda4 build(server): codes 依赖改伪版本 pin(经 gitea),去本地路径 replace
replace github.com/wangjia/codes => 本地路径 换成
require github.com/wangjia/codes v0.0.0-20260710045055-c772d5256794(gitea @ c772d52)。
本地/CI 需配 GOPRIVATE=github.com/wangjia/codes + git insteadOf(github→gitea ssh)。
交叉编译 linux/amd64 已验证:部署走本地编译+传二进制,目标机无需 go/gitea。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-11 09:21:53 +08:00
wangjia db25bccaba feat(website): /buy 加付款类型选择器(测 redirect/qr/crypto_address 三态渲染)
catalog 步骤新增付款类型分段控件(复用 .price-toggle 样式),选中值写入
下单 body 的 metadata.render(字符串),驱动 pay 侧 fake 渠道渲染分支。
method 仍固定 fake,未新增渠道名。
2026-07-11 07:35:09 +08:00
wangjia 43fead8823 feat(website): 本地联调 /buy 测试购买页(登录→下单→mock 付款→轮询开通)+ dev vite proxy 2026-07-11 01:32:11 +08:00
wangjia bc9aa38392 fix(server): pay webhook 兜底补行不再漏停用用户(修静默丢钱)+ 未知事件日志 + PAY_BIZ_SECRET 启动守卫
- webhook 兜底路径(台账缺行按 biz_ref 定位用户)去掉 status='active' 过滤,
  与正常路径(row.UserID 直接开通不看状态)对齐:钱已实收,不因用户被停用
  (banned)而拒绝补行开通,避免 500→pay 12 次重投后死信→静默丢钱。
- 未知 event_type 分支加 slog.Warn,便于将来 pay 侧误注册无 handler 的事件
  类型时能被观测到。
- PAY_BASE_URL 已设但 PAY_BIZ_SECRET 为空时 log.Fatal 拒绝启动,避免出站
  签名失败+入站验签全 401 的静默瘫痪。
- InsertFromWebhookTx 不再把 channel 冒充 method 写入台账(payload 无 method
  字段可复原,留空并加注释,消除台账观感误导)。

新增 TestWebhook_MissingLedgerFallsBackEvenWhenUserSuspended 覆盖 #1:
台账缺行 + 目标用户 banned 时,兜底补行仍成功开通并回 SUCCESS。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-11 00:48:57 +08:00
wangjia 390b8b8f84 chore(server): pay v2 接入终验 + OpenAPI 登记 + 交付说明
全量矩阵本地全绿(build/vet/test/run_sqlite_test.sh + 新增 SQLite 文件库带数据
升级彩排 + MySQL 8 容器验证 000021 MODIFY ENUM;client analyze/test 功能全绿,
27 个既有 golden 像素噪声与本次接入无关)。server/api/openapi.yaml 登记 6 个新
Pay 端点(catalog/orders/orderNo/retry/cancel/webhook)+ schema,通过结构校验。

顺手修复 run_mysql_test.sh 缺 multiStatements=true 的既有 bug(与 pay-v2 无关)。
新增 pay_migration_rehearsal_test.go 验证 000021 升级不丢行/AUTOINCREMENT 续序,
并确认一处安全特性:已有 source='pay' 行后 down 会被 CHECK 约束正确拒绝。

交付说明 docs/pay-v2-integration-delivery.html(含测试矩阵/联调 checklist/部署
附录)已登记 docs/index.html,记录一处既有但未修的部署前置阻断项(go.mod 的
wangjia/codes 本地路径 replace 会阻断异机构建,超出本任务授权范围)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-11 00:34:10 +08:00
wangjia 262773a9de fix(client): dispose 不再静默取消支付订单,redirect launchUrl 加失败兜底
- PaymentFlowController 新增 stopPolling():只停轮询 Timer,不发远程 cancel、
  不改 state;PaymentScreen.dispose() 改调它,不再复用 cancel()——离开支付页
  (切左导航 tab)不再等同于放弃订单,cancel() 只留给「取消订单」按钮的显式路径。
- redirect 分支 launchUrl 前加 canLaunchUrl 判定 + try-catch,失败时用
  showPangolinToast 给可见提示(新增 l10n openAlipayFailed,zh/en 均补)。
- purchase_page.dart::_choose 下单后严格判 phase == awaitingPayment 才跳转
  支付页,建单失败(failed)不再误跳。
- 回归测试:payment_pages_test.dart 补两条用例区分 dispose(不触发 cancel、
  state 不归 idle)与「取消订单」按钮(远程 cancel + state 归 idle);
  payment_flow_test.dart 补 stopPolling() 单元测试。
2026-07-11 00:06:03 +08:00
wangjia a5fff28134 feat(client): 购买页三档 + 支付页 render_type 多态(crypto/redirect/qr 预留)+ 导航接线
购买页三档 pro(月/季/年,金额来自 GET /v1/pay/catalog)选档 → 选支付方式 → 下单;
支付页按 session.render_type 多态渲染(crypto_address 地址+精确金额+复制、redirect
外链拉起、qr 预留复制兜底),轮询用 Task 6 的 paymentFlowProvider,409
CURRENCY_MISMATCH 走「换方式开新单」。同时收口 paymentFlowProvider 非 autoDispose
带来的轮询生命周期缺口——支付页 dispose 时停轮询(cancel() 延后到微任务执行,避免
在自身 unmount 期间同步改 state 炸 Riverpod 断言)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-10 23:50:35 +08:00
wangjia e66f79fdf6 feat(client): pay 支付领域模型 + PaymentApi + 支付流控制器(轮询 activated)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-10 23:19:57 +08:00
wangjia 16b8823f1f feat(server): pay webhook 接收器(验签/时间窗/nonce + out_trade_no 幂等开通,回 SUCCESS)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-10 22:38:01 +08:00
wangjia 932953bafe feat(server): /v1/pay 下单代理端点 + 购买台账(JWT 鉴权,user→biz_ref 映射)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-10 22:32:43 +08:00
wangjia db2461ed1d feat(server): pay v2 出站客户端(HMAC 签名下单/查单/retry/cancel) 2026-07-10 20:52:33 +08:00