Commit Graph

35 Commits

Author SHA1 Message Date
wangjia bfecb4314c merge: gRPC server + hub + 跨实例 pub/sub [tsk__58l3wTLvaSn] 2026-06-15 20:53:25 +08:00
wangjia bf3b6fd210 merge: apierr + idgen + CONVENTIONS.md [tsk_nI2T8qpcoier] 2026-06-15 20:51:08 +08:00
wangjia 628440b3f9 style(pangolin): gofmt store/mysql.go(import 排序)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 18:52:30 +08:00
wangjia 20a1f8e2af merge: 管理端最小后台 + TOTP 双因素 [tsk_SCMtcGF4F434]
以 main 现有 cmd/server 设计为准(保留 probe + /v1 路由),叠加分支新增:internal/admin/ 自包含后台(session/2FA/IP 白名单/审计/模板)、internal/totp/ + cmd/adminctl/(TOTP + 管理员创建 CLI)、cmd/server/main.go 的 startAdminIfConfigured()(opt-in:ADMIN_SECRET_KEY+DB_DSN 才起)。统一 getenvDefault 命名;go.mod 取 HEAD + go mod tidy(x/term 提为直接依赖);admins 迁移与 main 既有 000008_provision 撞号,重排为 000009_admins。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 18:47:38 +08:00
wangjia 03b00b9a0f merge: config + store(UTC DSN) + 自动迁移 [tsk_zRA6fGU1JuHj]
以 main 现有 config 设计为准(FromEnv/*Config/单 DSN/RS256),移植分支新增的 store 层:mysql.go 的 Open(*config.Config) 改用 cfg.DSN、结构化强制 UTC + 会话时区断言;migrate.go + migrations/embed.go 提供 golang-migrate 嵌入式迁移;cmd/migrate/main.go 实现 migrate CLI(读 DB_DSN)。未采用分支的 Load()/PANGOLIN_ 前缀/MySQL 拆分字段(与 main 设计冲突)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 18:42:46 +08:00
wangjia ed5eabea58 merge: 第三方拨测 API 适配 [tsk_-WC9smQK7YRt] 2026-06-13 17:32:26 +08:00
wangjia b05d7ed416 merge: oapi-codegen 接线 + 生成代码入库 [tsk_Kp80nvHV2yLc]
# Conflicts:
#	server/Makefile
#	server/cmd/server/main.go
#	server/go.mod
#	server/go.sum
2026-06-13 17:32:25 +08:00
wangjia 605bfa1ec9 feat(nodes): gRPC server + Hub + Redis pub/sub cross-instance delivery (tsk__58l3wTLvaSn)
Implements AgentService gRPC server with all 6 RPCs (Enroll/Register/Heartbeat/
Subscribe/Ack/ReportUsage), Hub command routing with Redis ZSET at-least-once
persistence and cross-instance pub/sub delivery, LoadCache for node:load metrics,
NodeStore SQL interface + MySQL implementation, and full mTLS gRPC listener in main.

Integration tests: 18 tests covering full Enroll→Register→Heartbeat→Subscribe→Ack
flow, reconnect resume with last_command_id, and cross-instance pub/sub delivery
via bufconn + miniredis + mockNodeStore + real mTLS certificates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 17:30:42 +08:00
wangjia 8de32eb611 merge: 弹性节点基建:Terraform + 一键更换 [tsk_6u0FxmbC7Yeq]
# Conflicts:
#	.gitignore
#	infra/cloud-init/node.yaml.tmpl
2026-06-13 17:30:37 +08:00
wangjia 4e2a444572 merge: usage 用量 + 广告解锁 [tsk_1taxhtV2k3RP]
# Conflicts:
#	server/internal/apierr/apierr.go
2026-06-13 17:30:36 +08:00
wangjia 34c9909608 merge: auth 模块 [tsk_2PFfyviECIXh]
# Conflicts:
#	server/go.mod
2026-06-13 17:29:49 +08:00
wangjia a47121f99b feat(probe): add third-party dial-test adapter for 15C (tsk_-WC9smQK7YRt)
Implements ProberAgent interface + Alibaba Cloud CloudMonitor Synthetic adapter:
- ProbeTarget / VantageResult types aligned with 15A schema (L3 always nil)
- AliyunSyntheticAgent: CreateSiteMonitor → poll → map to VantageResult
- ISP vantage uses "3rd-{ISP}" prefix; Redis key probe:{node}:CN:{region}:3rd-*
- Degradation: API error/rate-limit/timeout → skip vantage, log + counter (no write)
- RunOnce(ctx, targets) shape for 15H assembly wiring
- Credentials via cfg only (env-var injection, zero hardcoded secrets)
- 6 new tests (mock vendor server): normalization, rate-limit, timeout, Redis keys,
  failed-target L1, no-credentials check; all 22 probe-package tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 17:21:43 +08:00
wangjia 3bcf1d7b4c merge: maestro/tsk_NU9JuUweHWMt [tsk_NU9JuUweHWMt] 2026-06-13 17:04:17 +08:00
wangjia 5a6b4a2dbb merge: maestro/tsk__R8M4jEw43JR [tsk__R8M4jEw43JR] 2026-06-13 17:04:17 +08:00
wangjia 5a485fbcf6 merge: maestro/tsk_FrRarw9YO8Nf [tsk_FrRarw9YO8Nf] 2026-06-13 17:04:17 +08:00
wangjia 908f75de75 merge: maestro/tsk_VRzw-af__qWx [tsk_VRzw-af__qWx] 2026-06-13 17:04:17 +08:00
wangjia 6f3ceba7a9 merge: maestro/tsk_eu2qVvni1HJR [tsk_eu2qVvni1HJR] 2026-06-13 17:04:17 +08:00
wangjia 6ec7cd8146 merge: maestro/tsk_V9IrIk2g5Q78 [proto 契约 + buf 工具链] (tsk_FrRarw9YO8Nf)
合并 proto 契约 + buf 工具链任务到 main(已含 apierr + idgen + CONVENTIONS.md):

- server/proto/pangolin/agent/v1/agent.proto: AgentService 6 个 RPC(Enroll/Register/
  Heartbeat/Subscribe/Ack/ReportUsage)及全量消息类型,隐私红线注释保留
- server/buf.yaml: v2 格式,STANDARD lint + FILE breaking 规则
- server/buf.gen.yaml: remote protoc-gen-go v1.36.4 + protoc-gen-go-grpc v1.5.1 插件
- server/internal/pb/: 生成的 agent.pb.go + agent_grpc.pb.go 提交入库
- server/Makefile: 新增 proto / proto-lint / proto-breaking 目标
- server/tools.go: 新增 buf v1.70.0、protoc-gen-go、protoc-gen-go-grpc 工具依赖
- server/go.mod / go.sum: 合并 buf 工具链依赖;github.com/google/uuid 保留为 direct
  依赖(idgen 直接 import);go 版本升至 1.25.10

冲突解决:两侧均修改 go.mod,以 proto 分支(超集)为基础,将 google/uuid 移至
直接依赖 require 块,保留 apierr/idgen 的已有文件不变。

go build ./... ✓  |  go test ./internal/apierr/... ./internal/idgen/... ✓

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 15:11:11 +08:00
wangjia d88c1ae647 merge: maestro/tsk_x7wrlA87orsY [devices + 订阅校验中间件] (tsk_VRzw-af__qWx)
手动合并 tsk_x7wrlA87orsY(设备管理 + 订阅校验中间件)到 main:

冲突解决:
- server/internal/apierr/apierr.go:保留 tsk_GXDoc3Cs07Rn 版本(New/StatusFor/
  Middleware/ErrConflict/改善文档),并入 tsk_x7wrlA87orsY 新增的 ErrAccountBanned
  及对应 StatusFor case(→ 403)。

新增文件(来自 tsk_x7wrlA87orsY):
- server/internal/devices/doc.go       package 文档(替换占位 stub)
- server/internal/devices/context.go   CtxKeyUserID / Plan / WithPlan / PlanFromCtx
- server/internal/devices/handler.go   GET /v1/me/devices · DELETE /v1/me/devices/{id}
- server/internal/devices/middleware.go SubscriptionMiddleware · CheckDeviceQuota · RequirePaidTier
- server/internal/devices/service.go   RegisterIfAbsent / DeleteDevice / ResolvePlan + 纯函数 resolveEffectivePlan
- server/internal/devices/store.go     MySQL 数据访问层
- server/internal/devices/service_test.go          15 个单测(全通过)
- server/internal/devices/devices_integration_test.go  testcontainers 集成测试

OpenAPI 更新(来自 tsk_x7wrlA87orsY):
- server/api/openapi.yaml:SubscriptionInfo.source 枚举补 free
- design/server/openapi.yaml:SubscriptionInfo.source 枚举补 admin, free

测试:go build ./... ✓;go test ./internal/apierr/... ✓(8 tests);
      go test ./internal/devices/... ✓(15 tests)。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 15:09:51 +08:00
wangjia 52857d1d55 feat(1e): config PANGOLIN_ prefix + store.Open UTC DSN + go:embed migrations (tsk_zRA6fGU1JuHj)
- internal/config: rewrite Config with PANGOLIN_ prefix fields
  (HTTPAddr, AdminAddr, GRPCAddr, MySQL DSN/fields, RedisAddr,
  AutoMigrate, JWTSecret placeholder, WebhookSecret); Load() replaces
  FromEnv(); missing required MySQL vars return named-field error.

- internal/store/mysql.go: single Open(cfg) entry point; uses
  mysql.ParseDSN to structurally override ParseTime=true, Loc=UTC,
  Collation=utf8mb4_unicode_ci, Params[time_zone]='+00:00'; asserts
  SELECT @@session.time_zone=+00:00 after Ping (startup fatal).

- migrations/embed.go: //go:embed *.sql exposes var FS embed.FS.

- internal/store/migrate.go: MigrateUp/MigrateDown/MigrateVersion
  backed by golang-migrate iofs source + mysql driver; ErrNoChange
  treated as success.

- cmd/migrate/main.go: filled — up/down/version subcommands, reads
  config.Load() + store.Open.

- cmd/server/main.go: startup sequence Load → store.Open (UTC assert)
  → MigrateUp (if PANGOLIN_AUTO_MIGRATE=true) → HTTP listen;
  structured slog output at each step.

- internal/store/mysql_test.go: pure-function unit tests for buildDSN
  (empty-fields case + conflicting params overridden case); both pass.
- internal/store/mysql_integration_test.go: //go:build integration;
  testcontainers mysql:8 — UTC assertion + MigrateUp idempotency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:52:27 +08:00
wangjia 6e99e32285 feat(admin): 管理端最小后台(独立监听 + 白名单 + 2FA)tsk_SCMtcGF4F434
实现「一个二进制三个监听」中的管理端:

- 独立监听 ADMIN_LISTEN(默认 127.0.0.1:9443,FromEnv 拒绝 0.0.0.0/:: 等公网绑定)
- 中间件链:mw_ipallow(CIDR 白名单,仅信任 RemoteAddr,默认内网段)→
  mw_session(HttpOnly+Secure+SameSite=Strict cookie,Redis 30min 滑动 TTL)
- 登录双因素:argon2id 密码 + RFC6238 TOTP;失败限流与临时锁定;
  TOTP 密钥 AES-GCM 加密入库;migration 000008 增补 admins 表;
  首个管理员由 cmd/adminctl create 创建(终端输出 otpauth URI/Secret)
- 功能三块(html/template + embed 静态资源,原生 JS 二次确认):
  1. 码批次:表单生成→明文仅在本次 CSV 下载出现(不落盘/不入日志);
     批次列表 + 整批作废(复用 #3 codes,新增 Store.ListBatches/VoidBatch)
  2. 节点操作:列表 + 近期 node_events;replace 经 #14 ProvisionService、
     draining/up 经 #5 Lifecycle(二者未就绪→注入 stub,UI 置灰);二次确认 + CSRF
  3. audit_log 查看:actor/action/target/时间范围过滤分页;同页查 node_events
- 所有写操作写 audit_log(actor=用户名,meta 不含明文);管理端仅记安全事件
  (登录失败/锁定/白名单拦截),不记常规访问日志
- internal/totp 复用包(与 doc/05 用户中心 2FA 同算法,纯标准库)

测试:totp RFC6238 向量、argon2/AES 往返、白名单放行/拦截、会话滑动过期、
登录成功/密码错/TOTP 错/未知用户/锁定、批次 CSV 含明文且 audit 不泄露、
作废/节点操作的二次确认与 CSRF 缺失被拒、mock 断言 service 调用参数、审计过滤。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:49:32 +08:00
wangjia 25e0204471 merge: maestro/tsk_GXDoc3Cs07Rn [tsk_nI2T8qpcoier] apierr + idgen + CONVENTIONS.md
Apply changes from tsk_GXDoc3Cs07Rn (conflict rescue: original auto-merge
blocked by uncommitted changes on main; worktree was clean, no code conflicts).

Changes applied verbatim from b64c002:
- server/CONVENTIONS.md: new file — mandatory coding conventions
- server/internal/apierr: add New(), StatusFor(), ErrUnauthorized/Forbidden/
  NotFound/Conflict predefined errors, chi Middleware; add apierr_test.go (8 tests)
- server/internal/idgen: implement idgen.go (UUID v7 + Crockford Base32 moved
  from codes); add idgen_test.go (12 tests)
- server/internal/codes/generator.go: refactor to delegate to idgen.*
- server/go.mod: move google/uuid from indirect to direct dependency

All tests pass: go test ./internal/apierr/... ./internal/idgen/... ./internal/codes/...

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:45:46 +08:00
wangjia dd060fd34a feat(1d): wire oapi-codegen, generate types/server/client, add 501 stubs — tsk_Kp80nvHV2yLc
- Fix openapi.yaml: quote bearerAuth description that contained unquoted `: ` plain-scalar YAML
- Add server/api/cfg-{types,server,client}.yaml — oapi-codegen v2 config for three split outputs
- Add server/api/gen/gen.go — three //go:generate directives; run `make generate`
- Generate server/api/gen/{types,server,client}.gen.go from the contract (all 15 operations)
- Add github.com/oapi-codegen/runtime v1.4.1 to go.mod (runtime types needed by generated code)
- Add server/internal/httpapi/unimplemented.go — UnimplementedServer satisfies gen.ServerInterface;
  every method writes HTTP 501 + {code,message_zh,message_en} JSON body (no dep on task-1f apierr)
- Update server/cmd/server/main.go — mount /v1 API via gen.HandlerFromMuxWithBaseURL; keep /healthz
- Update server/Makefile — `generate` target now runs `go generate ./...`;
  add `check-generate` CI guard (regenerate + git diff --exit-code)
- Add server/internal/httpapi/unimplemented_test.go — 19 httptest cases covering all 15 API routes
  (501+body), undefined routes (404), and /healthz isolation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:36:21 +08:00
wangjia f76e1797c2 merge: maestro/tsk_rBPr0Xuy10bz [tsk_rBPr0Xuy10bz] 探针汇聚入口 + Redis 探针存储 (tsk_eu2qVvni1HJR)
Manually apply changes from maestro/tsk_rBPr0Xuy10bz that could not be
auto-merged due to uncommitted changes on main at merge time.

Added:
- server/internal/scheduler/probe/types.go  – frozen schema types (ReportRequest,
  VantagePoint, NodeReport, L1/L2/L3Result, ProbeSnapshot)
- server/internal/scheduler/probe/store.go  – Redis Store: SaveReports, CheckAndMarkSeen,
  SnapshotsByNode, AliveProbes with TTL constants and key-schema docs
- server/internal/scheduler/probe/ingest.go – IngestHandler (POST /probe/report),
  HMAC-SHA256 auth + timestamp window + replay prevention via SetNX
- server/internal/scheduler/probe/ingest_test.go – 17 tests (auth failures, integration,
  Store unit tests); all pass with miniredis

Updated:
- server/cmd/server/main.go – register /probe/report route when PROBE_SECRETS env is set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:35:24 +08:00
wangjia 3d5bac66b4 feat(provision): 弹性节点基建 Terraform + 一键更换 (tsk_6u0FxmbC7Yeq)
IaC 面 (infra/) 与控制面 (server/internal/provision) 双产出,落地 doc/04 §4
「节点是牲口」弹性拓扑与 make-before-break 一键更换。

server/internal/provision:
- CloudAdapter 适配层 + Registry(首发 vultr 消耗品池 / hetzner 精品池各一);
  厂商凭证仅从 PROVISION_<VENDOR>_* env 注入,不入库不入 git。
- ProvisionService:CreateNode(幂等键重放不重复开机)、DestroyNode(幂等)、
  RotateIP(换 IP 不换机 + version bump)、ListProviders。
- Replace 一键更换:先建后拆,新机 up 先于旧机 draining(容量不下降),
  replacement_uuid 幂等键 + replacements 表分步记录,崩溃可续跑不重复。
- RotatePool:池内滚动轮换,并发度 1–2。
- cmd/nodectl CLI:create/destroy/rotate-ip/replace/rotate-pool/providers。
- 单测(mock 厂商 API + 内存 Store):幂等重放、make-before-break 时序断言、
  开机失败/探活超时→destroyed+失败计数+告警钩子、崩溃续跑、RotatePool。

infra/:
- terraform/:探针机 + 控制面基线模块化(probe / control-plane)+ README,
  低频基线进 state,节点不进 Terraform。
- cloud-init/node.yaml.tmpl:节点引导模板(注入一次性 bootstrap token,task #5)。
- identity-isolation.md:身份隔离登记表(doc/06 §2 红线,无任何凭证)。

migrations/000008:nodes 增 provider_instance_id/elastic_ip_id、node_events
增 ip_rotated、provision_idempotency / replacements 表(附加式,不动现网)。

红线:仅面向新厂商池,绝不纳管现网生产 EC2(deploy/ marzban)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:23:39 +08:00
wangjia 7d89ec9d91 feat(infra/domains): 域名池 + CDN 前置 + 签名端点分发 (tsk_NU9JuUweHWMt)
- domains.md: 四组域名隔离登记 + 冷备池 ≥5 + 启用流程(不含身份信息)
- cdn/terraform: Cloudflare 配置即代码(WAF/bot/速率限制/代理DNS/回源鉴权注入)+ 30min 重放 Runbook
- server/internal/originauth: 回源鉴权中间件,非 CDN 网段或鉴权头不符一律 403,支持双值轮换
- tools/endpoint-signer: 离线 Ed25519 签名 CLI(端点 + 公告文档,单调版本防回滚,key_id 双公钥轮换)
- tools/publish-mirrors: ≥3 镜像发布 + hash 一致性校验 + 故障转移取回
- CLIENT-CONTRACT.md: schema/验签/防回滚/合并/兜底链/channel 客户端契约
- 出站独立出口要求写入部署文档;私钥/token/身份信息一律不入库

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:21:55 +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
wangjia a5e25b444f feat(auth): 验证码/注册/登录/JWT 鉴权模块 (tsk_2PFfyviECIXh)
实现 server/internal/auth 控制面鉴权底座,对应 doc/02 §4.1、doc/06 §3:

- POST /v1/auth/code:IP+邮箱双维度 Redis 滑窗限频(同邮箱 1/min、同 IP 10/h)
  + 一次性邮箱域黑名单(embed 词表)→ 6 位数字码写 auth:code:{email} TTL 10min
  → 异步发信(SMTP / 开发态 log mailer)。
- POST /v1/auth/register:验码(一次性,删 key;超次数烧码)→ 单事务建号
  (uuid + dp_uuid 应用层生成、argon2id)+ 7 天 PRO 试用(source='trial')→ 签发 JWT。
- POST /v1/auth/login:argon2id 校验(失败恒定时、未知用户走 dummy hash);
  失败计数限流 rl:login:{email} + 锁定;banned 拒绝。
- POST /v1/auth/refresh:RS256,access 15min + refresh 30d 落 Redis 白名单
  jwt:refresh:{jti},旋转时删旧写新;JWT 头带 kid,验证端接受新旧公钥支持轮换。
- RequireAuth 中间件:解析 Bearer access,注入 user id(复用 codes.CtxKeyUserID
  避免循环依赖)+ uuid + claims 到 context。
- 错误体统一走 internal/apierr 的 {code, message_zh, message_en},文案双语脱敏。
- 文件拆分:handler/service/password/token/ratelimit/emailcheck/store/mailer/
  middleware/errors/keyloader;config 增加可选 JWT PEM 路径/kid 加载。

测试:password/token/ratelimit/emailcheck/service/handler/middleware 单测
(miniredis,含注册全流程、重复邮箱 409、验证码错误/过期/复用/烧码、限频 429
带 Retry-After、登录失败锁定、banned 拒绝、refresh 旋转后旧 token 失效),
go test -race 通过;集成测试 integration_test.go(testcontainers MySQL8+Redis,
register→login→refresh→受保护接口全链路,构建 tag integration)与 codes 模块同构。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:19:10 +08:00
wangjia ece8ea3b57 feat(usage): usage_daily 聚合 + 广告解锁 + free 额度校验 (tsk_1taxhtV2k3RP)
server/internal/usage 模块实现(仅字节/分钟,无目的地,符合无日志口径):

- aggregator.go:实现 #5 的 ReportUsage 回调接口(UsageReporter),
  dp_uuid→user_id 走内存+Redis 短缓存,按上报批次 id Redis 去重防重放,
  按 At 的 UTC 日期 INSERT ... ON DUPLICATE KEY UPDATE 累加,并发安全。
- store.go:usage_daily 累加/区间查询/当日查询、MarkAdUnlocked(事务+FOR
  UPDATE 幂等)、EffectivePlan(活跃订阅取最高 tier,无则回落 free)。
- ads.go:AdVerifier 接口 + AdMob SSV 实现(拉取并缓存 Google ECDSA 公钥,
  ECDSA-SHA256 验签 + custom_data/ad_unit 匹配),Unity 留接口占位。
- service.go:UsageCurve(空日补零、仅当前用户)、TodaySummary(/v1/me)、
  UnlockAd(验签→ad_token 一次性 nonce 去重→写 ad_unlocked_at,当日二次幂等)。
- quota.go:CheckFreeConnect 供 #5 connect 使用:ad_gate=true 校验当日
  ad_unlocked_at + minutes_used<daily_minutes(free=10),返回剩余分钟;
  pro/team 不受 ad_gate 限制(返回 Unlimited);带双语 code 错误。
- handler.go:GET /v1/usage?days=7、POST /v1/ads/unlock(204)。
- apierr:新增 AD_NOT_UNLOCKED/QUOTA_EXHAUSTED/AD_VERIFY_FAILED/AD_TOKEN_REPLAY。
- openapi:/ads/unlock 补 409 Conflict(重放)。

测试:ads_test.go 单测覆盖 AdMob 验签全链路(合法/伪造/custom_data 不符/
ad_unit 不允许/畸形 token)+ Unity 未实现;usage_integration_test.go
(testcontainers, build tag integration) 覆盖并发多节点累加、批次重放去重、
跨 UTC 日界分桶、未知 dp_uuid 丢弃、free 额度四态、ads 解锁伪造/重放/幂等、
曲线补零/隔离、/me 摘要。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:16:50 +08:00
wangjia b64c002a33 feat(tsk_GXDoc3Cs07Rn): apierr + idgen + CONVENTIONS.md
apierr:
- Add New() constructor, StatusFor() HTTP-status mapping
- Add ErrUnauthorized, ErrForbidden, ErrNotFound, ErrConflict predefined errors
- Add chi-compatible Middleware for panic(*Error) → JSON recovery
- Add apierr_test.go (8 tests; covers New, StatusFor, WriteJSON, Middleware)

idgen:
- Implement idgen.go: New()/NewString() (UUID v7 via google/uuid v1.6.0)
- Implement GenerateCode/CanonicalizeCode/HashCode (Crockford Base32 moved from codes)
- Add idgen_test.go (12 tests; UUID v7 ordering/uniqueness + Crockford format/normalization/check)

codes:
- Refactor generator.go to delegate GenerateCode/Canonicalize/Hash to idgen
- All existing codes generator tests continue to pass unchanged

server:
- Add CONVENTIONS.md covering package structure, error handling, ID generation,
  database conventions, handler templates, auth context, testing, and logging rules
- Move google/uuid from indirect to direct dependency in go.mod

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 11:58:38 +08:00
wangjia 787151245e merge: maestro/tsk_tFMU7-hKzfOf [tsk_tFMU7-hKzfOf] codes 激活码模块
解决与 1A 骨架的冲突:module 统一为 github.com/wangjia/pangolin/server
(codes 分支原用 pangolin/server,7 个源文件 import 已改写);
go.mod require 并集(redis 取 9.20.1);Makefile 以骨架为基底并入
build-codegen/test-unit/test-integration target。
go build/vet 通过,codes 单测通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 03:03:11 +08:00
wangjia 8c516d4f2a merge: maestro/tsk_FUQws_DMIcXa [tsk_FUQws_DMIcXa] mTLS/CA + 引导 token 框架
解决与 Go 骨架(1A)的 add/add 冲突:go.mod 统一 module 路径为
github.com/wangjia/pangolin/server,require 取并集;setup.sh 合并两边意图。
go build ./... 与 go test ./internal/mtls/... 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 03:01:31 +08:00
wangjia afcd7b325c feat(codes): implement activation-code module (tsk_tFMU7-hKzfOf)
Implements server/internal/codes/ with all required functionality:

## Generator (generator.go)
- Crockford Base32 16-char codes (15 data + 1 Crockford mod-37 check char)
- crypto/rand for unbiased random generation with rejection sampling
- Canonicalize(): I/L→1, O→0 folding, hyphen/space stripping
- Hash(): SHA-256 of canonical plaintext (only value stored in DB)
- Algorithm hard-coded; check char detects all single-char substitution errors

## Store (store.go)
- MySQL-backed via database/sql
- CreateBatch / CreateCode with ErrDuplicate on UNIQUE conflict
- FindCodeByHashForUpdate: SELECT … FOR UPDATE for row-level concurrency control
- MarkRedeemed, ExtendSubscription, CreateSubscription, GetActiveSubscriptions
- WriteAuditLog, CodeExistsByHash
- BeginTx at READ COMMITTED (FOR UPDATE provides row exclusivity)

## Service (service.go)
- Redeem(): 9-step flow with full idempotency and concurrency safety
  - isLocked / recordFail / clearFail via Redis key redeem:fail:{user_id}
  - SELECT … FOR UPDATE → single winner under N-concurrent redemptions
  - Same-plan: extends existing subscription (max(expires_at,now)+days)
  - Cross-plan: creates new subscription (max(now,latest)+days)
  - Idempotent: same user re-submits → 200 without re-applying
  - 5 failures → ACCOUNT_LOCKED for 1 hour (sliding window via Redis)
- CreateBatch(): generates N codes, stores hashes, returns plaintext once
  - Automatic retry on hash collision (birthday probability ≈10⁻⁸)

## Webhook (webhook.go)
- POST /webhook/store/codes — outside /v1, no JWT required
- HMAC-SHA256 with hmac.Equal constant-time comparison
- ±5 min timestamp window
- Redis SetNX nonce deduplication (15-min TTL)
- Idempotent: duplicate nonce → 200; duplicate code_hash → 200

## HTTP Handler (handler.go)
- POST /v1/redeem endpoint wired to Service.Redeem
- Reads userID from context key (set by JWT middleware from auth module)
- Bilingual error responses {code, message_zh, message_en}

## Export (export.go)
- ExportCSV(): streams plaintext codes to io.Writer as CSV
- Plaintext NEVER stored in DB; only SHA-256 hash persists

## CLI (cmd/codegen/main.go)
- codegen -plan -days -count -channel -note -dsn [-out]
- Transition tool until admin panel (#8) is ready
- Outputs CSV to stdout or file; warns operator about plaintext sensitivity

## Infrastructure (skeleton)
- internal/config/config.go: env-var configuration
- internal/db/db.go: MySQL connection pool helper
- internal/redisutil/redis.go: Redis client constructor
- internal/apierr/apierr.go: bilingual error types
- migrations/001_init.sql: DDL for codes module tables
- go.mod with all dependencies

## Tests
- generator_test.go (unit, no deps):
  - Format, uniqueness (10k codes, zero collisions), normalization,
    check-char detection of all single-char errors, hash consistency
- webhook_test.go (unit, no deps):
  - Signature rejection, missing signature, stale/future timestamp,
    missing nonce, constant-time HMAC comparison
- service_test.go (//go:build integration, testcontainers):
  - N=20 concurrent redeemers → exactly 1 winner
  - Idempotent redeem returns success for same user
  - Other-user redemption → CODE_REDEEMED + failure counted
  - 5 failures → ACCOUNT_LOCKED on 6th attempt
  - Same-plan extension: expires_at precision assertion
  - Cross-plan creation: new subscription row
  - Audit log written on every successful redemption
  - CSV export: no plaintext in DB, hash present
  - Webhook nonce replay: idempotent 200
  - Webhook same-hash: idempotent 200, single DB row

Run unit tests: make test
Run integration tests: make test-integration (requires Docker)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 02:16:16 +08:00
wangjia 73fd281bb5 feat(mtls): implement mTLS/CA + bootstrap token framework [tsk_FUQws_DMIcXa]
ECDSA P-256 self-signed CA with disk persistence (load-or-generate),
CSR signing (CN=nodeUUID, 90d validity, EKU=ClientAuth), one-time
bootstrap tokens via Redis GETDEL (15min TTL), CRL revocation with
Redis SET + DB interface, gRPC unary+stream interceptors that extract
CN from verified TLS chains (Enroll whitelisted, others require cert),
and NewServerTLSConfig (VerifyClientCertIfGiven + TLS 1.3 + CRL hook).

Frozen API: SignCSR / CAPEM / IssueToken / ConsumeToken / Revoke /
            NewServerTLSConfig / UnaryServerInterceptor / NodeUUIDFromContext

Tests cover: CA sign+verify, token one-time guarantee, TTL expiry,
             revocation rejection, interceptor whitelist (5 categories).
             Redis layer backed by miniredis in tests.

Run setup.sh from server/ to fetch deps and verify tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 01:44:21 +08:00
wangjia 1629385c70 feat(server): Go 模块骨架 + 工具链 [tsk_L2k2VrEujof8]
新建 server/ Go 模块:
- go.mod: module github.com/wangjia/pangolin/server, Go 1.22
  依赖: go-chi/chi/v5, google/uuid
- tools.go: //go:build tools 锁定 oapi-codegen / golang-migrate 版本
- cmd/server/main.go: chi router + GET /healthz → {"status":"ok"},监听 :8080
- cmd/migrate/main.go: 占位入口(实逻辑归 1e)
- internal/{config,store,apierr,idgen,auth,codes,devices,nodes,usage,admin}/doc.go
  各包职责说明
- Makefile: build/test/vet/lint/generate/migrate-up/migrate-down
- .golangci.yml: govet/errcheck/staticcheck/revive/gofmt 基线
- README.md: 定位 + 目录树 + make 入口说明
- setup.sh: 首次 go mod tidy 引导脚本(go.sum 需运行后生成)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 01:28:37 +08:00