10 Commits

Author SHA1 Message Date
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 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 6f183ff0df feat(agent): SIGHUP 重读节点本地配置并热重渲染,不踢在线用户
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:15:27 +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 eb7c3c1062 feat(agent): WARP 域名分流 —— 命中域名走 Cloudflare 干净出口(#29)
节点 sing-box 渲染新增可选 WARP 分流:节点本地 warp.json(默认
<StateDir>/warp.json)配 WARP 凭证 + 域名清单 → 渲染时注入一个 userspace
WireGuard(WARP)endpoint + route(sniff 取 SNI/Host → domain_suffix 命中走
warp,其余 final=direct)。sing-box 1.11+ endpoints 语法,system=false 用户态
不依赖内核 wg 模块。

- 运营改域名只需编辑 warp.json + 重启 agent(sing-box 无热重载),即「配置的方式」。
- warp.json 不存在/enabled=false/域名空/凭证缺/坏 JSON → 一律按未启用,配置与旧
  节点逐字节一致,坏配置绝不产出无法启动的 sing-box config(渲染读失败仅记日志)。
- WARP 凭证节点私有(wgcf 注册免费匿名账号),不入 git、不经控制面。
- 测试:注入 endpoint+route/无配置无 route/禁用或残缺不注入/坏 JSON 优雅退化。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:40:00 +08:00
wangjia cf2bd93c93 feat(server): agent 热重载 sing-box(SIGHUP)替代全量重启(#3)
sing-box 支持 SIGHUP 热重载(cmd_run.go:进程内 check()验证→close 旧实例→
start 新实例;坏配置保留旧实例继续跑、不断网)。原 agent 用 systemctl restart
(全量重启:断所有连接 + 坏配置让 sing-box 起不来全断)。

- Restarter 接口加 Reload;SystemdRestarter.Reload 取 MainPID(systemctl show
  -p MainPID)发 SIGHUP——agent 与 sing-box 同 pangolin 用户,免 polkit/不改 unit;
  PID 取不到或发信号失败回退 Restart
- SingBox.started:首次/进程未起走 Restart(冷启动),之后配置变更走 Reload
- noopRestarter/fakeRestarter 补 Reload;加 TestWriteAndRestartFirstColdThenReload

价值:切节点/续期/加删用户不整进程重启、不全断流;坏配置不至于打死节点。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:36:46 +08:00
wangjia da8f3a4fef feat(server): Hy2 自签 TLS 证书(方案①)— 补全 hysteria2 入站可用
Hy2 跑在 QUIC 上、强制 TLS,需服务端证书(不像 REALITY 借大站证书)。此前
handler_grpc 只发 ListenPort、cert 路径为空 → hysteria2 入站起不来。改为节点自签:

- agentd/hy2cert.go: ensureSelfSignedCert 生成 ECDSA P-256 自签证书到
  /etc/sing-box/hy2.{crt,key}(幂等,缺失才生成;key 0600;SAN=reality SNI)
- singbox.go: writeAndRestart 渲染前对启用 hy2 的节点 ensure 证书并把
  CertPath/KeyPath 写回 hy2 配置
- httpapi/clientconfig.go: hy2 出站 TLS 加 insecure:true + server_name,收自签
  (两端自有,服务端鉴权靠 per-user 派生的 hy2 密码)

验证:单元测试(证书可被 crypto/tls 加载/幂等/0600)+ sing-box check 接受自签 hy2 入站。
注:节点实际启用 hy2 还需配 Hy2Port + 放行 UDP(单独步骤)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:12:35 +08:00
wangjia a6b35268ab feat(agent): #7 真实流量采集 — clash_api 节点总量 → usage_daily
sing-box 该构建未编入 v2ray_api、clash /connections 也不暴露用户,故无法做
准确 per-user 计费。改取节点累计总量(clash downloadTotal/uploadTotal)做
窗口增量,按当前 dp_uuid 均摊上报(单用户=准确,多用户近似,已注释标注)。

- render.go:节点 sing-box 配置加 experimental.clash_api(loopback+secret)。
- ClashUsageSource:轮询 /connections 总量,delta + 重启回绕保护;clash 的
  up/down 是代理视角,对调为用户视角(下载→bytes_down)。
- SingBox.DpUUIDs() 供归属;Agent.UseClashUsage() 在 cmd/agent 接上。

已节点 live 验证:usage_daily 实时入库,下载增量正确落 bytes_down。
局限:多 dp_uuid 时均摊(准确计费需重编 sing-box 带 with_v2ray_api)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 06:01:24 +08:00
wangjia f03d2dc8a6 feat(agent): node agent — enroll/mTLS, heartbeat, command stream, sing-box 用户表 (tsk__R8M4jEw43JR)
与控制面同仓同 go.mod,新增节点 agent 实现:

- proto/agent/v1/agent.proto + internal/pb/agentv1:冻结的控制面↔agent gRPC 契约
  (Enroll/Register/Heartbeat/Subscribe/Ack/ReportUsage)。仓库尚无 protoc 流水线,
  暂以手写 Go 类型 + JSON gRPC codec 实现,与 proto 1:1 对应,待 protoc 接入即可替换。
- internal/agentd:
  - enroll.go:首启生成 EC 密钥+CSR,持 bootstrap token 调 Enroll 换 90d 节点证书
    (CN=node_uuid),落 /etc/pangolin-agent/,此后 mTLS。
  - conn.go(agent.go)+creds.go:mTLS 主动拨号 + 指数退避重连;重连携带 last_command_id;
    Register 取 ConfigSnapshot 全量配置覆盖本地。
  - heartbeat.go:30s 上报 peer/带宽/CPU + config_version;need_full_resync→全量同步。
  - command.go:消费 Subscribe,Upsert/Revoke/Rotate/ApplyConfig/Lifecycle 幂等处理后
    Ack(at-least-once,按 command_id 去重)。
  - singbox.go+render.go:内存用户表 + 落盘 state.json(仅 dp_uuid+expires_at);任何变更
    渲染完整 sing-box 配置(REALITY users[uuid,flow] + Hy2 users[派生口令])→ 500ms 去抖
    合并 → systemd 重启。
  - ttl.go:凭证 TTL 定时移除并上报。
  - usage.go:按 dp_uuid 聚合上报,绝无 user_id/email/目的地址。
  - derive.go:Hy2 口令 = HMAC-SHA256(key, dp_uuid),与控制面同源派生。
- cmd/agent:入口(flag/env 配置)。
- infra/cloud-init/{node.yaml.tmpl,install-node.sh,README.md}:一段式安装,下载锁定版本
  二进制并校验 SHA-256,systemd 拉管,首启即 Enroll/Register。shellcheck -S warning 通过。

测试(bufconn mock 控制面,无需 docker):Enroll→Register→Heartbeat 全流转;Upsert/Revoke
渲染正确;Rotate 宽限期新旧并存到点移除;TTL 自动移除并上报;断流重连 last_command_id
续发不丢不重;need_full_resync 触发重注册;state.json 恢复;去抖合并;扫描确认无身份字段。
go test -race ./internal/agentd/... ./internal/pb/... 通过;go vet ./... 通过。

落实 doc/04 §2 节点无状态化与 doc/06 §3 数据面红线(节点仅见 dp_uuid)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:26:58 +08:00