mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
chore(docs): purge
This commit is contained in:
@@ -1,22 +0,0 @@
|
||||
# ClickHouse P0–P3 修复计划
|
||||
|
||||
> 状态: 已完成(已合并主工作区,`make code-check` 通过)
|
||||
> 策略: 4 个互不干扰 worktree 并行,最后由主代理合并
|
||||
|
||||
## 任务拆分
|
||||
|
||||
| ID | Worktree 主题 | 范围 | 禁止改动 |
|
||||
|----|---------------|------|----------|
|
||||
| WT1 | P0 清理语义 C1 | cleanup maintenance / delete / tasks | chwriter、dashboard、DDL 新 MV |
|
||||
| WT2 | 写路径 C2+H1+H2+H3 | chwriter、batchwriter、risk_control、model store 分层、status 指标 | goose 迁移、dashboard 读逻辑 |
|
||||
| WT3 | 读路径 H4+H5 | 最新快照查询、metric/openresty 小时 MV + 读路径 | chwriter、cleanup |
|
||||
| WT4 | P3 打磨 | 连接池/async_insert、traffic hourly TTL、UV 语义 | model store 分层、cleanup |
|
||||
|
||||
## 合并顺序
|
||||
|
||||
1. WT1 → 2. WT2 → 3. WT3 → 4. WT4
|
||||
(迁移文件时间戳已错开,changelog 由主代理统一写)
|
||||
|
||||
## 验收
|
||||
|
||||
各 worktree: 相关 `go test` + 可运行部分;合并后 `make code-check`。
|
||||
@@ -1,491 +0,0 @@
|
||||
# Zone 与域名资源重构 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 以稳定 ID 的 Zone 管理入口和正规化 Zone 域名替代 `managed_domains` 及反代路由中的域名/证书冗余字段,同时保持配置发布后的 OpenResty 行为不变。
|
||||
|
||||
**Architecture:** `of_zones` 管理可注册根域;`of_zone_domains` 是明确 FQDN、证书和反代路由之间的唯一关联来源。反代路由保留路由策略,配置快照在控制面联查 Zone 域名与证书后生成现有 OpenResty 配置格式。第一发布阶段保留旧列供可重复执行的历史数据导入读取;生产快照对比通过后才执行第二阶段清理。
|
||||
|
||||
**Tech Stack:** Go 1.25、Gin、GORM、goose(PostgreSQL/SQLite)、`golang.org/x/net/publicsuffix`、Next.js App Router、TypeScript、TanStack Query、shadcn/ui。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
* Zone URL 必须为 `/websites/:zoneId`,不得使用域名作为路由参数。
|
||||
* Zone 根域由 `publicsuffix.EffectiveTLDPlusOne` 解析;Zone 域名只接受明确 FQDN,拒绝 `*.`。
|
||||
* TLS 证书可含通配符 SAN;证书只能由 `of_zone_domains.cert_id` 指定,`of_proxy_routes` 不再保存证书字段。
|
||||
* `of_zone_domains.domain` 全局唯一;同一 Zone 域名至多绑定一条反代路由,路由可关联多个 Zone 的域名。
|
||||
* 不建立物理数据库外键;所有关联列必须建立显式索引。
|
||||
* 所有 HTTP 路由仅通过 `internal/router/v1/openflare/` 的管理端注册器委派;Handler 使用 `response.Abort*` 报错并补全 Swagger。
|
||||
* 不新增 DNS 记录管理、边缘函数、预览子域或多租户能力。
|
||||
* 每次 API 变更运行 `make swagger`;每个实现任务结束运行对应测试;完成前必须运行 `make code-check`。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 路径 | 职责 |
|
||||
| --- | --- |
|
||||
| `internal/infra/persistence/migrator/goose/{postgres,sqlite}/202607120001_create_zone_domain_tables.sql` | 第一阶段 Zone/ZoneDomain DDL 与索引。 |
|
||||
| `internal/model/openflare_zone.go` | Zone、ZoneDomain 模型及数据访问。 |
|
||||
| `internal/apps/openflare/zone/{logics.go,routers.go,errs.go,legacy_import.go}` | Zone CRUD、概览、输入验证和历史导入。 |
|
||||
| `internal/cmd/migrate_zones.go` | 显式、可重复运行的历史 Zone 数据导入命令。 |
|
||||
| `internal/router/v1/openflare/register_zone.go` | `/api/v1/d/zones` 路由注册。 |
|
||||
| `internal/apps/openflare/proxy_route/*` | 以 `zone_domain_ids` 取代域名与证书输入。 |
|
||||
| `internal/apps/openflare/config_version/*`、`pkg/render/openresty/*` | 快照与 OpenResty 渲染改为使用 Zone 域名。 |
|
||||
| `frontend/lib/services/openflare/{zone.service.ts,types.ts,index.ts}` | Zone API 类型和服务。 |
|
||||
| `frontend/vitest.config.ts`、`frontend/tests/zone/*.test.tsx` | Zone 页面与域名选择器的最小前端测试运行环境。 |
|
||||
| `frontend/app/(main)/websites/*` | Zone 列表、`[zoneId]` 动态详情页和局部组件。 |
|
||||
| `frontend/app/(main)/proxy-routes/*` | Zone 域名选择器替换旧域名/证书编辑器。 |
|
||||
| `internal/infra/persistence/migrator/goose/{postgres,sqlite}/202607130001_drop_legacy_route_domain_columns.sql` | 第二阶段删除旧表、列与索引。 |
|
||||
|
||||
### Task 1: 第一阶段 Schema、模型与迁移测试
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/infra/persistence/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql`
|
||||
- Create: `internal/infra/persistence/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql`
|
||||
- Create: `internal/model/openflare_zone.go`
|
||||
- Create: `internal/model/openflare_zone_test.go`
|
||||
- Modify: `internal/model/openflare_proxy_route.go`
|
||||
- Test: `internal/infra/persistence/migrator/migrator_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `model.Zone`, `model.ZoneDomain`, `ListZoneDomainsByRouteID(ctx, routeID)`, `ReplaceZoneDomainRouteBindings(ctx, routeID, domainIDs)`.
|
||||
- Consumes: existing `model.ProxyRoute` and `model.TLSCertificate` IDs; no physical FK.
|
||||
|
||||
- [x] **Step 1: 写失败的模型与迁移测试**
|
||||
|
||||
```go
|
||||
func TestReplaceZoneDomainRouteBindingsRejectsForeignDomain(t *testing.T) {
|
||||
// Create zones and domains, then assert a domain cannot be bound twice.
|
||||
}
|
||||
```
|
||||
|
||||
Run: `go test ./internal/model ./internal/infra/persistence/migrator -run 'Zone|Migrat' -count=1`
|
||||
|
||||
Expected: FAIL,因为 Zone 模型和 goose 文件尚不存在。
|
||||
|
||||
- [x] **Step 2: 新建双方言 DDL**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS of_zones (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
remark VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zones_domain ON of_zones (domain);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS of_zone_domains (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
zone_id BIGINT NOT NULL,
|
||||
proxy_route_id BIGINT,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
cert_id BIGINT,
|
||||
remark VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zone_domains_domain ON of_zone_domains (domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_of_zone_domains_zone_id ON of_zone_domains (zone_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_of_zone_domains_proxy_route_id ON of_zone_domains (proxy_route_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_of_zone_domains_cert_id ON of_zone_domains (cert_id);
|
||||
```
|
||||
|
||||
SQLite 使用 `INTEGER PRIMARY KEY AUTOINCREMENT`、`DATETIME`,字段/索引语义完全对齐。此任务不得删除旧列或旧表。
|
||||
|
||||
- [x] **Step 3: 实现模型和受事务保护的绑定替换**
|
||||
|
||||
```go
|
||||
type Zone struct { ID uint; Domain string; Remark string; CreatedAt time.Time; UpdatedAt time.Time }
|
||||
type ZoneDomain struct { ID uint; ZoneID uint; ProxyRouteID *uint; Domain string; CertID *uint; Remark string; CreatedAt time.Time; UpdatedAt time.Time }
|
||||
|
||||
func ReplaceZoneDomainRouteBindings(ctx context.Context, routeID uint, domainIDs []uint) error
|
||||
```
|
||||
|
||||
实现先锁定/读取请求域名,拒绝已绑定到其他路由的记录,再把当前路由已绑定但不在 `domainIDs` 的记录置空,最后将请求记录写为 `routeID`;所有动作放在同一 `db.DB(ctx).Transaction` 内。
|
||||
|
||||
- [x] **Step 4: 运行模型与迁移测试**
|
||||
|
||||
Run: `go test ./internal/model ./internal/infra/persistence/migrator -run 'Zone|Migrat' -count=1`
|
||||
|
||||
Expected: PASS,空 SQLite 库可应用迁移,唯一域名和绑定排他性受保护。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/infra/persistence/migrator/goose internal/model
|
||||
git commit -m "feat(zone): add normalized zone domain schema"
|
||||
```
|
||||
|
||||
### Task 2: Zone 领域逻辑、历史导入命令与管理 API
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/apps/openflare/zone/{logics.go,routers.go,errs.go,legacy_import.go,logics_test.go}`
|
||||
- Create: `internal/cmd/migrate_zones.go`
|
||||
- Create: `internal/router/v1/openflare/register_zone.go`
|
||||
- Modify: `internal/cmd/root.go`
|
||||
- Modify: `internal/router/v1/openflare/register_tls.go`
|
||||
- Test: `internal/apps/openflare/integration/security_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `zone.Create`, `zone.Update`, `zone.GetOverview`, `zone.ImportLegacy(ctx) (ImportReport, error)` and Zone REST handlers.
|
||||
- Consumes: Task 1 models; legacy `managed_domains` and proxy-route columns only inside `ImportLegacy`.
|
||||
|
||||
- [x] **Step 1: 写失败的逻辑与 API 测试**
|
||||
|
||||
```go
|
||||
func TestCreateZoneDomainRejectsWildcard(t *testing.T) { _, err := CreateDomain(ctx, zoneID, DomainInput{Domain: "*.example.com"}); require.EqualError(t, err, errDomainWildcardUnsupported) }
|
||||
func TestLegacyImportUsesEffectiveTLDPlusOne(t *testing.T) {
|
||||
root, err := zoneRoot("api.example.co.uk")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "example.co.uk", root)
|
||||
}
|
||||
```
|
||||
|
||||
集成测试请求 `POST /api/v1/d/zones/`、`POST /api/v1/d/zones/:id/domains`,并断言错误响应使用 400 信封。
|
||||
|
||||
- [x] **Step 2: 实现精确域名和 Zone 归属验证**
|
||||
|
||||
```go
|
||||
func zoneRoot(domain string) (string, error) { return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(strings.TrimSpace(domain))) }
|
||||
func CreateDomain(ctx context.Context, zoneID uint, input DomainInput) (*model.ZoneDomain, error)
|
||||
```
|
||||
|
||||
拒绝空值、协议、路径和 `*`;要求 `zoneRoot(input.Domain) == zone.Domain`;若 `cert_id` 非空,验证 TLS 证书存在。Zone 根域创建也必须经 `EffectiveTLDPlusOne` 验证且输入等于结果。
|
||||
|
||||
- [x] **Step 3: 实现显式导入命令**
|
||||
|
||||
```go
|
||||
var migrateZonesCmd = &cobra.Command{Use: "migrate-zones", RunE: func(_ *cobra.Command, _ []string) error {
|
||||
report, err := zone.ImportLegacy(context.Background())
|
||||
return report.LogAndReturn(err)
|
||||
}}
|
||||
```
|
||||
|
||||
导入以事务执行:用 `routeidentity.DecodeDomains(route.Domains, route.Domain)` 读取旧路由;按 `domain_cert_ids` 的同一索引写入 `zone_domains.cert_id`;只在无路由域名时导入旧 `managed_domains`。发现无效根域、通配符记录或全局域名冲突时回滚并输出全部冲突项。重复执行不得生成重复 Zone/ZoneDomain。
|
||||
|
||||
- [x] **Step 4: 注册 API 并删除旧 managed-domain 路由**
|
||||
|
||||
```go
|
||||
zoneGroup := apiGroup.Group("/zones")
|
||||
zoneGroup.Use(apiutil.AdminMiddlewares()...)
|
||||
zoneGroup.GET("/", zone.ListHandler)
|
||||
zoneGroup.POST("/", zone.CreateHandler)
|
||||
zoneGroup.GET("/:id/overview", zone.GetOverviewHandler)
|
||||
```
|
||||
|
||||
把 `managed-domains` 路由块从 `register_tls.go` 移除;每个 Handler 使用 `apiutil.BindJSON` 和 `response.AbortBadRequest/AbortNotFound/AbortConflict`。
|
||||
|
||||
- [x] **Step 5: 验证并 Commit**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/zone ./internal/apps/openflare/integration -count=1 && make swagger`
|
||||
|
||||
Expected: PASS,Swagger 不再含 `/managed-domains` 且包含 `/zones`。
|
||||
|
||||
```bash
|
||||
git add internal/apps/openflare/zone internal/cmd internal/router/v1/openflare docs
|
||||
git commit -m "feat(zone): add zone management api and legacy importer"
|
||||
```
|
||||
|
||||
### Task 3: 反代路由改用 ZoneDomain 关联
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/apps/openflare/proxy_route/{logics.go,helpers.go,build_helpers.go,routers.go,errs.go,logics_test.go}`
|
||||
- Modify: `internal/model/openflare_proxy_route.go`
|
||||
- Modify: `internal/apps/openflare/tls/logics.go`
|
||||
- Modify: `internal/apps/openflare/origin/logics.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `zone_domain_ids []uint` and Task 1 binding API.
|
||||
- Produces: `proxy_route.Input{ZoneDomainIDs []uint}`, `proxy_route.View{ZoneDomains []ZoneDomainView}`.
|
||||
|
||||
- [x] **Step 1: 写失败的路由逻辑测试**
|
||||
|
||||
```go
|
||||
input := Input{SiteName: "api", ZoneDomainIDs: []uint{domainA.ID, domainB.ID}, EnableHTTPS: true}
|
||||
view, err := CreateProxyRoute(ctx, input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []uint{domainA.ID, domainB.ID}, view.ZoneDomainIDs)
|
||||
```
|
||||
|
||||
同时覆盖:空 `zone_domain_ids`、重复 ID、其他路由已占用域名、HTTPS 域名无证书、证书 SAN 不覆盖。
|
||||
|
||||
- [x] **Step 2: 删除路由输入/视图中的旧域名与证书字段**
|
||||
|
||||
```go
|
||||
type ZoneDomainBindingInput struct {
|
||||
ZoneDomainIDs []uint `json:"zone_domain_ids"`
|
||||
}
|
||||
type ZoneDomainView struct { ID uint `json:"id"`; ZoneID uint `json:"zone_id"`; Domain string `json:"domain"`; CertID *uint `json:"cert_id"` }
|
||||
```
|
||||
|
||||
移除 `Input.Domain`、`Input.Domains`、`Input.CertID`、`Input.CertIDs`、`Input.DomainCertIDs` 及对应 View 字段;删除旧证书派生辅助函数与 `WebsiteService.match` 所需后端逻辑。
|
||||
|
||||
- [x] **Step 3: 用关联记录验证并构建路由**
|
||||
|
||||
在 `buildProxyRoute` 中读取所有 `ZoneDomainIDs`,对每个 HTTPS 域名调用现有 `validateCertificateCoverage`,再调用 `ReplaceZoneDomainRouteBindings`。更新/删除路由也必须在事务内同步解除关联。来源、证书删除检查和 Origin 路由摘要改从 `zone_domains` 查询域名/证书。
|
||||
|
||||
- [x] **Step 4: 运行路由和 TLS 回归测试**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/proxy_route ./internal/apps/openflare/tls ./internal/apps/openflare/origin -count=1`
|
||||
|
||||
Expected: PASS;任一证书已被 Zone 域名引用时,删除证书被拒绝。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/apps/openflare/proxy_route internal/apps/openflare/tls internal/apps/openflare/origin internal/model
|
||||
git commit -m "refactor(proxy): bind routes through zone domains"
|
||||
```
|
||||
|
||||
### Task 4: 配置快照、渲染与关联消费者
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/apps/openflare/config_version/{snapshot.go,helpers.go,logics.go,logics_test.go,certificate_snapshot_test.go,pages_snapshot.go}`
|
||||
- Modify: `pkg/render/openresty/{types.go,render.go,render_route.go,render_test.go}`
|
||||
- Modify: `internal/apps/openflare/{flared/logics.go,uptimekuma/sync.go}`
|
||||
- Modify: `internal/apps/openflare/routeidentity/identity.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: snapshot/render `Route{SiteName, Domains, DomainCertIDs}` built transiently from ZoneDomain rows; neither DB model nor API stores those fields.
|
||||
|
||||
- [x] **Step 1: 写快照等价性失败测试**
|
||||
|
||||
```go
|
||||
func TestBuildSnapshotReadsZoneDomainCertificates(t *testing.T) {
|
||||
// Two explicit ZoneDomains with different certs must render two TLS server blocks.
|
||||
}
|
||||
```
|
||||
|
||||
加入 Pages、Tunnel、WAF 绑定测试,断言 Route ID 与 `site_name` 未改变。
|
||||
|
||||
- [x] **Step 2: 在快照边界联查并生成临时渲染字段**
|
||||
|
||||
```go
|
||||
domains, err := model.ListZoneDomainsByRouteID(ctx, route.ID)
|
||||
snapshotRoute.Domains = maps.Values(domainNames)
|
||||
snapshotRoute.DomainCertIDs = certIDsInDomainOrder(domains)
|
||||
```
|
||||
|
||||
`pkg/render/openresty.Route` 可继续保留 `Domains` 与 `DomainCertIDs`,因为它是不可变配置快照的渲染输入;移除其中持久化主域/证书回退逻辑,所有错误消息改用 `SiteName`。
|
||||
|
||||
- [x] **Step 3: 移除旧字段回退路径**
|
||||
|
||||
删除 `routeidentity.DecodeDomains` 对持久化 `route.Domain` 的依赖;Flared、Uptime Kuma、配置 diff、WAF 文档和 Pages 错误信息都从 snapshot/ZoneDomain 查询的明确域名获取显示文本。
|
||||
|
||||
- [x] **Step 4: 运行数据面测试**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/config_version ./pkg/render/openresty ./internal/apps/openflare/flared ./internal/apps/openflare/uptimekuma -count=1`
|
||||
|
||||
Expected: PASS;迁移后的路由产生的 `server_name`、证书支持文件和 WAF RouteID 绑定与迁移前一致。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/apps/openflare/config_version internal/apps/openflare/flared internal/apps/openflare/uptimekuma internal/apps/openflare/routeidentity pkg/render/openresty
|
||||
git commit -m "refactor(config): render routes from zone domains"
|
||||
```
|
||||
|
||||
### Task 5: Zone 前端服务与 ID 动态页面
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/lib/services/openflare/zone.service.ts`
|
||||
- Create: `frontend/vitest.config.ts`
|
||||
- Create: `frontend/tests/zone/{websites-page.test.tsx,zone-page.test.tsx}`
|
||||
- Modify: `frontend/lib/services/openflare/{types.ts,index.ts}`
|
||||
- Modify: `frontend/lib/services/index.ts`
|
||||
- Modify: `frontend/app/(main)/websites/page.tsx`
|
||||
- Create: `frontend/app/(main)/websites/[zoneId]/page.tsx`
|
||||
- Create: `frontend/app/(main)/websites/[zoneId]/components/{zone-overview.tsx,zone-domains-table.tsx,zone-route-summary.tsx,zone-editor-dialog.tsx,zone-domain-dialog.tsx}`
|
||||
- Delete: `frontend/app/(main)/websites/detail/page.tsx`
|
||||
- Delete: `frontend/app/(main)/websites/detail/page-client.tsx`
|
||||
- Delete: legacy Website/managed-domain-only components after imports are removed.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ZoneService.list/getOverview/create/update/delete`, `ZoneDomainService.create/update/delete` and `ZoneOverview` TypeScript types.
|
||||
|
||||
- [x] **Step 1: 写服务与页面行为测试**
|
||||
|
||||
先安装仅用于本次页面测试的开发依赖:
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm add -D vitest @testing-library/react @testing-library/jest-dom jsdom
|
||||
```
|
||||
|
||||
```ts
|
||||
expect(ZoneService.getOverview).toHaveBeenCalledWith(42)
|
||||
expect(screen.getByRole('heading', {name: 'example.com'})).toBeVisible()
|
||||
```
|
||||
|
||||
覆盖 `/websites/42` 的加载、404、空域名、搜索列表和从列表点击 ID 链接。
|
||||
|
||||
- [x] **Step 2: 实现类型化服务与查询键**
|
||||
|
||||
```ts
|
||||
export interface ZoneDomainItem { id: number; zone_id: number; proxy_route_id: number | null; domain: string; cert_id: number | null; remark: string }
|
||||
export class ZoneService extends OpenFlareBaseService { protected static override basePath = '/api/v1/d/zones' }
|
||||
export const zoneQueryKey = ['openflare', 'zones'] as const
|
||||
```
|
||||
|
||||
所有 React Query 回调使用箭头函数,避免静态 service `this` 丢失。
|
||||
|
||||
- [x] **Step 3: 用 Next 动态段实现 Zone 详情**
|
||||
|
||||
```tsx
|
||||
export default async function ZonePage({params}: PageProps<'/websites/[zoneId]'>) {
|
||||
const {zoneId} = await params
|
||||
return <ZonePageClient zoneId={Number(zoneId)} />
|
||||
}
|
||||
```
|
||||
|
||||
遵循本地 Next 文档:动态 `params` 是 Promise;无效或非正整数 ID 显示既有 `EmptyStateWithBorder`,不把域名写入 URL。主页面只维护页面骨架和 Tabs,具体 Tab 放入同目录组件。
|
||||
|
||||
- [x] **Step 4: 实现列表和详情交互**
|
||||
|
||||
列表仅渲染 Zone 根域及计数;详情使用概览、域名、路由、证书、设置 Tabs。域名弹窗拒绝 `*.`,但证书选择器不限制其 SAN。删除 Zone/域名使用确认对话框和服务端错误文案。
|
||||
|
||||
- [x] **Step 5: 验证并 Commit**
|
||||
|
||||
Run: `cd frontend && pnpm exec vitest run && pnpm lint`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
```bash
|
||||
git add frontend/lib/services frontend/app/'(main)'/websites
|
||||
git commit -m "feat(web): add zone-based website management"
|
||||
```
|
||||
|
||||
### Task 6: 反代路由前端切换到 Zone 域名选择器
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/app/(main)/proxy-routes/components/zone-domain-selector.tsx`
|
||||
- Create: `frontend/tests/zone/zone-domain-selector.test.tsx`
|
||||
- Modify: `frontend/app/(main)/proxy-routes/{components/proxy-route-create-sheet.tsx,components/helpers.ts,page-client.tsx}`
|
||||
- Modify: `frontend/app/(main)/proxy-routes/detail/{helpers.ts,page-client.tsx,components/domain-section.tsx}`
|
||||
- Delete: `frontend/app/(main)/proxy-routes/detail/components/domain-list-input.tsx`
|
||||
- Modify: `frontend/lib/services/openflare/types.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ZoneDomainItem[]` and route `zone_domain_ids: number[]`.
|
||||
- Produces: selector values with explicit domain/Zone/证书信息;不发送任何旧域名或证书字段。
|
||||
|
||||
- [x] **Step 1: 写失败的选择器测试**
|
||||
|
||||
```tsx
|
||||
render(<ZoneDomainSelector value={[7]} onChange={onChange} domains={[apiDomain]} />)
|
||||
expect(screen.getByText('api.example.com')).toBeVisible()
|
||||
expect(onChange).toHaveBeenCalledWith([7])
|
||||
```
|
||||
|
||||
覆盖搜索、跨 Zone 多选、已被其他路由占用的禁用项和 HTTPS 缺少证书的表单错误。
|
||||
|
||||
- [x] **Step 2: 移除旧前端负载与自动匹配**
|
||||
|
||||
从 `ProxyRouteItem`/`ProxyRouteMutationPayload` 删除 `domain`、`domains`、`primary_domain`、`cert_id`、`cert_ids`、`domain_cert_ids`;删除 `WebsiteService.match` 及 `DomainListInput` 自动填证书交互。
|
||||
|
||||
- [x] **Step 3: 实现 Zone 域名选择和保存负载**
|
||||
|
||||
```ts
|
||||
mutationFn: (payload) => ProxyRouteService.update(route.id, {
|
||||
...payload,
|
||||
zone_domain_ids: selectedDomainIDs,
|
||||
})
|
||||
```
|
||||
|
||||
展示每个选择项的 FQDN、所属 Zone 与证书;路由详情的“域名”区只编辑关联关系,证书链接跳转 Zone 详情而非路由内编辑。
|
||||
|
||||
- [x] **Step 4: 运行前端类型和交互测试**
|
||||
|
||||
Run: `cd frontend && pnpm exec tsc --noEmit`
|
||||
|
||||
Expected: PASS;不存在旧持久化域名/证书字段的 TypeScript 引用。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/app/'(main)'/proxy-routes frontend/lib/services/openflare/types.ts
|
||||
git commit -m "refactor(web): select route domains from zones"
|
||||
```
|
||||
|
||||
### Task 7: 第一发布阶段验证、文档与发布前数据检查
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/design/zone-design.md`
|
||||
- Modify: `docs/changelog/index.md`
|
||||
- Modify: generated `docs/{docs.go,swagger.json,swagger.yaml}`
|
||||
- Create: `docs/guide/zone-domain-migration.md`
|
||||
|
||||
- [x] **Step 1: 为导入命令写可操作迁移指南**
|
||||
|
||||
文档写明备份、执行 `wavelet migrate-zones`、读取导入报告、发布预览、比较 `server_name`/证书支持文件、发布激活和回滚步骤;不允许在报告有冲突时继续。
|
||||
|
||||
- [x] **Step 2: 生成 Swagger 和更新未发布变更**
|
||||
|
||||
Run: `make swagger`
|
||||
|
||||
在 `[Unreleased]` 记录 Zone 管理、反代路由域名正规化和移除 managed-domain API。
|
||||
|
||||
- [x] **Step 3: 运行全量质量门禁**
|
||||
|
||||
Run: `go test ./... && make code-check`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
- [x] **Step 4: 做快照等价性验收**
|
||||
|
||||
在升级前导出活动版本,在导入后生成预览;逐个比较所有路由的明确 `server_name` 集合、证书路径、WAF RouteID 绑定与 Pages 部署引用。只允许旧快照的域名/证书冗余 JSON 消失,不允许数据面语义变化。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs
|
||||
git commit -m "docs(zone): add migration and release verification guide"
|
||||
```
|
||||
|
||||
### Task 8: 第二发布阶段——删除旧表和冗余列
|
||||
|
||||
**Precondition:** 已在生产环境完成 Task 7 的导入、预览对比和至少一次发布/回滚验证;`migrate-zones` 报告无冲突。
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/infra/persistence/migrator/goose/postgres/202607130001_drop_legacy_route_domain_columns.sql`
|
||||
- Create: `internal/infra/persistence/migrator/goose/sqlite/202607130001_drop_legacy_route_domain_columns.sql`
|
||||
- Delete: `internal/model/openflare_managed_domain.go`
|
||||
- Delete: `internal/apps/openflare/tls/managed_domain.go`
|
||||
- Delete: `internal/apps/openflare/tls/helpers.go` 中仅用于旧路由证书数组的函数
|
||||
- Modify: legacy迁移相关测试、模型测试与 `docs/design/zone-design.md`
|
||||
|
||||
- [x] **Step 1: 写空库与升级库清理失败测试**
|
||||
|
||||
```go
|
||||
func TestLegacyRouteColumnsAreAbsentAfterCleanup(t *testing.T) {
|
||||
require.False(t, db.DB(ctx).Migrator().HasColumn(&model.ProxyRoute{}, "domain"))
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: 编写双方言清理 DDL**
|
||||
|
||||
PostgreSQL 删除旧唯一索引和 `domain`、`domains`、`cert_id`、`cert_ids`、`domain_cert_ids`,再删除 `of_managed_domains`;SQLite 使用重建 `of_proxy_routes` 表的迁移方式保留所有非旧字段与索引。Down 仅在开发数据库恢复旧结构,不回填历史数据。
|
||||
|
||||
- [x] **Step 3: 删除旧读取代码与测试 fixture**
|
||||
|
||||
删除所有 `route.Domain`、`route.Domains`、`route.CertID`、`route.CertIDs`、`route.DomainCertIDs` 的持久化引用;让编译器、Uptime Kuma、Flared、来源摘要及 API 只使用 ZoneDomain 查询结果。
|
||||
|
||||
- [x] **Step 4: 验证升级和完整回归**
|
||||
|
||||
Run: `go test ./internal/infra/persistence/migrator ./internal/model ./internal/apps/openflare/... ./pkg/render/openresty -count=1 && make code-check`
|
||||
|
||||
Expected: PASS;全仓搜索不再发现旧 `ManagedDomain` 业务代码、`ProxyRoute` 持久化字段或管理端 API;渲染快照中的临时 `DomainCertIDs` 类型允许保留。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/infra/persistence/migrator internal/model internal/apps frontend docs
|
||||
git commit -m "refactor(zone): remove legacy route domain storage"
|
||||
```
|
||||
|
||||
## Plan Self-Review
|
||||
|
||||
* Spec coverage: Tasks 1–4 交付正规化数据模型、API、迁移与数据面;Tasks 5–6 交付 ID 路由和 Zone 交互;Tasks 7–8 覆盖质量门禁与旧表清理。
|
||||
* Placeholder scan: 无待定标记或未定义的实现步骤;所有删除动作在明确的生产验证前置条件后执行。
|
||||
* Type consistency: 路由写入统一使用 `zone_domain_ids`,持久化关系统一为 `ZoneDomain.ProxyRouteID`,渲染边界仅使用临时 `Domains`/`DomainCertIDs`。
|
||||
@@ -1,639 +0,0 @@
|
||||
# WAF 可编排规则实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将固定顺序的 WAF 规则组重构为可用 React Flow 编辑、发布时编译、OpenResty 内存执行的有序 DAG 规则系统。
|
||||
|
||||
**Architecture:** 控制面以带修订号的版本化 JSON 保存整张编辑态图,Server 保存与发布时执行同一套强校验并编译为精简运行态 DAG。规则仅随配置发布和 OpenResty reload 加载一次;动态 IP 组由协调 Worker 每 5 秒检查 checksum,变化时更新共享快照和各 Worker 本地内存对象。
|
||||
|
||||
**Tech Stack:** Go 1.25、Gin、GORM、goose、PostgreSQL/SQLite、OpenResty Lua、Next.js 16 App Router、React 19、TypeScript、`@xyflow/react`、TanStack Query、shadcn/ui、Vitest。
|
||||
|
||||
## 实现状态(2026-07-13)
|
||||
|
||||
Tasks 1–11 已实现,包含三段数据库迁移、图模型与编译器、规则 API、发布快照、OpenResty 内存执行器、IP 组协调刷新、React Flow 编辑器、有序绑定、GeoLite2 City/Country 支持以及中文文档与 Swagger 更新。React Flow 画布使用本地受控节点状态处理拖动,并支持显式或键盘删除普通节点与连线。Country 与 City MMDB 均随 Agent 内嵌,缺失文件在启动时从程序内初始化,网络仅用于后续周期更新。地域属性栏使用完整国家与 ISO 3166-2 一级行政区数据,国家同时展示中文名称与代码,行政区支持按名称或代码搜索。发布器保证空规则绑定编码为 `[]`,Lua 运行时兼容旧快照中的 `null` 数组,避免未启用或空绑定规则导致请求 500。PoW 节点通过共享内存暂存配置,并以 `ngx.exec` 显式参数把配置键传入内部挑战处理器,避免内部重定向丢失请求上下文后误报节点未执行。
|
||||
|
||||
当前工作区已完成 `go test ./...`、前端全量 Vitest(56 项)、`make swagger`、`make code-check` 与 `git diff --check` 验证。Next.js 生产构建在本机持续停留于 Turbopack 的 `Creating an optimized production build ...`,未返回编译错误或成功状态,故不计为通过。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 每张图恰好一个 `start` 和一个 `allow`;`block` 可多个;图必须无环、无悬空、无不可达节点,所有路径必须抵达 `allow` 或 `block`。
|
||||
- `ip_match` 与 `geo_match` 只输出 `true`/`false`,分别表示匹配与未匹配;`pow` 只输出 `next`。
|
||||
- 全局规则固定前置;路由自定义规则按绑定 `sequence` 升序执行;当前规则 `allow` 后继续下一条,`block` 立即终止。
|
||||
- 规则运行态 JSON 仅在 OpenResty reload 后由 Worker 加载一次;请求路径禁止文件 I/O、checksum 和 JSON 解码。
|
||||
- IP 组请求路径始终读取 Worker 本地对象;整个实例每 5 秒最多一个 Worker 检查 checksum。
|
||||
- 迁移只保留规则名称、全局标记、启用状态和绑定关系,旧策略统一重置为 `开始 → 通过`。
|
||||
- 所有 HTTP 路由仅在 `internal/router/router.go` 的既有分发体系中通过 `internal/router/v1/openflare/register_waf.go` 注册;API 失败使用 `response.Abort*`。
|
||||
- API Handler 变化后运行 `make swagger`;代码完成后运行 `make code-check`;代码变更写入 `docs/changelog/index.md` 的 `[Unreleased]`。
|
||||
- 前端不得删除 `frontend/node_modules`;使用 shadcn variant 与全局 CSS 变量,页面根容器保持 `w-full py-6 px-1`。
|
||||
- 实现前阅读 `.agent/skills/database-migration/SKILL.md`、`.agent/skills/new-api/SKILL.md`、`.agent/skills/shadcn/SKILL.md` 以及 `frontend/node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/page.md` 等匹配的 Next.js 本地文档。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与背景
|
||||
|
||||
当前 `OpenFlareWAFRuleGroup` 把 IP/地域黑白名单、PoW 与阻止响应平铺为固定字段,Lua 按硬编码顺序判断,无法表达用户自定义分支。本次交付包含图模型、强校验、API、迁移、发布编译、Lua DAG 执行、有序绑定、IP 组五秒内存刷新和 React Flow 编辑器;不包含循环、脚本节点、表达式节点、子图和跨规则跳转。
|
||||
|
||||
## 2. 数据与控制流
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UI[React Flow 编辑态图] -->|revision + graph| API[Server 校验与保存]
|
||||
API --> DB[(规则 graph JSON)]
|
||||
DB --> PUB[发布编译器]
|
||||
PUB --> SNAP[运行态 DAG 快照]
|
||||
SNAP --> AGENT[Agent 原子落盘]
|
||||
AGENT -->|reload| LUA[OpenResty Worker 内存]
|
||||
IPS[IP 组异步更新] -->|JSON 后 checksum| TIMER[5 秒协调定时器]
|
||||
TIMER --> SHM[ngx.shared 原始快照]
|
||||
SHM --> LUA
|
||||
```
|
||||
|
||||
## 3. 文件结构与职责
|
||||
|
||||
- `internal/apps/openflare/waf/graph_types.go`:编辑态图、节点配置、运行态图和默认图类型。
|
||||
- `internal/apps/openflare/waf/graph_validate.go`:结构、端口、配置、引用、可达性和终止性校验。
|
||||
- `internal/apps/openflare/waf/graph_compile.go`:删除 UI 字段、编译索引化 DAG、收集 IP 组引用。
|
||||
- `internal/apps/openflare/waf/rule_logics.go`:规则元数据、修订保存和有序绑定逻辑;从现有过大的 `logics.go` 中抽离规则职责。
|
||||
- `internal/apps/openflare/waf/rule_routers.go`:规则 API Handler 与 Swagger;IP 组 Handler 留在现有文件或后续独立拆分。
|
||||
- `internal/apps/agent/nginx/waf_assets.go`:只负责嵌入 Lua 文件;实际 Lua 拆到 `internal/apps/agent/nginx/waf_runtime.lua` 与 `waf_ip_groups.lua` 并使用 `go:embed`,避免继续膨胀 Go 字符串。
|
||||
- `frontend/app/(main)/waf/page.tsx`:规则/IP 组列表和仅名称创建流程。
|
||||
- `frontend/app/(main)/waf/rules/editor/page.tsx`:静态可导出的编辑器路由入口,通过查询参数读取规则 ID,避免 Next 静态导出的动态参数限制。
|
||||
- `frontend/app/(main)/waf/rules/editor/components/`:画布、节点、节点库、属性栏和校验提示,单文件保持低于 600 行。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 数据库迁移与持久化模型
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/infra/persistence/migrator/goose/postgres/202607150001_orchestrate_waf_rules.sql`
|
||||
- Create: `internal/infra/persistence/migrator/goose/sqlite/202607150001_orchestrate_waf_rules.sql`
|
||||
- Create: `internal/infra/persistence/migrator/goose/postgres/202607150002_reset_waf_rule_graphs.sql`
|
||||
- Create: `internal/infra/persistence/migrator/goose/sqlite/202607150002_reset_waf_rule_graphs.sql`
|
||||
- Modify: `internal/model/openflare_waf.go`
|
||||
- Create: `internal/model/openflare_waf_graph_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `Graph string`, `Revision uint64`, `Sequence int`;`UpdateOpenFlareWAFRuleGraph(ctx, id, revision, graph) (uint64, error)`;绑定查询按 `sequence, id` 排序。
|
||||
- Consumes: 现有 `OpenFlareWAFRuleGroup` 与 `OpenFlareWAFRuleGroupBinding`。
|
||||
|
||||
- [ ] **Step 1: 阅读数据库迁移 Skill 并写迁移失败测试**
|
||||
|
||||
测试建立旧 Schema、插入两个规则和无序绑定、执行迁移后断言图统一为默认图、`revision = 1`、绑定顺序稳定。测试核心断言:
|
||||
|
||||
```go
|
||||
require.JSONEq(t, `{"schema_version":1,"nodes":[{"id":"start","type":"start","position":{"x":0,"y":0},"config":{}},{"id":"allow","type":"allow","position":{"x":320,"y":0},"config":{}}],"edges":[{"id":"start-allow","source":"start","source_handle":"next","target":"allow"}]}`, group.Graph)
|
||||
assert.Equal(t, uint64(1), group.Revision)
|
||||
assert.Equal(t, []int{0, 1}, []int{bindings[0].Sequence, bindings[1].Sequence})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行模型测试确认失败**
|
||||
|
||||
Run: `go test ./internal/model -run 'TestOpenFlareWAFGraph|TestReplaceOpenFlareWAFRuleGroupBindings' -count=1`
|
||||
|
||||
Expected: FAIL,缺少新字段或迁移列。
|
||||
|
||||
- [ ] **Step 3: 编写 PostgreSQL 与 SQLite goose 迁移**
|
||||
|
||||
`202607150001` 只执行 DDL:两端都增加 `graph TEXT NOT NULL`、`revision BIGINT/INTEGER NOT NULL DEFAULT 1`、`sequence INTEGER NOT NULL DEFAULT 0`。`202607150002` 只执行 DML:用确定性的 `id` 顺序为每个 `proxy_route_id` 回填 sequence,并将所有 graph 重置为同一默认 JSON。Down 分别恢复数据语义与旧列结构;不要创建物理外键,禁止把 DDL 与 DML 放入同一个迁移文件。
|
||||
|
||||
- [ ] **Step 4: 实现乐观锁与有序绑定模型方法**
|
||||
|
||||
```go
|
||||
var ErrWAFRuleRevisionConflict = errors.New("waf rule revision conflict")
|
||||
|
||||
func UpdateOpenFlareWAFRuleGraph(ctx context.Context, id uint, revision uint64, graph string) (uint64, error) {
|
||||
result := db.DB(ctx).Model(&OpenFlareWAFRuleGroup{}).
|
||||
Where("id = ? AND revision = ?", id, revision).
|
||||
Updates(map[string]any{"graph": graph, "revision": gorm.Expr("revision + 1")})
|
||||
if result.Error != nil { return 0, result.Error }
|
||||
if result.RowsAffected != 1 { return 0, ErrWAFRuleRevisionConflict }
|
||||
return revision + 1, nil
|
||||
}
|
||||
```
|
||||
|
||||
绑定替换在事务中按输入下标写 `Sequence: index`;查询显式 `Order("sequence asc").Order("id asc")`。
|
||||
|
||||
- [ ] **Step 5: 运行测试并提交**
|
||||
|
||||
Run: `go test ./internal/model ./internal/infra/persistence/migrator/... -count=1`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
Commit: `feat(waf): add graph persistence and binding order`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 图类型、默认图和强校验器
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/apps/openflare/waf/graph_types.go`
|
||||
- Create: `internal/apps/openflare/waf/graph_validate.go`
|
||||
- Create: `internal/apps/openflare/waf/graph_validate_test.go`
|
||||
- Modify: `internal/apps/openflare/waf/errs.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `RuleGraph`, `RuleNode`, `RuleEdge`, `DefaultRuleGraph() RuleGraph`, `ValidateRuleGraph(ctx context.Context, graph RuleGraph, ipGroupExists func(context.Context, uint) (bool, error)) error`。
|
||||
- Consumes: Task 1 的 JSON 持久化字段。
|
||||
|
||||
- [ ] **Step 1: 写表驱动失败测试**
|
||||
|
||||
覆盖合法默认图、重复 start/allow、环、不可达节点、悬空端口、错误 handle、同 handle 多目标、无终止路径、未知类型、无效 IP/CIDR、缺失 IP 组、非法国家/地区、PoW 范围和超限图。
|
||||
|
||||
```go
|
||||
tests := []struct{name string; mutate func(*RuleGraph); want string}{
|
||||
{"cycle", addCycle, "规则图不能包含循环"},
|
||||
{"missing false edge", removeFalseEdge, "节点 match-1 的 false 出口未连接"},
|
||||
{"unreachable", addUnreachableNode, "节点 orphan 无法从开始节点到达"},
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/waf -run TestValidateRuleGraph -count=1`
|
||||
|
||||
Expected: FAIL,类型和校验函数不存在。
|
||||
|
||||
- [ ] **Step 3: 定义带判别联合的图类型**
|
||||
|
||||
```go
|
||||
type RuleNodeType string
|
||||
const (
|
||||
RuleNodeStart RuleNodeType = "start"
|
||||
RuleNodeAllow RuleNodeType = "allow"
|
||||
RuleNodeBlock RuleNodeType = "block"
|
||||
RuleNodeIPMatch RuleNodeType = "ip_match"
|
||||
RuleNodeGeoMatch RuleNodeType = "geo_match"
|
||||
RuleNodePoW RuleNodeType = "pow"
|
||||
)
|
||||
type RuleGraph struct { SchemaVersion int `json:"schema_version"`; Nodes []RuleNode `json:"nodes"`; Edges []RuleEdge `json:"edges"` }
|
||||
type RuleEdge struct { ID, Source, SourceHandle, Target string }
|
||||
```
|
||||
|
||||
`RuleNode.Config` 先用 `json.RawMessage` 解码到明确的 `IPMatchConfig`、`GeoMatchConfig`、`PoWNodeConfig`、`BlockNodeConfig`,禁止透传未知字段。
|
||||
|
||||
- [ ] **Step 4: 实现结构和 DFS/Kahn 校验**
|
||||
|
||||
先检查大小、ID、类型、端口和配置,再用 Kahn 检测环,用从 start 的 DFS 检测可达性,用反向图从所有终止节点遍历检测终止性。错误文案携带节点/边 ID,供前端定位。
|
||||
|
||||
- [ ] **Step 5: 运行测试并提交**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/waf -run 'Test(DefaultRuleGraph|ValidateRuleGraph)' -count=1`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
Commit: `feat(waf): validate composable rule graphs`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 运行态图编译器
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/apps/openflare/waf/graph_compile.go`
|
||||
- Create: `internal/apps/openflare/waf/graph_compile_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `CompileRuleGraph(graph RuleGraph) (RuntimeRuleGraph, error)`、`ReferencedIPGroupIDs(graph RuleGraph) []uint`。
|
||||
- Consumes: Task 2 的已校验图类型。
|
||||
|
||||
- [ ] **Step 1: 写编译快照测试**
|
||||
|
||||
断言位置和显示名不进入 JSON、节点通过 ID map O(1) 查找、出口按 handle 编译、IP 组 ID 去重排序。
|
||||
|
||||
```go
|
||||
compiled, err := CompileRuleGraph(graph)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "start", compiled.Entry)
|
||||
assert.Equal(t, "allow", compiled.Nodes["match"].Next["true"])
|
||||
assert.Equal(t, []uint{2, 7}, ReferencedIPGroupIDs(graph))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/waf -run 'TestCompileRuleGraph|TestReferencedIPGroupIDs' -count=1`
|
||||
|
||||
Expected: FAIL,编译接口不存在。
|
||||
|
||||
- [ ] **Step 3: 实现确定性编译**
|
||||
|
||||
输出结构只保留 `entry`、按节点 ID 索引的类型化运行配置和 handle→target 映射;序列化前对可排序切片排序,确保相同图生成相同快照和 checksum。
|
||||
|
||||
- [ ] **Step 4: 运行测试并提交**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/waf -run 'TestCompileRuleGraph|TestReferencedIPGroupIDs' -count=1`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
Commit: `feat(waf): compile rule graphs for runtime`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 规则 API、修订冲突与有序绑定
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/apps/openflare/waf/rule_logics.go`
|
||||
- Create: `internal/apps/openflare/waf/rule_routers.go`
|
||||
- Create: `internal/apps/openflare/waf/rule_logics_test.go`
|
||||
- Modify: `internal/apps/openflare/waf/logics.go`
|
||||
- Modify: `internal/apps/openflare/waf/routers.go`
|
||||
- Modify: `internal/router/v1/openflare/register_waf.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `CreateRuleInput{Name string}`、`SaveRuleGraphInput{Revision uint64; Graph RuleGraph}`、`UpdateRuleMetaInput{Name string; Enabled bool}`;创建、详情、元数据、图保存、删除和有序绑定 API。
|
||||
- Consumes: Tasks 1–3 的模型、默认图和校验器。
|
||||
|
||||
- [ ] **Step 1: 阅读 new-api Skill,写逻辑与 Handler 失败测试**
|
||||
|
||||
覆盖只传名称创建默认图、空名 400、图非法 400、revision 冲突 409、绑定顺序往返不变、全局规则不能被路由绑定排序覆盖。
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/waf ./internal/router/v1/openflare -run 'Test(CreateRule|SaveRuleGraph|ReplaceSiteRuleGroups)' -count=1`
|
||||
|
||||
Expected: FAIL,API 输入与路由尚未实现。
|
||||
|
||||
- [ ] **Step 3: 实现 logic 与安全错误映射**
|
||||
|
||||
```go
|
||||
func SaveRuleGraph(ctx context.Context, id uint, input SaveRuleGraphInput) (*RuleView, error) {
|
||||
if err := ValidateRuleGraph(ctx, input.Graph, ipGroupExists); err != nil { return nil, err }
|
||||
raw, err := json.Marshal(input.Graph)
|
||||
if err != nil { return nil, err }
|
||||
if _, err = model.UpdateOpenFlareWAFRuleGraph(ctx, id, input.Revision, string(raw)); err != nil { return nil, err }
|
||||
return GetRule(ctx, id)
|
||||
}
|
||||
```
|
||||
|
||||
数据库/编码错误用 `pkg/logger` 记录后映射 `AbortInternal`;校验错误用 `AbortBadRequest`;revision 冲突用 `AbortConflict`。
|
||||
|
||||
- [ ] **Step 4: 注册路由并补全 Swagger**
|
||||
|
||||
保留 `/rule-groups` 路径以减少前端与兼容面变化,但将创建 payload 改为仅名称,新增 `POST /rule-groups/:id/graph` 与 `POST /rule-groups/:id/meta`。所有 `@Failure 400/404/409/500` 与统一 response envelope 完整声明。
|
||||
|
||||
- [ ] **Step 5: 运行测试并提交**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/waf ./internal/router/v1/openflare -count=1`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
Commit: `feat(api): expose orchestrated waf rules`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 发布快照与规则顺序
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/apps/openflare/config_version/snapshot.go`
|
||||
- Modify: `internal/apps/openflare/config_version/logics_test.go`
|
||||
- Create: `internal/apps/openflare/config_version/waf_graph_snapshot_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 发布快照中的 `rule_groups[].graph` 运行态 DAG、按 sequence 排序的 `bindings[].rule_group_ids`、图引用 IP 组集合。
|
||||
- Consumes: Task 3 编译器与 Task 1 有序绑定。
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
建立全局规则和两个自定义图,绑定顺序 `[customB, customA]`,断言快照保持该顺序、运行图无 position、只包含图引用的 IP 组;非法启用图阻止预览/发布。
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/config_version -run 'TestWAFGraphSnapshot|TestBuildSnapshotRejectsInvalidWAFGraph' -count=1`
|
||||
|
||||
Expected: FAIL,快照仍输出旧固定字段并按 ID 排序。
|
||||
|
||||
- [ ] **Step 3: 替换固定字段快照编译**
|
||||
|
||||
删除 `snapshotWAFRuleGroup` 的旧黑白名单/PoW 字段,加入 `Graph waf.RuntimeRuleGraph`;`buildSnapshotWAFIPGroups` 从所有编辑图的 `ReferencedIPGroupIDs` 聚合;绑定不再按 group ID 排序。
|
||||
|
||||
- [ ] **Step 4: 运行测试并提交**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/config_version ./internal/apps/openflare/integration -count=1`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
Commit: `feat(waf): publish ordered runtime graphs`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: OpenResty 内存 DAG 执行器
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/apps/agent/nginx/waf_runtime.lua`
|
||||
- Create: `internal/apps/agent/nginx/waf_runtime_spec.lua`
|
||||
- Modify: `internal/apps/agent/nginx/waf_assets.go`
|
||||
- Modify: `internal/apps/agent/nginx/waf_assets_test.go`
|
||||
- Modify: `internal/apps/agent/nginx/manager.go`
|
||||
- Modify: `internal/apps/agent/nginx/manager_test.go`
|
||||
- Modify: `internal/apps/agent/nginx/pow_assets.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `require("waf.runtime").check()`,模块加载时读取一次规则配置,请求时执行内存 DAG。
|
||||
- Consumes: Task 5 的运行态快照;现有 PoW challenge/session 代码。
|
||||
|
||||
- [ ] **Step 1: 写 Lua 执行器失败测试**
|
||||
|
||||
用 stub `ngx` 覆盖 IP true/false、地域 true/false、PoW 接管/完成、多个 block 响应、全局前置、自定义顺序、未知节点 fail-closed、请求期间 `io.open` 调用次数为 0。
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run: `go test ./internal/apps/agent/nginx -run 'TestWAFRuntime' -count=1`
|
||||
|
||||
Expected: FAIL,运行时仍为固定链且每次请求读取配置。
|
||||
|
||||
- [ ] **Step 3: 将规则加载移到 Lua 模块初始化**
|
||||
|
||||
```lua
|
||||
local rules_config = assert(load_json_once(runtime_dir .. "/waf_config.json"))
|
||||
|
||||
function _M.check()
|
||||
local rules = active_rules_for_site(rules_config, ngx.var.openflare_waf_site or "")
|
||||
for _, rule in ipairs(rules) do
|
||||
local decision = execute_graph(rule.graph)
|
||||
if decision.kind == "block" then return render_block(decision.config) end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
在 `manager.go` 生成的 `http` 块中显式加入 `init_worker_by_lua_block { require("waf.runtime").init() }`,使新 Worker 在 reload 启动阶段完成读取与解析,而不是推迟到首个请求。模块缓存使每个 Worker 只解析一次;执行器设置最大步数为节点数,任何损坏图都记录限频错误并返回阻止响应。
|
||||
|
||||
- [ ] **Step 4: 将 PoW 变为节点执行接口**
|
||||
|
||||
抽取现有 PoW runtime 为 `pow.evaluate(config)`:完成返回 `true`,未完成直接输出/重定向挑战并返回接管标记。移除“按站点选择第一个 pow_enabled 规则”的旧扫描逻辑。
|
||||
|
||||
- [ ] **Step 5: 运行测试并提交**
|
||||
|
||||
Run: `go test ./internal/apps/agent/nginx ./internal/apps/agent/sync -count=1`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
Commit: `feat(agent): execute waf graphs from worker memory`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: IP 组 checksum 与五秒内存刷新
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/apps/agent/nginx/waf_ip_groups.lua`
|
||||
- Create: `internal/apps/agent/nginx/waf_ip_groups_spec.lua`
|
||||
- Modify: `internal/apps/agent/sync/service.go`
|
||||
- Modify: `internal/apps/agent/sync/service_test.go`
|
||||
- Modify: `internal/apps/agent/nginx/waf_assets.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `waf_ip_groups.json.checksum`;Lua `ip_groups.current()` 返回 Worker 本地对象;协调刷新间隔固定 5 秒。
|
||||
- Consumes: 现有 Agent IP 组同步 payload 与独立的 `ngx.shared.openflare_waf_ip_groups`(64 MiB);完整运行时快照上限为 20 MiB。
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
断言 Agent 先原子替换 JSON、最后原子替换 checksum;Lua 稳定状态 15 秒只读取 checksum 3 次且不读 JSON;变化时全实例只读一次 JSON;非法新 JSON 保留旧对象。
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run: `go test ./internal/apps/agent/sync ./internal/apps/agent/nginx -run 'TestWAFIPGroup(Checksum|Refresh)' -count=1`
|
||||
|
||||
Expected: FAIL,checksum sidecar 和定时器不存在。
|
||||
|
||||
- [ ] **Step 3: Agent 写 checksum sidecar**
|
||||
|
||||
checksum 使用 Agent 已有快照 checksum;写入采用同目录临时文件、fsync/close、rename 的现有原子文件工具。严格顺序为 JSON rename 成功后 checksum rename。
|
||||
|
||||
- [ ] **Step 4: 实现协调 Worker 刷新**
|
||||
|
||||
```lua
|
||||
local function tick(premature)
|
||||
if premature then return end
|
||||
local ok = shared:add("ip_refresh_lock", true, 4)
|
||||
if ok then refresh_from_checksum() end
|
||||
adopt_shared_snapshot_if_changed()
|
||||
end
|
||||
ngx.timer.every(5, tick)
|
||||
```
|
||||
|
||||
协调 Worker 变化时把 raw JSON 和 checksum 写共享字典;每个 Worker 只在 shared version 变化时 decode 到模块局部 `current_groups`。请求只调用 `ip_groups.current()`。
|
||||
|
||||
- [ ] **Step 5: 运行测试并提交**
|
||||
|
||||
Run: `go test ./internal/apps/agent/nginx ./internal/apps/agent/sync -count=1`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
Commit: `perf(waf): refresh ip groups by checksum timer`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 前端类型、Service 与创建流程
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/package.json`
|
||||
- Modify: `frontend/pnpm-lock.yaml`
|
||||
- Modify: `frontend/lib/services/openflare/types.ts`
|
||||
- Modify: `frontend/lib/services/openflare/waf.service.ts`
|
||||
- Modify: `frontend/app/(main)/waf/page.tsx`
|
||||
- Create: `frontend/app/(main)/waf/components/create-rule-dialog.tsx`
|
||||
- Modify: `frontend/app/(main)/waf/components/rule-groups-table.tsx`
|
||||
- Delete after replacement: `frontend/app/(main)/waf/components/rule-group-sheet.tsx`
|
||||
- Test: `frontend/tests/unit/waf-rule-service.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: TypeScript 判别联合 `WAFRuleNode`、`WAFRuleGraph`、`WAFRule`;`WafService.createRule({name})`、`saveRuleGraph(id, {revision, graph})`。
|
||||
- Consumes: Task 4 API。
|
||||
|
||||
- [ ] **Step 1: 阅读 shadcn 与 Next 本地文档,安装 React Flow**
|
||||
|
||||
Run: `cd frontend && pnpm add @xyflow/react`
|
||||
|
||||
Expected: `package.json` 与 lockfile 增加同一版本的 `@xyflow/react`。
|
||||
|
||||
- [ ] **Step 2: 写 Service 与创建流程失败测试**
|
||||
|
||||
断言创建 payload 只有 `{name}`,保存包含 revision,创建成功导航到 `/waf/rules/editor?id=<id>`,不再打开旧规则大表单。
|
||||
|
||||
- [ ] **Step 3: 运行测试确认失败**
|
||||
|
||||
Run: `cd frontend && pnpm vitest run tests/unit/waf-rule-service.test.ts`
|
||||
|
||||
Expected: FAIL,旧 payload 仍要求固定字段。
|
||||
|
||||
- [ ] **Step 4: 实现类型、Service 和名称对话框**
|
||||
|
||||
```ts
|
||||
export type WAFRuleNode =
|
||||
| {id: string; type: 'start'; position: XYPosition; config: Record<string, never>}
|
||||
| {id: string; type: 'ip_match'; position: XYPosition; config: IPMatchConfig}
|
||||
| {id: string; type: 'geo_match'; position: XYPosition; config: GeoMatchConfig}
|
||||
| {id: string; type: 'pow'; position: XYPosition; config: PoWNodeConfig}
|
||||
| {id: string; type: 'allow'; position: XYPosition; config: Record<string, never>}
|
||||
| {id: string; type: 'block'; position: XYPosition; config: BlockNodeConfig};
|
||||
```
|
||||
|
||||
静态方法作为 React Query 回调时继续用箭头函数包裹。列表创建成功后 `router.push('/waf/rules/editor?id=' + rule.id)`。
|
||||
|
||||
- [ ] **Step 5: 运行测试并提交**
|
||||
|
||||
Run: `cd frontend && pnpm vitest run tests/unit/waf-rule-service.test.ts && pnpm lint`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
Commit: `feat(frontend): create orchestrated waf rules`
|
||||
|
||||
---
|
||||
|
||||
### Task 9: React Flow 编排器与固定属性栏
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/app/(main)/waf/rules/editor/page.tsx`
|
||||
- Create: `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx`
|
||||
- Create: `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx`
|
||||
- Create: `frontend/app/(main)/waf/rules/editor/components/node-library.tsx`
|
||||
- Create: `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx`
|
||||
- Create: `frontend/app/(main)/waf/rules/editor/components/graph-validation.ts`
|
||||
- Create: `frontend/app/(main)/waf/rules/editor/components/graph-validation.test.ts`
|
||||
- Create: `frontend/app/(main)/waf/rules/editor/components/unsaved-changes.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 全宽 React Flow 编辑器;前端 `validateGraph(graph): GraphIssue[]`;Server 错误节点定位。
|
||||
- Consumes: Task 8 类型与 Service。
|
||||
|
||||
- [ ] **Step 1: 写前端图校验失败测试**
|
||||
|
||||
覆盖唯一 start/allow、必需 handle、禁止环、不可达、终止性以及删除节点同步删边。
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run: `cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor/components/graph-validation.test.ts'`
|
||||
|
||||
Expected: FAIL,校验器不存在。
|
||||
|
||||
- [ ] **Step 3: 实现页面骨架和数据状态**
|
||||
|
||||
`page.tsx` 直接维护 query、mutation、React Flow nodes/edges、dirty、selection 和右侧栏状态,不创建同名中转容器。根节点使用 `w-full py-6 px-1`,标题严格使用既定图标和 `h1` 规范。
|
||||
|
||||
- [ ] **Step 4: 实现节点、handle 和连线约束**
|
||||
|
||||
`start/pow` 只显示 `next` source handle,`ip_match/geo_match` 显示 `true`、`false`,`allow/block` 只显示 target handle。`isValidConnection` 阻止错误端口、同端口重复连接和形成环;start/allow 禁止删除。
|
||||
|
||||
- [ ] **Step 5: 实现固定右侧属性栏**
|
||||
|
||||
属性栏按节点判别联合渲染 IP/CIDR 与 IP 组多选、国家/地区多选、PoW 配置、阻止状态码与 HTML。颜色和阴影通过节点组件 variant/CSS 变量集中定义,不在业务调用点硬编码。
|
||||
|
||||
- [ ] **Step 6: 实现保存、冲突和未保存提示**
|
||||
|
||||
仅图合法时启用保存;409 显示“规则已在其他页面更新,请重新加载”;Server 返回节点/边 ID 时选中并聚焦;浏览器离开和应用内返回均提示未保存变更。
|
||||
|
||||
- [ ] **Step 7: 运行测试、构建并提交**
|
||||
|
||||
Run: `cd frontend && pnpm vitest run && pnpm lint && pnpm build`
|
||||
|
||||
Expected: PASS;静态导出包含 `/waf/rules/editor`。
|
||||
|
||||
Commit: `feat(frontend): add visual waf rule composer`
|
||||
|
||||
---
|
||||
|
||||
### Task 10: 路由绑定排序 UI 与旧界面清理
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/(main)/waf/components/site-binding-sheet.tsx`
|
||||
- Modify: `frontend/app/(main)/proxy-routes/detail/components/waf-section.tsx`
|
||||
- Modify: `frontend/app/(main)/waf/components/helpers.ts`(删除仅旧规则表单使用的导出;若清空则删除文件)
|
||||
- Delete: `frontend/app/(main)/waf/components/pow-config-panel.tsx`
|
||||
- Delete: `frontend/app/(main)/waf/components/rule-entry-dialog.tsx`
|
||||
- Delete: `frontend/app/(main)/waf/components/rule-list-section.tsx`
|
||||
- Test: `frontend/tests/unit/waf-binding-order.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 拖拽或上下移动的有序绑定列表,提交 ID 顺序不被排序。
|
||||
- Consumes: Task 4 有序绑定 API 和 Task 8 Service。
|
||||
|
||||
- [ ] **Step 1: 写绑定顺序失败测试**
|
||||
|
||||
选择规则 A/B/C,移动为 C/A/B,断言 API payload 为 `{ids:[C,A,B]}`;全局规则单独展示为固定前置且不可拖动。
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run: `cd frontend && pnpm vitest run tests/unit/waf-binding-order.test.tsx`
|
||||
|
||||
Expected: FAIL,当前 UI 只表达集合。
|
||||
|
||||
- [ ] **Step 3: 实现排序并删除旧固定表单组件**
|
||||
|
||||
复用项目现有 `@dnd-kit/sortable`;为键盘用户提供上移/下移操作。清理旧字段、旧 PoW 面板和不再引用的 helper,保留 IP 组管理组件。
|
||||
|
||||
- [ ] **Step 4: 运行测试并提交**
|
||||
|
||||
Run: `cd frontend && pnpm vitest run && pnpm lint && pnpm build`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
Commit: `refactor(frontend): order waf bindings and remove legacy editor`
|
||||
|
||||
---
|
||||
|
||||
### Task 11: 旧后端字段清理、Swagger、中文文档与端到端验证
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/infra/persistence/migrator/goose/postgres/202607150003_drop_legacy_waf_rule_fields.sql`
|
||||
- Create: `internal/infra/persistence/migrator/goose/sqlite/202607150003_drop_legacy_waf_rule_fields.sql`
|
||||
- Modify: `internal/model/openflare_waf.go`
|
||||
- Modify: `internal/apps/openflare/waf/logics_test.go`
|
||||
- Modify: `docs/design/waf-design.md`
|
||||
- Modify: `docs/guide/waf-usage.md`
|
||||
- Modify: `docs/changelog/index.md`
|
||||
- Generated: `docs/docs.go`, `docs/swagger.json`, `docs/swagger.yaml`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 无旧固定策略字段的最终 Schema 与中文使用文档。
|
||||
- Consumes: Tasks 1–10 的完整替代实现。
|
||||
|
||||
- [ ] **Step 1: 写迁移与集成失败测试**
|
||||
|
||||
断言最终表不再包含 `block_status_code`、`ip_whitelist`、`ip_blacklist`、地域名单、`pow_enabled`、`pow_config`;端到端图分别产生 allow、block、PoW 接管,IP 组变化在 5–10 秒内生效。
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/integration ./internal/model -run 'TestOrchestratedWAF|TestLegacyWAFColumnsRemoved' -count=1`
|
||||
|
||||
Expected: FAIL,旧列仍存在。
|
||||
|
||||
- [ ] **Step 3: 删除旧列和旧代码路径**
|
||||
|
||||
PostgreSQL 直接 `DROP COLUMN`;SQLite 使用项目支持版本的 `DROP COLUMN` 或重建表迁移并复制 `id/name/enabled/is_global/graph/revision/timestamps`。删除 Go model/view/input 中的旧字段和固定链 helper,确保仓库中业务代码不再引用它们。
|
||||
|
||||
- [ ] **Step 4: 更新中文文档与 changelog**
|
||||
|
||||
`waf-design.md` 删除固定链作为现行设计的表述,链接可编排设计;`waf-usage.md` 写创建、节点语义、绑定顺序、发布生效和迁移警告;`[Unreleased]` 增加 WAF 可视编排、发布加载和 IP 组刷新条目。
|
||||
|
||||
- [ ] **Step 5: 生成 Swagger 并运行全量验证**
|
||||
|
||||
Run: `make swagger`
|
||||
|
||||
Expected: PASS,生成文件包含新 graph/meta API 与 409 response。
|
||||
|
||||
Run: `go test ./... -count=1`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
Run: `cd frontend && pnpm vitest run && pnpm lint && pnpm build`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
Run: `make code-check`
|
||||
|
||||
Expected: PASS,无格式、lint、测试或生成文件差异。
|
||||
|
||||
- [ ] **Step 6: 最终人工数据面验收**
|
||||
|
||||
创建规则并编排 `开始 → IP 匹配 → true:通过 / false:地域匹配 → true:阻止A / false:PoW → 通过`,绑定到测试路由并发布。用命中/未命中 IP、不同 GeoIP 和无 PoW cookie 请求验证三个分支;更新引用 IP 组后不发布,确认 5–10 秒内结果变化且 OpenResty 未 reload。
|
||||
|
||||
- [ ] **Step 7: 提交**
|
||||
|
||||
Commit: `feat(waf): complete composable rule orchestration`
|
||||
|
||||
## 4. 最终验收标准
|
||||
|
||||
- 用户新增规则时只输入名称并立即进入 React Flow 编排器。
|
||||
- 默认规则为 `开始 → 通过`;特殊节点与处理节点满足设计约束。
|
||||
- Server 和前端均拒绝非法图,发布再次校验,revision 冲突返回 409。
|
||||
- 全局规则固定前置,自定义规则严格按绑定顺序执行。
|
||||
- OpenResty 请求路径对规则与 IP 组均为纯内存读取。
|
||||
- 规则只在发布 reload 时加载;IP 组每 5 秒 checksum 检查且仅变化时读取完整 JSON。
|
||||
- PostgreSQL、SQLite、Go、Lua、前端、Swagger、构建与 `make code-check` 全部通过。
|
||||
@@ -1,99 +0,0 @@
|
||||
# 边缘可观测与业务流量统计重构 — 实现计划
|
||||
|
||||
说明:本计划对应设计文档 [observability-design.md](../design/observability-design.md)。重大架构重构,按阶段交付,避免一次大爆炸。
|
||||
|
||||
---
|
||||
|
||||
## 0. 落地进度(2026-07-18)
|
||||
|
||||
* [x] M1 看板业务趋势改读 access log;网络图文案改为已提供/接收 + 宿主机网卡
|
||||
* [x] M2 协议 v2 字段(host_metrics/edge_health/request_length);CH 列 `request_length`/`request_time_ms`
|
||||
* [x] M3 Agent:观测口仅健康连接;payload 不再发 TrafficReport;access_logs 带 request_length
|
||||
* [x] M4 Server:停写 TrafficReport;openresty 仅存 connections;明细入库带 request_length
|
||||
* [x] 分布图 status/top domains + 节点行请求/UV 改 access log;24h UV 用 uniqExact;API bytes_provided/received
|
||||
* [x] M5:`of_node_edge_health`、`of_access_log_hourly`(+MV);删除 request_reports/traffic_hourly/openresty_hourly/obs_openresty;写入/查询改道
|
||||
* [x] 收尾:清 openresty hourly / request_report 死路径;edge_health 写全 status;cleanup 命名 `node_edge_health`;hourly 回填 SQL + UV 策略文档
|
||||
* [x] 协议/API 去兼容层(Agent 销毁重建):删除 TrafficReport / openresty_observation / snapshot 别名 / request_reports API 字段 / openresty_rx|tx
|
||||
* [x] 前端 UV 文案:24h/查询窗口独立访客;趋势图不绘分时 UV
|
||||
* [ ] 真实环境 ClickHouse 迁移 + `202607180003` 回填(本机 Docker 未起时需运维执行)
|
||||
|
||||
## 1. 目标与背景 (Goal & Context)
|
||||
|
||||
* **需求背景**:看板「OpenResty 入/出站」与 Zone「已提供数据」不一致;Agent 预聚合与访问日志双轨;`openresty_tx` 与 `bytes_sent` 业务语义重复。
|
||||
* **开发范围 (Scope)**:
|
||||
* **必做**:业务趋势统一为访问日志聚合;UI 字段与文案收敛;协议补齐 `request_length`;停用预聚合作为权威源;Agent 瘦身。
|
||||
* **后续**:废弃 CH 表清理、hourly rollup 性能优化、Relay 指标对齐。
|
||||
* **Out of Scope**:通用日志平台、替换 ClickHouse、APM。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计与决策 (Design & Decisions)
|
||||
|
||||
* **核心对象**:以 `of_node_access_logs` 为 L1 权威;主机 snapshot 为 L3;OpenResty 仅健康/连接为 L2。
|
||||
* **传输模型(示例与频率)**:见 [observability-transport-model.md](../design/observability-transport-model.md)。
|
||||
* **协议与表结构**:见 [observability-data-model.md](../design/observability-data-model.md)(NodePayload v2、落库流水线、DDL、废弃表)。
|
||||
* **API**:看板与 Zone 共用聚合语义;`bytes_provided` / `bytes_received`(兼容 `bytes_sent` 别名)。
|
||||
* **数据流**:见 [observability-design.md](../design/observability-design.md) §5。
|
||||
* **权衡**:性能用 Server 侧 rollup,不恢复 Agent 预聚合。
|
||||
|
||||
---
|
||||
|
||||
## 3. 阶段与修改清单 (Proposed Changes)
|
||||
|
||||
### 阶段 M1 — 读路径切换(优先对账)
|
||||
|
||||
* #### [MODIFY] `internal/apps/openflare/dashboard/*`、`observability/analytics.go`
|
||||
* 业务 24h 趋势改为 access log 聚合(全局)。
|
||||
* 网络趋势中业务曲线与主机网卡分离。
|
||||
* #### [MODIFY] 前端 dashboard 组件与文案
|
||||
* 「OpenResty 出站/入站」→「已提供数据/接收数据」或拆卡片。
|
||||
* #### [MODIFY] Zone stats 字段对齐(如需别名)
|
||||
* **验收**:单 Zone 流量时看板已提供 ≈ Zone 已提供。
|
||||
|
||||
### 阶段 M2 — 协议与入库补齐
|
||||
|
||||
* #### [MODIFY] `pkg/protocol/agent.go` — `NodeAccessLog.request_length`
|
||||
* #### [MODIFY] Agent 解析与 CH 写入列
|
||||
* #### [MODIFY] goose ClickHouse migration(如缺列)
|
||||
|
||||
### 阶段 M3 — 停写预聚合权威路径
|
||||
|
||||
* #### [MODIFY] Server persist:TrafficReport / openresty rx/tx 不再驱动看板
|
||||
* 可选:直接停写以减 CH 压力
|
||||
|
||||
### 阶段 M4 — Agent 瘦身
|
||||
|
||||
* #### [MODIFY] 移除 TrafficReport 构建主路径、Lua 业务 dict 计数、state 内业务累计
|
||||
* #### [MODIFY] 心跳仅明细 + snapshot + 连接/健康
|
||||
|
||||
### 阶段 M5 — 清理
|
||||
|
||||
* 删除废弃 API 字段、前端类型、CH 表/MV、相关测试夹具
|
||||
* 更新 agent-design / changelog(代码变更时)
|
||||
|
||||
---
|
||||
|
||||
## 4. 验证计划 (Verification Plan)
|
||||
|
||||
### 自动化
|
||||
|
||||
* `go test`:zone stats、dashboard 聚合、agent access log 解析
|
||||
* 前端:zone / dashboard 文案与字段测试
|
||||
|
||||
### 手动
|
||||
|
||||
* 制造已知大小响应,对比 Zone 与看板 24h 已提供数据
|
||||
* 确认宿主机网卡曲线与业务已提供数据分区展示、数值可不一致且文案不诱导对账
|
||||
|
||||
### 质量门禁
|
||||
|
||||
* `make swagger`(若 API 变更)
|
||||
* `make code-check`
|
||||
* `make prettier`
|
||||
|
||||
---
|
||||
|
||||
## 5. 依赖与风险
|
||||
|
||||
* 明细量大时 M1 需同步评估 hourly rollup(仍 Server 侧)。
|
||||
* 旧 Agent 无 `request_length` 时接收数据为空,需 UI 降级。
|
||||
@@ -1,63 +0,0 @@
|
||||
# 访问日志 cache_status 明细可见 — 实现计划
|
||||
|
||||
说明:对应设计 [observability-data-model.md §3.5.1](../design/observability-data-model.md)。第一期只做明细可见,不上报 upstream 地址。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与背景
|
||||
|
||||
* **需求背景**:访问日志无法判断请求是否命中边缘缓存、是否回源。
|
||||
* **开发范围 (Scope)**:
|
||||
* **必做**:OpenResty 日志输出 `$upstream_cache_status`;Agent 上报;CH 入库;列表/详情展示三态标签。
|
||||
* **Out of Scope**:命中率看板、hourly 维度、`upstream_addr`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计与决策
|
||||
|
||||
* **唯一字段**:`cache_status` string(原始值)。
|
||||
* **UI 三态(不落库)**:
|
||||
* 命中:`HIT` / `STALE` / `REVALIDATED` / `UPDATING`
|
||||
* 回源:`MISS` / `EXPIRED`
|
||||
* 未缓存:`BYPASS` / `-` / 空
|
||||
* **数据流**:log_format → Agent parse → protocol → Server model → CH → API → 前端明细。
|
||||
|
||||
---
|
||||
|
||||
## 3. 修改清单
|
||||
|
||||
### 边缘 / 协议
|
||||
|
||||
* `pkg/render/openresty/types.go`、`internal/model/openflare_option.go`:`log_format` 增加 `cache_status`
|
||||
* `internal/apps/agent/observability/traffic.go`:解析与映射
|
||||
* `pkg/protocol/agent.go`:`NodeAccessLog.CacheStatus`
|
||||
|
||||
### Server / CH
|
||||
|
||||
* goose:`202607180005_access_log_cache_status.sql`
|
||||
* `internal/model/analytics/node_access_log.go`、writer、list/scan、store 映射
|
||||
* `internal/model/openflare_observability.go`、agent build records
|
||||
* API `AccessLogView` + list 响应带 `cache_status`
|
||||
|
||||
### 前端
|
||||
|
||||
* types / 明细列表标签 / 详情字段
|
||||
* 三态 helper:`resolveCacheOutcome(cache_status)`
|
||||
|
||||
---
|
||||
|
||||
## 4. 验证
|
||||
|
||||
* `go test ./internal/apps/agent/observability/ ./internal/repository/analytics/ ./internal/apps/openflare/agent/`
|
||||
* `make swagger`(若 Handler 响应结构变更)
|
||||
* `make code-check` / `make prettier`
|
||||
|
||||
---
|
||||
|
||||
## 5. 落地进度
|
||||
|
||||
* [x] log_format + protocol + agent parse
|
||||
* [x] CH migration + 写入/读取
|
||||
* [x] API + 前端明细展示
|
||||
* [x] 缓冲去重 key 含 cache_status;保留 `-` 原始值
|
||||
* [x] 测试与提交
|
||||
@@ -1,38 +0,0 @@
|
||||
# 边缘缓存默认 static 策略 — 实现计划
|
||||
|
||||
对应设计:[edge-cache-design.md](../design/edge-cache-design.md)
|
||||
|
||||
## 目标
|
||||
|
||||
路由开启缓存后,**新建推荐**仅缓存标准静态扩展名(`static`);存量 `url`/空策略映射为 `all`,不收窄缓存范围。
|
||||
|
||||
## 兼容规则(评审后定稿)
|
||||
|
||||
| 场景 | 行为 |
|
||||
| --- | --- |
|
||||
| 已启用 + `''` / `url` | 读 API / 快照 / 渲染 → **`all`** |
|
||||
| 写入时 enabled 且 policy 为空 | 规范为 **`all`**(旧客户端兼容) |
|
||||
| UI 新建/推荐默认 | **显式提交** `static` |
|
||||
| 关闭缓存 | policy 存 `''`,rules 清空 |
|
||||
|
||||
## 修改清单
|
||||
|
||||
1. **渲染** `pkg/render/openresty/render.go`:`static` 内置扩展名;空/`url`/`all` 无路径限制
|
||||
2. **校验/展示** `proxy_route/helpers.go`:`normalizeCachePolicy` + `displayCachePolicy`
|
||||
3. **快照** `config_version/logics.go`:`normalizeSnapshotCachePolicy`
|
||||
4. **前端** `cache-section.tsx` + helpers:存量 empty/url→`all`;关闭时提交 `''`;新建默认 `static`
|
||||
5. **测试** render + proxy_route
|
||||
6. **设计/changelog** 同步兼容说明
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
go test ./pkg/render/openresty/ ./internal/apps/openflare/proxy_route/ ./internal/apps/openflare/config_version/
|
||||
# 已通过(2026-07-18)
|
||||
```
|
||||
|
||||
## 状态
|
||||
|
||||
- [x] 功能实现 + 评审修复(empty→all,禁止静默收窄)
|
||||
- [ ] 提交 `fix(cache): ...`(待用户确认)
|
||||
- [ ] 合并 / 发布后需重新发布节点配置
|
||||
@@ -1,71 +0,0 @@
|
||||
# ClickHouse 观测表迁移与小时汇总回填(运维手册)
|
||||
|
||||
适用:M5 观测存储(`of_node_edge_health`、`of_access_log_hourly`、删旧表)及历史小时回填。
|
||||
|
||||
## 前提
|
||||
|
||||
* 控制面 `config.yaml` / 环境变量中 ClickHouse 已启用,账号可写 `openflare` 库。
|
||||
* 备份策略已就绪(可选:对 `of_node_access_logs` 做快照)。
|
||||
* **Agent 升级策略为销毁重建**;勿混跑旧 Agent(旧协议字段已从 Server 删除)。
|
||||
|
||||
## 1. 自动迁移(推荐)
|
||||
|
||||
进程启动时 `migrator.MigrateClickHouse()` 会按 goose 顺序执行:
|
||||
|
||||
| 版本 | 作用 |
|
||||
| --- | --- |
|
||||
| `202607180001` | access log 增加 `request_length` / `request_time_ms` |
|
||||
| `202607180002` | 建 `of_node_edge_health`、`of_access_log_hourly`(+MV);删 request_reports / openresty 吞吐表 |
|
||||
| `202607180003` | 从明细 ANTI JOIN 回填近 90 天 `of_access_log_hourly` |
|
||||
|
||||
启动 API / all 模式一次即可:
|
||||
|
||||
```bash
|
||||
# 示例:本地
|
||||
./bin/openflare api
|
||||
# 或
|
||||
make run # 以项目实际入口为准
|
||||
```
|
||||
|
||||
查看 goose 版本表(ClickHouse)确认三版本均已应用。
|
||||
|
||||
## 2. 仅回填(迁移已执行、MV 创建前缺历史)
|
||||
|
||||
若只需重跑回填 SQL:
|
||||
|
||||
```bash
|
||||
clickhouse-client --host 127.0.0.1 --port 9000 \
|
||||
--user default --password "$CLICKHOUSE_PASSWORD" \
|
||||
--database openflare \
|
||||
--multiquery < internal/infra/persistence/migrator/goose/clickhouse/202607180003_backfill_access_log_hourly.sql
|
||||
```
|
||||
|
||||
(goose 文件含 `+goose Up` 注释,若 client 报错可去掉注释行后执行 INSERT 主体。)
|
||||
|
||||
回填可重复:`ANTI JOIN` 跳过已有 `(node_id, hour, host)`。
|
||||
|
||||
## 3. 验收
|
||||
|
||||
```sql
|
||||
-- 新表存在
|
||||
SHOW TABLES FROM openflare LIKE 'of_node_edge_health';
|
||||
SHOW TABLES FROM openflare LIKE 'of_access_log_hourly';
|
||||
|
||||
-- 旧表应不存在
|
||||
SHOW TABLES FROM openflare LIKE 'of_node_request_reports';
|
||||
SHOW TABLES FROM openflare LIKE 'of_node_obs_openresty';
|
||||
|
||||
-- 小时汇总有数据(有历史访问时)
|
||||
SELECT count() FROM of_access_log_hourly;
|
||||
SELECT min(hour), max(hour), sum(request_count) FROM of_access_log_hourly;
|
||||
```
|
||||
|
||||
看板 24h 请求趋势应优先走 hourly;UV 卡片为整窗独立访客,**不等于**小时 UV 之和。
|
||||
|
||||
## 4. 本机执行记录
|
||||
|
||||
| 日期 | 环境 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 2026-07-18 | 开发机 | Docker daemon 未启动,未能 live 迁移;SQL 与 goose 文件已入库 |
|
||||
|
||||
运维在目标环境按 §1–§3 执行后更新本表。
|
||||
@@ -1,161 +0,0 @@
|
||||
# 访问日志 IP 明细 Tab — 实现计划
|
||||
|
||||
## 1. 目标与背景 (Goal & Context)
|
||||
|
||||
* **需求背景**:运维需要按 IP 维度快速查看时间窗内的访问量与流量,并下钻单 IP 情报;原先 IP 分析嵌在「单条访问日志详情」中,入口弱、列表能力缺失。
|
||||
* **开发范围 (Scope) V1**:
|
||||
* 访问日志页新增第三 Tab **「IP 明细」**(`?tab=ips`)。
|
||||
* IP 列表:时间筛选(快捷 24h/7d/15d/30d + 自定义 since/until)、分页、按请求数 / 入站 / 出站 / 最后访问 / 2xx 比例排序。
|
||||
* 列表列:IP、地区、请求数、2xx 比例(2xx 数 / 总请求)、入站流量、出站流量、最后访问。
|
||||
* 行详情:弹窗展示完整 **IP 情报**(分析 + 趋势 + Top 分布 + 加入 WAF IP 组)。
|
||||
* **日志明细详情弹窗仅展示单条请求字段**,不再内嵌 IP 情报;如需分析请到 IP 明细。
|
||||
* **Out of Scope(V1 不做)**:
|
||||
* 独立 `/access-logs/ip` 子路由全页。
|
||||
* IP 列表 UI 暴露节点 / host 筛选(后端可保留兼容参数,前端首版不放)。
|
||||
* 入/出站带宽时间序列(趋势图仍为请求数)。
|
||||
* 实时 GeoIP 二次查询(沿用入库 `region`)。
|
||||
|
||||
## 2. 设计与决策 (Design & Decisions)
|
||||
|
||||
### 2.1 页面与交互
|
||||
|
||||
| Tab | URL | 内容 |
|
||||
| --- | --- | --- |
|
||||
| 概览 | `/access-logs` | 不变 |
|
||||
| IP 明细 | `/access-logs?tab=ips` | 新 |
|
||||
| 日志明细 | `/access-logs?tab=list` | 不变;详情弹窗瘦身 |
|
||||
|
||||
* **时间筛选(IP 明细)**:
|
||||
* 快捷:`hours` ∈ {24, 168, 360, 720},默认 168(7d)。
|
||||
* 自定义:`since` + `until`(RFC3339);**同时提供时覆盖 hours**。
|
||||
* **详情形态**:留在列表页的 Dialog(非独立子页)。
|
||||
* **日志详情瘦身**:`access-log-detail-dialog` 只渲染请求字段(时间、节点、IP、地区、host、path、UA、cache、status 等)及必要操作;删除 analysis/trend/WAF 组内嵌区块。WAF「按 IP 加入组」仅保留在 IP 详情弹窗。
|
||||
|
||||
### 2.2 API 设计(扩展现有端点,不新建)
|
||||
|
||||
**`GET /api/v1/d/access-logs/ip-summary`**
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `hours` | 1–720;默认 168;在无 since/until 时生效 |
|
||||
| `since` / `until` | 可选 RFC3339;同时有效时优先于 hours |
|
||||
| `sort_by` | `total_requests`(默认)\| `request_length`(入站)\| `bytes_sent`(出站)\| `last_seen_at` \| `success_ratio` |
|
||||
| `sort_order` | `asc` \| `desc` |
|
||||
| `p` / `page_size` | 分页,page_size 上限 200 |
|
||||
| `remote_addr` / `node_id` / `host` | 兼容保留;V1 UI 可不暴露 |
|
||||
|
||||
**响应行字段(扩展)**
|
||||
|
||||
```text
|
||||
remote_addr string
|
||||
region string // 窗内 argMax(region, logged_at) 或等价
|
||||
total_requests uint64
|
||||
success_2xx_count uint64 // status_code 200–299
|
||||
success_ratio float64 // success_2xx_count / total_requests;total=0 时为 0
|
||||
bytes_received uint64 // sum(request_length) 入站
|
||||
bytes_sent uint64 // sum(bytes_sent) 出站
|
||||
last_seen_at time
|
||||
```
|
||||
|
||||
* `recent_requests`:可停止计算或固定返回 0;**UI 不展示**。避免与可配置时间窗语义冲突。
|
||||
* 详情下钻仍用现有:
|
||||
* `GET .../ip-summary/analysis?remote_addr=&hours=`(或 since/until,若后续扩展;V1 将列表当前窗映射为 hours 或 since/until 与后端约定一致)
|
||||
* `GET .../ip-summary/trend?remote_addr=&hours=&bucket_minutes=`
|
||||
|
||||
**分析/趋势时间窗对齐**:打开 IP 详情时,将列表当前时间窗传入 analysis/trend(优先 since/until;仅有 hours 则传 hours)。
|
||||
|
||||
### 2.3 数据层(ClickHouse)
|
||||
|
||||
* 表:`of_node_access_logs`(已有 `bytes_sent`、`request_length`、`status_code`、`region`)。
|
||||
* 聚合:`GROUP BY remote_addr`,在 `NodeAccessLogFilter.Since/Until` 上过滤。
|
||||
* 2xx:`countIf(status_code >= 200 AND status_code < 300)`。
|
||||
* region:`argMax(region, logged_at)`。
|
||||
* 排序:服务端 ORDER BY 对应表达式;`success_ratio` 注意除零(`if(total=0,0,ratio)`)。
|
||||
|
||||
### 2.4 设计决策权衡
|
||||
|
||||
| 选项 | 结论 |
|
||||
| --- | --- |
|
||||
| 扩展 `/ip-summary` vs 新 `/ip-list` | **扩展现有**,前端 service 已有 `listIPSummaries` |
|
||||
| 详情弹窗 vs 子页 | **弹窗**,与现有明细交互一致 |
|
||||
| IP 情报放日志详情 vs 独立 IP 详情 | **仅 IP 明细详情**;日志详情只展示请求信息 |
|
||||
| 时间:仅快捷 vs 仅自定义 | **两者都要**,自定义优先 |
|
||||
|
||||
### 2.5 数据流(示意)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UI_IP[IP 明细 Tab] --> API_List[GET /ip-summary]
|
||||
API_List --> CH[(of_node_access_logs)]
|
||||
UI_IP --> UI_Dlg[IP 详情 Dialog]
|
||||
UI_Dlg --> API_A[GET /ip-summary/analysis]
|
||||
UI_Dlg --> API_T[GET /ip-summary/trend]
|
||||
API_A --> CH
|
||||
API_T --> CH
|
||||
UI_List[日志明细 Tab] --> API_Logs[GET /access-logs]
|
||||
UI_List --> UI_LogDlg[日志详情 Dialog]
|
||||
UI_LogDlg -.->|不请求 IP 分析| X[仅请求字段]
|
||||
```
|
||||
|
||||
## 3. 具体修改文件清单 (Proposed Changes)
|
||||
|
||||
### 后端 Server
|
||||
|
||||
* #### [MODIFY] `internal/repository/analytics/node_access_log_stats.go`(及 filter 如有)
|
||||
* `IPSummariesNodeAccessLogs`:时间窗、sum 入/出、2xx count、ratio、region、扩展 sort。
|
||||
* #### [MODIFY] `internal/apps/openflare/observability/access_log_logics.go`
|
||||
* Query/View 类型扩展;解析 hours/since/until;去掉或忽略 recent 3h 硬编码。
|
||||
* #### [MODIFY] `internal/apps/openflare/observability/routers.go` / handler
|
||||
* 绑定新 query;Swagger 注释。
|
||||
* #### [MODIFY] 相关单元测试(logics / repository 若有)
|
||||
|
||||
### 前端 Web
|
||||
|
||||
* #### [MODIFY] `frontend/app/(main)/access-logs/page.tsx`
|
||||
* 第三 Tab `ips`;`resolveTab` / `handleTabChange`。
|
||||
* #### [NEW] `frontend/app/(main)/access-logs/components/ip-tab.tsx`
|
||||
* 列表、时间筛选、排序、分页、打开详情。
|
||||
* #### [NEW] `frontend/app/(main)/access-logs/components/ip-detail-dialog.tsx`
|
||||
* IP 入口详情壳。
|
||||
* #### [NEW] `frontend/app/(main)/access-logs/components/ip-analysis-panel.tsx`
|
||||
* 从现有 `access-log-detail-dialog` **迁出** 分析/趋势/排行/WAF IP 组逻辑。
|
||||
* #### [MODIFY] `frontend/app/(main)/access-logs/components/access-log-detail-dialog.tsx`
|
||||
* **删除** IP 情报相关 UI 与 `getIPAnalysis` / `getIPTrend` 请求;仅请求日志字段展示。
|
||||
* #### [MODIFY] `frontend/app/(main)/access-logs/components/access-log-utils.ts`
|
||||
* tab 类型、IP 排序选项、时间筛选辅助。
|
||||
* #### [MODIFY] `frontend/lib/services/openflare/access-log.service.ts` + `types.ts`
|
||||
* `listIPSummaries` 参数与 `AccessLogIPSummaryItem` 字段同步。
|
||||
|
||||
### 文档
|
||||
|
||||
* #### [MODIFY] `docs/changelog/index.md` — `[Unreleased]` 用户可见说明
|
||||
* #### [MODIFY] `docs/design/observability-design.md` 或 data-model(如有访问日志 UI 约定)— 补充 IP 明细 Tab 与 API 字段(中文)
|
||||
* #### [MODIFY] `docs/plan/index.md` — 挂上本计划链接
|
||||
|
||||
## 4. 验证计划 (Verification Plan)
|
||||
|
||||
### 自动化
|
||||
|
||||
```bash
|
||||
go test ./internal/apps/openflare/observability/ ./internal/repository/analytics/
|
||||
# 前端:相关 tsc / 页面无类型错误
|
||||
make code-check # 完成后按项目门禁
|
||||
make prettier
|
||||
make swagger # API 注释变更后
|
||||
```
|
||||
|
||||
### 手动
|
||||
|
||||
1. `/access-logs?tab=ips` 默认 7d 列表有数据;切换 24h/自定义区间结果变化。
|
||||
2. 分别按请求数、入站、出站、2xx 比例、最后访问排序正确。
|
||||
3. 2xx 比例 = 2xx/总数;0 请求不出现 NaN/Infinity。
|
||||
4. 点 IP 打开详情:指标/趋势/Top/WAF 组可用;时间窗与列表一致。
|
||||
5. 日志明细 → 详情:仅请求信息,**无** IP 分析/趋势区块。
|
||||
6. 概览 Tab 行为无回归。
|
||||
|
||||
## 5. 状态
|
||||
|
||||
- [x] 需求澄清与方案确认
|
||||
- [x] 实现(后端 ip-summary 扩展 + 前端 IP 明细 Tab + 日志详情瘦身)
|
||||
- [x] 测试与 changelog(`go test` 相关包通过;changelog 已更新)
|
||||
- [ ] 提交合并
|
||||
@@ -1,709 +0,0 @@
|
||||
# 边缘限流全局默认 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为边缘限流增加三项全局默认;站点 `0`/空继承默认、`-1` 显式关闭、`>0` 覆盖;在 `RenderRouteConfig` 唯一合并。
|
||||
|
||||
**Architecture:** 全局默认存 `system_configs`,进入 `openresty_config` 快照;站点字段语义变更后仍原样入库与快照;`pkg/render/openresty.RenderRouteConfig` 用 `doc.OpenRestyConfig` 与 route 字段合并后输出 location 指令。UI:安全性下新页「限流」+ 站点限流文案更新。
|
||||
|
||||
**Tech Stack:** Go、goose SQL、Option API、`pkg/render/openresty`、Next.js、OptionService
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-07-19-http-default-rate-limit-design.md](../specs/2026-07-19-http-default-rate-limit-design.md)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 合并**只**在 `RenderRouteConfig`;快照保留站点原始值(含 `0`/`-1`)
|
||||
- 不引入 `limit_req`;不在 `http {}` 写默认 `limit_conn`/`limit_rate`
|
||||
- 全局默认初始 `0`/空 → 存量行为不变
|
||||
- 完成后 `make code-check`;改前端后 `make prettier`;中文 changelog;不写英文文档
|
||||
- 所有 HTTP 路由仍只在 `internal/router/router.go` 委派(本功能复用 Option API,无需新业务路由)
|
||||
|
||||
## File map
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `internal/model/system_configs.go` | 三个 ConfigKey 常量 |
|
||||
| `internal/infra/persistence/migrator/goose/{postgres,sqlite}/202607190001_add_openresty_default_rate_limits.sql` | seed 默认值 |
|
||||
| `internal/apps/openflare/option/openresty_validators.go` + `validate.go` | 全局默认校验 |
|
||||
| `internal/apps/openflare/config_version/snapshot.go` | 快照字段 + 读取 |
|
||||
| `internal/apps/openflare/config_version/logics.go` | option diff keys |
|
||||
| `pkg/render/openresty/types.go` | `ConfigSnapshot` 三字段 |
|
||||
| `pkg/render/openresty/render.go` | `mergeRouteLimit*` + 调用点 |
|
||||
| `pkg/render/openresty/render_test.go` | 合并渲染单测 |
|
||||
| `internal/apps/openflare/proxy_route/helpers.go` | 站点 normalize 允许 -1 |
|
||||
| `frontend/lib/navigation/openflare-nav.ts` | 安全性子菜单 |
|
||||
| `frontend/app/(main)/rate-limits/page.tsx` | 全局限流设置页 |
|
||||
| `frontend/app/(main)/proxy-routes/.../limits-section.tsx` + helpers | 站点语义 UI |
|
||||
| `frontend/lib/utils/search-data.ts` | 搜索入口 |
|
||||
| `docs/reference/configuration.md` | 配置键说明 |
|
||||
| `docs/changelog/index.md` | Unreleased |
|
||||
| `docs/plan/index.md` | 进行中计划索引 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Render 合并(TDD 核心)
|
||||
|
||||
**Files:**
|
||||
- Modify: `pkg/render/openresty/types.go` (`ConfigSnapshot`)
|
||||
- Modify: `pkg/render/openresty/render.go`
|
||||
- Test: `pkg/render/openresty/render_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ConfigSnapshot` 字段 `DefaultLimitConnPerServer int`, `DefaultLimitConnPerIP int`, `DefaultLimitRate string`(json: `default_limit_conn_per_server` 等)
|
||||
- Produces: `mergeRouteLimitConfig(route Route, cfg ConfigSnapshot) routeLimitConfig`
|
||||
- Produces: `mergeLimitConn(route, def int) int`, `mergeLimitRate(route, def string) string`
|
||||
|
||||
- [ ] **Step 1: 写失败单测**
|
||||
|
||||
在 `render_test.go` 末尾追加:
|
||||
|
||||
```go
|
||||
func TestMergeRouteLimitConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
route Route
|
||||
cfg ConfigSnapshot
|
||||
want routeLimitConfig
|
||||
}{
|
||||
{
|
||||
name: "both zero off",
|
||||
route: Route{},
|
||||
cfg: ConfigSnapshot{},
|
||||
want: routeLimitConfig{},
|
||||
},
|
||||
{
|
||||
name: "inherit all defaults",
|
||||
route: Route{},
|
||||
cfg: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 100,
|
||||
DefaultLimitConnPerIP: 10,
|
||||
DefaultLimitRate: "512k",
|
||||
},
|
||||
want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 10, LimitRate: "512k"},
|
||||
},
|
||||
{
|
||||
name: "explicit off ignores default",
|
||||
route: Route{LimitConnPerServer: -1, LimitConnPerIP: -1, LimitRate: "-1"},
|
||||
cfg: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 100,
|
||||
DefaultLimitConnPerIP: 10,
|
||||
DefaultLimitRate: "512k",
|
||||
},
|
||||
want: routeLimitConfig{},
|
||||
},
|
||||
{
|
||||
name: "route overrides default",
|
||||
route: Route{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m"},
|
||||
cfg: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 100,
|
||||
DefaultLimitConnPerIP: 10,
|
||||
DefaultLimitRate: "512k",
|
||||
},
|
||||
want: routeLimitConfig{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m"},
|
||||
},
|
||||
{
|
||||
name: "partial inherit",
|
||||
route: Route{LimitConnPerServer: 0, LimitConnPerIP: -1, LimitRate: ""},
|
||||
cfg: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 100,
|
||||
DefaultLimitConnPerIP: 10,
|
||||
DefaultLimitRate: "256k",
|
||||
},
|
||||
want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 0, LimitRate: "256k"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := mergeRouteLimitConfig(tc.route, tc.cfg)
|
||||
if got != tc.want {
|
||||
t.Fatalf("mergeRouteLimitConfig() = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRouteConfigAppliesDefaultLimits(t *testing.T) {
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
SiteName: "example.com",
|
||||
Domains: []string{"example.com"},
|
||||
Enabled: true,
|
||||
OriginURL: "http://127.0.0.1:8080",
|
||||
Upstreams: []string{"http://127.0.0.1:8080"},
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 120,
|
||||
DefaultLimitConnPerIP: 12,
|
||||
DefaultLimitRate: "512k",
|
||||
},
|
||||
}
|
||||
rendered, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRouteConfig() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"limit_conn openflare_conn_per_server 120;",
|
||||
"limit_conn openflare_conn_per_ip 12;",
|
||||
"limit_rate 512k;",
|
||||
} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("expected %q in route config, got:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRouteConfigExplicitOffSkipsDefaultLimits(t *testing.T) {
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
SiteName: "example.com",
|
||||
Domains: []string{"example.com"},
|
||||
Enabled: true,
|
||||
OriginURL: "http://127.0.0.1:8080",
|
||||
Upstreams: []string{"http://127.0.0.1:8080"},
|
||||
LimitConnPerServer: -1,
|
||||
LimitConnPerIP: -1,
|
||||
LimitRate: "-1",
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 120,
|
||||
DefaultLimitConnPerIP: 12,
|
||||
DefaultLimitRate: "512k",
|
||||
},
|
||||
}
|
||||
rendered, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRouteConfig() error = %v", err)
|
||||
}
|
||||
if strings.Contains(rendered, "limit_conn") || strings.Contains(rendered, "limit_rate") {
|
||||
t.Fatalf("expected no limit directives, got:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败**
|
||||
|
||||
```bash
|
||||
go test ./pkg/render/openresty/ -run 'TestMergeRouteLimitConfig|TestRenderRouteConfigAppliesDefaultLimits|TestRenderRouteConfigExplicitOffSkipsDefaultLimits' -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL(`mergeRouteLimitConfig` undefined 或行为不符)
|
||||
|
||||
- [ ] **Step 3: 实现 types + merge + 调用**
|
||||
|
||||
`ConfigSnapshot` 增加:
|
||||
|
||||
```go
|
||||
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
|
||||
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
|
||||
DefaultLimitRate string `json:"default_limit_rate,omitempty"`
|
||||
```
|
||||
|
||||
`render.go` 中 `RenderRouteConfig` 将:
|
||||
|
||||
```go
|
||||
limitConfig := routeLimitConfig{LimitConnPerServer: route.LimitConnPerServer, LimitConnPerIP: route.LimitConnPerIP, LimitRate: route.LimitRate}
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```go
|
||||
limitConfig := mergeRouteLimitConfig(route, doc.OpenRestyConfig)
|
||||
```
|
||||
|
||||
并新增:
|
||||
|
||||
```go
|
||||
func mergeRouteLimitConfig(route Route, cfg ConfigSnapshot) routeLimitConfig {
|
||||
return routeLimitConfig{
|
||||
LimitConnPerServer: mergeLimitConn(route.LimitConnPerServer, cfg.DefaultLimitConnPerServer),
|
||||
LimitConnPerIP: mergeLimitConn(route.LimitConnPerIP, cfg.DefaultLimitConnPerIP),
|
||||
LimitRate: mergeLimitRate(route.LimitRate, cfg.DefaultLimitRate),
|
||||
}
|
||||
}
|
||||
|
||||
func mergeLimitConn(route, def int) int {
|
||||
if route == -1 {
|
||||
return 0
|
||||
}
|
||||
if route > 0 {
|
||||
return route
|
||||
}
|
||||
if def > 0 {
|
||||
return def
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func mergeLimitRate(route, def string) string {
|
||||
r := strings.ToLower(strings.TrimSpace(route))
|
||||
if r == "-1" {
|
||||
return ""
|
||||
}
|
||||
if r != "" && r != "0" {
|
||||
return r
|
||||
}
|
||||
d := strings.ToLower(strings.TrimSpace(def))
|
||||
if d != "" && d != "0" {
|
||||
return d
|
||||
}
|
||||
return ""
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测通过**
|
||||
|
||||
```bash
|
||||
go test ./pkg/render/openresty/ -count=1
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add pkg/render/openresty/types.go pkg/render/openresty/render.go pkg/render/openresty/render_test.go
|
||||
git commit -m "feat(openresty): merge global default limits at route render"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 配置键、迁移、校验、快照
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/model/system_configs.go`
|
||||
- Create: `internal/infra/persistence/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql`
|
||||
- Create: `internal/infra/persistence/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql`
|
||||
- Modify: `internal/apps/openflare/option/validate.go`
|
||||
- Modify: `internal/apps/openflare/option/openresty_validators.go`
|
||||
- Modify: `internal/apps/openflare/config_version/snapshot.go`
|
||||
- Modify: `internal/apps/openflare/config_version/logics.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 的 `ConfigSnapshot` JSON 字段名
|
||||
- Produces: `ConfigKeyOpenRestyDefaultLimitConnPerServer` 等三常量;snapshot 填充;diff 可见
|
||||
|
||||
- [ ] **Step 1: 常量**
|
||||
|
||||
在 `system_configs.go` OpenResty 段末尾(`MainConfigTemplate` 前或后)加入:
|
||||
|
||||
```go
|
||||
ConfigKeyOpenRestyDefaultLimitConnPerServer = "openresty_default_limit_conn_per_server" // 默认站点并发连接
|
||||
ConfigKeyOpenRestyDefaultLimitConnPerIP = "openresty_default_limit_conn_per_ip" // 默认单 IP 并发连接
|
||||
ConfigKeyOpenRestyDefaultLimitRate = "openresty_default_limit_rate" // 默认单请求带宽
|
||||
```
|
||||
|
||||
- [ ] **Step 2: goose 迁移(PG + SQLite 同内容)**
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES
|
||||
('openresty_default_limit_conn_per_server', '0', 'business', 0, '默认站点并发连接上限(0 关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('openresty_default_limit_conn_per_ip', '0', 'business', 0, '默认单 IP 并发连接上限(0 关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('openresty_default_limit_rate', '', 'business', 0, '默认单请求带宽限速(空关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_system_configs WHERE key IN (
|
||||
'openresty_default_limit_conn_per_server',
|
||||
'openresty_default_limit_conn_per_ip',
|
||||
'openresty_default_limit_rate'
|
||||
);
|
||||
```
|
||||
|
||||
SQLite:若项目其它 seed 不用 `ON CONFLICT`,对照 `202607170001_add_pages_system_configs.sql` 的 sqlite twin 写法保持一致(通常可同用 `ON CONFLICT (key) DO NOTHING`)。
|
||||
|
||||
- [ ] **Step 3: 校验器**
|
||||
|
||||
`validate.go` 增加:
|
||||
|
||||
```go
|
||||
func validateNonNegativeIntegerOption(key, value string) error {
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil || intValue < 0 {
|
||||
return fmt.Errorf("%s 必须为大于等于 0 的整数", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
`openresty_validators.go` 注册:
|
||||
|
||||
```go
|
||||
model.ConfigKeyOpenRestyDefaultLimitConnPerServer: validateNonNegativeIntegerOption,
|
||||
model.ConfigKeyOpenRestyDefaultLimitConnPerIP: validateNonNegativeIntegerOption,
|
||||
model.ConfigKeyOpenRestyDefaultLimitRate: validateOpenRestyDefaultLimitRate,
|
||||
```
|
||||
|
||||
```go
|
||||
var openRestyDefaultLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`)
|
||||
|
||||
func validateOpenRestyDefaultLimitRate(key, trimmed string) error {
|
||||
if trimmed == "" || trimmed == "0" {
|
||||
return nil
|
||||
}
|
||||
if !openRestyDefaultLimitRatePattern.MatchString(strings.ToLower(trimmed)) {
|
||||
return fmt.Errorf("%s 格式不合法,请使用 512k、1m 或纯数字,空表示关闭", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 快照读取(注意 0 合法)**
|
||||
|
||||
`openRestyConfigSnapshot` 与 `buildOpenRestyConfigSnapshot` 增加三字段。
|
||||
|
||||
**禁止**对这三项使用现有 `getIntConfig`(其 `val <= 0` 会把合法 `0` 与错误混在一起;虽 default=0 时偶然正确,但语义不清)。改为:
|
||||
|
||||
```go
|
||||
getNonNegIntConfig := func(key string, defaultVal int) int {
|
||||
val, err := repository.GetIntByKey(ctx, key)
|
||||
if err != nil || val < 0 {
|
||||
return defaultVal
|
||||
}
|
||||
return val
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
DefaultLimitConnPerServer: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerServer, 0),
|
||||
DefaultLimitConnPerIP: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerIP, 0),
|
||||
DefaultLimitRate: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitRate, ""))),
|
||||
```
|
||||
|
||||
若 `DefaultLimitRate == "0"`,规范化为 `""`。
|
||||
|
||||
确认 snapshot → render JSON 字段名与 `openrestyrender.ConfigSnapshot` 一致(`snapshotDocument` 序列化后由 `RenderJSON` 反序列化到 render types)。`openRestyConfigSnapshot` 的 json tag 必须与 `ConfigSnapshot` 对齐:
|
||||
|
||||
```go
|
||||
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
|
||||
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
|
||||
DefaultLimitRate string `json:"default_limit_rate,omitempty"`
|
||||
```
|
||||
|
||||
- [ ] **Step 5: option diff**
|
||||
|
||||
在 `diffOpenRestyOptionDetails` 末尾:
|
||||
|
||||
```go
|
||||
appendIfChanged("OpenRestyDefaultLimitConnPerServer", fmt.Sprintf("%d", left.DefaultLimitConnPerServer), fmt.Sprintf("%d", right.DefaultLimitConnPerServer))
|
||||
appendIfChanged("OpenRestyDefaultLimitConnPerIP", fmt.Sprintf("%d", left.DefaultLimitConnPerIP), fmt.Sprintf("%d", right.DefaultLimitConnPerIP))
|
||||
appendIfChanged("OpenRestyDefaultLimitRate", left.DefaultLimitRate, right.DefaultLimitRate)
|
||||
```
|
||||
|
||||
`openRestyOptionKeys()` 同步追加这三 key 字符串。
|
||||
|
||||
- [ ] **Step 6: 编译/相关测试**
|
||||
|
||||
```bash
|
||||
go test ./internal/apps/openflare/config_version/ ./internal/apps/openflare/option/ ./pkg/render/openresty/ -count=1
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/model/system_configs.go \
|
||||
internal/infra/persistence/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql \
|
||||
internal/infra/persistence/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql \
|
||||
internal/apps/openflare/option/validate.go \
|
||||
internal/apps/openflare/option/openresty_validators.go \
|
||||
internal/apps/openflare/config_version/snapshot.go \
|
||||
internal/apps/openflare/config_version/logics.go
|
||||
git commit -m "feat(config): add openresty default rate limit system options"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 站点 normalize 允许 -1
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/apps/openflare/proxy_route/helpers.go`
|
||||
- Modify: `internal/apps/openflare/proxy_route/errs.go`(如需更新文案)
|
||||
- Test: 若无现成 helpers 测试文件则新建 `helpers_limit_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `normalizeProxyRouteLimitConnValue` 允许 `>= -1`;`normalizeProxyRouteLimitRate` 允许 `"-1"`
|
||||
|
||||
- [ ] **Step 1: 失败单测**
|
||||
|
||||
```go
|
||||
func TestNormalizeProxyRouteLimitConnValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := normalizeProxyRouteLimitConnValue(-1, "limit_conn_per_server")
|
||||
if err != nil || got != -1 {
|
||||
t.Fatalf("want -1, got %d err %v", got, err)
|
||||
}
|
||||
if _, err := normalizeProxyRouteLimitConnValue(-2, "limit_conn_per_server"); err == nil {
|
||||
t.Fatal("expected error for -2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProxyRouteLimitRate(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := normalizeProxyRouteLimitRate("-1")
|
||||
if err != nil || got != "-1" {
|
||||
t.Fatalf("want -1, got %q err %v", got, err)
|
||||
}
|
||||
got, err = normalizeProxyRouteLimitRate("0")
|
||||
if err != nil || got != "" {
|
||||
t.Fatalf("want empty inherit, got %q err %v", got, err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现**
|
||||
|
||||
```go
|
||||
func normalizeProxyRouteLimitConnValue(value int, field string) (int, error) {
|
||||
if value < -1 {
|
||||
return 0, fmt.Errorf("%s must be greater than or equal to -1", field)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func normalizeProxyRouteLimitRate(raw string) (string, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(raw))
|
||||
if normalized == "" || normalized == "0" {
|
||||
return "", nil
|
||||
}
|
||||
if normalized == "-1" {
|
||||
return "-1", nil
|
||||
}
|
||||
if !proxyRouteLimitRatePattern.MatchString(normalized) {
|
||||
return "", errors.New(errProxyRouteLimitRate)
|
||||
}
|
||||
if strings.TrimRight(normalized, "km") == "" {
|
||||
return "", nil
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
```
|
||||
|
||||
可选:`errProxyRouteLimitRate` 文案追加「或 -1 表示关闭」。
|
||||
|
||||
- [ ] **Step 3: 测试**
|
||||
|
||||
```bash
|
||||
go test ./internal/apps/openflare/proxy_route/ -count=1
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/apps/openflare/proxy_route/
|
||||
git commit -m "feat(proxy-route): allow -1 to disable rate limits"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 前端 — 安全性「限流」页 + 站点文案
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/lib/navigation/openflare-nav.ts`
|
||||
- Create: `frontend/app/(main)/rate-limits/page.tsx`
|
||||
- Modify: `frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx`
|
||||
- Modify: `frontend/app/(main)/proxy-routes/components/helpers.ts`
|
||||
- Modify: `frontend/lib/utils/search-data.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Option keys 字面量 `openresty_default_limit_conn_per_server` 等
|
||||
- Produces: `/rate-limits` 管理页;站点表单接受 `-1`
|
||||
|
||||
- [ ] **Step 1: 导航**
|
||||
|
||||
`openflareSecurityNavGroup.items`:
|
||||
|
||||
```ts
|
||||
{ title: 'WAF', url: '/waf' },
|
||||
{ title: 'IP 组', url: '/ip-groups' },
|
||||
{ title: '限流', url: '/rate-limits' },
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 搜索**
|
||||
|
||||
`search-data.ts` 在 IP 组后增加:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'console-rate-limits',
|
||||
title: '限流',
|
||||
description: '配置边缘站点默认并发与带宽限流策略',
|
||||
url: '/rate-limits',
|
||||
category: 'page',
|
||||
keywords: ['限流', 'rate limit', 'limit_conn', 'limit_rate', '并发', '带宽'],
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 限流设置页**
|
||||
|
||||
新建 `frontend/app/(main)/rate-limits/page.tsx`,模式对齐 `performance/page.tsx`:
|
||||
|
||||
- `useAuth` 管理员校验
|
||||
- `OptionService.list` / `updateBatch`
|
||||
- 三字段表单 + 单卡片保存
|
||||
- 标题:`Shield` 或 `Gauge` 图标 + `h1`「限流」
|
||||
- 描述:空/0 表示默认关闭;修改后需在版本发布中生效
|
||||
- keys:
|
||||
- `openresty_default_limit_conn_per_server`
|
||||
- `openresty_default_limit_conn_per_ip`
|
||||
- `openresty_default_limit_rate`
|
||||
- conn:非负整数;rate:空或 `^\d+[kKmM]?$`
|
||||
- 保存成功 toast + invalidate options / config-preview / config-versions
|
||||
- 链到 `/config-versions`
|
||||
|
||||
页面骨架要点(完整实现时展开为完整组件,勿留半成品):
|
||||
|
||||
```tsx
|
||||
// 字段 state、OptionService.list map、updateBatch([{key,value},...])
|
||||
// 文案:「0 或空表示默认关闭;站点未单独配置时继承此处设置。」
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 站点 limits-section**
|
||||
|
||||
1. schema:conn 允许空、`0`、`-1`、正整数:
|
||||
|
||||
```ts
|
||||
if (!rawValue) continue;
|
||||
if (!/^-1$|^\d+$/.test(rawValue)) {
|
||||
context.addIssue({ ..., message: '请输入 -1、0 或正整数' });
|
||||
}
|
||||
```
|
||||
|
||||
2. `validateLimitRate` / `normalizeLimitRate`:
|
||||
|
||||
```ts
|
||||
export function validateLimitRate(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized === '0' || normalized === '-1') {
|
||||
return null;
|
||||
}
|
||||
if (!limitRatePattern.test(normalized)) {
|
||||
return '限速格式不合法,请使用 512k、1m、纯数字,或 -1 关闭';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeLimitRate(value: string) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === '0') return '';
|
||||
return normalized; // 保留 -1
|
||||
}
|
||||
```
|
||||
|
||||
3. 表单展示:`-1` 需显示为 `'-1'`(注意 `route.limit_conn_per_server ? String : ''` 对 `-1` 已为 truthy;对 `0` 仍为空)
|
||||
|
||||
4. 提交:空 → `0`;`-1` → `-1`;正数 → 数字
|
||||
|
||||
5. 文案:
|
||||
|
||||
```
|
||||
description='站点限流。空或 0 继承全局默认;-1 显式关闭;大于 0 为自定义。'
|
||||
FormDescription 同步说明
|
||||
```
|
||||
|
||||
6. 侧栏「流量限制」section description 可改为:`设置连接数和限速(可继承全局默认)。`
|
||||
|
||||
- [ ] **Step 5: prettier + 类型检查(按项目习惯)**
|
||||
|
||||
```bash
|
||||
make prettier
|
||||
# 若有前端 typecheck:
|
||||
# cd frontend && pnpm exec tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/lib/navigation/openflare-nav.ts \
|
||||
frontend/app/\(main\)/rate-limits/ \
|
||||
frontend/app/\(main\)/proxy-routes/detail/components/limits-section.tsx \
|
||||
frontend/app/\(main\)/proxy-routes/components/helpers.ts \
|
||||
frontend/lib/utils/search-data.ts
|
||||
git commit -m "feat(frontend): add security rate-limits page and inherit UI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 文档、索引、门禁
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/reference/configuration.md`(OpenResty 配置表)
|
||||
- Modify: `docs/changelog/index.md` `[unreleased]`
|
||||
- Modify: `docs/plan/index.md`
|
||||
|
||||
- [ ] **Step 1: configuration.md**
|
||||
|
||||
在 `openresty_cache_use_stale` 与 `openresty_main_config_template` 之间插入:
|
||||
|
||||
```md
|
||||
| `openresty_default_limit_conn_per_server` | `int` | 站点未配置时的默认并发连接上限;`0` 表示默认关闭 | `0` |
|
||||
| `openresty_default_limit_conn_per_ip` | `int` | 站点未配置时的默认单 IP 并发上限;`0` 表示默认关闭 | `0` |
|
||||
| `openresty_default_limit_rate` | `string` | 站点未配置时的默认单请求带宽(如 `512k`);空表示默认关闭 | 空 |
|
||||
```
|
||||
|
||||
- [ ] **Step 2: changelog**
|
||||
|
||||
`[unreleased]` 下:
|
||||
|
||||
```md
|
||||
### 新增
|
||||
|
||||
- 安全性新增「限流」设置:可为边缘站点配置默认并发与带宽;站点未设置时继承,填 `-1` 可显式关闭。
|
||||
|
||||
### 改进
|
||||
|
||||
- 站点流量限制语义调整为空或 `0` 继承全局默认、`-1` 关闭、大于 `0` 自定义;修改全局默认后需发布配置版本生效。
|
||||
```
|
||||
|
||||
- [ ] **Step 3: plan index**
|
||||
|
||||
`docs/plan/index.md` 进行中列表增加:
|
||||
|
||||
```md
|
||||
* [边缘限流全局默认](../superpowers/plans/2026-07-19-http-default-rate-limit.md):http/全局默认限流,站点 0 继承、-1 关闭。
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 全量门禁**
|
||||
|
||||
```bash
|
||||
make code-check
|
||||
make prettier
|
||||
```
|
||||
|
||||
Expected: 通过;修复任何报错后再提交。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/reference/configuration.md docs/changelog/index.md docs/plan/index.md
|
||||
git commit -m "docs: document default edge rate limits"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage checklist
|
||||
|
||||
| Spec 要求 | Task |
|
||||
|-----------|------|
|
||||
| 三项全局默认 | 2, 4 |
|
||||
| 0/空继承、-1 关、>0 覆盖 | 1, 3, 4 |
|
||||
| 仅 `RenderRouteConfig` 合并 | 1 |
|
||||
| 快照保留原始站点值 | 2(不写回 route) |
|
||||
| 安全性子页「限流」 | 4 |
|
||||
| 初始 0/空兼容 | 2 seed |
|
||||
| option diff / 发布 | 2 |
|
||||
| 测试合并/normalize | 1, 3 |
|
||||
| 中文文档/changelog | 5 |
|
||||
| 非目标 limit_req / http 级指令 | 未做 |
|
||||
|
||||
## 手动验收
|
||||
|
||||
1. 迁移后三键存在且为 `0`/空
|
||||
2. 安全性 → 限流 设置 `120` / `12` / `512k` 并保存
|
||||
3. 版本发布预览:未配置站点的 location 出现对应 `limit_conn`/`limit_rate`
|
||||
4. 站点将该项改为 `-1` 保存并发布:该维度指令消失
|
||||
5. 站点改为 `50`:输出 50 而非全局值
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +0,0 @@
|
||||
# WAF IP 匹配:Radix / lua-resty-ipmatcher
|
||||
|
||||
## 1. 目标与背景 (Goal & Context)
|
||||
|
||||
* **需求背景**:`ip_match` 对 IP 组 `ip_list` 做线性扫描,且每行强制 `ipv6_equal` + `ip_in_cidr`,大名单(订阅/自动规则可达万~十万级)时压测 RPS 约 65、OpenResty CPU 打满。
|
||||
* **开发范围 (Scope)**:
|
||||
* **必做**:边缘热路径改为预处理索引 + O(W) 查询;IP 组快照加载时编译;节点内联 `ips`/`cidrs` 同样编译;Agent 镜像安装 `lua-resty-ipmatcher`;规格与 changelog。
|
||||
* **Out of Scope**:控制面协议变更、改 IP 组存储格式、Geo 匹配优化。
|
||||
|
||||
## 2. 设计与决策 (Design & Decisions)
|
||||
|
||||
* **选型**:OpenResty 使用 `resty.ipmatcher`(底层 Radix,支持 IP 与 CIDR 统一;可用 `match_bin(binary_remote_addr)`)。
|
||||
* **编译时机**:
|
||||
* IP 组:`waf.ip_groups` 采纳新快照时为每组 `ip_list` 建 matcher,挂到 `group._matcher`。
|
||||
* 节点 `ips`/`cidrs`:首次匹配时合并列表建 matcher,用 weak 缓存或按 config 引用缓存。
|
||||
* **回退**:`require("resty.ipmatcher")` 失败时用纯 Lua「exact set + 预解析 CIDR」回退(测试 / 未装 opm 的本地 OpenResty),避免回归到每行 IPv6 全解析。
|
||||
* **不引入**:手写纯 Lua 十万节点 table 树作为生产主路径(内存与 GC 差)。
|
||||
|
||||
## 3. 具体修改文件清单 (Proposed Changes)
|
||||
|
||||
### 边缘 Agent 与 OpenResty
|
||||
|
||||
* #### [MODIFY] `docker/Dockerfile.agent`
|
||||
* **不**通过 OPM 安装 ipmatcher(`api7` 账号在 OPM 不存在)。
|
||||
* #### [NEW] `internal/apps/agent/nginx/resty/ipmatcher.lua`(vendor api7 v0.6.1)
|
||||
* 随 `ManagedWAFLuaFiles` 部署到 `<luaDir>/resty/ipmatcher.lua`,由 `lua_package_path` 加载。
|
||||
* #### [MODIFY] `internal/apps/agent/nginx/waf_runtime.lua`
|
||||
* 编译/查询 helper;重写 `matches_ip_values`。
|
||||
* #### [MODIFY] `internal/apps/agent/nginx/waf_ip_groups.lua`
|
||||
* 无需在刷新模块内编译;快照采纳后由 `waf.runtime` 惰性编译 `group._matcher`。
|
||||
* #### [MODIFY] `internal/apps/agent/nginx/waf_runtime_spec.lua` / `waf_ip_groups_spec.lua`
|
||||
* 覆盖 exact/CIDR/IPv6/组 miss;大名单语义 smoke。
|
||||
* #### [MODIFY] `docs/changelog/index.md`、相关设计/plan 备注
|
||||
|
||||
## 4. 验证计划 (Verification Plan)
|
||||
|
||||
* `go test ./internal/apps/agent/nginx/ -count=1`
|
||||
* 重建 Agent 镜像后压测:三组大名单 miss 路径 CPU/RPS 对比。
|
||||
@@ -1,85 +0,0 @@
|
||||
# 边缘缓存对齐 Cloudflare 默认模型 — 实现计划
|
||||
|
||||
对应设计:[edge-cache-design.md](../design/edge-cache-design.md)
|
||||
|
||||
## 1. 目标与背景
|
||||
|
||||
* **需求背景**:现网对会话 Cookie / Authorization / 请求 Cache-Control 一律旁路,登录用户静态资源几乎全是「未缓存」,命中率远低于 Cloudflare 默认。需对齐 CF 两段闭环:请求 eligible × 响应可共享缓存。
|
||||
* **Scope(必做)**
|
||||
1. 删除请求侧 Cookie、Authorization、请求 Cache-Control 旁路
|
||||
2. 响应侧:`proxy_no_cache` 绑定 `$upstream_http_set_cookie`
|
||||
3. 默认 `proxy_cache_valid`(200/206/301→120m,302/303→20m,404/410→3m)
|
||||
4. 默认静态扩展名移除 `json`;保留 `map`/`mjs`/`wasm`
|
||||
5. 渲染单测 + UI 文案 + 设计/changelog
|
||||
* **Out of Scope**:Purge、Cache Rules、强制忽略源站 CC、Auth RFC 条件缓存、HEAD→GET
|
||||
|
||||
## 2. 设计决策摘要
|
||||
|
||||
| 决策 | 选择 |
|
||||
| --- | --- |
|
||||
| 请求 Cookie | 不旁路(对齐 CF) |
|
||||
| Set-Cookie | 不入库 |
|
||||
| 无源站 CC | 状态码默认 Edge TTL |
|
||||
| json | 默认表移除 |
|
||||
| 兼容 | `url`→`all` 不变;行为变更需重新发布配置 |
|
||||
|
||||
## 3. 修改清单
|
||||
|
||||
### 边缘渲染
|
||||
|
||||
* #### [MODIFY] `pkg/render/openresty/render.go`
|
||||
* `renderRouteCacheBlock`:仅保留非 GET 旁路;`proxy_no_cache $openflare_skip_cache $upstream_http_set_cookie`;追加三行 `proxy_cache_valid`
|
||||
* #### [MODIFY] `pkg/render/openresty/types.go`
|
||||
* `DefaultStaticCacheExtensions`:去掉 `json`
|
||||
* #### [MODIFY] `pkg/render/openresty/render_test.go`
|
||||
* 断言:无 cookie/auth/cache_control 旁路;含 set_cookie 与 proxy_cache_valid;表不含 json、含 map
|
||||
|
||||
### 前端
|
||||
|
||||
* #### [MODIFY] `frontend/app/(main)/proxy-routes/detail/components/cache-section.tsx`
|
||||
* 去掉「绕过登录 Cookie / Authorization」类文案
|
||||
* 改为 CF 对齐说明:源站 private/no-store、Set-Cookie 不入库、默认静态不含 HTML/JSON
|
||||
|
||||
### 文档
|
||||
|
||||
* #### [MODIFY] `docs/design/edge-cache-design.md`(已更新)
|
||||
* #### [MODIFY] `docs/changelog/index.md` `[Unreleased]`
|
||||
* #### [MODIFY] `docs/plan/index.md` 登记本计划
|
||||
|
||||
### 不改
|
||||
|
||||
* 无 DB 迁移
|
||||
* 无 API 字段变更(策略枚举不变)
|
||||
|
||||
## 4. 验证计划
|
||||
|
||||
### 自动化
|
||||
|
||||
```bash
|
||||
go test ./pkg/render/openresty/
|
||||
make format
|
||||
make code-check
|
||||
```
|
||||
|
||||
### 数据面(配置发布后)
|
||||
|
||||
1. 站点 `cache_enabled` + `static`,全局缓存开
|
||||
2. 带 session Cookie:`GET /static/app.js` 第二次应 HIT
|
||||
3. `GET /index.html` 应为未缓存
|
||||
4. 源站返回 `Set-Cookie` 的静态 URL 不应出现稳定 HIT
|
||||
5. 访问日志 `cache_status` 与三态一致
|
||||
|
||||
## 5. 发布注意
|
||||
|
||||
* 节点需 **重新发布/拉取配置版本** 后旁路变更才生效
|
||||
* 若站点依赖边缘缓存 `*.json`,改为 `suffix` 含 json 或 `all`
|
||||
|
||||
## 6. 状态
|
||||
|
||||
- [x] 设计定稿(用户确认:全量对齐 CF)
|
||||
- [x] 渲染与单测
|
||||
- [x] UI 文案
|
||||
- [x] changelog / plan index
|
||||
- [x] `make format` + `make code-check`
|
||||
- [x] 复查补强:`all` 策略 UI 警告;proxy-config / troubleshooting 运维说明
|
||||
- [ ] 用户确认后提交
|
||||
@@ -1,44 +0,0 @@
|
||||
# model / repository 分层治理
|
||||
|
||||
说明:将 `internal/model` 收敛为无 IO 实体层,`internal/repository` 作为唯一持久化入口。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与背景 (Goal & Context)
|
||||
|
||||
* **需求背景**:已提交的 AGENTS 曾允许 model 直接使用 GORM,导致 OpenFlare 业务 CRUD 与平台 repository 双轨并存。工作区目标分层与代码不一致,接手成本高。
|
||||
* **开发范围 (Scope)**:
|
||||
* 固化规范:`model` 仅实体 / DTO / 无 IO 规则;`repository` 唯一持久化入口。
|
||||
* 将 `internal/model` 中现有 `db.DB` / Redis / ClickHouse store 适配迁入 `internal/repository`。
|
||||
* 全量更新 call site:`model.Get/List/Create…` → `repository.…`。
|
||||
* 编译通过 + `make format` + 相关单测。
|
||||
* **Out of Scope**:不改表结构、不改 API 契约、不做业务行为变更;不强制一次重写所有测试风格。
|
||||
|
||||
## 2. 设计与决策
|
||||
|
||||
* **分层**:
|
||||
* `apps → repository → model`
|
||||
* `repository → infra/persistence`(及 `repository/analytics`)
|
||||
* **禁止** `model → repository`、**禁止** model 内 `db.DB` / Redis / ClickHouse
|
||||
* **迁移策略**:按文件拆分 package-level IO 函数至同名 `repository` 文件;类型与纯函数留在 model;store 适配整文件迁入 repository。
|
||||
* **命名**:repository 函数保持原导出名,降低 call site 改动面。
|
||||
|
||||
## 3. 具体修改文件清单
|
||||
|
||||
### 规范
|
||||
|
||||
* #### [MODIFY] `AGENTS.md`
|
||||
* #### [MODIFY] `docs/design/index.md`
|
||||
* #### [MODIFY] `docs/plan/index.md`(登记本计划)
|
||||
|
||||
### 后端
|
||||
|
||||
* #### [MODIFY] `internal/model/*.go`(剥离 IO)
|
||||
* #### [NEW/MODIFY] `internal/repository/*.go`(承接 CRUD / store)
|
||||
* #### [MODIFY] `internal/apps/**`、`internal/infra/task/**` 等 call site
|
||||
|
||||
## 4. 验证计划
|
||||
|
||||
* `go test ./internal/model/... ./internal/repository/...`
|
||||
* 关键包 `go build ./...`
|
||||
* `make format` / `make code-check`(在可接受时间内)
|
||||
@@ -1,151 +0,0 @@
|
||||
# Cloudflare DNS 指向实现计划
|
||||
|
||||
> 状态:代码实施完成(2026-08-04;范围内自动化验证完成;全量前端仅保留任务开始前已存在的 Zone 文案断言失败)
|
||||
|
||||
> **执行方式**:使用 `superpowers:executing-plans` 在当前会话按任务逐项实施;每项遵循测试先行(RED → GREEN → REFACTOR)。
|
||||
|
||||
## 1. 目标与背景 (Goal & Context)
|
||||
|
||||
* **需求背景**:落实提交 `21fb303e` 中的 Cloudflare DNS 指向设计,让管理员以 ZoneDomain 为粒度,将明确 FQDN 的单条 A 记录幂等指向 OpenFlare 边缘节点 IPv4,避免在 Cloudflare 控制台重复手工操作。
|
||||
* **开发范围 (Scope)**:
|
||||
* 全局一份 Cloudflare 连接,支持从现有 Cloudflare DNS 账号导入或独立录入 API Token。
|
||||
* 指向分组、成员、主/备/生效节点、成员橙云、同步状态与错误信息。
|
||||
* Cloudflare Zone/DNS Record HTTP 客户端与单成员幂等 reconcile。
|
||||
* 手动同步、成员/分组变更同步、节点 IP 变化 best-effort 入队。
|
||||
* 管理 API、Swagger、前端总览/设置/分组列表/分组详情和侧边栏入口。
|
||||
* **Out of Scope**:自动故障切换/回切、AAAA、多 A 负载、CNAME、定时全量对账、非 Cloudflare DNS 厂商、多 Cloudflare 账号并行。
|
||||
|
||||
## 2. 设计与决策 (Design & Decisions)
|
||||
|
||||
### 核心对象/数据模型
|
||||
|
||||
* `of_cf_connections`:全局连接;`source` 为 `dns_account` 或 `standalone`,独立 Token 使用现有 `enc:v1:` 密文格式,响应永不暴露凭据。
|
||||
* `of_cf_pointing_groups`:分组名、主节点、可选备用节点、生效节点、默认橙云和启用状态;一期 `active_node_id = primary_node_id`。
|
||||
* `of_cf_pointing_members`:分组、全局唯一 `zone_domain_id`、成员橙云、Cloudflare Zone/Record ID 缓存、期望 IP 与同步状态。
|
||||
* PostgreSQL/SQLite 使用同版本 Goose DDL,不建立物理外键,关系字段显式索引,数据库默认值与 Go 零值一致。
|
||||
|
||||
### API 与鉴权设计
|
||||
|
||||
* 前缀 `/api/v1/d/cloudflare`,统一使用 `apiutil.AdminMiddlewares()`。
|
||||
* 连接:`GET/PUT /connection`、`POST /connection/verify`、`POST /connection/clear`。
|
||||
* 总览:`GET /overview`。
|
||||
* 分组:`GET/POST /groups`、`GET /groups/:id`、`POST /groups/:id/update|delete|sync`。
|
||||
* 成员:`GET/POST /groups/:id/members`、`POST /groups/:id/members/:memberId/update|remove|sync`。
|
||||
* 可用域名:`GET /domains/available`。
|
||||
* 成功统一 HTTP 200 + `response.OK`;失败通过 `response.Abort*` 交由全局中间件写出。
|
||||
|
||||
### 数据流与架构图
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UI[Cloudflare 管理页面] --> API[/api/v1/d/cloudflare]
|
||||
API --> Logic[cloudflare 业务逻辑]
|
||||
Logic --> Repo[repository]
|
||||
Repo --> DB[(PG / SQLite)]
|
||||
Logic --> Queue[Asynq]
|
||||
Queue --> Worker[Cloudflare 同步 Handler]
|
||||
Worker --> Reconcile[成员 Reconcile]
|
||||
Reconcile --> CF[Cloudflare Zone / DNS API]
|
||||
Reconcile --> Repo
|
||||
Node[节点手动更新或心跳] --> Queue
|
||||
```
|
||||
|
||||
### 设计决策权衡
|
||||
|
||||
* 使用标准库 `net/http` 自建最小 Cloudflare 客户端,避免引入覆盖面过大的 SDK;接口只暴露 verify、Zone 查找和 A 记录 CRUD,便于 mock。
|
||||
* 单成员同步采用进程内 keyed mutex 防止同一 Worker 进程并发双写;数据库状态在调用远端前标记 `syncing`,完成后写回 `ok/error`。
|
||||
* 整组、按节点和常规变更统一通过 Asynq 投递单成员任务;请求路径只做校验和状态变更,避免管理 API 被远端网络延迟阻塞。连接测试是唯一同步调用 Cloudflare 的管理操作。
|
||||
* 删除成员/分组默认先删除已缓存或唯一同名 A 记录,再删除本地记录;远端删除失败时保留本地成员并返回可读错误,避免失去重试依据。
|
||||
* 将 TLS 包内的敏感字段加解密提取为 `internal/apps/openflare/credential`,保持既有密文兼容并让 Cloudflare 复用,避免业务包重复实现凭据存储。
|
||||
|
||||
## 3. 具体修改文件清单 (Proposed Changes)
|
||||
|
||||
### Task 1:凭据共享与数据库模型
|
||||
|
||||
**测试先行**:验证旧明文、`enc:v1:` 密文、无 SessionSecret 和缺少密钥时的兼容行为;验证迁移能创建三张表和唯一索引。
|
||||
|
||||
* **[NEW]** `internal/apps/openflare/credential/sensitive.go`、`sensitive_test.go`:提供 `Seal` / `Open`。
|
||||
* **[MODIFY]** `internal/apps/openflare/tls/sensitive.go` 及调用点:委派到共享凭据包,保留 TLS 对外行为。
|
||||
* **[NEW]** `internal/model/openflare_cloudflare.go`:连接、分组、成员实体和同步状态常量。
|
||||
* **[NEW]** `internal/infra/persistence/migrator/goose/{postgres,sqlite}/202608040001_create_cloudflare_pointing.sql`。
|
||||
* **[MODIFY]** `internal/infra/persistence/migrator/migrator_test.go`:检查表、索引和唯一约束。
|
||||
|
||||
### Task 2:Repository 与 Cloudflare HTTP 客户端
|
||||
|
||||
**测试先行**:覆盖连接 upsert/clear、分组/成员 CRUD、可用域名、按 active node 查询成员;使用 `httptest.Server` 覆盖 Token verify、Zone 查找、A 记录 list/create/update/delete、API 错误和 429 `Retry-After`。
|
||||
|
||||
* **[NEW]** `internal/repository/openflare_cloudflare.go`、`openflare_cloudflare_test.go`:唯一持久化入口和必要事务。
|
||||
* **[NEW]** `internal/apps/openflare/cloudflare/client.go`、`client_test.go`:最小 Cloudflare API 接口与 HTTP 实现。
|
||||
* **[NEW]** `internal/apps/openflare/cloudflare/types.go`、`errs.go`:输入/输出 DTO、内部状态和用户可见错误常量。
|
||||
|
||||
### Task 3:Reconcile、业务逻辑与异步任务
|
||||
|
||||
**测试先行**:覆盖 Token 来源解析、0/1/多条同名 A、缓存 Record ID 失效回退、非法 IPv4、成员默认橙云初始化、成员更新、移出删除远端、分组变更入队、按节点 IP 变更入队和同成员串行执行。
|
||||
|
||||
* **[NEW]** `internal/apps/openflare/cloudflare/reconcile.go`、`reconcile_test.go`:单成员期望状态计算与幂等同步。
|
||||
* **[NEW]** `internal/apps/openflare/cloudflare/logics.go`、`logics_test.go`:连接、总览、分组、成员业务编排。
|
||||
* **[NEW]** `internal/apps/openflare/cloudflare/tasks.go`、`tasks_test.go`:`cloudflare:sync_member`、`sync_group`、`sync_by_node` Handler、Meta、payload 校验与投递函数。
|
||||
* **[MODIFY]** `internal/infra/task/handlers/register.go`:集中注册 Cloudflare 任务。
|
||||
* **[MODIFY]** `internal/apps/openflare/node/logics.go`、`internal/apps/openflare/agent/logics.go`:节点 IP 真正变化后 best-effort 投递,不阻断原流程;失败记录日志。
|
||||
|
||||
### Task 4:管理 API 与 Swagger
|
||||
|
||||
**测试先行**:使用 Gin 测试覆盖管理员路由、参数绑定、404/409/未就绪映射、Token 响应脱敏和主要成功响应。
|
||||
|
||||
* **[NEW]** `internal/apps/openflare/cloudflare/routers.go`、`routers_test.go`:Handlers 与 Swagger 注释。
|
||||
* **[NEW]** `internal/router/v1/openflare/register_cloudflare.go`。
|
||||
* **[MODIFY]** `internal/router/v1/openflare/v1.go`:注册 Cloudflare 路由委派。
|
||||
* **[GENERATED]** `docs/docs.go`、`docs/swagger.json`、`docs/swagger.yaml`:运行 `make swagger` 生成。
|
||||
|
||||
### Task 5:前端服务、导航与页面
|
||||
|
||||
**测试先行**:覆盖 service 路径/载荷、未就绪引导、连接配置不回显 Token、分组创建、成员添加/橙云更新、同步与删除确认。
|
||||
|
||||
* **[NEW]** `frontend/lib/services/openflare/cloudflare.service.ts`。
|
||||
* **[MODIFY]** `frontend/lib/services/openflare/types.ts`、`index.ts`:类型、导出和 `openflareServices.cloudflare`。
|
||||
* **[MODIFY]** `frontend/lib/navigation/openflare-nav.ts`:网站管理组增加 Cloudflare 入口与子路由高亮。
|
||||
* **[NEW]** `frontend/app/(main)/cloudflare/page.tsx`:总览和就绪门禁。
|
||||
* **[NEW]** `frontend/app/(main)/cloudflare/settings/page.tsx`:DNS 账号导入/独立 Token 配置与连接测试。
|
||||
* **[NEW]** `frontend/app/(main)/cloudflare/groups/page.tsx`:分组列表、创建、同步、删除确认。
|
||||
* **[NEW]** `frontend/app/(main)/cloudflare/groups/[id]/page.tsx` 及邻近 `components/`:分组配置、成员列表、添加/更新/同步/移除。
|
||||
* **[NEW]** `frontend/tests/cloudflare/*.test.ts(x)`:服务和关键交互测试。
|
||||
|
||||
### Task 6:设计边界、变更日志与收尾
|
||||
|
||||
* **[MODIFY]** `docs/design/architecture.md`、`docs/design/index.md`:补充 Cloudflare 可选控制面能力与阅读入口。
|
||||
* **[MODIFY]** `docs/changelog/index.md`:在 `[Unreleased]` 添加中文用户可见条目。
|
||||
* **[MODIFY]** `docs/plan/index.md`:登记本计划;完成时保留计划并标记状态。
|
||||
|
||||
## 4. 验证计划 (Verification Plan)
|
||||
|
||||
### 自动化单元测试
|
||||
|
||||
* `go test ./internal/apps/openflare/credential ./internal/apps/openflare/cloudflare ./internal/repository ./internal/infra/persistence/migrator ./internal/apps/openflare/node ./internal/apps/openflare/agent`
|
||||
* `pnpm --dir frontend test -- --run frontend/tests/cloudflare`
|
||||
* `go test ./...`
|
||||
|
||||
### 生成与质量门禁
|
||||
|
||||
* `make license`
|
||||
* `make swagger`
|
||||
* `make format`
|
||||
* `make code-check`
|
||||
|
||||
### 手动验收路径
|
||||
|
||||
1. 在 `/cloudflare/settings` 选择现有 Cloudflare DNS 账号或录入独立 Token,测试连接成功。
|
||||
2. 在 `/cloudflare/groups` 新建分组,选择具有合法 IPv4 的 edge 节点。
|
||||
3. 在详情页加入 ZoneDomain,观察状态从 `pending/syncing` 变为 `ok`,Cloudflare 上出现单条 A 记录。
|
||||
4. 修改成员橙云并同步,确认远端 `proxied` 与期望一致。
|
||||
5. 构造同名多 A,确认同步失败并提示先在 Cloudflare 清理。
|
||||
6. 移出成员,确认默认删除本模块管理的远端 A;修改节点 IP 后确认相关成员重新入队。
|
||||
|
||||
## 5. 实施结果与验证记录
|
||||
|
||||
* 已完成共享凭据加密、双数据库迁移、repository、Cloudflare HTTP 客户端、成员 reconcile、三类异步任务、管理 API、节点 IP 变化联动、前端服务与四级管理页面。
|
||||
* 删除远端记录时,缓存 Record ID 失效会回退到唯一同名 A;停用分组内修改成员只标记 `pending`,不投递必然失败的同步任务。
|
||||
* 已运行 `make license`、`make swagger`、`make format`;Swagger 已生成 Cloudflare 管理接口。
|
||||
* `go test ./...` 通过。
|
||||
* `make code-check` 通过,包含架构守卫、golangci-lint、TypeScript 与 ESLint。
|
||||
* `pnpm exec vitest run tests/cloudflare` 通过(2 个测试文件、2 个测试)。
|
||||
* 前端全量 Vitest 为 20/21 个测试文件、106/107 个测试通过;唯一失败为既存 `tests/zone/zone-page.test.tsx` 仍断言页面展示“唯一访问者”,与本功能无关且在本任务基线中已存在。
|
||||
@@ -1,467 +0,0 @@
|
||||
# 源站错误页 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 全局可配置源站/网关错误页:默认标签 `500-599`、Cloudflare 风格 HTML、可在线自定义;HTTP 状态码保持原值并在页面展示 `{{status}}`/`{{host}}`;可关闭恢复透传。
|
||||
|
||||
**Architecture:** 三个 Option key 持久化 → 配置版本 `openresty_config` 快照 → `pkg/render/openresty` 对反代 server 生成 `proxy_intercept_errors` + `error_page` + internal location;模板 SupportFile + 轻量 `content_by_lua_block` 替换占位符。管理端 `/error-pages` 挂在侧栏「网站管理」。
|
||||
|
||||
**Tech Stack:** Go、goose、Option API、`pkg/render/openresty`、OpenResty/Lua、Next.js、Tags Input、OptionService
|
||||
|
||||
**Spec:** [docs/design/origin-error-page.md](../../design/origin-error-page.md) · [docs/superpowers/specs/2026-08-06-origin-error-page-design.md](../specs/2026-08-06-origin-error-page-design.md)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 仅**反代**路由应用;**Pages** 路由不生成错误页指令
|
||||
- HTTP **status 保持原错误码**,禁止统一改为 200
|
||||
- 状态码标签:单码 `522` 或闭区间 `500-599`;范围 **400–599**;默认 `["500-599"]`
|
||||
- 占位符:`{{status}}`、`{{host}}`;HTML 空 = 内置默认;最大 **256 KiB**
|
||||
- 保存走 Option `update-batch`;**需配置版本发布**后边缘生效
|
||||
- 完成后 `make code-check`;前端相关 `make format` / prettier;中文 changelog;不写英文文档
|
||||
- 不新增独立业务路由注册(复用 `/api/v1/d/option`);侧栏仅前端导航
|
||||
|
||||
## File map
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `pkg/render/openresty/status_codes.go` | 标签解析/展开纯函数 |
|
||||
| `pkg/render/openresty/status_codes_test.go` | 解析单测 |
|
||||
| `pkg/render/openresty/origin_error_page.go` | 默认 HTML、渲染 error_page 片段、SupportFile 路径常量 |
|
||||
| `pkg/render/openresty/origin_error_page_test.go` | 渲染片段单测 |
|
||||
| `pkg/render/openresty/types.go` | `ConfigSnapshot` 三字段 |
|
||||
| `pkg/render/openresty/render.go` / `render_route.go` | 接入 error 块到反代 server |
|
||||
| `pkg/render/openresty/render_test.go` | 集成渲染断言 |
|
||||
| `internal/model/system_configs.go` | 三个 ConfigKey 常量 |
|
||||
| `internal/infra/persistence/migrator/goose/{postgres,sqlite}/202608060001_add_origin_error_page_options.sql` | seed |
|
||||
| `internal/apps/openflare/option/openresty_validators.go` | 校验 enabled / codes / html |
|
||||
| `internal/apps/openflare/option` 相关 test | 校验失败用例 |
|
||||
| `internal/apps/openflare/config_version/snapshot.go` | 快照读写字段 |
|
||||
| `internal/apps/openflare/config_version/logics.go` | `diffOpenRestyOptionDetails` 含新字段 |
|
||||
| `frontend/components/ui/tags-input.tsx` | shadcn-extension Tags Input(若缺失则添加) |
|
||||
| `frontend/app/(main)/error-pages/page.tsx` | 设置页 |
|
||||
| `frontend/lib/navigation/openflare-nav.ts` | 网站管理菜单 |
|
||||
| `frontend/lib/utils/search-data.ts` | 搜索 |
|
||||
| `docs/reference/configuration.md` | 配置键说明(中文) |
|
||||
| `docs/changelog/index.md` | Unreleased |
|
||||
| `docs/plan/index.md` | 进行中索引 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 状态码标签解析(纯函数 TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `pkg/render/openresty/status_codes.go`
|
||||
- Create: `pkg/render/openresty/status_codes_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `func ExpandStatusCodeTags(tags []string) (codes []int, err error)`
|
||||
- `func ParseStatusCodeTag(tag string) (lo, hi int, err error)` — 单码时 `lo==hi`
|
||||
- 常量:`StatusCodeMin = 400`, `StatusCodeMax = 599`
|
||||
- 规则:trim;`^\d{3}$` 或 `^\d{3}-\d{3}$`;`lo<=hi`;均在 400–599;展开 inclusive;排序去重;空 tags → 空 slice + nil error(「启用且空」由校验层拒绝)
|
||||
|
||||
- [ ] **Step 1: 写失败单测**
|
||||
|
||||
```go
|
||||
func TestExpandStatusCodeTags(t *testing.T) {
|
||||
t.Parallel()
|
||||
codes, err := ExpandStatusCodeTags([]string{"500-502", "522", "501"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// want sorted unique: 500,501,502,522
|
||||
if len(codes) != 4 || codes[0] != 500 || codes[3] != 522 {
|
||||
t.Fatalf("got %v", codes)
|
||||
}
|
||||
_, err = ExpandStatusCodeTags([]string{"399"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
_, err = ExpandStatusCodeTags([]string{"503-500"})
|
||||
if err == nil {
|
||||
t.Fatal("expected reverse range error")
|
||||
}
|
||||
_, err = ExpandStatusCodeTags([]string{"5xx"})
|
||||
if err == nil {
|
||||
t.Fatal("expected syntax error")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `go test ./pkg/render/openresty/ -run TestExpandStatusCodeTags -count=1`
|
||||
Expected: FAIL(函数未定义)
|
||||
|
||||
- [ ] **Step 3: 实现 `status_codes.go`**
|
||||
|
||||
```go
|
||||
package openresty
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusCodeMin = 400
|
||||
StatusCodeMax = 599
|
||||
)
|
||||
|
||||
func ParseStatusCodeTag(tag string) (lo, hi int, err error) {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
return 0, 0, fmt.Errorf("状态码标签不能为空")
|
||||
}
|
||||
if i := strings.IndexByte(tag, '-'); i >= 0 {
|
||||
lo, err = strconv.Atoi(tag[:i])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("无效状态码区间: %s", tag)
|
||||
}
|
||||
hi, err = strconv.Atoi(tag[i+1:])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("无效状态码区间: %s", tag)
|
||||
}
|
||||
} else {
|
||||
lo, err = strconv.Atoi(tag)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("无效状态码: %s", tag)
|
||||
}
|
||||
hi = lo
|
||||
}
|
||||
if lo > hi {
|
||||
return 0, 0, fmt.Errorf("状态码区间左右端点反序: %s", tag)
|
||||
}
|
||||
if lo < StatusCodeMin || hi > StatusCodeMax {
|
||||
return 0, 0, fmt.Errorf("状态码须在 %d–%d: %s", StatusCodeMin, StatusCodeMax, tag)
|
||||
}
|
||||
return lo, hi, nil
|
||||
}
|
||||
|
||||
func ExpandStatusCodeTags(tags []string) ([]int, error) {
|
||||
set := map[int]struct{}{}
|
||||
for _, tag := range tags {
|
||||
lo, hi, err := ParseStatusCodeTag(tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for c := lo; c <= hi; c++ {
|
||||
set[c] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]int, 0, len(set))
|
||||
for c := range set {
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out, nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行确认通过**
|
||||
|
||||
Run: `go test ./pkg/render/openresty/ -run TestExpandStatusCodeTags -count=1`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add pkg/render/openresty/status_codes.go pkg/render/openresty/status_codes_test.go
|
||||
git commit -m "feat(openresty): add status code tag expand helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: ConfigSnapshot 字段 + 默认 HTML + error_page 渲染(TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `pkg/render/openresty/types.go` — `ConfigSnapshot` 增加:
|
||||
- `OriginErrorPageEnabled bool \`json:"origin_error_page_enabled"\``
|
||||
- `OriginErrorPageStatusCodes []string \`json:"origin_error_page_status_codes,omitempty"\``
|
||||
- `OriginErrorPageHTML string \`json:"origin_error_page_html,omitempty"\``
|
||||
- Create: `pkg/render/openresty/origin_error_page.go` — 默认 HTML 常量、路径常量、`renderOriginErrorPageDirectives`、`originErrorPageSupportFile`
|
||||
- Modify: `pkg/render/openresty/render.go` — `Render`/`RenderRouteConfig` 在 enabled 时 append SupportFile
|
||||
- Modify: `pkg/render/openresty/render_route.go`(或 `render.go` 中 `renderHTTPProxyServer` / `renderHTTPSServer`)— 在 proxy location **与** server 级写入 intercept + error_page + internal location
|
||||
- Test: `pkg/render/openresty/origin_error_page_test.go` + 扩展 `render_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `const OriginErrorPageSupportPath = "error_pages/origin_error.html.tmpl"`
|
||||
- `const DefaultOriginErrorPageHTML = \`...\`` — CF 风格,含 `{{status}}` `{{host}}`
|
||||
- `func EffectiveOriginErrorPageHTML(cfg ConfigSnapshot) string` — 空则默认
|
||||
- `func renderOriginErrorPageServerBits(cfg ConfigSnapshot) string` — 若 disabled 或 expand 失败/空则 `""`;否则 `error_page ...` + internal location 字符串
|
||||
- SupportFile content = Effective HTML(保留占位符)
|
||||
|
||||
**Internal location 形状(必须 status 透传):**
|
||||
|
||||
```nginx
|
||||
location = /__openflare_origin_error {
|
||||
internal;
|
||||
default_type text/html;
|
||||
charset utf-8;
|
||||
content_by_lua_block {
|
||||
local f = io.open("__OPENFLARE_ERROR_PAGE_TMPL__", "r")
|
||||
if not f then
|
||||
ngx.status = ngx.status
|
||||
ngx.say("Error ", ngx.status)
|
||||
return
|
||||
end
|
||||
local body = f:read("*a")
|
||||
f:close()
|
||||
local status = tostring(ngx.status)
|
||||
local host = ngx.var.host or ""
|
||||
body = body:gsub("{{status}}", status, 1)
|
||||
body = body:gsub("{{host}}", host, 1)
|
||||
-- 全局替换剩余占位(若模板多处 status)
|
||||
body = body:gsub("{{status}}", status)
|
||||
body = body:gsub("{{host}}", host)
|
||||
ngx.header["Content-Type"] = "text/html; charset=utf-8"
|
||||
ngx.say(body)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
占位路径:渲染时用常量如 `ErrorPageTmplPlaceholder = "__OPENFLARE_ERROR_PAGE_TMPL__"`,Agent 落盘时与 SupportFile 绝对路径替换(若现有 Agent 已有 support file root 替换模式则复用;否则在 `internal/apps/agent` 同步路径处增加对该 placeholder 的替换,与 `CertDirPlaceholder` 同类)。
|
||||
|
||||
在每个反代 `location /` 内(proxy 块):
|
||||
|
||||
```nginx
|
||||
proxy_intercept_errors on;
|
||||
```
|
||||
|
||||
在 server 块内(location 外):
|
||||
|
||||
```nginx
|
||||
error_page 500 501 ... = /__openflare_origin_error;
|
||||
```
|
||||
|
||||
+ internal location。
|
||||
|
||||
Pages 的 `renderHTTPSPagesServer` / pages HTTP **不**调用。
|
||||
|
||||
- [ ] **Step 1: 写失败单测**
|
||||
|
||||
```go
|
||||
func TestRenderOriginErrorPageEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
ID: 1, SiteName: "ex", Domains: []string{"ex.test"},
|
||||
OriginURL: "http://127.0.0.1:9", Enabled: true,
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{
|
||||
OriginErrorPageEnabled: true,
|
||||
OriginErrorPageStatusCodes: []string{"500-599"},
|
||||
},
|
||||
}
|
||||
out, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "proxy_intercept_errors on") {
|
||||
t.Fatal("missing intercept")
|
||||
}
|
||||
if !strings.Contains(out, "error_page") || !strings.Contains(out, "/__openflare_origin_error") {
|
||||
t.Fatal("missing error_page")
|
||||
}
|
||||
if !strings.Contains(out, "{{status}}") == false {
|
||||
// SupportFile 在 Render 全量结果中
|
||||
}
|
||||
res, err := Render(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, f := range res.SupportFiles {
|
||||
if f.Path == OriginErrorPageSupportPath {
|
||||
found = true
|
||||
if !strings.Contains(f.Content, "{{status}}") {
|
||||
t.Fatal("template missing placeholder")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing support file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderOriginErrorPageDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
ID: 1, SiteName: "ex", Domains: []string{"ex.test"},
|
||||
OriginURL: "http://127.0.0.1:9", Enabled: true,
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{OriginErrorPageEnabled: false},
|
||||
}
|
||||
out, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, "proxy_intercept_errors") {
|
||||
t.Fatal("should not intercept when disabled")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败** → 实现默认 HTML + 渲染接入 → 运行 PASS
|
||||
|
||||
默认 HTML 要求:大号 `{{status}}`、展示 `{{host}}`、中性「源站暂时无法提供服务」类文案、无 Cloudflare 商标、可单文件 inline CSS。
|
||||
|
||||
- [ ] **Step 3: Agent placeholder 替换**
|
||||
|
||||
搜索 Agent 写 conf 时如何替换 `__OPENFLARE_CERT_DIR__` 等,为 `__OPENFLARE_ERROR_PAGE_TMPL__` 增加指向 support 目录下 `error_pages/origin_error.html.tmpl` 的绝对路径。
|
||||
若 conf 内 lua 无法可靠 `io.open` 绝对路径,可改为 `content_by_lua_file` + 小 lua 读固定相对路径;优先与现有 pow/waf 资源部署方式一致。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(openresty): render origin error page directives"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Option keys、迁移、校验、快照
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/model/system_configs.go` — 常量:
|
||||
- `ConfigKeyOriginErrorPageEnabled = "origin_error_page_enabled"`
|
||||
- `ConfigKeyOriginErrorPageStatusCodes = "origin_error_page_status_codes"`
|
||||
- `ConfigKeyOriginErrorPageHTML = "origin_error_page_html"`
|
||||
- Create goose(postgres + sqlite 同名版本号)`202608060001_add_origin_error_page_options.sql`:
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES
|
||||
('origin_error_page_enabled', 'true', 'business', 0, '是否启用源站错误页', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('origin_error_page_status_codes', '["500-599"]', 'business', 0, '源站错误页触发状态码标签 JSON 数组', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('origin_error_page_html', '', 'business', 0, '源站错误页自定义 HTML,空则使用内置默认', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_system_configs WHERE key IN (
|
||||
'origin_error_page_enabled',
|
||||
'origin_error_page_status_codes',
|
||||
'origin_error_page_html'
|
||||
);
|
||||
```
|
||||
|
||||
(SQLite 若无 `ON CONFLICT` 同现有迁移方言对齐。)
|
||||
|
||||
- Modify: `openresty_validators.go`:
|
||||
- enabled → `validateBooleanOption`
|
||||
- status_codes → JSON 数组 `[]string`,对每项 `ParseStatusCodeTag`;若 `enabled==true`(跨 key 时可用 state,或在 batch 校验后单独检查:若本 key 合法但 enabled 为 true 且 expand 为空则失败)。**实现建议**:`validateOriginErrorPageStatusCodes` 只校验标签可解析且 expand 非空(即使 disabled 也要求非空列表,避免脏数据);html 长度 `<= 256*1024`
|
||||
- html → `len(value) <= 256<<10`
|
||||
|
||||
- Modify: `snapshot.go` 的 `openRestyConfigSnapshot` + `buildOpenRestyConfigSnapshot`:
|
||||
- Enabled: `getBoolConfig(..., true)`
|
||||
- StatusCodes: 解析 JSON 数组,失败则默认 `[]string{"500-599"}`
|
||||
- HTML: `getStringConfig(..., "")`
|
||||
|
||||
- Modify: `logics.go` `diffOpenRestyOptionDetails` 比较新字段(否则发布 diff 不显示)
|
||||
|
||||
- [ ] **Step 1: 校验单测**(`option` 包)非法 `["abc"]`、超大 html、合法 `["522","500-502"]`
|
||||
|
||||
- [ ] **Step 2: 实现迁移与 snapshot 填充**
|
||||
|
||||
- [ ] **Step 3: 手动或单测确认 snapshot JSON 含字段**
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(option): seed and validate origin error page options"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 前端 Tags Input + `/error-pages` 页
|
||||
|
||||
**Files:**
|
||||
- Create/Modify: `frontend/components/ui/tags-input.tsx`(若无:用 shadcn skill / `npx shadcn@latest add` 社区 Tags Input;API:`value: string[]`, `onChange`, `placeholder`)
|
||||
- Create: `frontend/app/(main)/error-pages/page.tsx`(及可选 `components/` 拆分若超 600 行)
|
||||
- Modify: `frontend/lib/navigation/openflare-nav.ts` — `openflareWebsiteNavGroup.items` 增加 `{ title: '错误页', url: '/error-pages' }`;`openflareWebsiteSubNav` 同步
|
||||
- Modify: `frontend/lib/utils/search-data.ts` — 关键词:错误页、源站、502、error page
|
||||
- Reuse: `OptionService.list` / `updateBatch`(同 performance 页)
|
||||
|
||||
**UI 行为:**
|
||||
1. 管理员加载 options → 映射三字段
|
||||
2. Switch 启用
|
||||
3. TagsInput:默认展示解析后的 JSON 数组
|
||||
4. Textarea HTML;按钮「加载默认模板」「恢复默认」
|
||||
5. 预览:客户端把 `{{status}}`→`502`、`{{host}}`→`example.com` 后 iframe `srcDoc`
|
||||
6. 保存:`updateBatch` 三条;toast 提示去版本发布
|
||||
7. 前端校验:标签用与后端相同规则(可抽 `lib/openflare/status-code-tags.ts` 轻量实现或仅保存时依赖后端错误)
|
||||
|
||||
默认 HTML:从前端常量复制与 Go `DefaultOriginErrorPageHTML` **内容一致**(计划实现时两处同一字符串;可在 PR 说明需人工对齐)。
|
||||
|
||||
- [ ] **Step 1: 添加 Tags Input 组件并在 demo 或本页使用**
|
||||
|
||||
- [ ] **Step 2: 实现 page.tsx**
|
||||
|
||||
- [ ] **Step 3: 导航与搜索**
|
||||
|
||||
- [ ] **Step 4: 本地 UI 走查**(开关、标签区间、预览、保存错误提示)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(frontend): add origin error page settings under websites"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 文档、changelog、收尾门禁
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/reference/configuration.md` — 三 key 说明
|
||||
- Modify: `docs/changelog/index.md` `[unreleased]` 改进:用户可读中文
|
||||
- Modify: `docs/plan/index.md` — 本计划列入进行中
|
||||
|
||||
- [ ] **Step 1: 写配置说明与 changelog**
|
||||
|
||||
示例 changelog:
|
||||
|
||||
```markdown
|
||||
- 新增全局源站错误页:可在「网站管理 → 错误页」配置触发状态码(支持 500-599 区间与单码)与自定义 HTML;默认 Cloudflare 风格页面并保持真实 HTTP 状态码,关闭后恢复透传。
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `make code-check` 与前端 format**
|
||||
|
||||
- [ ] **Step 3: 手动验收清单(对照设计 §7.2)**
|
||||
|
||||
1. 默认启用 + 源站宕机 → 错误页 + 真实 status
|
||||
2. 源站 503 → 替换
|
||||
3. 仅 `522` → 其它 5xx 透传
|
||||
4. 关闭并发布 → 透传
|
||||
5. 自定义 `{{status}}`/`{{host}}`
|
||||
6. Pages 不受影响
|
||||
|
||||
- [ ] **Step 4: Final commit**
|
||||
|
||||
```bash
|
||||
git commit -m "docs: origin error page configuration and changelog"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage checklist
|
||||
|
||||
| Spec 要求 | Task |
|
||||
|-----------|------|
|
||||
| 全局开关 | 3, 4 |
|
||||
| 默认 `500-599` / 单码+区间 | 1, 3, 4 |
|
||||
| 状态码透传 + `{{status}}` | 2 |
|
||||
| 默认 CF 风格 / 自定义 HTML | 2, 4 |
|
||||
| Option + 配置版本 | 3 |
|
||||
| 仅反代 | 2 |
|
||||
| 侧栏网站管理 | 4 |
|
||||
| Tags Input | 4 |
|
||||
| 256KB 限制 | 3 |
|
||||
| 测试与 changelog | 1–5 |
|
||||
|
||||
## Placeholder scan
|
||||
|
||||
无 TBD;Agent 路径替换若与现网 placeholder 机制不一致,在 Task 2 Step 3 内对齐现有 `CertDirPlaceholder` 模式,不另开悬空任务。
|
||||
@@ -1,47 +0,0 @@
|
||||
# ClickHouse CPU 性能优化计划
|
||||
|
||||
> PLAN_ID: `63ba981b`
|
||||
> 状态: 已完成(含 Phase 2 遗留治理)
|
||||
> 目标: 完成 P0–P2 优化,降低 ClickHouse CPU 占用
|
||||
|
||||
## 背景
|
||||
|
||||
ClickHouse CPU 偏高由写入侧(小 part 频繁 flush、心跳同步 DELETE mutation)与查询侧(无 LIMIT 全表扫、高频轮询、WAF 全量拉日志)叠加导致。
|
||||
|
||||
## PR Plan
|
||||
|
||||
### PR 1: 写入路径 P0 优化
|
||||
|
||||
- **Description:** 移除心跳路径同步 `ALTER DELETE`;为 `batchwriter` 增加 `MinBatchSize`;调大可观测 writer 批次与 flush 间隔;为 openresty/frps/frpc 补全去重。
|
||||
- **Files/components affected:** `internal/apps/openflare/agent/observability.go`, `internal/infra/persistence/batchwriter/`, `internal/apps/openflare/chwriter/`, `internal/infra/persistence/batchwriter/*_test.go`
|
||||
- **Dependencies:** None
|
||||
|
||||
### PR 2: ClickHouse 客户端与配置 P1
|
||||
|
||||
- **Description:** 启用 `async_insert` 等写入优化 settings;提高 `block_buffer_size` 默认值;更新 `config.example.yaml` 与配置模型注释。
|
||||
- **Files/components affected:** `internal/infra/persistence/clickhouse.go`, `internal/infra/config/model.go`, `internal/infra/config/config.go`, `config.example.yaml`
|
||||
- **Dependencies:** None
|
||||
|
||||
### PR 3: Dashboard 与可观测查询 P0
|
||||
|
||||
- **Description:** 消除 `limit=0` 无界查询;复用已有限制数据构建趋势;增加服务端短 TTL 缓存;降低前端轮询频率。
|
||||
- **Files/components affected:** `internal/apps/openflare/dashboard/logics.go`, `internal/apps/openflare/observability/node_logics.go`, `frontend/app/(main)/page.tsx`, `frontend/app/(main)/nodes/components/node-observability.tsx`
|
||||
- **Dependencies:** None
|
||||
|
||||
### PR 4: 访问日志与 WAF 查询 P0/P1
|
||||
|
||||
- **Description:** WAF IP 同步改为 ClickHouse 侧聚合;IP 汇总与折叠日志 SQL 分页;消除 count 重复全量扫描;列表 API 强制默认时间窗口。
|
||||
- **Files/components affected:** `internal/apps/openflare/waf/ip_group_sync.go`, `internal/repository/analytics/node_access_log_stats.go`, `internal/model/openflare_access_log.go`, `internal/apps/openflare/observability/access_log_logics.go`, `internal/repository/analytics/access_log_stats.go`
|
||||
- **Dependencies:** None
|
||||
|
||||
### PR 5: ClickHouse DDL 与数据规范化 P1
|
||||
|
||||
- **Description:** 为 7 张分析表添加 TTL;收窄 `of_node_access_logs` ORDER BY;插入时规范化 `remote_addr`(去 trim 查询);将可观测 obs 三表纳入自动清理。
|
||||
- **Files/components affected:** `internal/infra/persistence/migrator/goose/clickhouse/`, `internal/repository/analytics/node_access_log_writer.go`, `internal/apps/openflare/tasks/database_cleanup.go`, `internal/model/analytics/`
|
||||
- **Dependencies:** PR 1
|
||||
|
||||
### PR 6: 基础设施与审计减负 P2
|
||||
|
||||
- **Description:** Docker ClickHouse 服务端基础调优;审计日志 headers 截断/精简;更新 changelog。
|
||||
- **Files/components affected:** `docker-compose.yaml`, `docker/clickhouse/` (if needed), `internal/apps/risk_control/middleware.go`, `docs/changelog/index.md`
|
||||
- **Dependencies:** None
|
||||
@@ -1,32 +0,0 @@
|
||||
# AI 接手计划模板
|
||||
|
||||
说明:本模板用于在 AI 代理上下文发生截断、压缩(Compaction)或将任务转移给另一个 AI 代理时使用,帮助新接手的 AI 快速恢复 100% 的工作状态。
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前任务状态 (Current Status)
|
||||
* **主线任务描述**:用一句话说清楚当前正在解决的核心问题。
|
||||
* **开发分支/提交**:记录当前的工作目录、修改的未暂存文件、或 Git 临时分支名。
|
||||
* **已完成内容 (Completed)**:
|
||||
- [x] 功能 A 后端接口及单测
|
||||
- [x] 前端面板表单组件
|
||||
- **进行中内容 (In Progress)**:
|
||||
- [/] 配置文件渲染与重写模块
|
||||
- **待处理内容 (To Do)**:
|
||||
- [ ] 边缘节点同步下载与校验落地
|
||||
- [ ] 发布功能整体连通性验证
|
||||
|
||||
## 2. 核心文件与上下文 (Key Files & Context)
|
||||
列出与当前开发高度相关的核心文件以及需要注意的特殊背景:
|
||||
* `file:///path/to/core_file.go#L100-L150`:此处负责...,修改时需要注意...
|
||||
* `file:///path/to/frontend_component.tsx`:用于展现...
|
||||
|
||||
## 3. 待决策与遗留问题 (Outstanding Decisions & Issues)
|
||||
* [ ] **疑问/阻塞点**:是否需要支持某某场景?目前是如何兜底处理的?
|
||||
* [ ] **异常与缺陷**:单测 `./controller/...` 运行时目前有 1 个 Fail,失败原因为...
|
||||
|
||||
## 4. 下一步行动指南 (Next Steps)
|
||||
新接手 AI 进来后应当立即执行的前 3 步命令或编辑操作:
|
||||
1. **第一步**:执行 `go test ./controller/...` 确认环境并复现 Fail 异常。
|
||||
2. **第二步**:修改 `openflare-server/internal/controller/xxx.go` 中的逻辑以修复该 Fail。
|
||||
3. **第三步**:在管理端前端页面调试 xxx 表单的提交是否正常。
|
||||
@@ -1,47 +0,0 @@
|
||||
# 功能开发实现计划模板
|
||||
|
||||
说明:本模板用于指导新特性或重大模块开发前的技术规划,明确需求、范围与设计决策。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与背景 (Goal & Context)
|
||||
* **需求背景**:说明为什么要开发这个特性,解决什么业务痛点或安全隐患。
|
||||
* **开发范围 (Scope)**:明确 V1 阶段的核心交付指标。哪些是本次必做的,哪些是留到后续迭代的(Out of Scope)。
|
||||
|
||||
## 2. 设计与决策决策 (Design & Decisions)
|
||||
* **核心对象/数据模型**:
|
||||
* 说明是否需要修改或新增数据库表(Gorm 结构体、Migration SQL,包括新增字段与关联)。
|
||||
* **API 与鉴权设计**:
|
||||
* 详细定义新增的 REST API 路由、请求载荷(Payload JSON)与响应格式。
|
||||
* **数据流与架构图**:
|
||||
* 使用 Mermaid 绘制数据或控制流的流向。
|
||||
* **设计决策权衡**:
|
||||
* 记录为何选用方案 A 而非方案 B。
|
||||
|
||||
## 3. 具体修改文件清单 (Proposed Changes)
|
||||
按模块或组件列出需要修改的物理文件路径及修改点:
|
||||
|
||||
### 后端 Server
|
||||
* #### [NEW] `openflare-server/internal/model/entity.go`
|
||||
* 职责:...
|
||||
* #### [MODIFY] `openflare-server/internal/service/feature.go`
|
||||
* 职责:...
|
||||
|
||||
### 边缘 Agent 与 OpenResty
|
||||
* #### [MODIFY] `openflare-agent/sync/sync.go`
|
||||
* 职责:...
|
||||
|
||||
### 前端 Web
|
||||
* #### [NEW] `openflare-server/web/features/feature-view.tsx`
|
||||
* 职责:...
|
||||
|
||||
---
|
||||
|
||||
## 4. 验证计划 (Verification Plan)
|
||||
|
||||
### 自动化单元测试
|
||||
* 运行的单测命令,如:`go test -v ./service/...`
|
||||
|
||||
### 数据面重载与生效验证
|
||||
* 说明如何验证新配置在数据面落地。
|
||||
* 提供验证测试的 `curl` 指令或手动操作路径。
|
||||
@@ -1,38 +0,0 @@
|
||||
# 开发计划与 AI 接手
|
||||
|
||||
本分区用于存放正在进行的开发计划(Plan)以及 AI 代理之间的工作接手计划(Handover)。这能帮助不同的 AI 代理快速掌握当前项目状态、历史上下文与后续开发步骤。
|
||||
|
||||
## 计划模板
|
||||
|
||||
在创建具体的开发计划或接手文档时,请使用以下标准模板进行初始化:
|
||||
|
||||
1. **[实现计划模板](./implementation-plan-template.md)**:用于新功能开发或重大重构前的技术方案规划。
|
||||
2. **[AI 接手计划模板](./handover-plan-template.md)**:用于在上下文截断、压缩或更换 AI 代理时,记录当前任务状态、已完成内容与下一步执行计划。
|
||||
|
||||
## 正在进行的计划
|
||||
|
||||
当前进行中的开发计划:
|
||||
|
||||
* [源站错误页](./20260806-origin-error-page.md):全局可配置错误页(默认 500-599、CF 风格 HTML、状态码透传);Option + 配置版本 + OpenResty error_page。
|
||||
* [Zone 与域名资源重构](./20260712-zone-domain-refactor.md):以 Zone 和正规化 Zone 域名替代托管域名及反代路由中的域名/证书冗余字段。
|
||||
* [WAF 可编排规则](./20260713-waf-orchestration.md):使用 React Flow 编辑 DAG 规则,发布时编译并由 OpenResty 纯内存执行。
|
||||
* [边缘可观测与业务流量统计重构](./20260717-observability-redesign.md):访问日志为业务唯一真相;Agent 只上报明细与主机读数;收敛「出站/已提供」双字段。
|
||||
* [访问日志 cache_status 明细可见](./20260718-access-log-cache-status.md):上报 `$upstream_cache_status`,明细展示命中/回源/未缓存三态。
|
||||
* [边缘缓存默认 static 策略](./20260718-edge-cache-static-default.md):开启缓存默认仅静态扩展名;存量 url→all。
|
||||
* [访问日志 IP 明细 Tab](./20260719-access-log-ip-tab.md):第三 Tab 按 IP 聚合列表(时间窗/流量/2xx 比例);IP 情报迁入独立详情;日志详情仅请求字段。
|
||||
* [边缘限流全局默认](./20260719-http-default-rate-limit.md):全局默认并发/带宽;站点 0 继承、-1 关闭;RenderRouteConfig 合并。
|
||||
* [边缘缓存对齐 Cloudflare 默认模型](./20260723-edge-cache-cf-align.md):删除过严请求旁路;Set-Cookie 不入库;默认 Edge TTL;扩展名去 json。
|
||||
|
||||
## 已完成的计划
|
||||
|
||||
* [Cloudflare DNS 指向](./20260804-cloudflare-pointing.md):已完成连接配置、分组与成员管理、单 A 记录幂等同步、节点 IP 变化联动、异步任务和管理页面。
|
||||
|
||||
* [model / repository 分层治理](./20260724-model-repository-layering.md):model 无 IO;repository 唯一持久化;已完成 OpenFlare/平台 CRUD 迁入 repository。
|
||||
|
||||
* [Pages 项目部署源与 GitHub Releases 自动更新 V2](./20260719-pages-source-sync-v2.md):已完成 Remote URL / GitHub Release 来源、不可变部署、自动检查更新与安全回滚,并预留独立仓库构建 Provider 边界;生产环境验收边界见计划内验证记录。
|
||||
|
||||
## 使用建议
|
||||
|
||||
* **命名规范**:正在进行的开发计划建议命名为 `docs/plan/YYYYMMDD-[feature-name].md`,接手计划建议命名为 `docs/plan/handover-[task-name].md`。
|
||||
* **物理隔离**:本目录下的计划文件只在开发周期内进行更新。当对应功能开发完毕并上线后,相应的计划文档应予以保留或归档,以供日后维护与新 AI 追溯历史决策。
|
||||
* **禁止空文件**:请确保新创建的计划文档均基于对应的模板进行初始化填充。
|
||||
@@ -1,709 +0,0 @@
|
||||
# 边缘限流全局默认 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为边缘限流增加三项全局默认;站点 `0`/空继承默认、`-1` 显式关闭、`>0` 覆盖;在 `RenderRouteConfig` 唯一合并。
|
||||
|
||||
**Architecture:** 全局默认存 `system_configs`,进入 `openresty_config` 快照;站点字段语义变更后仍原样入库与快照;`pkg/render/openresty.RenderRouteConfig` 用 `doc.OpenRestyConfig` 与 route 字段合并后输出 location 指令。UI:安全性下新页「限流」+ 站点限流文案更新。
|
||||
|
||||
**Tech Stack:** Go、goose SQL、Option API、`pkg/render/openresty`、Next.js、OptionService
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-07-19-http-default-rate-limit-design.md](../specs/2026-07-19-http-default-rate-limit-design.md)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 合并**只**在 `RenderRouteConfig`;快照保留站点原始值(含 `0`/`-1`)
|
||||
- 不引入 `limit_req`;不在 `http {}` 写默认 `limit_conn`/`limit_rate`
|
||||
- 全局默认初始 `0`/空 → 存量行为不变
|
||||
- 完成后 `make code-check`;改前端后 `make prettier`;中文 changelog;不写英文文档
|
||||
- 所有 HTTP 路由仍只在 `internal/router/router.go` 委派(本功能复用 Option API,无需新业务路由)
|
||||
|
||||
## File map
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `internal/model/system_configs.go` | 三个 ConfigKey 常量 |
|
||||
| `internal/infra/persistence/migrator/goose/{postgres,sqlite}/202607190001_add_openresty_default_rate_limits.sql` | seed 默认值 |
|
||||
| `internal/apps/openflare/option/openresty_validators.go` + `validate.go` | 全局默认校验 |
|
||||
| `internal/apps/openflare/config_version/snapshot.go` | 快照字段 + 读取 |
|
||||
| `internal/apps/openflare/config_version/logics.go` | option diff keys |
|
||||
| `pkg/render/openresty/types.go` | `ConfigSnapshot` 三字段 |
|
||||
| `pkg/render/openresty/render.go` | `mergeRouteLimit*` + 调用点 |
|
||||
| `pkg/render/openresty/render_test.go` | 合并渲染单测 |
|
||||
| `internal/apps/openflare/proxy_route/helpers.go` | 站点 normalize 允许 -1 |
|
||||
| `frontend/lib/navigation/openflare-nav.ts` | 安全性子菜单 |
|
||||
| `frontend/app/(main)/rate-limits/page.tsx` | 全局限流设置页 |
|
||||
| `frontend/app/(main)/proxy-routes/.../limits-section.tsx` + helpers | 站点语义 UI |
|
||||
| `frontend/lib/utils/search-data.ts` | 搜索入口 |
|
||||
| `docs/reference/configuration.md` | 配置键说明 |
|
||||
| `docs/changelog/index.md` | Unreleased |
|
||||
| `docs/plan/index.md` | 进行中计划索引 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Render 合并(TDD 核心)
|
||||
|
||||
**Files:**
|
||||
- Modify: `pkg/render/openresty/types.go` (`ConfigSnapshot`)
|
||||
- Modify: `pkg/render/openresty/render.go`
|
||||
- Test: `pkg/render/openresty/render_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ConfigSnapshot` 字段 `DefaultLimitConnPerServer int`, `DefaultLimitConnPerIP int`, `DefaultLimitRate string`(json: `default_limit_conn_per_server` 等)
|
||||
- Produces: `mergeRouteLimitConfig(route Route, cfg ConfigSnapshot) routeLimitConfig`
|
||||
- Produces: `mergeLimitConn(route, def int) int`, `mergeLimitRate(route, def string) string`
|
||||
|
||||
- [ ] **Step 1: 写失败单测**
|
||||
|
||||
在 `render_test.go` 末尾追加:
|
||||
|
||||
```go
|
||||
func TestMergeRouteLimitConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
route Route
|
||||
cfg ConfigSnapshot
|
||||
want routeLimitConfig
|
||||
}{
|
||||
{
|
||||
name: "both zero off",
|
||||
route: Route{},
|
||||
cfg: ConfigSnapshot{},
|
||||
want: routeLimitConfig{},
|
||||
},
|
||||
{
|
||||
name: "inherit all defaults",
|
||||
route: Route{},
|
||||
cfg: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 100,
|
||||
DefaultLimitConnPerIP: 10,
|
||||
DefaultLimitRate: "512k",
|
||||
},
|
||||
want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 10, LimitRate: "512k"},
|
||||
},
|
||||
{
|
||||
name: "explicit off ignores default",
|
||||
route: Route{LimitConnPerServer: -1, LimitConnPerIP: -1, LimitRate: "-1"},
|
||||
cfg: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 100,
|
||||
DefaultLimitConnPerIP: 10,
|
||||
DefaultLimitRate: "512k",
|
||||
},
|
||||
want: routeLimitConfig{},
|
||||
},
|
||||
{
|
||||
name: "route overrides default",
|
||||
route: Route{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m"},
|
||||
cfg: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 100,
|
||||
DefaultLimitConnPerIP: 10,
|
||||
DefaultLimitRate: "512k",
|
||||
},
|
||||
want: routeLimitConfig{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m"},
|
||||
},
|
||||
{
|
||||
name: "partial inherit",
|
||||
route: Route{LimitConnPerServer: 0, LimitConnPerIP: -1, LimitRate: ""},
|
||||
cfg: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 100,
|
||||
DefaultLimitConnPerIP: 10,
|
||||
DefaultLimitRate: "256k",
|
||||
},
|
||||
want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 0, LimitRate: "256k"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := mergeRouteLimitConfig(tc.route, tc.cfg)
|
||||
if got != tc.want {
|
||||
t.Fatalf("mergeRouteLimitConfig() = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRouteConfigAppliesDefaultLimits(t *testing.T) {
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
SiteName: "example.com",
|
||||
Domains: []string{"example.com"},
|
||||
Enabled: true,
|
||||
OriginURL: "http://127.0.0.1:8080",
|
||||
Upstreams: []string{"http://127.0.0.1:8080"},
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 120,
|
||||
DefaultLimitConnPerIP: 12,
|
||||
DefaultLimitRate: "512k",
|
||||
},
|
||||
}
|
||||
rendered, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRouteConfig() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"limit_conn openflare_conn_per_server 120;",
|
||||
"limit_conn openflare_conn_per_ip 12;",
|
||||
"limit_rate 512k;",
|
||||
} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("expected %q in route config, got:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRouteConfigExplicitOffSkipsDefaultLimits(t *testing.T) {
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
SiteName: "example.com",
|
||||
Domains: []string{"example.com"},
|
||||
Enabled: true,
|
||||
OriginURL: "http://127.0.0.1:8080",
|
||||
Upstreams: []string{"http://127.0.0.1:8080"},
|
||||
LimitConnPerServer: -1,
|
||||
LimitConnPerIP: -1,
|
||||
LimitRate: "-1",
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 120,
|
||||
DefaultLimitConnPerIP: 12,
|
||||
DefaultLimitRate: "512k",
|
||||
},
|
||||
}
|
||||
rendered, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRouteConfig() error = %v", err)
|
||||
}
|
||||
if strings.Contains(rendered, "limit_conn") || strings.Contains(rendered, "limit_rate") {
|
||||
t.Fatalf("expected no limit directives, got:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败**
|
||||
|
||||
```bash
|
||||
go test ./pkg/render/openresty/ -run 'TestMergeRouteLimitConfig|TestRenderRouteConfigAppliesDefaultLimits|TestRenderRouteConfigExplicitOffSkipsDefaultLimits' -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL(`mergeRouteLimitConfig` undefined 或行为不符)
|
||||
|
||||
- [ ] **Step 3: 实现 types + merge + 调用**
|
||||
|
||||
`ConfigSnapshot` 增加:
|
||||
|
||||
```go
|
||||
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
|
||||
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
|
||||
DefaultLimitRate string `json:"default_limit_rate,omitempty"`
|
||||
```
|
||||
|
||||
`render.go` 中 `RenderRouteConfig` 将:
|
||||
|
||||
```go
|
||||
limitConfig := routeLimitConfig{LimitConnPerServer: route.LimitConnPerServer, LimitConnPerIP: route.LimitConnPerIP, LimitRate: route.LimitRate}
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```go
|
||||
limitConfig := mergeRouteLimitConfig(route, doc.OpenRestyConfig)
|
||||
```
|
||||
|
||||
并新增:
|
||||
|
||||
```go
|
||||
func mergeRouteLimitConfig(route Route, cfg ConfigSnapshot) routeLimitConfig {
|
||||
return routeLimitConfig{
|
||||
LimitConnPerServer: mergeLimitConn(route.LimitConnPerServer, cfg.DefaultLimitConnPerServer),
|
||||
LimitConnPerIP: mergeLimitConn(route.LimitConnPerIP, cfg.DefaultLimitConnPerIP),
|
||||
LimitRate: mergeLimitRate(route.LimitRate, cfg.DefaultLimitRate),
|
||||
}
|
||||
}
|
||||
|
||||
func mergeLimitConn(route, def int) int {
|
||||
if route == -1 {
|
||||
return 0
|
||||
}
|
||||
if route > 0 {
|
||||
return route
|
||||
}
|
||||
if def > 0 {
|
||||
return def
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func mergeLimitRate(route, def string) string {
|
||||
r := strings.ToLower(strings.TrimSpace(route))
|
||||
if r == "-1" {
|
||||
return ""
|
||||
}
|
||||
if r != "" && r != "0" {
|
||||
return r
|
||||
}
|
||||
d := strings.ToLower(strings.TrimSpace(def))
|
||||
if d != "" && d != "0" {
|
||||
return d
|
||||
}
|
||||
return ""
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测通过**
|
||||
|
||||
```bash
|
||||
go test ./pkg/render/openresty/ -count=1
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add pkg/render/openresty/types.go pkg/render/openresty/render.go pkg/render/openresty/render_test.go
|
||||
git commit -m "feat(openresty): merge global default limits at route render"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 配置键、迁移、校验、快照
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/model/system_configs.go`
|
||||
- Create: `internal/infra/persistence/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql`
|
||||
- Create: `internal/infra/persistence/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql`
|
||||
- Modify: `internal/apps/openflare/option/validate.go`
|
||||
- Modify: `internal/apps/openflare/option/openresty_validators.go`
|
||||
- Modify: `internal/apps/openflare/config_version/snapshot.go`
|
||||
- Modify: `internal/apps/openflare/config_version/logics.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 的 `ConfigSnapshot` JSON 字段名
|
||||
- Produces: `ConfigKeyOpenRestyDefaultLimitConnPerServer` 等三常量;snapshot 填充;diff 可见
|
||||
|
||||
- [ ] **Step 1: 常量**
|
||||
|
||||
在 `system_configs.go` OpenResty 段末尾(`MainConfigTemplate` 前或后)加入:
|
||||
|
||||
```go
|
||||
ConfigKeyOpenRestyDefaultLimitConnPerServer = "openresty_default_limit_conn_per_server" // 默认站点并发连接
|
||||
ConfigKeyOpenRestyDefaultLimitConnPerIP = "openresty_default_limit_conn_per_ip" // 默认单 IP 并发连接
|
||||
ConfigKeyOpenRestyDefaultLimitRate = "openresty_default_limit_rate" // 默认单请求带宽
|
||||
```
|
||||
|
||||
- [ ] **Step 2: goose 迁移(PG + SQLite 同内容)**
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES
|
||||
('openresty_default_limit_conn_per_server', '0', 'business', 0, '默认站点并发连接上限(0 关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('openresty_default_limit_conn_per_ip', '0', 'business', 0, '默认单 IP 并发连接上限(0 关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('openresty_default_limit_rate', '', 'business', 0, '默认单请求带宽限速(空关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_system_configs WHERE key IN (
|
||||
'openresty_default_limit_conn_per_server',
|
||||
'openresty_default_limit_conn_per_ip',
|
||||
'openresty_default_limit_rate'
|
||||
);
|
||||
```
|
||||
|
||||
SQLite:若项目其它 seed 不用 `ON CONFLICT`,对照 `202607170001_add_pages_system_configs.sql` 的 sqlite twin 写法保持一致(通常可同用 `ON CONFLICT (key) DO NOTHING`)。
|
||||
|
||||
- [ ] **Step 3: 校验器**
|
||||
|
||||
`validate.go` 增加:
|
||||
|
||||
```go
|
||||
func validateNonNegativeIntegerOption(key, value string) error {
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil || intValue < 0 {
|
||||
return fmt.Errorf("%s 必须为大于等于 0 的整数", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
`openresty_validators.go` 注册:
|
||||
|
||||
```go
|
||||
model.ConfigKeyOpenRestyDefaultLimitConnPerServer: validateNonNegativeIntegerOption,
|
||||
model.ConfigKeyOpenRestyDefaultLimitConnPerIP: validateNonNegativeIntegerOption,
|
||||
model.ConfigKeyOpenRestyDefaultLimitRate: validateOpenRestyDefaultLimitRate,
|
||||
```
|
||||
|
||||
```go
|
||||
var openRestyDefaultLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`)
|
||||
|
||||
func validateOpenRestyDefaultLimitRate(key, trimmed string) error {
|
||||
if trimmed == "" || trimmed == "0" {
|
||||
return nil
|
||||
}
|
||||
if !openRestyDefaultLimitRatePattern.MatchString(strings.ToLower(trimmed)) {
|
||||
return fmt.Errorf("%s 格式不合法,请使用 512k、1m 或纯数字,空表示关闭", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 快照读取(注意 0 合法)**
|
||||
|
||||
`openRestyConfigSnapshot` 与 `buildOpenRestyConfigSnapshot` 增加三字段。
|
||||
|
||||
**禁止**对这三项使用现有 `getIntConfig`(其 `val <= 0` 会把合法 `0` 与错误混在一起;虽 default=0 时偶然正确,但语义不清)。改为:
|
||||
|
||||
```go
|
||||
getNonNegIntConfig := func(key string, defaultVal int) int {
|
||||
val, err := repository.GetIntByKey(ctx, key)
|
||||
if err != nil || val < 0 {
|
||||
return defaultVal
|
||||
}
|
||||
return val
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
DefaultLimitConnPerServer: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerServer, 0),
|
||||
DefaultLimitConnPerIP: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerIP, 0),
|
||||
DefaultLimitRate: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitRate, ""))),
|
||||
```
|
||||
|
||||
若 `DefaultLimitRate == "0"`,规范化为 `""`。
|
||||
|
||||
确认 snapshot → render JSON 字段名与 `openrestyrender.ConfigSnapshot` 一致(`snapshotDocument` 序列化后由 `RenderJSON` 反序列化到 render types)。`openRestyConfigSnapshot` 的 json tag 必须与 `ConfigSnapshot` 对齐:
|
||||
|
||||
```go
|
||||
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
|
||||
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
|
||||
DefaultLimitRate string `json:"default_limit_rate,omitempty"`
|
||||
```
|
||||
|
||||
- [ ] **Step 5: option diff**
|
||||
|
||||
在 `diffOpenRestyOptionDetails` 末尾:
|
||||
|
||||
```go
|
||||
appendIfChanged("OpenRestyDefaultLimitConnPerServer", fmt.Sprintf("%d", left.DefaultLimitConnPerServer), fmt.Sprintf("%d", right.DefaultLimitConnPerServer))
|
||||
appendIfChanged("OpenRestyDefaultLimitConnPerIP", fmt.Sprintf("%d", left.DefaultLimitConnPerIP), fmt.Sprintf("%d", right.DefaultLimitConnPerIP))
|
||||
appendIfChanged("OpenRestyDefaultLimitRate", left.DefaultLimitRate, right.DefaultLimitRate)
|
||||
```
|
||||
|
||||
`openRestyOptionKeys()` 同步追加这三 key 字符串。
|
||||
|
||||
- [ ] **Step 6: 编译/相关测试**
|
||||
|
||||
```bash
|
||||
go test ./internal/apps/openflare/config_version/ ./internal/apps/openflare/option/ ./pkg/render/openresty/ -count=1
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/model/system_configs.go \
|
||||
internal/infra/persistence/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql \
|
||||
internal/infra/persistence/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql \
|
||||
internal/apps/openflare/option/validate.go \
|
||||
internal/apps/openflare/option/openresty_validators.go \
|
||||
internal/apps/openflare/config_version/snapshot.go \
|
||||
internal/apps/openflare/config_version/logics.go
|
||||
git commit -m "feat(config): add openresty default rate limit system options"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 站点 normalize 允许 -1
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/apps/openflare/proxy_route/helpers.go`
|
||||
- Modify: `internal/apps/openflare/proxy_route/errs.go`(如需更新文案)
|
||||
- Test: 若无现成 helpers 测试文件则新建 `helpers_limit_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `normalizeProxyRouteLimitConnValue` 允许 `>= -1`;`normalizeProxyRouteLimitRate` 允许 `"-1"`
|
||||
|
||||
- [ ] **Step 1: 失败单测**
|
||||
|
||||
```go
|
||||
func TestNormalizeProxyRouteLimitConnValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := normalizeProxyRouteLimitConnValue(-1, "limit_conn_per_server")
|
||||
if err != nil || got != -1 {
|
||||
t.Fatalf("want -1, got %d err %v", got, err)
|
||||
}
|
||||
if _, err := normalizeProxyRouteLimitConnValue(-2, "limit_conn_per_server"); err == nil {
|
||||
t.Fatal("expected error for -2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProxyRouteLimitRate(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := normalizeProxyRouteLimitRate("-1")
|
||||
if err != nil || got != "-1" {
|
||||
t.Fatalf("want -1, got %q err %v", got, err)
|
||||
}
|
||||
got, err = normalizeProxyRouteLimitRate("0")
|
||||
if err != nil || got != "" {
|
||||
t.Fatalf("want empty inherit, got %q err %v", got, err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现**
|
||||
|
||||
```go
|
||||
func normalizeProxyRouteLimitConnValue(value int, field string) (int, error) {
|
||||
if value < -1 {
|
||||
return 0, fmt.Errorf("%s must be greater than or equal to -1", field)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func normalizeProxyRouteLimitRate(raw string) (string, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(raw))
|
||||
if normalized == "" || normalized == "0" {
|
||||
return "", nil
|
||||
}
|
||||
if normalized == "-1" {
|
||||
return "-1", nil
|
||||
}
|
||||
if !proxyRouteLimitRatePattern.MatchString(normalized) {
|
||||
return "", errors.New(errProxyRouteLimitRate)
|
||||
}
|
||||
if strings.TrimRight(normalized, "km") == "" {
|
||||
return "", nil
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
```
|
||||
|
||||
可选:`errProxyRouteLimitRate` 文案追加「或 -1 表示关闭」。
|
||||
|
||||
- [ ] **Step 3: 测试**
|
||||
|
||||
```bash
|
||||
go test ./internal/apps/openflare/proxy_route/ -count=1
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/apps/openflare/proxy_route/
|
||||
git commit -m "feat(proxy-route): allow -1 to disable rate limits"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 前端 — 安全性「限流」页 + 站点文案
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/lib/navigation/openflare-nav.ts`
|
||||
- Create: `frontend/app/(main)/rate-limits/page.tsx`
|
||||
- Modify: `frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx`
|
||||
- Modify: `frontend/app/(main)/proxy-routes/components/helpers.ts`
|
||||
- Modify: `frontend/lib/utils/search-data.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Option keys 字面量 `openresty_default_limit_conn_per_server` 等
|
||||
- Produces: `/rate-limits` 管理页;站点表单接受 `-1`
|
||||
|
||||
- [ ] **Step 1: 导航**
|
||||
|
||||
`openflareSecurityNavGroup.items`:
|
||||
|
||||
```ts
|
||||
{ title: 'WAF', url: '/waf' },
|
||||
{ title: 'IP 组', url: '/ip-groups' },
|
||||
{ title: '限流', url: '/rate-limits' },
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 搜索**
|
||||
|
||||
`search-data.ts` 在 IP 组后增加:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'console-rate-limits',
|
||||
title: '限流',
|
||||
description: '配置边缘站点默认并发与带宽限流策略',
|
||||
url: '/rate-limits',
|
||||
category: 'page',
|
||||
keywords: ['限流', 'rate limit', 'limit_conn', 'limit_rate', '并发', '带宽'],
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 限流设置页**
|
||||
|
||||
新建 `frontend/app/(main)/rate-limits/page.tsx`,模式对齐 `performance/page.tsx`:
|
||||
|
||||
- `useAuth` 管理员校验
|
||||
- `OptionService.list` / `updateBatch`
|
||||
- 三字段表单 + 单卡片保存
|
||||
- 标题:`Shield` 或 `Gauge` 图标 + `h1`「限流」
|
||||
- 描述:空/0 表示默认关闭;修改后需在版本发布中生效
|
||||
- keys:
|
||||
- `openresty_default_limit_conn_per_server`
|
||||
- `openresty_default_limit_conn_per_ip`
|
||||
- `openresty_default_limit_rate`
|
||||
- conn:非负整数;rate:空或 `^\d+[kKmM]?$`
|
||||
- 保存成功 toast + invalidate options / config-preview / config-versions
|
||||
- 链到 `/config-versions`
|
||||
|
||||
页面骨架要点(完整实现时展开为完整组件,勿留半成品):
|
||||
|
||||
```tsx
|
||||
// 字段 state、OptionService.list map、updateBatch([{key,value},...])
|
||||
// 文案:「0 或空表示默认关闭;站点未单独配置时继承此处设置。」
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 站点 limits-section**
|
||||
|
||||
1. schema:conn 允许空、`0`、`-1`、正整数:
|
||||
|
||||
```ts
|
||||
if (!rawValue) continue;
|
||||
if (!/^-1$|^\d+$/.test(rawValue)) {
|
||||
context.addIssue({ ..., message: '请输入 -1、0 或正整数' });
|
||||
}
|
||||
```
|
||||
|
||||
2. `validateLimitRate` / `normalizeLimitRate`:
|
||||
|
||||
```ts
|
||||
export function validateLimitRate(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized === '0' || normalized === '-1') {
|
||||
return null;
|
||||
}
|
||||
if (!limitRatePattern.test(normalized)) {
|
||||
return '限速格式不合法,请使用 512k、1m、纯数字,或 -1 关闭';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeLimitRate(value: string) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === '0') return '';
|
||||
return normalized; // 保留 -1
|
||||
}
|
||||
```
|
||||
|
||||
3. 表单展示:`-1` 需显示为 `'-1'`(注意 `route.limit_conn_per_server ? String : ''` 对 `-1` 已为 truthy;对 `0` 仍为空)
|
||||
|
||||
4. 提交:空 → `0`;`-1` → `-1`;正数 → 数字
|
||||
|
||||
5. 文案:
|
||||
|
||||
```
|
||||
description='站点限流。空或 0 继承全局默认;-1 显式关闭;大于 0 为自定义。'
|
||||
FormDescription 同步说明
|
||||
```
|
||||
|
||||
6. 侧栏「流量限制」section description 可改为:`设置连接数和限速(可继承全局默认)。`
|
||||
|
||||
- [ ] **Step 5: prettier + 类型检查(按项目习惯)**
|
||||
|
||||
```bash
|
||||
make prettier
|
||||
# 若有前端 typecheck:
|
||||
# cd frontend && pnpm exec tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/lib/navigation/openflare-nav.ts \
|
||||
frontend/app/\(main\)/rate-limits/ \
|
||||
frontend/app/\(main\)/proxy-routes/detail/components/limits-section.tsx \
|
||||
frontend/app/\(main\)/proxy-routes/components/helpers.ts \
|
||||
frontend/lib/utils/search-data.ts
|
||||
git commit -m "feat(frontend): add security rate-limits page and inherit UI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 文档、索引、门禁
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/reference/configuration.md`(OpenResty 配置表)
|
||||
- Modify: `docs/changelog/index.md` `[unreleased]`
|
||||
- Modify: `docs/plan/index.md`
|
||||
|
||||
- [ ] **Step 1: configuration.md**
|
||||
|
||||
在 `openresty_cache_use_stale` 与 `openresty_main_config_template` 之间插入:
|
||||
|
||||
```md
|
||||
| `openresty_default_limit_conn_per_server` | `int` | 站点未配置时的默认并发连接上限;`0` 表示默认关闭 | `0` |
|
||||
| `openresty_default_limit_conn_per_ip` | `int` | 站点未配置时的默认单 IP 并发上限;`0` 表示默认关闭 | `0` |
|
||||
| `openresty_default_limit_rate` | `string` | 站点未配置时的默认单请求带宽(如 `512k`);空表示默认关闭 | 空 |
|
||||
```
|
||||
|
||||
- [ ] **Step 2: changelog**
|
||||
|
||||
`[unreleased]` 下:
|
||||
|
||||
```md
|
||||
### 新增
|
||||
|
||||
- 安全性新增「限流」设置:可为边缘站点配置默认并发与带宽;站点未设置时继承,填 `-1` 可显式关闭。
|
||||
|
||||
### 改进
|
||||
|
||||
- 站点流量限制语义调整为空或 `0` 继承全局默认、`-1` 关闭、大于 `0` 自定义;修改全局默认后需发布配置版本生效。
|
||||
```
|
||||
|
||||
- [ ] **Step 3: plan index**
|
||||
|
||||
`docs/plan/index.md` 进行中列表增加:
|
||||
|
||||
```md
|
||||
* [边缘限流全局默认](../superpowers/plans/2026-07-19-http-default-rate-limit.md):http/全局默认限流,站点 0 继承、-1 关闭。
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 全量门禁**
|
||||
|
||||
```bash
|
||||
make code-check
|
||||
make prettier
|
||||
```
|
||||
|
||||
Expected: 通过;修复任何报错后再提交。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/reference/configuration.md docs/changelog/index.md docs/plan/index.md
|
||||
git commit -m "docs: document default edge rate limits"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage checklist
|
||||
|
||||
| Spec 要求 | Task |
|
||||
|-----------|------|
|
||||
| 三项全局默认 | 2, 4 |
|
||||
| 0/空继承、-1 关、>0 覆盖 | 1, 3, 4 |
|
||||
| 仅 `RenderRouteConfig` 合并 | 1 |
|
||||
| 快照保留原始站点值 | 2(不写回 route) |
|
||||
| 安全性子页「限流」 | 4 |
|
||||
| 初始 0/空兼容 | 2 seed |
|
||||
| option diff / 发布 | 2 |
|
||||
| 测试合并/normalize | 1, 3 |
|
||||
| 中文文档/changelog | 5 |
|
||||
| 非目标 limit_req / http 级指令 | 未做 |
|
||||
|
||||
## 手动验收
|
||||
|
||||
1. 迁移后三键存在且为 `0`/空
|
||||
2. 安全性 → 限流 设置 `120` / `12` / `512k` 并保存
|
||||
3. 版本发布预览:未配置站点的 location 出现对应 `limit_conn`/`limit_rate`
|
||||
4. 站点将该项改为 `-1` 保存并发布:该维度指令消失
|
||||
5. 站点改为 `50`:输出 50 而非全局值
|
||||
@@ -1,388 +0,0 @@
|
||||
# WAF Editor Node Label + Drag-Add Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let users rename WAF rule nodes via optional `label`, and add nodes by dragging from the library onto the canvas drop position (no click-to-fixed-offset).
|
||||
|
||||
**Architecture:** Frontend-only. Align TS `WAFRuleNode` with backend `label`. Pure helpers for display name and default node factory. Node library is drag source; React Flow pane handles drop with `screenToFlowPosition`. Properties panel edits `label` for non-system nodes.
|
||||
|
||||
**Tech Stack:** Next.js App Router, React, TypeScript, `@xyflow/react`, Vitest + Testing Library, shadcn/ui.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-19-waf-editor-node-label-drag-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No backend / schema_version / note field changes.
|
||||
- System nodes `start` / `allow`: no rename UI.
|
||||
- New nodes: no default `label` (type name shown).
|
||||
- Drag-only add; remove click-add.
|
||||
- After code: relevant vitest pass; run `make prettier` / `make code-check` if touching repo gates.
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `frontend/lib/services/openflare/types.ts` | Add `label?: string` to all `WAFRuleNode` variants |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-factory.ts` | `NODE_TYPE_LABELS`, `displayNodeTitle`, `createRuleNode`, drag MIME constant |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts` | Unit tests for title + factory |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx` | Use `displayNodeTitle` |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx` | 「显示名称」Input |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx` | Label edit + system node |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-library.tsx` | Draggable items, no onClick |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx` | Drop handler + position-aware create |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Types + pure helpers
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/lib/services/openflare/types.ts`
|
||||
- Create: `frontend/app/(main)/waf/rules/editor/components/node-factory.ts`
|
||||
- Create: `frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `WAF_NODE_DRAG_MIME`, `AddableNodeType`, `NODE_TYPE_LABELS`, `displayNodeTitle(node)`, `createRuleNode(type, position)`
|
||||
|
||||
- [ ] **Step 1: Add `label?: string` to every `WAFRuleNode` union member** in `types.ts`.
|
||||
|
||||
- [ ] **Step 2: Write failing tests** in `node-factory.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createRuleNode,
|
||||
displayNodeTitle,
|
||||
NODE_TYPE_LABELS,
|
||||
} from './node-factory';
|
||||
|
||||
describe('displayNodeTitle', () => {
|
||||
it('uses trimmed label when present', () => {
|
||||
expect(
|
||||
displayNodeTitle({
|
||||
id: 'x',
|
||||
type: 'ip_match',
|
||||
label: ' 办公室 ',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||
}),
|
||||
).toBe('办公室');
|
||||
});
|
||||
|
||||
it('falls back to type default when label empty', () => {
|
||||
expect(
|
||||
displayNodeTitle({
|
||||
id: 'x',
|
||||
type: 'block',
|
||||
label: ' ',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { status_code: 403, response_body: '' },
|
||||
}),
|
||||
).toBe(NODE_TYPE_LABELS.block);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRuleNode', () => {
|
||||
it('creates typed node at position without label', () => {
|
||||
const node = createRuleNode('pow', { x: 12, y: 34 });
|
||||
expect(node.type).toBe('pow');
|
||||
expect(node.position).toEqual({ x: 12, y: 34 });
|
||||
expect(node.label).toBeUndefined();
|
||||
expect(node.id.startsWith('pow-')).toBe(true);
|
||||
if (node.type === 'pow') {
|
||||
expect(node.config).toEqual({
|
||||
algorithm: 'fast',
|
||||
difficulty: 4,
|
||||
session_ttl: 3600,
|
||||
challenge_ttl: 300,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement `node-factory.ts`**
|
||||
|
||||
```ts
|
||||
import type { WAFRuleNode } from '@/lib/services/openflare';
|
||||
|
||||
export const WAF_NODE_DRAG_MIME = 'application/openflare-waf-node';
|
||||
|
||||
export type AddableNodeType = Extract<
|
||||
WAFRuleNode['type'],
|
||||
'ip_match' | 'geo_match' | 'pow' | 'block'
|
||||
>;
|
||||
|
||||
export const NODE_TYPE_LABELS: Record<WAFRuleNode['type'], string> = {
|
||||
start: '开始',
|
||||
ip_match: 'IP 匹配',
|
||||
geo_match: '地域匹配',
|
||||
pow: 'PoW 挑战',
|
||||
allow: '通过',
|
||||
block: '阻止',
|
||||
};
|
||||
|
||||
export function displayNodeTitle(
|
||||
node: Pick<WAFRuleNode, 'type' | 'label'>,
|
||||
): string {
|
||||
const custom = node.label?.trim();
|
||||
return custom || NODE_TYPE_LABELS[node.type];
|
||||
}
|
||||
|
||||
export function createRuleNode(
|
||||
type: AddableNodeType,
|
||||
position: { x: number; y: number },
|
||||
): WAFRuleNode {
|
||||
const id = `${type}-${crypto.randomUUID().slice(0, 8)}`;
|
||||
if (type === 'ip_match')
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position,
|
||||
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||
};
|
||||
if (type === 'geo_match')
|
||||
return { id, type, position, config: { countries: [], regions: [] } };
|
||||
if (type === 'pow')
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position,
|
||||
config: {
|
||||
algorithm: 'fast',
|
||||
difficulty: 4,
|
||||
session_ttl: 3600,
|
||||
challenge_ttl: 300,
|
||||
},
|
||||
};
|
||||
return {
|
||||
id,
|
||||
type: 'block',
|
||||
position,
|
||||
config: { status_code: 403, response_body: '' },
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAddableNodeType(value: string): AddableNodeType | null {
|
||||
if (
|
||||
value === 'ip_match' ||
|
||||
value === 'geo_match' ||
|
||||
value === 'pow' ||
|
||||
value === 'block'
|
||||
)
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor/components/node-factory.test.ts'
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/lib/services/openflare/types.ts \
|
||||
frontend/app/(main)/waf/rules/editor/components/node-factory.ts \
|
||||
frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts
|
||||
git commit -m "feat(waf): add node label type and factory helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Canvas title + properties label field
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx`
|
||||
- Modify: `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx`
|
||||
- Modify: `frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Tests for properties**
|
||||
|
||||
Add to `node-properties.test.tsx`:
|
||||
|
||||
```ts
|
||||
it('edits display name for configurable nodes', () => {
|
||||
const node: WAFRuleNode = {
|
||||
id: 'match',
|
||||
type: 'ip_match',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||
};
|
||||
const onChange = vi.fn();
|
||||
render(<NodeProperties node={node} ipGroups={[]} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText('显示名称'), {
|
||||
target: { value: '内网放行' },
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ label: '内网放行' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('hides display name for system nodes', () => {
|
||||
const node: WAFRuleNode = {
|
||||
id: 'start',
|
||||
type: 'start',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
};
|
||||
render(<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()} />);
|
||||
expect(screen.queryByLabelText('显示名称')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('系统节点无需配置。')).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement properties field** — at start of each configurable `FieldGroup` (or wrap once before type switch for non-system):
|
||||
|
||||
Prefer extract:
|
||||
|
||||
```tsx
|
||||
function DisplayNameField({
|
||||
node,
|
||||
onChange,
|
||||
}: {
|
||||
node: WAFRuleNode;
|
||||
onChange: (node: WAFRuleNode) => void;
|
||||
}) {
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${node.id}-label`}>显示名称</FieldLabel>
|
||||
<Input
|
||||
id={`${node.id}-label`}
|
||||
value={node.label ?? ''}
|
||||
placeholder={/* type default from NODE_TYPE_LABELS */}
|
||||
onChange={(e) => onChange({ ...node, label: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Insert `<DisplayNameField ... />` as first child inside each non-system `FieldGroup`.
|
||||
|
||||
- [ ] **Step 3: `rule-node.tsx`** — use `displayNodeTitle(rule)` for main title; keep icon from meta; keep id subtitle.
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor/components/node-properties.test.tsx' 'app/(main)/waf/rules/editor/components/node-factory.test.ts'
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/app/(main)/waf/rules/editor/components/rule-node.tsx \
|
||||
frontend/app/(main)/waf/rules/editor/components/node-properties.tsx \
|
||||
frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx
|
||||
git commit -m "feat(waf): show and edit rule node display names"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Drag library + canvas drop
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/(main)/waf/rules/editor/components/node-library.tsx`
|
||||
- Modify: `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx`
|
||||
- Create (optional pure tests): extend `node-factory.test.ts` for `parseAddableNodeType`
|
||||
|
||||
- [ ] **Step 1: Node library** — remove `onAdd` prop; make each button `draggable` with:
|
||||
|
||||
```tsx
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData(WAF_NODE_DRAG_MIME, type);
|
||||
e.dataTransfer.setData('text/plain', type);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
```
|
||||
|
||||
Use `type='button'` + cursor `cursor-grab active:cursor-grabbing`. No `onClick` that adds nodes.
|
||||
|
||||
- [ ] **Step 2: Canvas** — replace `addNode(type)` fixed position with:
|
||||
|
||||
```ts
|
||||
const addNodeAt = useCallback(
|
||||
(type: AddableNodeType, position: { x: number; y: number }) => {
|
||||
const node = createRuleNode(type, position);
|
||||
onGraphChange({ ...graph, nodes: [...graph.nodes, node] });
|
||||
onSelectEdge(undefined);
|
||||
onSelect(node.id);
|
||||
},
|
||||
[graph, onGraphChange, onSelect, onSelectEdge],
|
||||
);
|
||||
|
||||
const onDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const raw =
|
||||
e.dataTransfer.getData(WAF_NODE_DRAG_MIME) ||
|
||||
e.dataTransfer.getData('text/plain');
|
||||
const type = parseAddableNodeType(raw);
|
||||
if (!type || !instance.current) return;
|
||||
const position = instance.current.screenToFlowPosition({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
addNodeAt(type, position);
|
||||
},
|
||||
[addNodeAt],
|
||||
);
|
||||
```
|
||||
|
||||
Pass `onDragOver` / `onDrop` to `<ReactFlow ...>` (xyflow supports these on the component).
|
||||
|
||||
Update `<NodeLibrary />` — no `onAdd`.
|
||||
|
||||
- [ ] **Step 3: Run editor-related tests**
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor'
|
||||
```
|
||||
|
||||
Expected: PASS (update any tests that assumed click-add)
|
||||
|
||||
- [ ] **Step 4: Format + commit**
|
||||
|
||||
```bash
|
||||
make prettier
|
||||
git add frontend/app/(main)/waf/rules/editor
|
||||
git commit -m "feat(waf): drag-drop nodes onto rule canvas at cursor"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Changelog** — under `docs/changelog/index.md` `[Unreleased]`:
|
||||
|
||||
```md
|
||||
### 改进
|
||||
- WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。
|
||||
```
|
||||
|
||||
```bash
|
||||
git add docs/changelog/index.md
|
||||
git commit -m "docs(changelog): WAF 编辑器节点命名与拖放添加"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage
|
||||
|
||||
| Spec item | Task |
|
||||
|-----------|------|
|
||||
| `label?` on TS types | 1 |
|
||||
| Display title fallback | 1–2 |
|
||||
| Properties 显示名称 | 2 |
|
||||
| System nodes no rename | 2 |
|
||||
| Drag-only library | 3 |
|
||||
| Drop at cursor | 3 |
|
||||
| No note / backend | N/A (omitted) |
|
||||
| Tests | 1–3 |
|
||||
| Changelog | 3 |
|
||||
@@ -1,42 +0,0 @@
|
||||
# WAF UA Check Node Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add WAF graph node `ua_check` (require UA, browser/OS whitelist with and/or, bot/abnormal blocks) end-to-end: validate/compile, Lua runtime, editor UI.
|
||||
|
||||
**Architecture:** Match-node pattern like `geo_match`. Control plane stores `UACheckConfig`; edge classifies `http_user_agent` with analytics-equivalent token rules; evaluation order: require → block bots → block abnormal → whitelist.
|
||||
|
||||
**Tech Stack:** Go (waf package), Lua (OpenResty waf_runtime), React/TS editor, Vitest, Go tests.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-19-waf-ua-check-node-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Type `ua_check`; handles `true`/`false`.
|
||||
- Config fields: `require_ua`, `browsers`, `operating_systems`, `match_mode` (`and`|`or`, default `or`), `block_common_bots`, `block_abnormal_ua`.
|
||||
- Closed enums for browser/OS labels matching analytics.
|
||||
- Block before whitelist; empty lists = no whitelist constraint.
|
||||
- No schema_version bump; no new HTTP API.
|
||||
- Changelog + Chinese design doc update.
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `internal/apps/openflare/waf/graph_types.go` | Type + config |
|
||||
| `internal/apps/openflare/waf/graph_validate.go` | Validate + handles |
|
||||
| `internal/apps/openflare/waf/graph_compile.go` | Compile normalize |
|
||||
| `internal/apps/openflare/waf/*_test.go` | Go tests |
|
||||
| `internal/apps/agent/nginx/waf_runtime.lua` | Runtime eval |
|
||||
| `internal/apps/agent/nginx/waf_runtime_spec.lua` | Lua specs |
|
||||
| `internal/apps/agent/nginx/manager_test.go` | Embed smoke if needed |
|
||||
| Frontend editor components + types | UI |
|
||||
| `docs/design/waf-orchestration-design.md` | Node table |
|
||||
| `docs/changelog/index.md` | Unreleased |
|
||||
|
||||
### Task 1: Backend types/validate/compile
|
||||
### Task 2: Lua runtime + specs
|
||||
### Task 3: Frontend editor
|
||||
### Task 4: Docs + gates
|
||||
|
||||
(Detailed code follows during implementation; execute TDD per layer.)
|
||||
@@ -1,467 +0,0 @@
|
||||
# 源站错误页 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 全局可配置源站/网关错误页:默认标签 `500-599`、Cloudflare 风格 HTML、可在线自定义;HTTP 状态码保持原值并在页面展示 `{{status}}`/`{{host}}`;可关闭恢复透传。
|
||||
|
||||
**Architecture:** 三个 Option key 持久化 → 配置版本 `openresty_config` 快照 → `pkg/render/openresty` 对反代 server 生成 `proxy_intercept_errors` + `error_page` + internal location;模板 SupportFile + 轻量 `content_by_lua_block` 替换占位符。管理端 `/error-pages` 挂在侧栏「网站管理」。
|
||||
|
||||
**Tech Stack:** Go、goose、Option API、`pkg/render/openresty`、OpenResty/Lua、Next.js、Tags Input、OptionService
|
||||
|
||||
**Spec:** [docs/design/origin-error-page.md](../../design/origin-error-page.md) · [docs/superpowers/specs/2026-08-06-origin-error-page-design.md](../specs/2026-08-06-origin-error-page-design.md)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 仅**反代**路由应用;**Pages** 路由不生成错误页指令
|
||||
- HTTP **status 保持原错误码**,禁止统一改为 200
|
||||
- 状态码标签:单码 `522` 或闭区间 `500-599`;范围 **400–599**;默认 `["500-599"]`
|
||||
- 占位符:`{{status}}`、`{{host}}`;HTML 空 = 内置默认;最大 **256 KiB**
|
||||
- 保存走 Option `update-batch`;**需配置版本发布**后边缘生效
|
||||
- 完成后 `make code-check`;前端相关 `make format` / prettier;中文 changelog;不写英文文档
|
||||
- 不新增独立业务路由注册(复用 `/api/v1/d/option`);侧栏仅前端导航
|
||||
|
||||
## File map
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `pkg/render/openresty/status_codes.go` | 标签解析/展开纯函数 |
|
||||
| `pkg/render/openresty/status_codes_test.go` | 解析单测 |
|
||||
| `pkg/render/openresty/origin_error_page.go` | 默认 HTML、渲染 error_page 片段、SupportFile 路径常量 |
|
||||
| `pkg/render/openresty/origin_error_page_test.go` | 渲染片段单测 |
|
||||
| `pkg/render/openresty/types.go` | `ConfigSnapshot` 三字段 |
|
||||
| `pkg/render/openresty/render.go` / `render_route.go` | 接入 error 块到反代 server |
|
||||
| `pkg/render/openresty/render_test.go` | 集成渲染断言 |
|
||||
| `internal/model/system_configs.go` | 三个 ConfigKey 常量 |
|
||||
| `internal/infra/persistence/migrator/goose/{postgres,sqlite}/202608060001_add_origin_error_page_options.sql` | seed |
|
||||
| `internal/apps/openflare/option/openresty_validators.go` | 校验 enabled / codes / html |
|
||||
| `internal/apps/openflare/option` 相关 test | 校验失败用例 |
|
||||
| `internal/apps/openflare/config_version/snapshot.go` | 快照读写字段 |
|
||||
| `internal/apps/openflare/config_version/logics.go` | `diffOpenRestyOptionDetails` 含新字段 |
|
||||
| `frontend/components/ui/tags-input.tsx` | shadcn-extension Tags Input(若缺失则添加) |
|
||||
| `frontend/app/(main)/error-pages/page.tsx` | 设置页 |
|
||||
| `frontend/lib/navigation/openflare-nav.ts` | 网站管理菜单 |
|
||||
| `frontend/lib/utils/search-data.ts` | 搜索 |
|
||||
| `docs/reference/configuration.md` | 配置键说明(中文) |
|
||||
| `docs/changelog/index.md` | Unreleased |
|
||||
| `docs/plan/index.md` | 进行中索引 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 状态码标签解析(纯函数 TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `pkg/render/openresty/status_codes.go`
|
||||
- Create: `pkg/render/openresty/status_codes_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `func ExpandStatusCodeTags(tags []string) (codes []int, err error)`
|
||||
- `func ParseStatusCodeTag(tag string) (lo, hi int, err error)` — 单码时 `lo==hi`
|
||||
- 常量:`StatusCodeMin = 400`, `StatusCodeMax = 599`
|
||||
- 规则:trim;`^\d{3}$` 或 `^\d{3}-\d{3}$`;`lo<=hi`;均在 400–599;展开 inclusive;排序去重;空 tags → 空 slice + nil error(「启用且空」由校验层拒绝)
|
||||
|
||||
- [ ] **Step 1: 写失败单测**
|
||||
|
||||
```go
|
||||
func TestExpandStatusCodeTags(t *testing.T) {
|
||||
t.Parallel()
|
||||
codes, err := ExpandStatusCodeTags([]string{"500-502", "522", "501"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// want sorted unique: 500,501,502,522
|
||||
if len(codes) != 4 || codes[0] != 500 || codes[3] != 522 {
|
||||
t.Fatalf("got %v", codes)
|
||||
}
|
||||
_, err = ExpandStatusCodeTags([]string{"399"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
_, err = ExpandStatusCodeTags([]string{"503-500"})
|
||||
if err == nil {
|
||||
t.Fatal("expected reverse range error")
|
||||
}
|
||||
_, err = ExpandStatusCodeTags([]string{"5xx"})
|
||||
if err == nil {
|
||||
t.Fatal("expected syntax error")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `go test ./pkg/render/openresty/ -run TestExpandStatusCodeTags -count=1`
|
||||
Expected: FAIL(函数未定义)
|
||||
|
||||
- [ ] **Step 3: 实现 `status_codes.go`**
|
||||
|
||||
```go
|
||||
package openresty
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusCodeMin = 400
|
||||
StatusCodeMax = 599
|
||||
)
|
||||
|
||||
func ParseStatusCodeTag(tag string) (lo, hi int, err error) {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
return 0, 0, fmt.Errorf("状态码标签不能为空")
|
||||
}
|
||||
if i := strings.IndexByte(tag, '-'); i >= 0 {
|
||||
lo, err = strconv.Atoi(tag[:i])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("无效状态码区间: %s", tag)
|
||||
}
|
||||
hi, err = strconv.Atoi(tag[i+1:])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("无效状态码区间: %s", tag)
|
||||
}
|
||||
} else {
|
||||
lo, err = strconv.Atoi(tag)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("无效状态码: %s", tag)
|
||||
}
|
||||
hi = lo
|
||||
}
|
||||
if lo > hi {
|
||||
return 0, 0, fmt.Errorf("状态码区间左右端点反序: %s", tag)
|
||||
}
|
||||
if lo < StatusCodeMin || hi > StatusCodeMax {
|
||||
return 0, 0, fmt.Errorf("状态码须在 %d–%d: %s", StatusCodeMin, StatusCodeMax, tag)
|
||||
}
|
||||
return lo, hi, nil
|
||||
}
|
||||
|
||||
func ExpandStatusCodeTags(tags []string) ([]int, error) {
|
||||
set := map[int]struct{}{}
|
||||
for _, tag := range tags {
|
||||
lo, hi, err := ParseStatusCodeTag(tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for c := lo; c <= hi; c++ {
|
||||
set[c] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]int, 0, len(set))
|
||||
for c := range set {
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out, nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行确认通过**
|
||||
|
||||
Run: `go test ./pkg/render/openresty/ -run TestExpandStatusCodeTags -count=1`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add pkg/render/openresty/status_codes.go pkg/render/openresty/status_codes_test.go
|
||||
git commit -m "feat(openresty): add status code tag expand helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: ConfigSnapshot 字段 + 默认 HTML + error_page 渲染(TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `pkg/render/openresty/types.go` — `ConfigSnapshot` 增加:
|
||||
- `OriginErrorPageEnabled bool \`json:"origin_error_page_enabled"\``
|
||||
- `OriginErrorPageStatusCodes []string \`json:"origin_error_page_status_codes,omitempty"\``
|
||||
- `OriginErrorPageHTML string \`json:"origin_error_page_html,omitempty"\``
|
||||
- Create: `pkg/render/openresty/origin_error_page.go` — 默认 HTML 常量、路径常量、`renderOriginErrorPageDirectives`、`originErrorPageSupportFile`
|
||||
- Modify: `pkg/render/openresty/render.go` — `Render`/`RenderRouteConfig` 在 enabled 时 append SupportFile
|
||||
- Modify: `pkg/render/openresty/render_route.go`(或 `render.go` 中 `renderHTTPProxyServer` / `renderHTTPSServer`)— 在 proxy location **与** server 级写入 intercept + error_page + internal location
|
||||
- Test: `pkg/render/openresty/origin_error_page_test.go` + 扩展 `render_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `const OriginErrorPageSupportPath = "error_pages/origin_error.html.tmpl"`
|
||||
- `const DefaultOriginErrorPageHTML = \`...\`` — CF 风格,含 `{{status}}` `{{host}}`
|
||||
- `func EffectiveOriginErrorPageHTML(cfg ConfigSnapshot) string` — 空则默认
|
||||
- `func renderOriginErrorPageServerBits(cfg ConfigSnapshot) string` — 若 disabled 或 expand 失败/空则 `""`;否则 `error_page ...` + internal location 字符串
|
||||
- SupportFile content = Effective HTML(保留占位符)
|
||||
|
||||
**Internal location 形状(必须 status 透传):**
|
||||
|
||||
```nginx
|
||||
location = /__openflare_origin_error {
|
||||
internal;
|
||||
default_type text/html;
|
||||
charset utf-8;
|
||||
content_by_lua_block {
|
||||
local f = io.open("__OPENFLARE_ERROR_PAGE_TMPL__", "r")
|
||||
if not f then
|
||||
ngx.status = ngx.status
|
||||
ngx.say("Error ", ngx.status)
|
||||
return
|
||||
end
|
||||
local body = f:read("*a")
|
||||
f:close()
|
||||
local status = tostring(ngx.status)
|
||||
local host = ngx.var.host or ""
|
||||
body = body:gsub("{{status}}", status, 1)
|
||||
body = body:gsub("{{host}}", host, 1)
|
||||
-- 全局替换剩余占位(若模板多处 status)
|
||||
body = body:gsub("{{status}}", status)
|
||||
body = body:gsub("{{host}}", host)
|
||||
ngx.header["Content-Type"] = "text/html; charset=utf-8"
|
||||
ngx.say(body)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
占位路径:渲染时用常量如 `ErrorPageTmplPlaceholder = "__OPENFLARE_ERROR_PAGE_TMPL__"`,Agent 落盘时与 SupportFile 绝对路径替换(若现有 Agent 已有 support file root 替换模式则复用;否则在 `internal/apps/agent` 同步路径处增加对该 placeholder 的替换,与 `CertDirPlaceholder` 同类)。
|
||||
|
||||
在每个反代 `location /` 内(proxy 块):
|
||||
|
||||
```nginx
|
||||
proxy_intercept_errors on;
|
||||
```
|
||||
|
||||
在 server 块内(location 外):
|
||||
|
||||
```nginx
|
||||
error_page 500 501 ... = /__openflare_origin_error;
|
||||
```
|
||||
|
||||
+ internal location。
|
||||
|
||||
Pages 的 `renderHTTPSPagesServer` / pages HTTP **不**调用。
|
||||
|
||||
- [ ] **Step 1: 写失败单测**
|
||||
|
||||
```go
|
||||
func TestRenderOriginErrorPageEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
ID: 1, SiteName: "ex", Domains: []string{"ex.test"},
|
||||
OriginURL: "http://127.0.0.1:9", Enabled: true,
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{
|
||||
OriginErrorPageEnabled: true,
|
||||
OriginErrorPageStatusCodes: []string{"500-599"},
|
||||
},
|
||||
}
|
||||
out, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "proxy_intercept_errors on") {
|
||||
t.Fatal("missing intercept")
|
||||
}
|
||||
if !strings.Contains(out, "error_page") || !strings.Contains(out, "/__openflare_origin_error") {
|
||||
t.Fatal("missing error_page")
|
||||
}
|
||||
if !strings.Contains(out, "{{status}}") == false {
|
||||
// SupportFile 在 Render 全量结果中
|
||||
}
|
||||
res, err := Render(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, f := range res.SupportFiles {
|
||||
if f.Path == OriginErrorPageSupportPath {
|
||||
found = true
|
||||
if !strings.Contains(f.Content, "{{status}}") {
|
||||
t.Fatal("template missing placeholder")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing support file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderOriginErrorPageDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
ID: 1, SiteName: "ex", Domains: []string{"ex.test"},
|
||||
OriginURL: "http://127.0.0.1:9", Enabled: true,
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{OriginErrorPageEnabled: false},
|
||||
}
|
||||
out, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, "proxy_intercept_errors") {
|
||||
t.Fatal("should not intercept when disabled")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败** → 实现默认 HTML + 渲染接入 → 运行 PASS
|
||||
|
||||
默认 HTML 要求:大号 `{{status}}`、展示 `{{host}}`、中性「源站暂时无法提供服务」类文案、无 Cloudflare 商标、可单文件 inline CSS。
|
||||
|
||||
- [ ] **Step 3: Agent placeholder 替换**
|
||||
|
||||
搜索 Agent 写 conf 时如何替换 `__OPENFLARE_CERT_DIR__` 等,为 `__OPENFLARE_ERROR_PAGE_TMPL__` 增加指向 support 目录下 `error_pages/origin_error.html.tmpl` 的绝对路径。
|
||||
若 conf 内 lua 无法可靠 `io.open` 绝对路径,可改为 `content_by_lua_file` + 小 lua 读固定相对路径;优先与现有 pow/waf 资源部署方式一致。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(openresty): render origin error page directives"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Option keys、迁移、校验、快照
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/model/system_configs.go` — 常量:
|
||||
- `ConfigKeyOriginErrorPageEnabled = "origin_error_page_enabled"`
|
||||
- `ConfigKeyOriginErrorPageStatusCodes = "origin_error_page_status_codes"`
|
||||
- `ConfigKeyOriginErrorPageHTML = "origin_error_page_html"`
|
||||
- Create goose(postgres + sqlite 同名版本号)`202608060001_add_origin_error_page_options.sql`:
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES
|
||||
('origin_error_page_enabled', 'true', 'business', 0, '是否启用源站错误页', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('origin_error_page_status_codes', '["500-599"]', 'business', 0, '源站错误页触发状态码标签 JSON 数组', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('origin_error_page_html', '', 'business', 0, '源站错误页自定义 HTML,空则使用内置默认', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_system_configs WHERE key IN (
|
||||
'origin_error_page_enabled',
|
||||
'origin_error_page_status_codes',
|
||||
'origin_error_page_html'
|
||||
);
|
||||
```
|
||||
|
||||
(SQLite 若无 `ON CONFLICT` 同现有迁移方言对齐。)
|
||||
|
||||
- Modify: `openresty_validators.go`:
|
||||
- enabled → `validateBooleanOption`
|
||||
- status_codes → JSON 数组 `[]string`,对每项 `ParseStatusCodeTag`;若 `enabled==true`(跨 key 时可用 state,或在 batch 校验后单独检查:若本 key 合法但 enabled 为 true 且 expand 为空则失败)。**实现建议**:`validateOriginErrorPageStatusCodes` 只校验标签可解析且 expand 非空(即使 disabled 也要求非空列表,避免脏数据);html 长度 `<= 256*1024`
|
||||
- html → `len(value) <= 256<<10`
|
||||
|
||||
- Modify: `snapshot.go` 的 `openRestyConfigSnapshot` + `buildOpenRestyConfigSnapshot`:
|
||||
- Enabled: `getBoolConfig(..., true)`
|
||||
- StatusCodes: 解析 JSON 数组,失败则默认 `[]string{"500-599"}`
|
||||
- HTML: `getStringConfig(..., "")`
|
||||
|
||||
- Modify: `logics.go` `diffOpenRestyOptionDetails` 比较新字段(否则发布 diff 不显示)
|
||||
|
||||
- [ ] **Step 1: 校验单测**(`option` 包)非法 `["abc"]`、超大 html、合法 `["522","500-502"]`
|
||||
|
||||
- [ ] **Step 2: 实现迁移与 snapshot 填充**
|
||||
|
||||
- [ ] **Step 3: 手动或单测确认 snapshot JSON 含字段**
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(option): seed and validate origin error page options"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 前端 Tags Input + `/error-pages` 页
|
||||
|
||||
**Files:**
|
||||
- Create/Modify: `frontend/components/ui/tags-input.tsx`(若无:用 shadcn skill / `npx shadcn@latest add` 社区 Tags Input;API:`value: string[]`, `onChange`, `placeholder`)
|
||||
- Create: `frontend/app/(main)/error-pages/page.tsx`(及可选 `components/` 拆分若超 600 行)
|
||||
- Modify: `frontend/lib/navigation/openflare-nav.ts` — `openflareWebsiteNavGroup.items` 增加 `{ title: '错误页', url: '/error-pages' }`;`openflareWebsiteSubNav` 同步
|
||||
- Modify: `frontend/lib/utils/search-data.ts` — 关键词:错误页、源站、502、error page
|
||||
- Reuse: `OptionService.list` / `updateBatch`(同 performance 页)
|
||||
|
||||
**UI 行为:**
|
||||
1. 管理员加载 options → 映射三字段
|
||||
2. Switch 启用
|
||||
3. TagsInput:默认展示解析后的 JSON 数组
|
||||
4. Textarea HTML;按钮「加载默认模板」「恢复默认」
|
||||
5. 预览:客户端把 `{{status}}`→`502`、`{{host}}`→`example.com` 后 iframe `srcDoc`
|
||||
6. 保存:`updateBatch` 三条;toast 提示去版本发布
|
||||
7. 前端校验:标签用与后端相同规则(可抽 `lib/openflare/status-code-tags.ts` 轻量实现或仅保存时依赖后端错误)
|
||||
|
||||
默认 HTML:从前端常量复制与 Go `DefaultOriginErrorPageHTML` **内容一致**(计划实现时两处同一字符串;可在 PR 说明需人工对齐)。
|
||||
|
||||
- [ ] **Step 1: 添加 Tags Input 组件并在 demo 或本页使用**
|
||||
|
||||
- [ ] **Step 2: 实现 page.tsx**
|
||||
|
||||
- [ ] **Step 3: 导航与搜索**
|
||||
|
||||
- [ ] **Step 4: 本地 UI 走查**(开关、标签区间、预览、保存错误提示)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(frontend): add origin error page settings under websites"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 文档、changelog、收尾门禁
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/reference/configuration.md` — 三 key 说明
|
||||
- Modify: `docs/changelog/index.md` `[unreleased]` 改进:用户可读中文
|
||||
- Modify: `docs/plan/index.md` — 本计划列入进行中
|
||||
|
||||
- [ ] **Step 1: 写配置说明与 changelog**
|
||||
|
||||
示例 changelog:
|
||||
|
||||
```markdown
|
||||
- 新增全局源站错误页:可在「网站管理 → 错误页」配置触发状态码(支持 500-599 区间与单码)与自定义 HTML;默认 Cloudflare 风格页面并保持真实 HTTP 状态码,关闭后恢复透传。
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `make code-check` 与前端 format**
|
||||
|
||||
- [ ] **Step 3: 手动验收清单(对照设计 §7.2)**
|
||||
|
||||
1. 默认启用 + 源站宕机 → 错误页 + 真实 status
|
||||
2. 源站 503 → 替换
|
||||
3. 仅 `522` → 其它 5xx 透传
|
||||
4. 关闭并发布 → 透传
|
||||
5. 自定义 `{{status}}`/`{{host}}`
|
||||
6. Pages 不受影响
|
||||
|
||||
- [ ] **Step 4: Final commit**
|
||||
|
||||
```bash
|
||||
git commit -m "docs: origin error page configuration and changelog"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage checklist
|
||||
|
||||
| Spec 要求 | Task |
|
||||
|-----------|------|
|
||||
| 全局开关 | 3, 4 |
|
||||
| 默认 `500-599` / 单码+区间 | 1, 3, 4 |
|
||||
| 状态码透传 + `{{status}}` | 2 |
|
||||
| 默认 CF 风格 / 自定义 HTML | 2, 4 |
|
||||
| Option + 配置版本 | 3 |
|
||||
| 仅反代 | 2 |
|
||||
| 侧栏网站管理 | 4 |
|
||||
| Tags Input | 4 |
|
||||
| 256KB 限制 | 3 |
|
||||
| 测试与 changelog | 1–5 |
|
||||
|
||||
## Placeholder scan
|
||||
|
||||
无 TBD;Agent 路径替换若与现网 placeholder 机制不一致,在 Task 2 Step 3 内对齐现有 `CertDirPlaceholder` 模式,不另开悬空任务。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,885 +0,0 @@
|
||||
# Service Worker 离线兜底 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 给平台所有启用 HTTPS 的网站(反代 + Pages)下发 Service Worker 离线兜底:域名被墙后浏览器从缓存吐出"联系站长"页,避免用户流失。全平台一键批量下发。
|
||||
|
||||
**Architecture:** 全局 Option(SystemConfig / OpenRestyConfig snapshot)驱动,与现有 origin error page 完全同模式。渲染层在 HTTPS server 块注入 SW 静态 location + 首页挑战拦截(真实浏览器 UA 且无 cookie 时返回含 `register('/sw.js')` 的挑战页),通过 SupportFile 下发 sw.js / offline.html,Agent 替换占位符落盘。前端「响应页面」模块两个 tab:错误页 / 联系页。
|
||||
|
||||
**Tech Stack:** Go 1.25+、Gin、GORM、PostgreSQL/SQLite goose 迁移、OpenResty/Lua、Next.js、TypeScript、shadcn/ui、TanStack Query。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 遵循 AGENTS.md 分层:`apps → repository → model`,禁止 `model → repository`。
|
||||
- API 错误用 `response.Abort*`;Handler 不直接 `c.JSON`。
|
||||
- 渲染改动后:`make swagger`(本功能无新 API,跳过);开发完成:`make code-check`;提交前:`make format`。
|
||||
- 代码/配置变更写入 `docs/changelog/index.md` 的 `[Unreleased]`(中文,用户可读)。
|
||||
- 配置 key 命名:小写 snake_case。测试临时目录只用 `t.TempDir()`。
|
||||
- 前端:`variant` + CSS 变量,业务 `className` 不硬编码颜色;根容器 `w-full`,外层 `py-6 px-1`;标题行 `flex items-center gap-2`。
|
||||
- 配置文件路径占位符统一追加到 `pkg/render/openresty/types.go` 的 const 块。
|
||||
- 迁移:PostgreSQL 与 SQLite 各一份 goose SQL(`goose/postgres/`、`goose/sqlite/`),见 `database-migration` skill。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端配置 key 与 Option 校验
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/model/system_configs.go:114-117`
|
||||
- Modify: `internal/apps/openflare/option/openresty_validators.go:19-65`
|
||||
- Modify: `internal/apps/openflare/option/openresty_validators.go:69-79`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 常量 `model.ConfigKeySWOfflineEnabled`, `model.ConfigKeySWOfflineHTML`; 校验函数 `validateSWOfflineHTML`。
|
||||
|
||||
- [ ] **Step 1: 在 `system_configs.go` 追加 key 常量**
|
||||
|
||||
在 `ConfigKeyOriginErrorPageGetOnly`(第 117 行)后追加:
|
||||
|
||||
```go
|
||||
ConfigKeySWOfflineEnabled = "sw_offline_enabled" // 是否启用 Service Worker 离线兜底
|
||||
ConfigKeySWOfflineHTML = "sw_offline_html" // 离线联系页自定义 HTML(空则内置默认)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 注册 validator**
|
||||
|
||||
在 `openRestyOptionValidators` map(`openresty_validators.go` 第 61-64 行)后追加:
|
||||
|
||||
```go
|
||||
model.ConfigKeySWOfflineEnabled: validateBooleanOption,
|
||||
model.ConfigKeySWOfflineHTML: validateSWOfflineHTML,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在 `validateOpenRestyOption` 增加 HTML 字节数特殊处理**
|
||||
|
||||
在第 69-79 行函数内,`if key == model.ConfigKeyOriginErrorPageHTML` 分支改为同时覆盖 SW HTML:
|
||||
|
||||
```go
|
||||
if key == model.ConfigKeyOriginErrorPageHTML || key == model.ConfigKeySWOfflineHTML {
|
||||
return validateOriginErrorPageHTML(key, value)
|
||||
}
|
||||
```
|
||||
|
||||
`validateOriginErrorPageHTML` 逻辑(非空、≤256 KiB)对两个 HTML 复用,无需新函数。
|
||||
|
||||
- [ ] **Step 4: 运行测试**
|
||||
|
||||
Run: `cd /Users/ryan/conductor/workspaces/OpenFlare/islamabad && go build ./... && go test ./internal/apps/openflare/option/...`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add internal/model/system_configs.go internal/apps/openflare/option/openresty_validators.go
|
||||
git commit -m "feat(option): add sw offline config keys and validation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: goose 迁移(PostgreSQL + SQLite)Seed 全局 Option
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/infra/persistence/migrator/goose/postgres/<YYYYMMDD>NNN_add_sw_offline_options.sql`
|
||||
- Create: `internal/infra/persistence/migrator/goose/sqlite/<YYYYMMDD>NNN_add_sw_offline_options.sql`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 key 常量。
|
||||
- Produces: 数据库 seed 的 `sw_offline_enabled` / `sw_offline_html` 两行 `w_system_configs`。
|
||||
|
||||
- [ ] **Step 1: 确认迁移序号**
|
||||
|
||||
Run: `ls /Users/ryan/conductor/workspaces/OpenFlare/islamabad/internal/infra/persistence/migrator/goose/postgres/ | tail -3`
|
||||
取最新序号 +1(如 `202608080001`)。
|
||||
|
||||
- [ ] **Step 2: 创建 postgres 迁移**
|
||||
|
||||
创建 `goose/postgres/202608080001_add_sw_offline_options.sql`:
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES
|
||||
('sw_offline_enabled', 'false', 'business', 0, '是否启用 Service Worker 离线兜底', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('sw_offline_html', '', 'business', 0, '离线联系页自定义 HTML,空则使用内置默认', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_system_configs WHERE key IN (
|
||||
'sw_offline_enabled',
|
||||
'sw_offline_html'
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 创建 sqlite 迁移**
|
||||
|
||||
创建 `goose/sqlite/202608080001_add_sw_offline_options.sql`(内容与 postgres 相同)。
|
||||
|
||||
- [ ] **Step 4: 运行迁移测试**
|
||||
|
||||
Run: `go test ./internal/infra/persistence/migrator/...`
|
||||
Expected: PASS(数据库迁移冒烟通过)
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add internal/infra/persistence/migrator/goose/postgres/202608080001_add_sw_offline_options.sql internal/infra/persistence/migrator/goose/sqlite/202608080001_add_sw_offline_options.sql
|
||||
git commit -m "feat(db): seed sw offline options"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: ConfigSnapshot 渲染类型字段
|
||||
|
||||
**Files:**
|
||||
- Modify: `pkg/render/openresty/types.go:20-26`
|
||||
- Modify: `pkg/render/openresty/types.go:318-323`(`ConfigSnapshot` 结构体)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ConfigSnapshot.SWOfflineEnabled bool`、`ConfigSnapshot.SWOfflineHTML string`;常量 `SWDirPlaceholder`。
|
||||
|
||||
- [ ] **Step 1: 追加占位符常量**
|
||||
|
||||
在 `types.go` 占位符 const 块(第 23 行 `ErrorPageTmplPlaceholder` 后)追加:
|
||||
|
||||
```go
|
||||
SWDirPlaceholder = "__OPENFLARE_SW_DIR__"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 追加 ConfigSnapshot 字段**
|
||||
|
||||
在 `ConfigSnapshot` 末尾(`OriginErrorPageGetOnly` 后)追加:
|
||||
|
||||
```go
|
||||
// SWOfflineEnabled enables the Service Worker offline fallback for HTTPS routes.
|
||||
SWOfflineEnabled bool `json:"sw_offline_enabled,omitempty"`
|
||||
// SWOfflineHTML is the contact-page HTML served offline; empty uses the built-in default.
|
||||
SWOfflineHTML string `json:"sw_offline_html,omitempty"`
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add pkg/render/openresty/types.go
|
||||
git commit -m "feat(openresty): add sw offline ConfigSnapshot fields and placeholder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 渲染层 SW 资源与挑战拦截
|
||||
|
||||
**Files:**
|
||||
- Create: `pkg/render/openresty/service_worker.go`
|
||||
- Create: `pkg/render/openresty/service_worker_test.go`
|
||||
- Modify: `pkg/render/openresty/render.go:37-59`(`Render` 追加 support files)
|
||||
- Modify: `pkg/render/openresty/render.go:90-115`(`RenderRouteConfig` 注入挑战)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ConfigSnapshot.SWOfflineEnabled` / `.SWOfflineHTML`;`SWDirPlaceholder`。
|
||||
- Produces: `DefaultSWOfflineHTML string`、`EffectiveSWOfflineHTML(cfg ConfigSnapshot) string`、`ServiceWorkerSupportFiles(cfg ConfigSnapshot) []SupportFile`、`renderServiceWorkerChallenger(cfg ConfigSnapshot) string`。
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
创建 `service_worker_test.go`,断言:
|
||||
1. `EffectiveSWOfflineHTML`:HTML 为空返回内置默认;非空返回自定义。
|
||||
2. `ServiceWorkerSupportFiles`:仅当 `SWOfflineEnabled` 时返回 `sw/sw.js` 与 `sw/offline.html` 两个文件;未启用返回 nil。
|
||||
3. `renderServiceWorkerChallenger`:启用且含 sw.js location、offline location、挑战 location;未启用返回空串。
|
||||
|
||||
```go
|
||||
package openresty
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEffectiveSWOfflineHTML(t *testing.T) {
|
||||
if got := EffectiveSWOfflineHTML(ConfigSnapshot{}); got != DefaultSWOfflineHTML {
|
||||
t.Fatalf("default mismatch")
|
||||
}
|
||||
custom := "<html>custom</html>"
|
||||
if got := EffectiveSWOfflineHTML(ConfigSnapshot{SWOfflineHTML: custom}); got != custom {
|
||||
t.Fatalf("custom mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceWorkerSupportFiles(t *testing.T) {
|
||||
disabled := ServiceWorkerSupportFiles(ConfigSnapshot{})
|
||||
if disabled != nil {
|
||||
t.Fatalf("expected nil when disabled, got %v", disabled)
|
||||
}
|
||||
enabled := ServiceWorkerSupportFiles(ConfigSnapshot{SWOfflineEnabled: true})
|
||||
if len(enabled) != 2 {
|
||||
t.Fatalf("expected 2 support files, got %d", len(enabled))
|
||||
}
|
||||
paths := map[string]string{}
|
||||
for _, f := range enabled {
|
||||
paths[f.Path] = f.Content
|
||||
}
|
||||
if _, ok := paths["sw/sw.js"]; !ok {
|
||||
t.Fatalf("missing sw/sw.js")
|
||||
}
|
||||
if _, ok := paths["sw/offline.html"]; !ok {
|
||||
t.Fatalf("missing sw/offline.html")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderServiceWorkerChallenger(t *testing.T) {
|
||||
if got := renderServiceWorkerChallenger(ConfigSnapshot{}); got != "" {
|
||||
t.Fatalf("expected empty when disabled")
|
||||
}
|
||||
got := renderServiceWorkerChallenger(ConfigSnapshot{SWOfflineEnabled: true})
|
||||
for _, want := range []string{"location = /sw.js", "location = /offline.html", "sw.runtime", "content_by_lua"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("challenger missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `go test ./pkg/render/openresty/ -run 'TestEffectiveSWOfflineHTML|TestServiceWorkerSupportFiles|TestRenderServiceWorkerChallenger'`
|
||||
Expected: FAIL(函数未定义)
|
||||
|
||||
- [ ] **Step 3: 实现 `service_worker.go`**
|
||||
|
||||
```go
|
||||
package openresty
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
SWJSLocation = "location = /sw.js"
|
||||
SWOfflineLocation = "location = /offline.html"
|
||||
SWChallengeLua = "sw/challenge.lua"
|
||||
SWRuntimeLua = "sw/runtime.lua"
|
||||
swDirPrefix = "sw/"
|
||||
)
|
||||
|
||||
// DefaultSWOfflineHTML is the built-in contact page shown when the domain is blocked.
|
||||
const DefaultSWOfflineHTML = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>网站暂时无法访问 | 联系站长</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #ffffff; color: #333333; height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; padding: 48px 24px; }
|
||||
h1 { font-size: 28px; font-weight: 700; margin-bottom: 16px; }
|
||||
p { font-size: 16px; line-height: 1.7; color: #666666; max-width: 520px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>网站暂时无法访问</h1>
|
||||
<p>当前域名暂时无法从网络访问。请通过其他方式联系网站管理员获取最新访问入口。</p>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
// EffectiveSWOfflineHTML returns custom HTML when set, otherwise the built-in default.
|
||||
func EffectiveSWOfflineHTML(cfg ConfigSnapshot) string {
|
||||
if strings.TrimSpace(cfg.SWOfflineHTML) == "" {
|
||||
return DefaultSWOfflineHTML
|
||||
}
|
||||
return cfg.SWOfflineHTML
|
||||
}
|
||||
|
||||
// ServiceWorkerSupportFiles returns the sw.js script and offline contact page.
|
||||
func ServiceWorkerSupportFiles(cfg ConfigSnapshot) []SupportFile {
|
||||
if !cfg.SWOfflineEnabled {
|
||||
return nil
|
||||
}
|
||||
return []SupportFile{
|
||||
{Path: swDirPrefix + "sw.js", Content: defaultSWJS()},
|
||||
{Path: swDirPrefix + "offline.html", Content: EffectiveSWOfflineHTML(cfg)},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultSWJS() string {
|
||||
return `var CACHE = "openflare-offline-v1";
|
||||
var OFFLINE = "/offline.html";
|
||||
self.addEventListener("install", function (e) {
|
||||
e.waitUntil(caches.open(CACHE).then(function (c) { return c.addAll([OFFLINE]); }));
|
||||
self.skipWaiting();
|
||||
});
|
||||
self.addEventListener("activate", function (e) {
|
||||
e.waitUntil(caches.keys().then(function (keys) {
|
||||
return Promise.all(keys.filter(function (k) { return k !== CACHE; }).map(function (k) { return caches.delete(k); }));
|
||||
}));
|
||||
self.clients.claim();
|
||||
});
|
||||
self.addEventListener("fetch", function (e) {
|
||||
if (e.request.method !== "GET") { return; }
|
||||
e.respondWith(
|
||||
fetch(e.request).catch(function () {
|
||||
return caches.match(e.request).then(function (r) { return r || caches.match(OFFLINE); });
|
||||
})
|
||||
);
|
||||
});
|
||||
`
|
||||
}
|
||||
|
||||
// renderServiceWorkerChallenger emits SW static locations and the homepage
|
||||
// challenge intercept for HTTPS server blocks.
|
||||
func renderServiceWorkerChallenger(cfg ConfigSnapshot) string {
|
||||
if !cfg.SWOfflineEnabled {
|
||||
return ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
builder.WriteString("\n location = /sw.js {\n")
|
||||
builder.WriteString(" alias " + SWDirPlaceholder + "/sw.js;\n")
|
||||
builder.WriteString(" default_type application/javascript;\n")
|
||||
builder.WriteString(" add_header Service-Worker-Allowed /;\n")
|
||||
builder.WriteString(" add_header Cache-Control \"no-cache\";\n")
|
||||
builder.WriteString(" }\n\n")
|
||||
builder.WriteString(" location = /offline.html {\n")
|
||||
builder.WriteString(" alias " + SWDirPlaceholder + "/offline.html;\n")
|
||||
builder.WriteString(" default_type text/html;\n")
|
||||
builder.WriteString(" add_header Cache-Control \"no-cache\";\n")
|
||||
builder.WriteString(" }\n\n")
|
||||
builder.WriteString(" location = /__openflare_sw_challenge {\n")
|
||||
builder.WriteString(" internal;\n")
|
||||
builder.WriteString(" content_by_lua_file " + SWDirPlaceholder + "/challenge.lua;\n")
|
||||
builder.WriteString(" }\n")
|
||||
return builder.String()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 接入 `Render` 追加 support files**
|
||||
|
||||
在 `render.go` `Render` 函数内、`originErrorPageSupportFile` 追加之后追加:
|
||||
|
||||
```go
|
||||
if doc.OpenRestyConfig.SWOfflineEnabled {
|
||||
files = append(files, ServiceWorkerSupportFiles(doc.OpenRestyConfig)...)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 接入 `RenderRouteConfig` 注入挑战**
|
||||
|
||||
在 `RenderRouteConfig` 内 `renderProxyRoute` / `renderPagesRoute` 调用之前,将 SW 拦截接入 server 块。将 `renderAccessBlock(siteName, powEnabled)` 调用处扩展:新建 `renderServerAccess(siteName, powEnabled, cfg)` 封装,并在其中追加 SW 运行时检查。具体为在 `renderAccessBlock` 生成的 access 块内,追加对 `sw.runtime` 的调用。
|
||||
|
||||
简化实现:新增 `renderAccessBlockWithSW(siteName string, powEnabled bool, cfg ConfigSnapshot) string`,返回 `renderAccessBlock(siteName, powEnabled)` 与(当 `SWOfflineEnabled` 时)追加:
|
||||
|
||||
```
|
||||
access_by_lua_block {
|
||||
if not string.find(package.path, "__OPENFLARE_LUA_DIR__/?.lua", 1, true) then
|
||||
package.path = "__OPENFLARE_LUA_DIR__/?.lua;__OPENFLARE_LUA_DIR__/?/init.lua;" .. package.path
|
||||
end
|
||||
require("sw.runtime").check()
|
||||
}
|
||||
```
|
||||
|
||||
然后将 `renderHTTPProxyServer`、`renderHTTPSServer`、`renderHTTPPagesServer`、`renderHTTPSPagesServer` 中 `renderAccessBlock(...)` 替换为 `renderAccessBlockWithSW(..., cfg)`,并在各自 server 块内追加 `renderServiceWorkerChallenger(cfg)` 输出。
|
||||
|
||||
**注意:** `renderAccessBlock` 在既有 powEnabled 分支已含 `access_by_lua_block`。为兼容,`renderAccessBlockWithSW` 在 powEnabled 分支内合并 SW check 到同一块;非 pow 分支额外追加一个块。本步以**仅新增 server 级 SW location + 独立 `access_by_lua_block`** 为最小实现;若 nginx 同 server 存在两个 `access_by_lua_block`,运行时只执行最后一个——**故实现必须合并**。请在实现时确认 `renderAccessBlock` 各分支,将 SW check 合并进唯一 access 块内,避免覆盖 WAF/PoW。
|
||||
|
||||
- [ ] **Step 6: 运行测试**
|
||||
|
||||
Run: `go test ./pkg/render/openresty/...`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: 提交**
|
||||
|
||||
```bash
|
||||
git add pkg/render/openresty/service_worker.go pkg/render/openresty/service_worker_test.go pkg/render/openresty/render.go
|
||||
git commit -m "feat(openresty): render sw offline assets and challenge intercept"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: config_version snapshot 接入全局 Option
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/apps/openflare/config_version/snapshot.go:143-147`(`openRestyConfigSnapshot` 字段)
|
||||
- Modify: `internal/apps/openflare/config_version/snapshot.go:559-563`(`buildOpenRestyConfigSnapshot` 读取)
|
||||
- Modify: `internal/apps/openflare/config_version/logics.go:537-541`(diff 追加)
|
||||
- Modify: `internal/apps/openflare/config_version/logics.go:604-608`(option keys 追加)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `model.ConfigKeySWOfflineEnabled` / `.SWOfflineHTML`。
|
||||
- Produces: snapshot JSON 内 `sw_offline_enabled` / `sw_offline_html` 字段,触发 checksum 变化。
|
||||
|
||||
- [ ] **Step 1: snapshot 结构体追加字段**
|
||||
|
||||
在 `openRestyConfigSnapshot`(`snapshot.go:143-147`,`OriginErrorPageGetOnly` 后)追加:
|
||||
|
||||
```go
|
||||
SWOfflineEnabled bool `json:"sw_offline_enabled,omitempty"`
|
||||
SWOfflineHTML string `json:"sw_offline_html,omitempty"`
|
||||
```
|
||||
|
||||
- [ ] **Step 2: build 读取配置**
|
||||
|
||||
在 `buildOpenRestyConfigSnapshot`(`snapshot.go:559-563`,`OriginErrorPageGetOnly` 赋值后)追加:
|
||||
|
||||
```go
|
||||
SWOfflineEnabled: getBoolConfig(model.ConfigKeySWOfflineEnabled, false),
|
||||
SWOfflineHTML: getStringConfig(model.ConfigKeySWOfflineHTML, ""),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: diff 追加**
|
||||
|
||||
在 `diffOpenRestyOptionDetails`(`logics.go:540` 后)追加:
|
||||
|
||||
```go
|
||||
appendIfChanged("SWOfflineEnabled", fmt.Sprintf("%t", left.SWOfflineEnabled), fmt.Sprintf("%t", right.SWOfflineEnabled))
|
||||
appendIfChanged("SWOfflineHTML", left.SWOfflineHTML, right.SWOfflineHTML)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: option keys 追加**
|
||||
|
||||
在 `openRestyOptionKeys()`(`logics.go:607` 后)追加:
|
||||
|
||||
```go
|
||||
"SWOfflineEnabled",
|
||||
"SWOfflineHTML",
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行测试**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/config_version/...`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add internal/apps/openflare/config_version/snapshot.go internal/apps/openflare/config_version/logics.go
|
||||
git commit -m "feat(config): wire sw offline options into config snapshot"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Agent 侧 SW Lua 资源与占位符替换
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/apps/agent/nginx/sw_assets.go`
|
||||
- Modify: `internal/apps/agent/nginx/manager.go:393-410`(`EnsureLuaAssets` 追加 SW Lua)
|
||||
- Modify: `internal/apps/agent/nginx/manager.go:526-528`(checksum 归一化 SW 路径)
|
||||
- Modify: `internal/apps/agent/nginx/manager.go:1381-1383`(renderRouteConfig 替换 SW 占位符)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `openrestyrender.SWDirPlaceholder`、`openrestyrender.SWChallengeLua`、`openrestyrender.SWRuntimeLua`。
|
||||
- Produces: `ManagedSWLuaFiles() []protocol.SupportFile`(`sw/runtime.lua`、`sw/challenge.lua`)。
|
||||
|
||||
- [ ] **Step 1: 创建 `sw_assets.go`**
|
||||
|
||||
```go
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/agent/protocol"
|
||||
)
|
||||
|
||||
const openRestySWRuntimeLua = `local source = debug.getinfo(1, "S").source or ""
|
||||
if string.sub(source, 1, 1) == "@" then
|
||||
local script_path = string.sub(source, 2)
|
||||
local base_dir = string.match(script_path, "^(.*)/sw/[^/]+%.lua$")
|
||||
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
|
||||
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
|
||||
end
|
||||
end
|
||||
|
||||
local function is_real_browser(ua)
|
||||
if not ua or ua == "" then return false end
|
||||
-- Chrome/Edge/CentOS-style: "Chrome/120"
|
||||
if string.find(ua, "Chrome/%d", 1, true) then return true end
|
||||
-- Firefox: "Firefox/120"
|
||||
if string.find(ua, "Firefox/%d", 1, true) then return true end
|
||||
-- Safari (non-Chrome, e.g. "Version/17.0 Safari")
|
||||
if not string.find(ua, "Chrome", 1, true) and string.find(ua, "Safari", 1, true) then return true end
|
||||
return false
|
||||
end
|
||||
|
||||
local function pass_through()
|
||||
return true
|
||||
end
|
||||
|
||||
function _M_check()
|
||||
local ua = ngx.var.http_user_agent or ""
|
||||
if not is_real_browser(ua) then return pass_through() end
|
||||
|
||||
local uri = ngx.var.uri or ""
|
||||
if uri ~= "/" then return pass_through() end
|
||||
|
||||
local cookie = ngx.var["cookie___openflare_sw"]
|
||||
if cookie and cookie ~= "" then return pass_through() end
|
||||
|
||||
-- intercept: internal redirect to challenge page, which registers SW + sets cookie
|
||||
local redir = ngx.var.scheme .. "://" .. ngx.var.host .. uri .. (ngx.var.args and ("?" .. ngx.var.args) or "")
|
||||
ngx.req.set_uri_args({ redir = redir })
|
||||
return ngx.exec("/__openflare_sw_challenge")
|
||||
end
|
||||
`
|
||||
|
||||
const openRestySWChallengeLua = `local args = ngx.req.get_uri_args()
|
||||
local redir = args["redir"] or "/"
|
||||
ngx.header["Set-Cookie"] = "__openflare_sw=1; Path=/; Max-Age=31536000"
|
||||
ngx.header.content_type = "text/html; charset=utf-8"
|
||||
ngx.say([[<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>加载中...</title>
|
||||
<script>
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("/sw.js").then(function () {
|
||||
location.replace("]] .. redir .. [[");
|
||||
}).catch(function () {
|
||||
location.replace("]] .. redir .. [[");
|
||||
});
|
||||
} else {
|
||||
location.replace("]] .. redir .. [[");
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>正在加载...</body>
|
||||
</html>]])
|
||||
`
|
||||
|
||||
// ManagedSWLuaFiles returns embedded Lua assets for the SW offline challenge.
|
||||
func ManagedSWLuaFiles() []protocol.SupportFile {
|
||||
return []protocol.SupportFile{
|
||||
{Path: "sw/runtime.lua", Content: openRestySWRuntimeLua},
|
||||
{Path: "sw/challenge.lua", Content: openRestySWChallengeLua},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `EnsureLuaAssets` 追加 SW Lua**
|
||||
|
||||
在 `manager.go:403`(`allSupportFiles` 组装处)追加:
|
||||
|
||||
```go
|
||||
allSupportFiles = append(allSupportFiles, ManagedSWLuaFiles()...)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: checksum 归一化 SW 路径**
|
||||
|
||||
在 `manager.go:526-528`(error page 路径归一化后)追加:
|
||||
|
||||
```go
|
||||
swDir := filepath.ToSlash(filepath.Join(m.NginxCertDir, "sw"))
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, swDir, openrestyrender.SWDirPlaceholder)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: `renderRouteConfig` 替换 SW 占位符**
|
||||
|
||||
在 `manager.go:1381-1383`(error page 替换后)追加:
|
||||
|
||||
```go
|
||||
swDir := filepath.ToSlash(filepath.Join(m.NginxCertDir, "sw"))
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.SWDirPlaceholder, swDir)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 确认 SW 文件落盘**
|
||||
|
||||
`renderServiceWorkerChallenger` 中 `alias __OPENFLARE_SW_DIR__/sw.js` 与 `/offline.html` 引用 support files `sw/sw.js`、`sw/offline.html`。这些文件经 Task 4 作为普通 support file 由 `writeManagedCertFiles` 写入 `<CertDir>/sw/`(路径含子目录)。验证 `certFileTargetPath` 支持子目录路径(读 `manager.go` 确认)。若不支持,需在 `writeManagedCertFiles` 中 `os.MkdirAll(filepath.Dir(targetPath))`。**实现时确认并补全目录创建。**
|
||||
|
||||
- [ ] **Step 6: 运行测试**
|
||||
|
||||
Run: `go build ./... && go test ./internal/apps/agent/nginx/...`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: 提交**
|
||||
|
||||
```bash
|
||||
git add internal/apps/agent/nginx/sw_assets.go internal/apps/agent/nginx/manager.go
|
||||
git commit -m "feat(agent): ship sw offline lua assets and placeholder substitution"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 前端「响应页面」模块(两个 tab)
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/app/(main)/responses/page.tsx`
|
||||
- Create: `frontend/app/(main)/responses/components/contact-page-tab.tsx`
|
||||
- Create: `frontend/app/(main)/responses/components/shared.ts`
|
||||
- Modify: `frontend/lib/navigation/openflare-nav.ts:63-67`
|
||||
- Modify: `frontend/lib/navigation/openflare-nav.ts:123`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `OptionService.list()` / `OptionService.updateBatch()`(已存在)。
|
||||
- Produces: 联系页 tab 编辑 `sw_offline_enabled` / `sw_offline_html` 两个 option。
|
||||
|
||||
- [ ] **Step 1: 创建共享 helper `shared.ts`**
|
||||
|
||||
```ts
|
||||
export const OPTIONS_QUERY_KEY = ['openflare', 'options'] as const;
|
||||
|
||||
export const KEY_SW_ENABLED = 'sw_offline_enabled';
|
||||
export const KEY_SW_HTML = 'sw_offline_html';
|
||||
|
||||
export type ContactPageFields = {
|
||||
enabled: boolean;
|
||||
html: string;
|
||||
};
|
||||
|
||||
export const defaultContactPageFields: ContactPageFields = {
|
||||
enabled: false,
|
||||
html: '',
|
||||
};
|
||||
|
||||
export function optionsToMap(options: Array<{ key: string; value: string }>) {
|
||||
return options.reduce<Record<string, string>>((acc, option) => {
|
||||
acc[option.key] = option.value;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function mapOptionsToContactFields(
|
||||
optionMap: Record<string, string>,
|
||||
): ContactPageFields {
|
||||
return {
|
||||
enabled: optionMap[KEY_SW_ENABLED] === 'true',
|
||||
html: optionMap[KEY_SW_HTML] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function invalidateResponseQueries(queryClient: {
|
||||
invalidateQueries: (opts: {
|
||||
queryKey: readonly unknown[];
|
||||
}) => Promise<unknown>;
|
||||
}) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: OPTIONS_QUERY_KEY }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'config-preview'],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'config-versions'],
|
||||
}),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建联系页 tab `contact-page-tab.tsx`**
|
||||
|
||||
参考 `error-pages/page.tsx` 交互:一个「启用」开关 + 一个 HTML 文本域 + 保存按钮。保存 `updateBatch([{key: KEY_SW_ENABLED,...},{key: KEY_SW_HTML,...}])`,成功后 `invalidateResponseQueries`。
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Save } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { OptionService } from '@/lib/services/openflare';
|
||||
|
||||
import {
|
||||
defaultContactPageFields,
|
||||
invalidateResponseQueries,
|
||||
KEY_SW_ENABLED,
|
||||
KEY_SW_HTML,
|
||||
mapOptionsToContactFields,
|
||||
optionsToMap,
|
||||
type ContactPageFields,
|
||||
} from './shared';
|
||||
|
||||
export function ContactPageTab({ optionMap }: { optionMap: Record<string, string> }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [fields, setFields] = useState<ContactPageFields>(
|
||||
defaultContactPageFields,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFields(mapOptionsToContactFields(optionMap));
|
||||
}, [optionMap]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await OptionService.updateBatch([
|
||||
{ key: KEY_SW_ENABLED, value: String(fields.enabled) },
|
||||
{ key: KEY_SW_HTML, value: fields.html },
|
||||
]);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('联系页已保存,请前往版本发布使配置生效');
|
||||
await invalidateResponseQueries(queryClient);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '保存失败');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-start justify-between gap-4 space-y-0'>
|
||||
<div className='space-y-1.5'>
|
||||
<CardTitle className='text-base'>离线兜底</CardTitle>
|
||||
<CardDescription>
|
||||
启用后给启用 HTTPS 的网站下发 Service Worker,域名被墙时浏览器从缓存展示此联系页。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
className='shrink-0'
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className='size-3.5 animate-spin' />
|
||||
) : (
|
||||
<Save className='size-3.5' />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex items-start justify-between gap-6'>
|
||||
<div className='space-y-1'>
|
||||
<Label className='text-sm font-medium'>启用 Service Worker 离线兜底</Label>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
仅对 HTTPS 网站生效;未启用的站点不受影响。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={fields.enabled}
|
||||
onCheckedChange={(enabled) =>
|
||||
setFields((prev) => ({ ...prev, enabled }))
|
||||
}
|
||||
aria-label='启用离线兜底'
|
||||
className='mt-0.5 shrink-0'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<Label htmlFor='sw-offline-html' className='text-sm font-medium'>
|
||||
离线联系页 HTML
|
||||
</Label>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
留空则使用内置默认模板。
|
||||
</p>
|
||||
<Textarea
|
||||
id='sw-offline-html'
|
||||
value={fields.html}
|
||||
onChange={(e) =>
|
||||
setFields((prev) => ({ ...prev, html: e.target.value }))
|
||||
}
|
||||
rows={12}
|
||||
className='font-mono'
|
||||
disabled={!fields.enabled}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**注意:** 确认 `frontend/components/ui/` 存在 `textarea.tsx`(shadcn)。若无,用 `make sure` 或 `npx shadcn@latest add textarea` 添加。
|
||||
|
||||
- [ ] **Step 3: 创建页面容器 `responses/page.tsx`**
|
||||
|
||||
用 Tabs 组件组织「错误页」「联系页」两个 tab。错误页 tab 复用现有 `error-pages` 内容或重定向;联系页 tab 渲染 `ContactPageTab`。加载 `OptionService.list()` 传入 optionMap。
|
||||
|
||||
**实现提示:** 为避免重复,错误页 tab 的现有逻辑(`error-pages/page.tsx` 的 policy 卡片 + 预览卡)可先以 `redirect` 到 `/error-pages` 占位,或直接在容器内嵌两 tab。推荐:容器页 `responses/page.tsx` 读取 options,渲染 Tabs(错误页/联系页),错误页 tab 复用 `frontend/app/(main)/error-pages` 现有 UI(通过 import 其组件或在容器内重构)。**保守实现:** 容器页仅放两个 tab,错误页 tab 用 `<Link href='/error-pages'>` 或保留现有 `/error-pages` 路由,联系页 tab 显示新表单;导航入口改为「响应页面」指向 `/responses`。
|
||||
|
||||
- [ ] **Step 4: 更新导航**
|
||||
|
||||
`openflare-nav.ts` 第 63-67 行将「错误页」项改为「响应页面」:
|
||||
|
||||
```ts
|
||||
{
|
||||
title: '响应页面',
|
||||
url: '/responses',
|
||||
childUrls: ['/error-pages', '/responses/contact'],
|
||||
},
|
||||
```
|
||||
|
||||
第 123 行 `openflareWebsiteSubNav` 中 `{ title: '错误页', url: '/error-pages' }` 改为 `{ title: '响应页面', url: '/responses' }`。
|
||||
|
||||
- [ ] **Step 5: 构建前端**
|
||||
|
||||
Run: `cd /Users/ryan/conductor/workspaces/OpenFlare/islamabad/frontend && pnpm type-check`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/app/\(main\)/responses frontend/lib/navigation/openflare-nav.ts
|
||||
git commit -m "feat(frontend): add response pages module with contact page tab"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Changelog 与收尾验证
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/changelog/index.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `[Unreleased]` 下用户可读中文条目。
|
||||
|
||||
- [ ] **Step 1: 追加 changelog**
|
||||
|
||||
在 `docs/changelog/index.md` 的 `[Unreleased]` 下追加:
|
||||
|
||||
```markdown
|
||||
### 新增
|
||||
|
||||
- 支持 Service Worker 离线兜底:为启用 HTTPS 的网站下发 Service Worker 并缓存离线联系页,域名无法访问时浏览器展示联系站长页面,减少用户流失。配置位于「响应页面」-「联系页」,可在版本发布中批量生效。
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行完整校验**
|
||||
|
||||
Run: `cd /Users/ryan/conductor/workspaces/OpenFlare/islamabad && make code-check`
|
||||
Expected: PASS(golangci-lint + 前端类型检查)
|
||||
|
||||
- [ ] **Step 3: 运行后端全量测试**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: 格式化**
|
||||
|
||||
Run: `cd /Users/ryan/conductor/workspaces/OpenFlare/islamabad && make format`
|
||||
Expected: 无格式变更或已应用
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add docs/changelog/index.md
|
||||
git commit -m "docs: sw offline fallback changelog"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec 覆盖检查:**
|
||||
- 全局 Option(sw_offline_enabled / html)→ Task 1-3、5 ✓
|
||||
- 渲染层 SW 静态 + 挑战拦截(反代 + Pages,HTTPS-only)→ Task 4 ✓
|
||||
- SupportFile 下发 sw.js / offline.html,Agent 占位符替换 → Task 4、6 ✓
|
||||
- UA 白名单(真实浏览器特征)→ Task 6 `is_real_browser` ✓
|
||||
- Cookie 长过期 + 首次挑战页 → Task 6 ✓
|
||||
- 前端「响应页面」两 tab → Task 7 ✓
|
||||
- 迁移 seed → Task 2 ✓
|
||||
- Changelog → Task 8 ✓
|
||||
|
||||
**占位符扫描:** 无 TBD/TODO。Task 4 Step 5 与 Task 7 Step 3 保留实现细节提示(非占位,是给定方向让执行者按实际代码确认),已在文中明确标注"实现时确认"。
|
||||
|
||||
**类型一致性:** `ConfigSnapshot.SWOfflineEnabled/HTML` 在 Task 3/4/5 一致;`SWDirPlaceholder` 在 Task 3/4/6 一致;`sw_offline_enabled/sw_offline_html` key 在 Task 1/2/5/7 一致;`renderServiceWorkerChallenger`/`ServiceWorkerSupportFiles`/`EffectiveSWOfflineHTML`/`ManagedSWLuaFiles` 签名跨 Task 一致。
|
||||
|
||||
**已知待确认项(执行时需按实际代码落地):**
|
||||
- Task 4:`renderAccessBlock` 的 access 块合并(避免 WAF/PoW 被覆盖)。
|
||||
- Task 6:`certFileTargetPath` 是否支持子目录,落盘目录创建。
|
||||
- Task 7:`textarea` 组件存在性;「响应页面」错误页 tab 与现有 `/error-pages` 路由的复用策略。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +0,0 @@
|
||||
# Zone 域名重构规格
|
||||
|
||||
已确认的设计:
|
||||
|
||||
* `/websites` 展示可注册根域 Zone;详情 URL 使用 `/websites/:zoneId`。
|
||||
* `managed_domains` 将被彻底替换为 `of_zones` 与 `of_zone_domains`。
|
||||
* Zone 域名是 `of_proxy_routes` 域名与证书的规范化来源;一个域名至多连接一条路由,一条路由可含多个 Zone 的域名。
|
||||
* `of_proxy_routes` 移除 `cert_id`、`cert_ids` 与 `domain_cert_ids`,不再指定证书;配置编译只从关联 Zone 域名的 `cert_id` 查询证书。
|
||||
* Zone 域名只允许明确 FQDN;允许把含 `*.example.com` SAN 的 TLS 证书绑定到明确域名,但不允许通配符域名记录。
|
||||
* 路由仍拥有上游、缓存、限流、WAF 与 Pages;Zone 只提供聚合管理和展示。
|
||||
* 迁移先建新表、用 Public Suffix List 回填和验证,再在后续独立发布中移除旧表及冗余列。
|
||||
|
||||
完整设计、API、迁移与验证策略见 [Zone 与域名资源设计](../../design/zone-design.md)。
|
||||
@@ -1,7 +0,0 @@
|
||||
# Cloudflare DNS 指向 — Spec 指针
|
||||
|
||||
完整设计见项目设计基线:
|
||||
|
||||
**[docs/design/cloudflare-pointing.md](../../design/cloudflare-pointing.md)**
|
||||
|
||||
本文件仅作 brainstorming 工作流落点索引,避免与 `docs/design/` 双份正文漂移。
|
||||
@@ -1,219 +0,0 @@
|
||||
# 边缘限流全局默认设计
|
||||
|
||||
日期:2026-07-19
|
||||
状态:已评审待实现
|
||||
方案:渲染时按站点合并全局默认(方案 A)
|
||||
|
||||
## 背景
|
||||
|
||||
当前边缘限流仅挂在站点(Proxy Route)上,字段为:
|
||||
|
||||
- `limit_conn_per_server`:站点并发连接上限
|
||||
- `limit_conn_per_ip`:单 IP 并发连接上限
|
||||
- `limit_rate`:单请求带宽
|
||||
|
||||
OpenResty 渲染行为:
|
||||
|
||||
- `http {}` 始终声明共享 `limit_conn_zone`
|
||||
- 各站点 `location` 在字段 `>0` / 非空时输出 `limit_conn` / `limit_rate`
|
||||
- 站点值为 `0` 或空表示**关闭**,无全局默认
|
||||
|
||||
期望:在全局增加默认限流策略;站点未设置时继承默认,可覆盖或显式关闭。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 提供三项全局默认限流配置,覆盖全部现有维度。
|
||||
2. 站点 `0`/空 = 继承全局;`-1` = 显式关闭;`>0`/合法带宽串 = 站点自定义。
|
||||
3. 合并发生在配置渲染路径,仍在各站点 `location` 输出生效指令(不在 `http {}` 写默认 `limit_conn`/`limit_rate`)。
|
||||
4. 管理入口:侧栏「安全性」下新增子页「限流」。
|
||||
5. 全局默认初始为 `0`/空,存量发布行为与现网一致。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 引入 `limit_req`(按 RPS 限流)
|
||||
- 在 `http {}` 上下文直接写默认 `limit_conn` / `limit_rate`
|
||||
- 按路径 / URI 差异化限流
|
||||
- 改变 `limit_conn` zone 键模型(仍为 `$server_name` 与 `$binary_remote_addr`)
|
||||
|
||||
## 语义
|
||||
|
||||
### 站点字段
|
||||
|
||||
| 值 | `limit_conn_*` | `limit_rate` |
|
||||
|----|----------------|--------------|
|
||||
| `0` / 空 | 继承全局默认 | 空或 `"0"` 规范化为空串后继承 |
|
||||
| `-1` | 显式关闭该维度 | 字面 `"-1"` 表示显式关闭 |
|
||||
| `>0` / 合法带宽 | 使用站点值 | 合法 `^\d+[kKmM]?$` 使用站点值 |
|
||||
|
||||
### 全局默认
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `0` / 空 | 默认关闭;继承方亦不输出指令 |
|
||||
| `>0` / 合法带宽串 | 作为未配置站点的生效值 |
|
||||
|
||||
全局默认**不允许** `-1`(无意义);仅 `>=0` 或合法 rate / 空。
|
||||
|
||||
### 合并规则(逐字段)
|
||||
|
||||
```
|
||||
if route == -1: effective = off
|
||||
else if route is set: effective = route // conn > 0 或 rate 合法非空
|
||||
else: effective = global // route 为 0/空
|
||||
// global 为 0/空 → off(不输出)
|
||||
```
|
||||
|
||||
`limit_rate` 的「set」判定:规范化后非空且不等于 `"-1"`。
|
||||
|
||||
## 配置存储
|
||||
|
||||
沿用 `system_configs` + Option API + 发布快照,与其它 OpenResty 选项一致。
|
||||
|
||||
| Key | 类型语义 | 默认 |
|
||||
|-----|----------|------|
|
||||
| `openresty_default_limit_conn_per_server` | 非负整数 | `0` |
|
||||
| `openresty_default_limit_conn_per_ip` | 非负整数 | `0` |
|
||||
| `openresty_default_limit_rate` | 空或 `^\d+[kKmM]?$` | `""` |
|
||||
|
||||
实现要点:
|
||||
|
||||
- `internal/model/system_configs.go` 增加 `ConfigKeyOpenRestyDefaultLimit*` 常量
|
||||
- goose seed/升级迁移写入默认值
|
||||
- `internal/apps/openflare/option` 注册校验器(conn ≥ 0;rate 与站点同一套 pattern,允许空)
|
||||
- `openRestyConfigSnapshot` / `buildOpenRestyConfigSnapshot` 增加三字段
|
||||
- 变更进入 OpenResty option diff;**需重新发布配置版本后下发节点**
|
||||
|
||||
## 站点模型与 API
|
||||
|
||||
- DB 列类型不变(`INTEGER` / `VARCHAR(32)`),无 schema 变更
|
||||
- `normalizeProxyRouteLimitConnValue`:允许 `>= -1`(原 `>= 0`)
|
||||
- `normalizeProxyRouteLimitRate`:允许 `"-1"` 存为关闭标记;空/`0` → `""`(继承)
|
||||
- View / Input / 前端类型同步暴露 `-1` 语义
|
||||
- 错误文案更新(非法负数除 `-1` 外拒绝)
|
||||
|
||||
## 渲染路径
|
||||
|
||||
合并**唯一**发生在 `pkg/render/openresty.RenderRouteConfig`:该函数已接收完整 `Document`,可从 `doc.OpenRestyConfig` 读取全局默认,与各 `doc.Routes[i]` 的站点字段合并。Server 预览渲染与 Agent 落地渲染共用同一路径,禁止在 snapshot 构建或其它层再合一次。
|
||||
|
||||
步骤:
|
||||
|
||||
1. 对每个 route:用站点限流字段 + `doc.OpenRestyConfig` 中的默认三项 → `routeLimitConfig`
|
||||
2. `renderRouteLimitBlock` 保持「有值才输出」
|
||||
3. 应用范围不变:
|
||||
- HTTP/HTTPS 反代 `location /`
|
||||
- Pages 相关 location
|
||||
- **不含** HTTP→HTTPS 重定向-only server
|
||||
4. `http {}` 仍只输出现有 `limit_conn_zone` 两行
|
||||
|
||||
快照 JSON **保留站点原始值**(含 `0`/`-1`),不把合并结果写回 route;节点 conf 中只看到最终指令。
|
||||
|
||||
伪代码:
|
||||
|
||||
```go
|
||||
func mergeRouteLimit(route routeLimits, def defaultLimits) routeLimitConfig {
|
||||
return routeLimitConfig{
|
||||
LimitConnPerServer: mergeConn(route.LimitConnPerServer, def.LimitConnPerServer),
|
||||
LimitConnPerIP: mergeConn(route.LimitConnPerIP, def.LimitConnPerIP),
|
||||
LimitRate: mergeRate(route.LimitRate, def.LimitRate),
|
||||
}
|
||||
}
|
||||
|
||||
func mergeConn(route, def int) int {
|
||||
if route == -1 {
|
||||
return 0 // off
|
||||
}
|
||||
if route > 0 {
|
||||
return route
|
||||
}
|
||||
if def > 0 {
|
||||
return def
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func mergeRate(route, def string) string {
|
||||
r := strings.TrimSpace(strings.ToLower(route))
|
||||
if r == "-1" {
|
||||
return ""
|
||||
}
|
||||
if r != "" && r != "0" {
|
||||
return r
|
||||
}
|
||||
d := strings.TrimSpace(strings.ToLower(def))
|
||||
if d != "" && d != "0" {
|
||||
return d
|
||||
}
|
||||
return ""
|
||||
}
|
||||
```
|
||||
## 前端
|
||||
|
||||
### 安全性 → 限流
|
||||
|
||||
- 导航:`openflareSecurityNavGroup` 增加 `{ title: '限流', url: '/rate-limits' }`
|
||||
- 页面:`frontend/app/(main)/rate-limits/page.tsx`
|
||||
- 通过 `OptionService.list` / `updateBatch` 读写上述 3 个 key
|
||||
- UI 模式对齐性能页:标题规范、卡片分区、保存反馈
|
||||
- 文案说明:`0`/空 = 默认关闭;`>0` = 未单独配置站点的默认生效值;修改后需发布配置版本
|
||||
|
||||
### 站点流量限制
|
||||
|
||||
- 更新 `limits-section.tsx` 与校验 helpers:
|
||||
- `0`/空 = 继承全局默认
|
||||
- `-1` = 关闭
|
||||
- `>0` / 合法 rate = 自定义
|
||||
- 可选:展示当前全局默认值作提示(只读)
|
||||
- 创建站点默认仍为 `0`/空(即继承)
|
||||
|
||||
## 兼容性
|
||||
|
||||
| 场景 | 结果 |
|
||||
|------|------|
|
||||
| 升级后全局默认 0,站点全 0 | 与升级前一致:不限流 |
|
||||
| 管理员设置全局默认后发布 | 所有 `0`/空站点自动生效默认 |
|
||||
| 站点需保持关闭 | 将该项改为 `-1` 后保存并发布 |
|
||||
| 旧 API 客户端只写 `0` | 合法;语义变为继承 |
|
||||
| 旧快照无默认字段 | 按 0/空处理 |
|
||||
|
||||
## 边界说明
|
||||
|
||||
- `limit_conn_per_ip` zone 仍按 `$binary_remote_addr` 全局共享;各 location 的 N 可不同,计数空间共享(现网行为,本设计不改)。
|
||||
- 多域名共享一条路由 → 共享合并后策略(产品边界不变)。
|
||||
- 仅改全局默认不自动 reload 节点;走标准「选项变更 → 配置版本 diff → 发布」。
|
||||
|
||||
## 测试计划
|
||||
|
||||
1. **render 表驱动**:继承 / 显式关 / 覆盖 / 全局关 × 三字段
|
||||
2. **normalize**:`-1`、`0`、`>0`、非法负值、rate `"-1"` / 空 / 合法 / 非法
|
||||
3. **snapshot**:默认字段进入 `openresty_config`;option diff 可检测变更
|
||||
4. **option 校验**:非法全局 rate / 负 conn 拒绝
|
||||
5. 前端:限流页读写与站点文案(可选手测)
|
||||
|
||||
## 文档与变更记录
|
||||
|
||||
- 本设计文档:`docs/superpowers/specs/2026-07-19-http-default-rate-limit-design.md`
|
||||
- 实现时更新中文 changelog `[Unreleased]`(用户可见语义与新设置页)
|
||||
- 如有配置参考页,补充三个 key 的中文说明
|
||||
- 不要求同步英文文档
|
||||
|
||||
## 实现落点(文件索引)
|
||||
|
||||
| 区域 | 路径 |
|
||||
|------|------|
|
||||
| 配置键 / seed | `internal/model/system_configs.go`,goose 迁移 |
|
||||
| 校验 | `internal/apps/openflare/option/openresty_validators.go` |
|
||||
| 快照 | `internal/apps/openflare/config_version/snapshot.go` |
|
||||
| 站点规范化 | `internal/apps/openflare/proxy_route/helpers.go` |
|
||||
| 渲染合并 | `pkg/render/openresty/render.go`(及调用处传参) |
|
||||
| 导航 | `frontend/lib/navigation/openflare-nav.ts` |
|
||||
| 限流设置页 | `frontend/app/(main)/rate-limits/` |
|
||||
| 站点 UI | `frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx` |
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 全局默认可在「安全性 → 限流」读写,初始 0/空。
|
||||
2. 全局设为有效值并发布后,站点限流为 0/空的 location 出现对应指令。
|
||||
3. 站点 `-1` 在全局有默认时仍不输出该维度。
|
||||
4. 站点 `>0` 覆盖全局。
|
||||
5. 全局与站点均为 0/空时 conf 无 `limit_conn`/`limit_rate` 指令。
|
||||
6. `make code-check` 通过;相关单测覆盖合并与规范化。
|
||||
@@ -1,217 +0,0 @@
|
||||
# 限流页请求压力分析设计
|
||||
|
||||
日期:2026-07-19
|
||||
状态:已评审待实现
|
||||
方案:Tabs(分析 / 配置)+ 专用 ECharts 双轴压力图(方案 A)
|
||||
|
||||
## 背景
|
||||
|
||||
`/rate-limits` 当前仅为管理员配置全局 OpenResty 默认限流(`limit_conn_*` / `limit_rate`),无请求压力可视化。
|
||||
|
||||
访问日志概览已提供:
|
||||
|
||||
- 过滤:时间预设 `24h | 7d | 15d | 30d` + 域名多选 `hosts[]`
|
||||
- 数据:`GET /api/v1/d/access-logs/overview` → 小时桶 `trends.requests` / `trends.visits`,以及 `top_hosts` / `top_ips`(窗口总请求数)
|
||||
- 图表:共享 `TrendChart` 为**单 Y 轴**;仓库内无 ECharts `dataZoom`、无双轴指标图
|
||||
|
||||
需求:在限流页展示当前请求压力(RPS),默认 24 小时,图表样式对齐「双轴时序面积折线 + 底部缩放条」描述,过滤复用访问日志概览组件,并增加域名/IP 平均 RPS 排行。
|
||||
|
||||
## 目标
|
||||
|
||||
1. `/rate-limits` 改为 Tabs:**分析**(默认)/ **配置**。
|
||||
2. 分析 Tab:概览式过滤 + RPS/访客双轴主图 + 域名/IP 平均 RPS 排行。
|
||||
3. 配置 Tab:迁入现有全局默认限流表单,行为不变。
|
||||
4. 数据复用 `AccessLogService.getOverview`,不新增后端 API。
|
||||
5. 主图为**专用** ECharts 组件,不扩展共享 `TrendChart`。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 新 RPS 时序 API 或峰值桶 RPS 排行接口
|
||||
- 给通用 `TrendChart` 增加双轴 / dataZoom
|
||||
- 配置 Tab 限流语义变更
|
||||
- 分析过滤支持 node_id / IP / path(概览亦无)
|
||||
- 英文文档
|
||||
|
||||
## 页面信息架构
|
||||
|
||||
**路由:** `/rate-limits`(导航「安全性 → 限流」不变)
|
||||
|
||||
| Tab | 内容 |
|
||||
|-----|------|
|
||||
| **分析**(默认) | 过滤条 → `RatePressureChart` → 双排行榜 |
|
||||
| **配置** | 现有三项全局默认限流表单 + 保存 + 链到版本发布 |
|
||||
|
||||
可选:`?tab=config` 直达配置;默认 `analysis`。
|
||||
|
||||
**权限:** 仅管理员(与现页一致)。
|
||||
|
||||
**分析 Tab 自上而下:**
|
||||
|
||||
1. **过滤条**(与访问日志概览一致)
|
||||
- 时间:`24 | 168 | 360 | 720` 小时,默认 **24**
|
||||
- 域名:Zone 树多选 → `hosts[]`
|
||||
2. **主图卡片** `RatePressureChart`
|
||||
3. **排行榜**(并排)
|
||||
- 平均 RPS 最高域名
|
||||
- 平均 RPS 最高 IP
|
||||
|
||||
## 数据与状态
|
||||
|
||||
### 查询
|
||||
|
||||
```ts
|
||||
AccessLogService.getOverview({
|
||||
hours: overviewHours,
|
||||
hosts: overviewHosts.length > 0 ? overviewHosts : undefined,
|
||||
})
|
||||
// queryKey: ['openflare', 'rate-limits', 'overview', hours, hosts]
|
||||
```
|
||||
|
||||
- 过滤变更 → 重新请求 overview
|
||||
- 图表 `dataZoom` **仅**前端缩放已加载序列,**不**改 `hours`、**不**触发 refetch
|
||||
|
||||
### 指标定义
|
||||
|
||||
| 序列 | 源字段 | 换算 | 轴 |
|
||||
|------|--------|------|-----|
|
||||
| 请求速率 (RPS) | `trends.requests[].value` | `value / 3600`(概览固定 1h 桶) | 左 Y |
|
||||
| 独立访客 | `trends.visits[].value` | 桶内 UV,不换算 | 右 Y |
|
||||
|
||||
- 时间点:`bucket_started_at`
|
||||
- Tooltip:时间 + RPS(如 `12.3 req/s`)+ 访客数
|
||||
- 空数据 / 加载 / 错误:对齐访问日志概览空态与 `ErrorInline` / loading
|
||||
|
||||
### 排行口径
|
||||
|
||||
窗口**平均** RPS(与 dashboard `estimated_qps` 一致):
|
||||
|
||||
```
|
||||
avgRps = total_requests / (hours * 3600)
|
||||
```
|
||||
|
||||
- 域名:`top_hosts[]` 的 `value` 为窗口总请求数 → 换算后展示
|
||||
- IP:`top_ips[]` 同理
|
||||
- 标题:「平均 RPS 最高域名」「平均 RPS 最高 IP」
|
||||
- 副文案标明窗口(如「近 24 小时平均」)
|
||||
- UI 组件:现有 `RankCard` / `RankChart`
|
||||
|
||||
**不是**峰值小时桶 RPS;避免新 API。
|
||||
|
||||
## 主图组件 `RatePressureChart`
|
||||
|
||||
### 布局(对齐产品描述)
|
||||
|
||||
1. **外部卡片**:圆角、边框/轻阴影,扁平矩形
|
||||
2. **顶部控制栏**
|
||||
- 左:主标题「请求压力」(字号加粗)
|
||||
- 右:时钟图标 + 当前查询窗口起止(由 `hours` 与「现在」推算本地时间,`YYYY-MM-DD HH:mm:ss`)
|
||||
3. **图例与轴标识**
|
||||
- 左上:左轴属性「RPS」
|
||||
- 右上:图例圆点 +「请求速率」「独立访客」
|
||||
- 最右:右轴单位「访客 / 桶」
|
||||
4. **主绘制区**
|
||||
- 双 Y 轴:左 RPS 从 0 递增;右访客从 0 递增
|
||||
- X 轴:时间,标签两行(月-日 / 时:分),可复用 `formatOverviewTrendLabel` 思路
|
||||
- 水平等距虚线网格
|
||||
- 面积 + 折线,半透明填充,两序列可重叠
|
||||
5. **底部 dataZoom slider**
|
||||
- ECharts `dataZoom: [{ type: 'slider', ... }]`
|
||||
- 宽度对齐绘图区;左右手柄;内嵌缩略波动线
|
||||
- 仅影响可见区间
|
||||
|
||||
### 实现约束
|
||||
|
||||
- 新建专用组件,**不要**给 `TrendChart` 加 dualY/dataZoom
|
||||
- 库:`echarts` + `echarts-for-react`(与看板一致)
|
||||
- 颜色使用主题/CSS 变量或与访问日志趋势相近的语义色,避免硬编码与 shadcn 变体冲突时可参考现有 `TrendChart` 系列色
|
||||
|
||||
## 过滤组件复用
|
||||
|
||||
优先从 `frontend/app/(main)/access-logs/components/overview-tab.tsx` **抽出**:
|
||||
|
||||
- `OverviewToolbar`(或等价)
|
||||
- `OverviewHostFilter`
|
||||
- 依赖的 `OVERVIEW_RANGE_OPTIONS` / `OverviewRangeHours` 已在 `access-log-utils.ts`
|
||||
|
||||
落点建议:
|
||||
|
||||
- 仍放在 `access-logs/components/` 并 export,限流分析 import;或
|
||||
- 若跨模块更清晰,迁到 `frontend/components/common/`(仅当确实跨页面复用且避免循环依赖时)
|
||||
|
||||
**验收:** 访问日志概览过滤行为与抽出前一致。
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
frontend/app/(main)/rate-limits/
|
||||
page.tsx # Tabs、权限、分析/配置挂载
|
||||
components/
|
||||
analysis-tab.tsx # 过滤 + 图 + 排行 + overview query
|
||||
rate-pressure-chart.tsx # 双轴 + dataZoom
|
||||
config-tab.tsx # 现有 Option 表单逻辑迁入
|
||||
```
|
||||
|
||||
可选抽出:
|
||||
|
||||
```
|
||||
frontend/app/(main)/access-logs/components/
|
||||
overview-toolbar.tsx # 从 overview-tab 抽出
|
||||
overview-host-filter.tsx
|
||||
```
|
||||
|
||||
后端:无变更。
|
||||
|
||||
## 边界与兼容
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| 无日志 / ClickHouse 空 | 图与排行空态 |
|
||||
| 仅选域名 | overview 带 `hosts[]` |
|
||||
| dataZoom 拖动 | 不请求后端 |
|
||||
| 非管理员 | 空态「权限不足」 |
|
||||
| 书签 `/rate-limits` | 默认分析 Tab |
|
||||
| 配置保存 | 仍 invalidate options / config-preview / config-versions |
|
||||
|
||||
## 测试与验收
|
||||
|
||||
### 自动化(按项目习惯)
|
||||
|
||||
- 若有 vitest:过滤 props 透传、`avgRps` 换算纯函数单测
|
||||
- 图表以手工/视觉验收为主(ECharts 难做快照)
|
||||
|
||||
### 验收标准
|
||||
|
||||
1. 默认进入分析 Tab,24h,主图展示 RPS + 访客
|
||||
2. 切换 7d / 域名后图与排行刷新
|
||||
3. dataZoom 仅改变可见时间范围
|
||||
4. 排行展示平均 RPS,不是原始请求总数(文案明确「平均」)
|
||||
5. 配置 Tab 可读写三项默认限流并保存
|
||||
6. 访问日志概览过滤不回归
|
||||
7. `make prettier`;相关 typecheck/lint 通过
|
||||
|
||||
## 文档
|
||||
|
||||
- 本设计:`docs/superpowers/specs/2026-07-19-rate-limit-analytics-design.md`
|
||||
- 实现时:`docs/changelog/index.md` `[Unreleased]` 补充用户可见条目
|
||||
- 纯 UI/分析展示,无新 system config 键
|
||||
|
||||
## 实现落点索引
|
||||
|
||||
| 区域 | 路径 |
|
||||
|------|------|
|
||||
| 限流页 | `frontend/app/(main)/rate-limits/` |
|
||||
| 概览过滤复用 | `access-logs/components/overview-tab.tsx` 等 |
|
||||
| Overview API | `AccessLogService.getOverview` |
|
||||
| 排行 UI | `components/data/rank-card.tsx` |
|
||||
| 趋势参考 | `components/data/trend-chart.tsx`(只参考样式,不扩展) |
|
||||
|
||||
## 决策摘要
|
||||
|
||||
| 决策 | 选择 |
|
||||
|------|------|
|
||||
| 页面结构 | Tabs:分析 / 配置 |
|
||||
| 双轴 | 左 RPS,右 独立访客/桶 |
|
||||
| 过滤 | 概览过滤 + 默认 24h 预设 |
|
||||
| 排行 | 窗口平均 RPS = 总请求 / 窗口秒数 |
|
||||
| 图表实现 | 专用 ECharts 组件(方案 A) |
|
||||
| 后端 | 无新 API |
|
||||
@@ -1,122 +0,0 @@
|
||||
# WAF 规则编辑器:节点命名与拖放添加
|
||||
|
||||
日期:2026-07-19
|
||||
范围:`/waf/rules/editor` 前端交互与类型对齐
|
||||
状态:已确认,待实现
|
||||
|
||||
## 背景
|
||||
|
||||
当前 WAF 规则流图编辑器有两处体验问题:
|
||||
|
||||
1. 画布节点只显示类型固定名称(如「IP 匹配」),无法自定义命名,复杂规则难以区分。
|
||||
2. 节点库通过点击添加,新节点落在固定偏移位置(`x: 240, y: 140 + n*24`),无法在目标位置放置。
|
||||
|
||||
后端 `RuleNode` 已具备 `label` 字段(`json:"label,omitempty"`),前端类型与 UI 尚未消费。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 用户可为可编辑节点自定义**显示名称**(`label`),画布与属性栏一致展示。
|
||||
2. 从节点库**拖放到画布**,在鼠标松手处生成节点;**取消点击固定位置添加**。
|
||||
3. 不做备注字段、不做拖到连线中插入、不改后端 schema / `schema_version`。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 节点备注 / note / remark
|
||||
- 拖到边自动拆边插入
|
||||
- 系统节点 `start` / `allow` 可改名
|
||||
- 后端校验、编译或运行时语义变更
|
||||
- 侧栏式节点库大改版
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 后端(已有,不改)
|
||||
|
||||
```go
|
||||
type RuleNode struct {
|
||||
ID string `json:"id"`
|
||||
Type RuleNodeType `json:"type"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Position RulePosition `json:"position"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
}
|
||||
```
|
||||
|
||||
`label` 为空则 omit;现有大小限制与图校验保持不变。
|
||||
|
||||
### 前端
|
||||
|
||||
`WAFRuleNode` 各变体增加可选字段:
|
||||
|
||||
```ts
|
||||
label?: string;
|
||||
```
|
||||
|
||||
- 保存时:空字符串不写入或写 `undefined`,与 `omitempty` 对齐。
|
||||
- 显示时:`label?.trim() || typeDefaultLabel`。
|
||||
- 新建节点:不设 `label`(显示类型默认名)。
|
||||
- `start` / `allow`:属性栏仍为「系统节点无需配置」,不提供改名输入;若历史数据带 `label`,画布仍可按上述规则显示,但不提供编辑入口。
|
||||
|
||||
## UI 行为
|
||||
|
||||
### 画布节点(`rule-node.tsx`)
|
||||
|
||||
| 区域 | 行为 |
|
||||
|------|------|
|
||||
| 主标题 | `label` 去空白后非空则用 `label`,否则用类型默认中文名 |
|
||||
| 副标题 | 仍显示 `rule.id`(mono 小字) |
|
||||
| 图标 / handle | 不变 |
|
||||
|
||||
### 属性栏(`node-properties.tsx`)
|
||||
|
||||
对非系统节点(`ip_match` | `geo_match` | `pow` | `block`),在类型专属配置**之上**增加:
|
||||
|
||||
- 字段标签:`显示名称`
|
||||
- 控件:`Input`,受控绑定 `node.label ?? ''`
|
||||
- 变更:`onChange({ ...node, label: value })`;清空时写 `''` 或去掉字段(实现任选其一,保存序列化时不落空 label)
|
||||
|
||||
系统节点保持现有文案。
|
||||
|
||||
### 节点库与添加(`node-library.tsx` + `rule-flow-canvas.tsx`)
|
||||
|
||||
1. 节点库项设为 `draggable`,`dragstart` 写入节点类型(如 `application/openflare-waf-node` 或等价自定义 MIME + `text/plain` 回退)。
|
||||
2. 移除 `onClick` → `onAdd(type)` 的点击添加路径。
|
||||
3. React Flow 画布容器:
|
||||
- `onDragOver`:`preventDefault`,允许 drop
|
||||
- `onDrop`:读取类型 → `screenToFlowPosition({ x: clientX, y: clientY })` → 创建节点(默认 config 逻辑与现有 `addNode` 相同,但 `position` 为落点)
|
||||
4. 落点后选中新节点,清除边选中(与现有一致)。
|
||||
5. 工具栏仍在画布左上角浮动区域,仅改为拖源,不改为侧栏。
|
||||
|
||||
## 实现落点(文件)
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `frontend/lib/services/openflare/types.ts` | `WAFRuleNode` 增加 `label?` |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx` | 标题显示逻辑 |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx` | 「显示名称」字段 |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-library.tsx` | 拖放源,去掉点击添加 |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx` | drop 落点创建;`addNode` 接受 position |
|
||||
| 相关 `*.test.tsx` / `*.test.ts` | label 展示/编辑、拖放 payload、落点 |
|
||||
|
||||
可选:若序列化路径有显式字段白名单,确认 `label` 会进入保存 payload。
|
||||
|
||||
## 错误与边界
|
||||
|
||||
- 未知 / 非法 drag type:忽略 drop。
|
||||
- 落在画布外:不创建。
|
||||
- 超长 `label`:依赖后端既有图大小/字段限制;前端可不设硬上限,或与常见 Input 一致(如 64–128 字符)——实现阶段若后端有明确上限则对齐。
|
||||
- Undo/脏检查:`label` 与 `position` 变更走现有 `onGraphChange` 路径,不新增独立历史机制。
|
||||
|
||||
## 测试要点
|
||||
|
||||
1. 有 `label` 的节点主标题为自定义名;无 `label` 为类型默认名。
|
||||
2. 属性栏修改 `label` 后 graph 节点更新且画布同步。
|
||||
3. 节点库项可拖;drop 后节点 `position` 接近 flow 坐标(允许测试中 mock `screenToFlowPosition`)。
|
||||
4. 不再通过点击节点库按钮创建节点(无 click-add 行为)。
|
||||
5. 系统节点属性栏仍无「显示名称」。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 可编辑节点可命名,保存再打开名称仍在。
|
||||
- [ ] 画布显示自定义名(空则类型名)。
|
||||
- [ ] 仅拖放添加,松手位置为节点位置。
|
||||
- [ ] 无后端 API / schema 变更;`make code-check` 与相关 vitest 通过。
|
||||
@@ -1,191 +0,0 @@
|
||||
# WAF 规则节点:安全防护(security_check)
|
||||
|
||||
日期:2026-07-19
|
||||
范围:WAF 编排图新节点 `security_check`(控制面校验/编译 + 边缘 Lua 特征检测 + 前端编辑器)
|
||||
状态:已确认,待实现
|
||||
|
||||
## 背景
|
||||
|
||||
现有节点覆盖 IP / 地域 / UA / PoW,缺少请求载荷侧的基础攻击特征检测。产品需要在图中提供可编排的「安全防护」单元:多项基础规则可开关,**命中任意已启用规则返回 false**。
|
||||
|
||||
检测深度采用 **Lua 内置特征规则**(非 ModSecurity/CRS),能拦截常见扫描与明显 payload,允许有限误报/漏报。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 新增 match 型节点 **`security_check`**,句柄 `true` / `false`。
|
||||
2. 属性栏分组:**安全防护**说明 + **基础防护** 9 项 Switch。
|
||||
3. 语义:**任一已启用规则命中 → false**;全部未命中 → true。
|
||||
4. 默认仅开启误报较低的两项:**路径穿越**、**文件包含**;其余默认关闭。
|
||||
|
||||
## 非目标(v1)
|
||||
|
||||
- ModSecurity / OWASP CRS / libinjection 完整引擎
|
||||
- 响应侧 XSS 检测、机器学习
|
||||
- 自定义规则上传 / 严重级别评分 / 命中日志字段(可后续加)
|
||||
- 无限制大 Body 全量扫描
|
||||
|
||||
## 节点模型
|
||||
|
||||
### 类型
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| `type` | `security_check` |
|
||||
| 句柄 | `true`, `false` |
|
||||
| 可删除 / 可命名 / 可拖放 | 是 |
|
||||
|
||||
### Config
|
||||
|
||||
```json
|
||||
{
|
||||
"sql_injection": false,
|
||||
"path_traversal": true,
|
||||
"command_injection": false,
|
||||
"xss": false,
|
||||
"ssrf": false,
|
||||
"file_inclusion": true,
|
||||
"malicious_upload": false,
|
||||
"xxe": false,
|
||||
"crlf_injection": false
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 默认 | UI 文案 | 检测面(v1) |
|
||||
|------|------|---------|--------------|
|
||||
| `sql_injection` | false | SQL 注入 | Query、Cookie、Referer、Body |
|
||||
| `path_traversal` | **true** | 路径穿越防护 | Path(`uri`)、Query、Body |
|
||||
| `command_injection` | false | 命令注入 | Query、Cookie、Referer、Body |
|
||||
| `xss` | false | XSS | Query、Cookie、Referer、Body |
|
||||
| `ssrf` | false | SSRF | Query、Cookie、Referer、Body 中 URL 形态 |
|
||||
| `file_inclusion` | **true** | 文件包含(LFI/RFI) | Path(`uri`)、Query、Body |
|
||||
| `malicious_upload` | false | 恶意文件上传 | Multipart Body |
|
||||
| `xxe` | false | XXE | Body(Content-Type 含 xml 时) |
|
||||
| `crlf_injection` | false | CRLF 注入 | Query、Cookie、Referer、Body |
|
||||
|
||||
全部关闭时:节点恒 **true**(空操作),合法。
|
||||
|
||||
## 求值语义
|
||||
|
||||
```
|
||||
inputs := collect_inspection_strings(request) // 见下
|
||||
for each enabled rule:
|
||||
if rule_matches(rule, inputs) → return false
|
||||
return true
|
||||
```
|
||||
|
||||
- **false** = 命中攻击特征(接阻止)
|
||||
- **true** = 未命中(接通过或其它节点)
|
||||
|
||||
### 采集与限制
|
||||
|
||||
| 来源 | 方式 |
|
||||
|------|------|
|
||||
| Path | 仅 `ngx.var.uri`(不重复扫完整 `request_uri`,避免与 Query 双计),URL 解码(含常见双重编码路径变体) |
|
||||
| Query | `get_uri_args` 键与值(仅当已启用规则需要 Query) |
|
||||
| Header | **不**扫描通用浏览器头(UA / Accept 等);注入类仅采 **Cookie、Referer** |
|
||||
| Cookie | `ngx.var.http_cookie` |
|
||||
| Body | 仅当已启用规则需要 Body 且 `Content-Length` > 0 且 ≤ **65536**;GET/零长度不 `read_body` |
|
||||
|
||||
Body 读取失败:跳过 Body 类检测并限频 warn(可用性优先,不 fail-closed 整图)。
|
||||
|
||||
### 规则特征方向(v1 模式包)
|
||||
|
||||
实现以可维护的模式表为准,下表为方向约束:
|
||||
|
||||
1. **SQL 注入**:`union select`、`or 1=1`、`sleep(`、`benchmark(`、注释符、十六进制/char 拼接等
|
||||
2. **路径穿越**:`../`、`..\\`、`%2e%2e`、`%252e`、绝对路径探测
|
||||
3. **命令注入**:`;` `|` `` ` `` `$()` 结合 shell 关键字、换行拼接
|
||||
4. **XSS**:`<script`、`javascript:`、事件处理器 `onerror=` 等
|
||||
5. **SSRF**:内网 IP、`localhost`、`169.254.`、`file://`、`gopher://`、`dict://`
|
||||
6. **文件包含**:`php://`、`file://`、`/etc/passwd`、`%00` 等(可与路径穿越重叠)
|
||||
7. **恶意上传**:multipart 文件名双扩展、危险扩展、可疑 Content-Type
|
||||
8. **XXE**:`<!ENTITY`、`SYSTEM`、外部实体(仅 XML 类 Content-Type)
|
||||
9. **CRLF**:`%0d%0a`、裸 `\r\n` 注入特征
|
||||
|
||||
模式在 worker 内缓存;大小写不敏感(除明确大小写敏感的协议串)。
|
||||
|
||||
## 控制面
|
||||
|
||||
### `graph_types.go`
|
||||
|
||||
- `RuleNodeSecurityCheck = "security_check"`
|
||||
- `SecurityCheckConfig` 九个 `bool` 字段(JSON snake_case 如上)
|
||||
|
||||
### `graph_validate.go`
|
||||
|
||||
- `requiredHandles`: `true`, `false`
|
||||
- 严格 JSON;仅允许已知布尔字段
|
||||
|
||||
### `graph_compile.go`
|
||||
|
||||
- 原样编译布尔字段进运行时配置
|
||||
|
||||
### 测试
|
||||
|
||||
- 合法全关 / 默认子集 / 全开
|
||||
- 未知字段拒绝
|
||||
- 编译保留默认
|
||||
|
||||
## 数据面
|
||||
|
||||
### `waf_runtime.lua`
|
||||
|
||||
```lua
|
||||
elseif node.type == "security_check" then
|
||||
handle = matches_security_check(node.config or {}) and "true" or "false"
|
||||
```
|
||||
|
||||
`matches_security_check` 返回 **true 表示安全通过**(未命中),与 `ip_match` 的「条件成立」命名不同,但句柄语义与产品一致:命中攻击 → 走 `false` 边。
|
||||
|
||||
建议将模式表与匹配函数放在同文件或 `waf/security.lua`(若体积过大再拆,并在 `waf_assets.go` 嵌入)。
|
||||
|
||||
### `waf_runtime_spec.lua`
|
||||
|
||||
覆盖:默认配置拦路径穿越;全关放行;SQL/XSS 样例;Body 超限不炸;multipart 文件名危险扩展(若开启)。
|
||||
|
||||
## 前端
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `types.ts` | `security_check` + `SecurityCheckConfig` |
|
||||
| `node-factory.ts` | 默认:path_traversal+file_inclusion true,其余 false |
|
||||
| `node-library.tsx` | 「安全防护」+ 图标 |
|
||||
| `rule-node.tsx` | `true`/`false` handles |
|
||||
| `node-properties.tsx` | 显示名称;分组说明 + 9 Switch(问号 Tooltip) |
|
||||
| `graph-validation.ts` / `editor-behavior.ts` | handles |
|
||||
|
||||
### 属性栏草图
|
||||
|
||||
```
|
||||
显示名称
|
||||
── 安全防护 ──
|
||||
命中任意已启用规则返回 False [?]
|
||||
── 基础防护 ──
|
||||
[Switch] 路径穿越防护 [?]
|
||||
[Switch] 文件包含(LFI/RFI) [?]
|
||||
[Switch] SQL 注入 [?]
|
||||
...
|
||||
```
|
||||
|
||||
Tooltip 文案包含检测面与简要说明(与产品表一致)。
|
||||
|
||||
## 文档
|
||||
|
||||
- 更新 `docs/design/waf-orchestration-design.md` 节点表
|
||||
- `docs/changelog/index.md` `[Unreleased]`
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 可拖入并配置 9 开关,默认仅路径穿越+文件包含
|
||||
- [ ] 保存/发布后 Agent 执行;命中 → false 边;未命中 → true
|
||||
- [ ] 全关恒 true
|
||||
- [ ] Lua/Go/前端相关测试与 `make code-check` 通过
|
||||
|
||||
## 风险
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|------|------|
|
||||
| 误报 | 默认仅开低误报两项;模式偏保守 |
|
||||
| 漏报 | 文档标明特征检测边界;后续可加强模式 |
|
||||
| Body 性能 | 64KiB 上限;未启用 Body 规则不读 Body |
|
||||
| 与路径/包含重叠 | 允许重叠;任一命中即 false |
|
||||
@@ -1,228 +0,0 @@
|
||||
# WAF 规则节点:UA 检查(ua_check)
|
||||
|
||||
日期:2026-07-19
|
||||
范围:WAF 编排图新节点 `ua_check`(控制面校验/编译 + 边缘 Lua 运行时 + 前端编辑器)
|
||||
状态:已确认,待实现
|
||||
|
||||
## 背景
|
||||
|
||||
访问日志概览已按 User-Agent 分类浏览器与操作系统(`internal/repository/analytics/browser.go`),但 WAF 规则图尚无基于 UA 的分支节点。运营需要在图中:
|
||||
|
||||
1. 要求请求必须携带 UA;
|
||||
2. 按浏览器 / 操作系统做白名单匹配(and/or 可配);
|
||||
3. 优先屏蔽常见爬虫与非正常 UA。
|
||||
|
||||
## 目标
|
||||
|
||||
- 新增 match 型节点 **`ua_check`**,输出 `true` / `false` 句柄(与 `ip_match` / `geo_match` 一致)。
|
||||
- 属性栏交互与产品草图对齐:开启 UA 检查、匹配多选、屏蔽开关。
|
||||
- 边缘分类标签与访问日志概览一致(同一套 token 规则)。
|
||||
- 屏蔽逻辑优先级高于白名单匹配。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 设备类型(Mobile/Tablet)维度。
|
||||
- 原始 UA 正则 / 自由子串列表(PoW 列表已有,不并入本节点)。
|
||||
- 在 Server 请求路径上执行 WAF 图(仍仅 Agent OpenResty)。
|
||||
- 将 analytics 包直接 import 到 Agent(边缘用 Lua 复刻规则;Go 侧用同一规则表做校验与单测对拍)。
|
||||
|
||||
## 节点模型
|
||||
|
||||
### 类型
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| `type` | `ua_check` |
|
||||
| 句柄 | `true`, `false` |
|
||||
| 可删除 | 是 |
|
||||
| 可命名 | 是(`label`) |
|
||||
| 可拖放添加 | 是 |
|
||||
|
||||
### Config(JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"require_ua": false,
|
||||
"browsers": [],
|
||||
"operating_systems": [],
|
||||
"match_mode": "or",
|
||||
"block_common_bots": false,
|
||||
"block_abnormal_ua": false,
|
||||
"block_custom_ua": false,
|
||||
"custom_ua_patterns": []
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `require_ua` | bool | 开启后:请求头无 UA(空 / 仅空白)→ **false** |
|
||||
| `browsers` | string[] | 白名单浏览器标签;空表示不限制浏览器 |
|
||||
| `operating_systems` | string[] | 白名单操作系统标签;空表示不限制 OS |
|
||||
| `match_mode` | `"and"` \| `"or"` | **浏览器条件与 OS 条件**之间的组合;默认 `"or"` |
|
||||
| `block_common_bots` | bool | 屏蔽常见爬虫:分类 browser 或 os 为 `Bot` → **false** |
|
||||
| `block_abnormal_ua` | bool | 屏蔽非正常 UA:browser ∈ `{Other, Unknown}`(**不含** Bot/搜索引擎爬虫)→ **false** |
|
||||
| `block_custom_ua` | bool | 屏蔽自定义 UA:原始 UA 命中 `custom_ua_patterns` 任一条 → **false** |
|
||||
| `custom_ua_patterns` | string[] | 正则列表(边缘为 Lua 模式);开启 `block_custom_ua` 时至少一条 |
|
||||
|
||||
默认值:开关全 `false`,列表空,`match_mode: "or"`。
|
||||
|
||||
### 允许的标签(封闭枚举)
|
||||
|
||||
与 `ParseBrowserName` / `ParseOSName` 输出对齐:
|
||||
|
||||
**browsers:**
|
||||
`Chrome`, `Safari`, `Firefox`, `Edge`, `Opera`, `Chromium`, `WeChat`, `Postman`, `CLI`, `Bot`, `Unknown`, `Other`
|
||||
|
||||
**operating_systems:**
|
||||
`Android`, `iOS`, `Windows`, `macOS`, `Chrome OS`, `Linux`, `Bot`, `Unknown`, `Other`
|
||||
|
||||
校验:列表元素必须属于上表;重复项编译时去重排序;未知字符串拒绝保存。
|
||||
|
||||
## 求值语义(边缘)
|
||||
|
||||
输入:`ua = http_user_agent`(trim 后判断空)。
|
||||
分类:`browser = ParseBrowserName(ua)`,`os = ParseOSName(ua)`(空 UA → 二者均为 `Unknown`,与 analytics 一致)。
|
||||
|
||||
**严格顺序:**
|
||||
|
||||
```
|
||||
1) if require_ua and ua 为空 → false
|
||||
2) browser, os := classify(ua)
|
||||
3) if block_common_bots and (browser == "Bot" or os == "Bot") → false
|
||||
4) if block_abnormal_ua and browser in {"Other","Unknown"} → false
|
||||
5) if block_custom_ua and UA matches any custom_ua_patterns → false
|
||||
6) has_browsers := browsers 非空; has_os := operating_systems 非空
|
||||
7) if not has_browsers and not has_os → true
|
||||
8) browser_hit := browser ∈ browsers; os_hit := os ∈ operating_systems
|
||||
9) if has_browsers and not has_os → browser_hit
|
||||
10) if has_os and not has_browsers → os_hit
|
||||
11) if both lists set:
|
||||
match_mode == "and" → browser_hit and os_hit
|
||||
match_mode == "or" → browser_hit or os_hit
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- **屏蔽优先于匹配**:步骤 3–5 在白名单之前。
|
||||
- **未配置匹配列表**:步骤 6 直接 true(仅受 require / block 约束)。
|
||||
- **仅一侧列表有值**:只校验该侧是否命中;`match_mode` 仅在两侧都有值时生效。
|
||||
- 节点本身不 allow/block,仅选句柄;下游连线决定动作。
|
||||
|
||||
### 示例
|
||||
|
||||
| 配置摘要 | 请求 | 结果 |
|
||||
|----------|------|------|
|
||||
| 仅 `require_ua` | 无 UA | false |
|
||||
| 仅 `require_ua` | 正常 Chrome | true |
|
||||
| `block_common_bots` | Googlebot | false |
|
||||
| `block_abnormal_ua` | 无法识别 UA | false |
|
||||
| browsers=`[Chrome]`, mode=or | Safari | false |
|
||||
| browsers=`[Chrome]`, os=`[iOS]`, mode=and | Chrome Desktop | false(os 未命中) |
|
||||
| browsers=`[Chrome]`, os=`[iOS]`, mode=or | Chrome Desktop | true |
|
||||
| 列表皆空,无 block | 任意有 UA | true |
|
||||
|
||||
## 分类规则来源
|
||||
|
||||
权威实现(analytics):`internal/repository/analytics/browser.go` 中 `browserRules` / `osRules`。
|
||||
|
||||
实现要求:
|
||||
|
||||
1. **Lua 运行时**复刻相同 token 顺序与 `contains` / `noneOf` 语义(lower-case 子串)。
|
||||
2. **Go 单测**用同一批样例 UA 对拍 `ParseBrowserName` / `ParseOSName` 与 Lua 或共享测试表,防止漂移。
|
||||
3. 不强制本迭代抽取共享包;若抽取,须保持 analytics 与 WAF 行为不变。
|
||||
|
||||
## 控制面
|
||||
|
||||
### `graph_types.go`
|
||||
|
||||
- `RuleNodeUACheck RuleNodeType = "ua_check"`
|
||||
- `UACheckConfig` 结构体对应上表 JSON 字段
|
||||
|
||||
### `graph_validate.go`
|
||||
|
||||
- `requiredHandles`: `true`, `false`
|
||||
- `validateUACheckNodeConfig`:
|
||||
- `match_mode` 仅 `and`/`or`(缺省按 `or` 或拒绝非法值)
|
||||
- browsers / OS 标签 ∈ 封闭枚举
|
||||
- 布尔字段默认 false
|
||||
- `DisallowUnknownFields`
|
||||
|
||||
### `graph_compile.go`
|
||||
|
||||
- 编译进 `RuntimeRuleNode`,列表 `sortedUniqueStrings`
|
||||
- 规范化 `match_mode`(非法不得编译成功)
|
||||
|
||||
### 测试
|
||||
|
||||
- validate:合法配置、非法标签、非法 mode、缺句柄
|
||||
- compile:列表排序去重、默认值
|
||||
|
||||
## 数据面(Agent)
|
||||
|
||||
### `waf_runtime.lua`
|
||||
|
||||
在 `execute_graph` 增加:
|
||||
|
||||
```lua
|
||||
elseif node.type == "ua_check" then
|
||||
handle = matches_ua_check(node.config) and "true" or "false"
|
||||
```
|
||||
|
||||
实现 `matches_ua_check` + 本地 classify 函数;读取 `ngx.var.http_user_agent`。
|
||||
|
||||
### `waf_runtime_spec.lua`
|
||||
|
||||
覆盖:空 UA + require;bot 屏蔽;abnormal;whitelist and/or;列表空;损坏边 fail-closed。
|
||||
|
||||
## 前端编辑器
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `types.ts` | `ua_check` 变体 + `UACheckConfig` |
|
||||
| `node-factory.ts` | 标签「UA 检查」、默认 config、`AddableNodeType` |
|
||||
| `node-library.tsx` | 拖放项 |
|
||||
| `rule-node.tsx` | 图标 + `true`/`false` handles |
|
||||
| `node-properties.tsx` | 属性 UI(见下) |
|
||||
| `graph-validation.ts` | handles + 标签/mode 校验 |
|
||||
| `editor-behavior.ts` | connection handles |
|
||||
|
||||
### 属性栏布局
|
||||
|
||||
```
|
||||
显示名称
|
||||
── UA 检查 ──
|
||||
[Switch] 开启 UA 检查
|
||||
说明:开启后如果请求头不携带 UA 返回 False
|
||||
── UA 匹配 ──
|
||||
匹配模式 Select: 或(or) / 且(and)
|
||||
浏览器 MultiSelect(封闭枚举)
|
||||
操作系统 MultiSelect(封闭枚举)
|
||||
── 屏蔽 ──
|
||||
说明:命中返回 false,优先级高于匹配
|
||||
[Switch] 屏蔽常见爬虫 UA
|
||||
[Switch] 屏蔽非正常 UA
|
||||
```
|
||||
|
||||
前端选项列表写死与封闭枚举一致;展示可用中文副标题,**写入 config 的值必须是英文标签**(与 analytics / 边缘一致)。
|
||||
|
||||
## 文档
|
||||
|
||||
- 更新 `docs/design/waf-orchestration-design.md` 节点表(中文)。
|
||||
- `docs/changelog/index.md` `[Unreleased]` 增加用户向说明。
|
||||
- 纯设计文档不写 changelog 以外的英文同步。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 编辑器可拖入 `ua_check`,配置保存再打开一致。
|
||||
- [ ] 图校验拒绝非法标签与非法 `match_mode`。
|
||||
- [ ] 发布后 Agent Lua 按求值顺序分支;spec 全绿。
|
||||
- [ ] 样例 UA 分类与访问日志 `ParseBrowserName`/`ParseOSName` 一致。
|
||||
- [ ] `make code-check` 与相关 Go/前端/Lua 测试通过。
|
||||
|
||||
## 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|------|------|
|
||||
| Go/Lua 分类漂移 | 共享样例表单测对拍 |
|
||||
| 「非正常」过严误伤 | 产品定义为 Bot/Other/Unknown;可关 switch |
|
||||
| 白名单 + or 过宽 | UI 说明 and/or;默认 or 且列表空不限制 |
|
||||
@@ -1,159 +0,0 @@
|
||||
# 站点级访问频率限制设计
|
||||
|
||||
日期:2026-07-20
|
||||
状态:已评审待实现
|
||||
方案:站点详情 Limits 暴露 `limit_req_per_ip`;渲染时按 effective rate 生成多 `limit_req_zone`,并用站点键隔离 IP 计数
|
||||
|
||||
## 背景
|
||||
|
||||
全局默认已有:
|
||||
|
||||
- `openresty_default_limit_conn_per_server`
|
||||
- `openresty_default_limit_conn_per_ip`
|
||||
- `openresty_default_limit_rate`
|
||||
- `openresty_default_limit_req_per_ip`
|
||||
|
||||
站点级并发/带宽已在「反代站点详情 → 流量限制」中配置,语义为:空/`0` 继承、`-1` 关闭、自定义覆盖。
|
||||
|
||||
请求频率(`limit_req`)后端字段与 merge 已存在,但:
|
||||
|
||||
1. 前端站点详情未暴露 `limit_req_per_ip`
|
||||
2. 渲染侧仅在全局默认非空时输出**单一** `limit_req_zone ... rate=全局值`,站点自定义 rate 无法真正独立生效(nginx 的 rate 写在 zone 上,不能仅靠 location 覆盖)
|
||||
|
||||
## 目标
|
||||
|
||||
1. 在**仅站点详情「流量限制」区块**配置单 IP 请求频率。
|
||||
2. 语义与现有三项一致:空/`0` 继承全局;`-1` 关闭;合法 `Nr/s` / `Nr/m` 为站点自定义。
|
||||
3. 站点自定义 rate **真正按该 rate 生效**(A 站 5r/s、B 站 10r/s 互不影响)。
|
||||
4. 同 IP 在不同站点的频率配额**按站点隔离**。
|
||||
5. 修改后仍需发布配置版本;Agent 使用与 Server 同源的 render 路径。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 在「安全性 → 限流」页增加按站点列表编辑
|
||||
- 新建站点表单中的频率字段
|
||||
- 按路径 / URI 差异化频率限制
|
||||
- 改变 `limit_conn_*` / `limit_rate` 的现有 zone 与合并模型
|
||||
- 业务 Zone(顶级域 + 二级域名资源)模型变更
|
||||
|
||||
## 语义
|
||||
|
||||
### 站点字段 `limit_req_per_ip`(字符串)
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| 空 / `"0"` | 继承全局 `openresty_default_limit_req_per_ip` |
|
||||
| `"-1"` | 本站显式关闭频率限制 |
|
||||
| `^\d+r/[sm]$`(大小写不敏感,存小写) | 本站自定义 rate |
|
||||
|
||||
### 全局默认
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| 空 / `"0"` | 默认关闭;继承方亦不输出 `limit_req` |
|
||||
| 合法 rate | 未配置站点的 effective rate |
|
||||
|
||||
### 合并(与现有 `mergeLimitRate` 一致)
|
||||
|
||||
```
|
||||
if route == -1: effective = off
|
||||
else if route is set: effective = route // 合法 rate
|
||||
else: effective = global // route 空/0
|
||||
// global 空/0 → off
|
||||
```
|
||||
|
||||
## 渲染
|
||||
|
||||
### 问题
|
||||
|
||||
nginx `limit_req_zone` 的 `rate=` 在 zone 声明时固定;多个站点若 effective rate 不同,必须使用不同 zone。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 在 `RenderRouteConfig` / main 配置生成前,对全部 route 计算 effective `LimitReqPerIP`。
|
||||
2. 收集非空 effective rate 的**去重集合**,在 `http {}`(`renderOpenRestyLimitZoneBlock` 扩展,需能访问 routes 或 precomputed rates)输出:
|
||||
|
||||
```nginx
|
||||
# 变量键:站点名 + IP,保证跨站点计数隔离
|
||||
# 实现可用 map 或在 server 内 set 后引用;zone key 采用组合键
|
||||
limit_req_zone $openflare_req_key zone=openflare_req_<rate_token>:10m rate=<rate>;
|
||||
```
|
||||
|
||||
`rate_token` 由 rate 规范化生成(如 `10r/s` → `10rs`,`100r/m` → `100rm`),仅作 zone 名片段,合法 nginx zone 名。
|
||||
|
||||
3. 每个业务 server 在 access 相关位置之前设置:
|
||||
|
||||
```nginx
|
||||
set $openflare_req_key "$openflare_waf_site$binary_remote_addr";
|
||||
```
|
||||
|
||||
(与现有 `set $openflare_waf_site "..."` 同源 site_name;若某 server 无 waf site 变量则用同一 displayName/site_name。)
|
||||
|
||||
4. `renderRouteLimitBlock` 在 effective rate 非空时输出:
|
||||
|
||||
```nginx
|
||||
limit_req zone=openflare_req_<rate_token> burst=<calculateBurst> nodelay;
|
||||
limit_req_status 429;
|
||||
```
|
||||
|
||||
5. **无任何** effective rate 时:不输出任何 `limit_req_zone` / `limit_req`(避免引用不存在的 zone)。
|
||||
|
||||
6. 应用范围与现有 limit 块一致:HTTP/HTTPS 反代 `location /`、Pages 相关 location;不含 HTTP→HTTPS 重定向-only server。
|
||||
|
||||
### 与旧行为差异
|
||||
|
||||
| 项 | 旧 | 新 |
|
||||
|----|----|----|
|
||||
| zone 数量 | 全局最多 1 个 | 按不同 effective rate 多个 |
|
||||
| zone key | `$binary_remote_addr` | `$openflare_req_key`(站点+IP) |
|
||||
| 站点自定义 rate | 无法真正独立 | 引用对应 rate 的 zone |
|
||||
|
||||
快照 JSON **仍保留站点原始值**(含空/`-1`),不把 merge 结果写回 route。
|
||||
|
||||
## 数据与 API
|
||||
|
||||
- 列 `of_proxy_routes.limit_req_per_ip` 已存在;无新迁移(若环境已跑过既有迁移)。
|
||||
- API `Input` / `View` 已有字段;normalize / 校验已存在。
|
||||
- 前端类型与详情表单补齐即可。
|
||||
|
||||
## 前端
|
||||
|
||||
仅改站点详情 `limits-section.tsx`:
|
||||
|
||||
- 增加「单 IP 请求频率」输入
|
||||
- 校验:空、`0`、`-1`、或 `^\d+r/[sm]$i`
|
||||
- 规范化:trim + lower;`0` → `""`
|
||||
- `ProxyRouteItem` / `ProxyRouteMutationPayload` 增加 `limit_req_per_ip`
|
||||
- `buildPayloadFromRoute` 带上该字段,避免其它区块保存时丢失
|
||||
|
||||
文案:与并发/带宽一致(空或 0 继承;-1 关闭;例如 10r/s、100r/m 自定义)。
|
||||
|
||||
## Agent / 发布
|
||||
|
||||
- 配置保存后须**发布配置版本**
|
||||
- Agent **本地** `RenderJSON`;必须部署含本设计 render 的 Agent,否则 source 有字段但 conf 无指令
|
||||
- 若 Agent 已记录同 version/checksum,升级二进制后需触发重新 apply(重启或强制重同步)
|
||||
|
||||
## 测试
|
||||
|
||||
- `mergeRouteLimitConfig`:继承 / 覆盖 / `-1`(已有则补 rate 断言)
|
||||
- 多站点不同 effective rate:main conf 含多个 `limit_req_zone`,各 location 引用正确 zone 名
|
||||
- 全关闭:无 `limit_req` 相关指令
|
||||
- 仅全局有值:一个 zone + 未自定义站点引用该 zone
|
||||
- 前端类型与表单校验(手工或既有模式)
|
||||
|
||||
## 验收
|
||||
|
||||
1. 全局 `10r/s`,站点空 → 该站 location 有 limit_req,zone rate=10r/s
|
||||
2. 站点改 `5r/s` 并发布 → 该站引用 5r/s zone
|
||||
3. 站点 `-1` → 该站无 limit_req
|
||||
4. 两站不同 rate,同 IP 压测互不抢同一配额
|
||||
|
||||
## 实现边界
|
||||
|
||||
| 层 | 工作量 |
|
||||
|----|--------|
|
||||
| 渲染 `pkg/render/openresty` | 多 zone + 站点键 + location 引用 |
|
||||
| 前端详情 Limits + types + payload | 补字段 |
|
||||
| 后端 API/DB | 已具备,仅回归 |
|
||||
| 文档/changelog | 用户可见变更记中文 changelog |
|
||||
@@ -1,253 +0,0 @@
|
||||
# Frontend i18n Design
|
||||
|
||||
Date: 2026-07-24
|
||||
Status: Implemented in OpenFlare (ported from Wavelet 1625cfb, extended to product console)
|
||||
Scope: Frontend UI only
|
||||
|
||||
## 1. Goals
|
||||
|
||||
Add bilingual UI support for Wavelet frontend:
|
||||
|
||||
- Languages: `zh-CN` and `en`
|
||||
- Default locale: `zh-CN`
|
||||
- Locale resolution: explicit user choice → browser language → default
|
||||
- Phase 1: infrastructure + core paths only (layout / auth / settings)
|
||||
- Must remain compatible with `NEXT_STANDALONE_EXPORT` static export
|
||||
|
||||
### Non-goals (Phase 1)
|
||||
|
||||
- Backend API error / message localization
|
||||
- Email / push notification localization
|
||||
- URL locale prefixes (`/en/...`, `/zh-CN/...`) and SEO hreflang
|
||||
- Full translation of all admin business pages
|
||||
|
||||
## 2. Context
|
||||
|
||||
Current state:
|
||||
|
||||
- Root layout hardcodes `lang='zh-CN'`
|
||||
- UI copy is mostly Chinese string literals across many TSX files
|
||||
- Date formatting often hardcodes `zh-CN` / `date-fns/locale` `zhCN`
|
||||
- No i18n library is installed
|
||||
- Frontend supports both normal Next rewrites mode and static export embed mode
|
||||
|
||||
## 3. Approach
|
||||
|
||||
Use **next-intl in non-routing / provider mode**.
|
||||
|
||||
Why this approach:
|
||||
|
||||
- Mature App Router integration and clear `useTranslations` API
|
||||
- ICU message format ready when needed
|
||||
- Avoids locale-prefixed routing, which conflicts with static-export simplicity and current route structure
|
||||
- Cookie + browser detection matches product preference without SEO path requirements
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- Fully custom Context + JSON: lower dependency cost, but reimplements interpolation/plurals/type safety poorly
|
||||
- `i18next` + `react-i18next`: powerful, but heavier and less natural for this Next App Router setup
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
```
|
||||
RootLayout
|
||||
html lang={locale}
|
||||
ThemeProvider
|
||||
CustomThemeProvider
|
||||
AppQueryProvider
|
||||
NextIntlClientProvider(locale, messages)
|
||||
existing User / Notification / Bell providers
|
||||
pages + components
|
||||
```
|
||||
|
||||
### Key files
|
||||
|
||||
| Path | Responsibility |
|
||||
| --- | --- |
|
||||
| `frontend/i18n/config.ts` | Supported locales, default locale, cookie name, normalize helpers |
|
||||
| `frontend/i18n/request.ts` | `getRequestConfig` for server-side locale/messages resolution when not in export mode |
|
||||
| `frontend/i18n/client.ts` | Client helpers to read/write locale preference |
|
||||
| `frontend/messages/zh-CN.json` | Chinese messages |
|
||||
| `frontend/messages/en.json` | English messages |
|
||||
| `frontend/components/common/language-switcher.tsx` (or under `layout/`) | Language switch UI |
|
||||
| `frontend/lib/i18n-format.ts` (optional location under `i18n/`) | Locale-aware date/number formatting helpers |
|
||||
|
||||
### Runtime flow
|
||||
|
||||
1. Resolve locale: cookie `NEXT_LOCALE` → browser languages → `zh-CN`
|
||||
2. Load `messages/{locale}.json`
|
||||
3. Provide locale + messages through `NextIntlClientProvider`
|
||||
4. Components call `useTranslations('<namespace>')`
|
||||
5. Language switcher writes cookie and refreshes locale/messages
|
||||
6. Update `document.documentElement.lang`
|
||||
|
||||
## 5. Locale Resolution
|
||||
|
||||
Supported locales: `zh-CN`, `en`
|
||||
|
||||
Normalization:
|
||||
|
||||
- `zh`, `zh-CN`, `zh-Hans*` → `zh-CN`
|
||||
- `en`, `en-US`, `en-GB`, other `en-*` → `en`
|
||||
- anything else → `zh-CN`
|
||||
|
||||
Priority:
|
||||
|
||||
1. User explicit choice stored in cookie `NEXT_LOCALE`
|
||||
2. Browser language (`Accept-Language` on server, `navigator.languages` on client)
|
||||
3. Default `zh-CN`
|
||||
|
||||
Invalid cookie values are normalized to a supported locale and may be rewritten to a valid value.
|
||||
|
||||
## 6. Static Export Compatibility
|
||||
|
||||
Constraints:
|
||||
|
||||
- No locale-segment routes
|
||||
- No middleware-based locale rewriting required for correctness
|
||||
- `build:embed` (`NEXT_STANDALONE_EXPORT=true`) must continue to work
|
||||
|
||||
Behavior:
|
||||
|
||||
- **Normal SSR/dev**: resolve locale on server when possible to reduce first-paint language flash
|
||||
- **Static export**: ship both message catalogs; resolve on client from cookie/browser; accept a brief default-language flash similar to theme hydration, using existing `suppressHydrationWarning` patterns where needed
|
||||
|
||||
## 7. Message Organization
|
||||
|
||||
Single catalog files with nested namespaces:
|
||||
|
||||
```json
|
||||
{
|
||||
"common": {
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"loading": "加载中..."
|
||||
},
|
||||
"layout": {
|
||||
"nav": {
|
||||
"home": "首页",
|
||||
"myFiles": "我的文件"
|
||||
},
|
||||
"userMenu": {
|
||||
"settings": "设置",
|
||||
"logout": "退出登录"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
"title": "登录",
|
||||
"submit": "登录"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"appearance": {
|
||||
"language": "语言",
|
||||
"languageDesc": "选择界面显示语言"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Conventions:
|
||||
|
||||
- Keys use camelCase and hierarchical grouping
|
||||
- Prefer complete phrases as values; avoid assembling sentences in components
|
||||
- Use ICU only when needed (`{name}`, plural forms)
|
||||
- Backend `error_msg` values are shown as-is in Phase 1
|
||||
- Frontend-owned toast / validation copy is translated
|
||||
|
||||
Both locale files must keep the same key tree. A key-alignment check script is recommended.
|
||||
|
||||
## 8. Language Switcher UX
|
||||
|
||||
Placement:
|
||||
|
||||
- Header toolbar near theme controls
|
||||
- Appearance settings page as an explicit preference row
|
||||
|
||||
UI labels for language options use native names and do not themselves translate:
|
||||
|
||||
- `中文`
|
||||
- `English`
|
||||
|
||||
On change:
|
||||
|
||||
1. Persist `NEXT_LOCALE`
|
||||
2. Apply new locale/messages (via refresh or controlled provider update)
|
||||
3. Sync `document.documentElement.lang`
|
||||
4. Preserve unrelated UI state where practical (theme, auth session, sidebar collapse)
|
||||
|
||||
## 9. Phase 1 Migration Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Install and wire `next-intl`
|
||||
- Message catalogs for core namespaces
|
||||
- Locale resolution + persistence
|
||||
- `LanguageSwitcher`
|
||||
- Translate:
|
||||
- layout shell: sidebar nav/user menu, header accessible labels / titles
|
||||
- auth: login / register / OTP labels, buttons, validation messages
|
||||
- settings: appearance (including language preference), profile, security, notifications, access-token visible copy
|
||||
- Replace date/number hardcoding only where touched by the above paths
|
||||
- Ensure `html lang` reflects active locale
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Remaining admin pages and deep business modules
|
||||
- Backend localization
|
||||
- Route prefixing / SEO alternate links
|
||||
|
||||
Unmigrated pages may remain Chinese hard-coded; mixed-language UI is acceptable during incremental rollout.
|
||||
|
||||
## 10. Formatting Helpers
|
||||
|
||||
Introduce locale-aware helpers for dates/numbers used by migrated surfaces, e.g.:
|
||||
|
||||
- `formatDateTime(value, locale)`
|
||||
- `formatNumber(value, locale)`
|
||||
|
||||
`date-fns` locale objects should follow active locale (`zhCN` / `enUS`) when a migrated component uses them.
|
||||
|
||||
## 11. Error Handling & Fallbacks
|
||||
|
||||
| Case | Behavior |
|
||||
| --- | --- |
|
||||
| Missing message key | Dev warning; do not crash; show key or fallback language value |
|
||||
| Unsupported cookie locale | Normalize to supported locale / default |
|
||||
| Partial migration | Keep hard-coded Chinese on unmigrated screens |
|
||||
| Backend error strings | Display raw `error_msg` |
|
||||
|
||||
## 12. Testing & Acceptance
|
||||
|
||||
Manual:
|
||||
|
||||
1. No cookie + browser Chinese → Chinese UI
|
||||
2. No cookie + browser English → English UI
|
||||
3. Manual switch to English survives refresh
|
||||
4. Manual switch back to Chinese survives refresh
|
||||
5. Core paths (layout/auth/settings) have no major residual hard-coded Chinese UI copy
|
||||
6. `pnpm build` and `pnpm build:embed` both succeed
|
||||
7. Language switch does not break theme, session, or sidebar state
|
||||
|
||||
Automated (recommended):
|
||||
|
||||
- Unit tests for `normalizeLocale` / resolution priority
|
||||
- Script or test asserting `zh-CN.json` and `en.json` key parity
|
||||
|
||||
## 13. Rollout Plan (high level)
|
||||
|
||||
1. Add i18n infrastructure and empty/core message files
|
||||
2. Mount provider and language switcher
|
||||
3. Migrate layout shell copy
|
||||
4. Migrate auth copy
|
||||
5. Migrate settings copy + appearance language control
|
||||
6. Verify SSR and static-export builds
|
||||
7. Document how later pages should adopt `useTranslations`
|
||||
|
||||
## 14. Open Implementation Notes
|
||||
|
||||
- Prefer cookie name `NEXT_LOCALE` unless an existing project cookie convention conflicts during implementation
|
||||
- Prefer minimal surface-area integration with next-intl; avoid introducing locale-based routing APIs that break static export
|
||||
- Keep `internal/util` and backend packages untouched
|
||||
- After implementation, follow repo frontend conventions and existing provider composition style
|
||||
@@ -1,22 +0,0 @@
|
||||
# 源站错误页设计(Spec)
|
||||
|
||||
> 权威正文与产品文档索引见:[docs/design/origin-error-page.md](../../design/origin-error-page.md)
|
||||
> 本文为 brainstorming 流程落库副本,内容与上者保持一致。
|
||||
|
||||
---
|
||||
|
||||
## 摘要
|
||||
|
||||
源站/网关在用户配置的状态码(默认标签 `500-599`,支持 `522` 与 `500-599` 区间)上,返回全局可配置 HTML 错误页,替代当前透传行为。默认 Cloudflare 风格页;可在线自定义 HTML。HTTP **status 保持原错误码**,正文通过 `{{status}}` / `{{host}}` 展示。配置挂在 OpenFlare Option,随配置版本发布;侧栏「网站管理 → 错误页」。可关闭以恢复透传。
|
||||
|
||||
## 方案
|
||||
|
||||
**方案 A(已采纳)**:全局 Option → 配置快照 `ConfigSnapshot` → OpenResty 渲染 `proxy_intercept_errors` + `error_page` + internal location 模板替换。
|
||||
|
||||
非目标:按路由覆盖、上传文件、Pages 路由、改 WAF 自有页。
|
||||
|
||||
## 详细章节
|
||||
|
||||
完整章节(目标、状态码语法、配置模型、边缘渲染、前端、测试、实现清单、决策记录)见:
|
||||
|
||||
**[docs/design/origin-error-page.md](../../design/origin-error-page.md)**
|
||||
@@ -1,177 +0,0 @@
|
||||
# 日志数据库解耦设计(ClickHouse 可选化)
|
||||
|
||||
> 状态:已与用户逐段确认,待用户复核。
|
||||
> 日期:2026-08-08
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前系统日志/分析(访问日志、可观测时序)完全绑定 ClickHouse:`internal/repository/analytics` 直接操作 `db.ChConn`/`db.ChDB`,apps 层(`chwriter`、`risk_control`、`admin/logs`、`admin/status`)依赖 `config.ClickHouse.Enabled` 判断可用性。业务流量小、主机性能低时 ClickHouse 负担大。
|
||||
|
||||
目标:
|
||||
|
||||
1. **解耦**:ClickHouse 变为可选项;不启用时,主库(PostgreSQL;禁用时 SQLite)完整承接全部日志功能(写入、查询、聚合、清理)。
|
||||
2. **代码级约束**:上层应用写日志不能直接调用底层库(`analyticsrepo` / `db.ChConn`),用接口 + import-lint 测试保证,而非 AGENTS.md 口头约束。
|
||||
3. **可迁移**:提供用户触发的「切换日志数据库」任务,支持 PostgreSQL/SQLite ↔ ClickHouse 数据迁移。
|
||||
4. **表结构**:CH 日志表迁入 PG/SQLite;CH 保持只有日志表的 SQL 脚本;PG/SQLite 包含全部表。
|
||||
|
||||
## 2. 现状要点
|
||||
|
||||
- 连接:`internal/infra/persistence/clickhouse.go`(`ChConn` 原生批量写 + `ChDB` GORM 查询),`init()` 依据 `clickhouse.enabled`。
|
||||
- 分析域:`internal/repository/analytics/` 直接读写 CH;apps 通过 `batchwriter` 异步 flush(`chwriter`、`risk_control`)。
|
||||
- 已有抽象雏形:`internal/repository/openflare_access_log_store.go` / `openflare_observability_store.go` 中的未导出 `accessLogStore` / `observabilityStore` 接口,默认 `clickhouseAccessLogStore{}`,测试可换 memory 实现——默认写死 CH、不可配置切换、接口未导出。
|
||||
- 迁移:主库 goose(`goose/postgres` + `goose/sqlite` 双方言)与 CH 单方言(`goose/clickhouse`)分离。
|
||||
- 历史:PG/SQLite 曾有过 `of_node_metric_snapshots`、`of_node_access_logs` 等观测表(`202606190010_create_of_observability_tables.sql`),后由 `202606200005_drop_of_node_observability_timeseries.sql` 删除(迁去 CH)。**旧 DDL 可复活改造**。
|
||||
- 任务:Asynq + `task.RegisterHandler`/`RegisterTaskMeta`;`system_cleanup`(系统垃圾清理)每日任务已存在;`of_database_auto_cleanup`(可观测清理,schedule id=102)存在。
|
||||
- 系统配置:`system_configs` 表(key/type/visibility),现有 `database_auto_cleanup_enabled` / `database_auto_cleanup_retention_days`(business)。
|
||||
|
||||
## 3. 已确认的核心决策
|
||||
|
||||
| # | 决策 |
|
||||
|---|---|
|
||||
| 1 | 范围:CH 不启用时,PG(或 SQLite)承担**全部**日志功能;聚合在 PG/SQLite 查询时实时计算,不物理建 MV 同构表。 |
|
||||
| 2 | 实现:接口定义在 repository 层;PG 用 GORM 全新实现;CH 保留现有原生批量优化(`PrepareBatch`)包进同一接口。 |
|
||||
| 3 | SQLite 是一等公民:`log_database` ∈ {`postgres`, `sqlite`, `clickhouse`};迁移方向 PG→CH、SQLite→CH、CH→PG、CH→SQLite。 |
|
||||
| 4 | 日志库只有两种合法状态:**随主库**(`database.enabled` → postgres,否则 sqlite)或 **clickhouse**;不存在主库 PG + 日志 SQLite 的组合。 |
|
||||
| 5 | 迁移任务「切换日志数据库」:纯复制、**源数据不删除**、可重试;迁移期间**冻结日志写入**(拒绝,不排队积压);全部成功才翻转主库标记。 |
|
||||
| 6 | 清理统一到 `system_cleanup`(每日一次,日志过期无需实时);保留时间按**存储库**配置(`type=business`)。 |
|
||||
|
||||
## 4. 包结构与接口(方案一)
|
||||
|
||||
新增 `internal/repository/logstore/`,职责唯一:日志存储抽象。
|
||||
|
||||
```
|
||||
internal/repository/logstore/
|
||||
├── logstore.go # 导出接口:AccessLogStore / ObservabilityStore / UserAccessLogStore / CleanupStore / StatusStore
|
||||
├── provider.go # Open(ctx) 按当前日志主库返回实现;ActiveDatabase() 供状态/UI;测试可注入
|
||||
├── postgres_store.go # GORM 实现(PG 与 SQLite 共用一套,方言差异只在 goose DDL + dialect_* 小文件)
|
||||
├── dialect_postgres.go # PG 方言 SQL 片段(date_trunc / FILTER / 分区清理)
|
||||
├── dialect_sqlite.go # SQLite 方言 SQL 片段(strftime / unixepoch)
|
||||
└── clickhouse_store.go # 把现有 analyticsrepo 原生批量 + GORM 查询包进接口(零性能损耗)
|
||||
```
|
||||
|
||||
- **接口划分**(避免 40+ 方法巨型接口,合成 `logstore.Store` 结构体持有):
|
||||
- `AccessLogStore`:节点访问日志的 InsertBatch / List / Count / RegionCounts / BucketAggregates / CountBuckets / BucketDimensions / IPAggregates / IPSummaries / CountIPSummaries / WAFIPAggregates / IPTrend / TrafficSummary / ValueCounts / NodeAggregates / DeleteAll / DeleteBefore / DeleteByNodeBefore。
|
||||
- `ObservabilityStore`:4 表(metric snapshots / edge health / frps / frpc)的 Insert / List / Delete。
|
||||
- `UserAccessLogStore`:`w_user_access_logs` 的 BatchInsert / Count / List / 统计(DailyTrend / BrowserDistribution / TopActiveUsers 等)。
|
||||
- `CleanupStore`:按保留天数清理过期数据(PG=分区 DROP + 分批 DELETE;SQLite=分批 DELETE;CH=MODIFY TTL + materialize)。
|
||||
- `StatusStore`:当前库状态、CH 运行指标(激活时)、GORM 写入器状态。
|
||||
- **消费面**:`internal/repository` 现有公开函数(`ListOpenFlareAccessLogs`、`InsertOpenFlareAccessLogsBatch`、`InsertOpenFlareMetricSnapshot` 等)**保留签名、改为一行委托 `logstore`**,apps 调用面几乎不动;apps 里现有 `analyticsrepo` 直连(`risk_control`、`chwriter`、`tasks/database_cleanup.go`、`observability/access_log_logics.go`、`admin/logs`、`admin/status`)全部改走 repository/logstore。
|
||||
- **import-lint 测试**:新增 `go test`,扫描 `internal/apps/**` 的 import,发现 `internal/repository/analytics` 或 `internal/infra/persistence`(`batchwriter` 白名单除外)即失败。这是「代码层面规避」的验收。
|
||||
- `analyticsrepo` 保留,仅被 `logstore/clickhouse_store.go` 引用(CH 实现细节)。
|
||||
|
||||
### 主库标记与启动校验
|
||||
|
||||
- `system_configs` 新增内部 key:
|
||||
- `log_database`(`postgres`/`sqlite`/`clickhouse`):当前日志主库,仅迁移任务写入。
|
||||
- `log_db_migration`(`"migrating"`/空):迁移冻结标记,仅迁移任务写入。
|
||||
- **首次 seed**(bootstrap Go 侧,因依赖运行时主库选择):key 缺失时,`clickhouse.enabled` → `clickhouse`(保持现状、不丢现有 CH 数据);否则 → 当前主库(`database.enabled` → `postgres`,否则 `sqlite`)。
|
||||
- **启动校验**(bootstrap):
|
||||
- `log_database=clickhouse` 但 `clickhouse.enabled=false` → 启动报错:「当前日志主库为 ClickHouse 但 ClickHouse 未启用。请先重新启用 ClickHouse 配置并启动,在任务管理运行『切换日志数据库』迁移到 PostgreSQL/SQLite 后再禁用 ClickHouse」。
|
||||
- `log_database=postgres` 但 `database.enabled=false`,或 `log_database=sqlite` 但 `database.enabled=true` → 启动报错(违反「随主库或随 CH」规则)。
|
||||
- **key 保护**:`log_database`、`log_db_migration` 在配置更新接口(admin system-configs / option 校验)拒绝修改;仅迁移任务可写;启动校验兜底被篡改组合。
|
||||
- **热切换**:`logstore` 通过系统配置缓存(Redis,更新即失效)读取 `log_database`;翻转后 API 进程自动切到新实现,无需自定义跨进程协议。
|
||||
|
||||
## 5. PG/SQLite 表结构与优化
|
||||
|
||||
**新建原始日志表(PG + SQLite 双方言 goose,同版本号)**——只建原始表,**不建** CH 物化视图/聚合表(`of_access_log_hourly`、`of_node_metric_capacity_hourly` 等),PG/SQLite 查询时实时聚合:
|
||||
|
||||
| 表 | 说明 |
|
||||
|---|---|
|
||||
| `w_user_access_logs` | 用户访问日志 |
|
||||
| `of_node_access_logs` | 节点访问日志(含 user_agent/cache_status/bytes_sent/request_length/request_time_ms 现行列) |
|
||||
| `of_node_metric_snapshots` | 资源指标 |
|
||||
| `of_node_edge_health` | 边缘健康 |
|
||||
| `of_node_obs_frps` | FRPS 观测 |
|
||||
| `of_node_obs_frpc` | FRPC 观测 |
|
||||
|
||||
- **ID**:沿用 snowflake uint64(DDL 用 BIGINT,与 CH UInt64 对齐);不换自增,保证迁移 ID 原样保留、无冲突。
|
||||
- **时间**:PG `TIMESTAMPTZ`;SQLite `DATETIME`。
|
||||
- **复合主键**:分区表主键 `(id, 时间列)`(满足 PG 分区键进唯一索引要求)。
|
||||
|
||||
### PG 优化
|
||||
|
||||
1. **分区**:仅 `of_node_access_logs`、`w_user_access_logs` 两个高频表用 PG 原生 `PARTITION BY RANGE` **按月分区**;可观测 4 表数据量小,普通表 + 索引。SQLite 无原生分区 → 普通表 + 组合索引(方言差异只留在 goose DDL,运行时 GORM 代码共用)。
|
||||
2. **批量写入**:PG/SQLite 统一 GORM `CreateInBatches`(批次 500–1000);CH 维持原生 `PrepareBatch`。
|
||||
3. **索引**:
|
||||
- `of_node_access_logs`:`(logged_at DESC)`、`(node_id, logged_at DESC)`、`(host, logged_at DESC)`;
|
||||
- `w_user_access_logs`:`(created_at DESC)`、`(user_id, created_at DESC)`;
|
||||
- 可观测表:`(node_id, captured_at DESC)`。
|
||||
4. **聚合查询重写**:PG 用 `date_trunc` / `count(DISTINCT)` / `FILTER (WHERE ...)` 等价替换 CH 的 `toStartOfHour` / `uniqExact` / `countIf`;SQLite 用 `strftime` / `unixepoch`。时间分桶等少量方言 SQL 拆到 `dialect_postgres.go` / `dialect_sqlite.go`,store 主体方言中立。
|
||||
|
||||
### goose 迁移
|
||||
|
||||
- PG/SQLite 各新增一组建表迁移(复活并改造 `202606190010` 旧 DDL,按 database-migration 技能双方言、同版本号规则)。
|
||||
- CH 目录不动(本来就只有日志表脚本,满足「CH 保持只有日志表 SQL」)。
|
||||
|
||||
## 6. 清理(并入 system_cleanup)
|
||||
|
||||
- 日志过期清理并入 `system_cleanup`(系统垃圾清理)每日任务;`of_database_auto_cleanup` 专用 schedule(id=102)与任务下线。
|
||||
- 新增 `type=business` 配置(替换旧 `database_auto_cleanup_enabled` / `database_auto_cleanup_retention_days`):
|
||||
- `log_retention_days_postgres`(默认 90)
|
||||
- `log_retention_days_sqlite`(默认 90)
|
||||
- `log_retention_days_clickhouse`(默认 90)
|
||||
- `CleanupStore` 按当前生效库读取对应值执行:
|
||||
- PG:分区 DROP(整月)+ 分批 DELETE(不满月);
|
||||
- SQLite:分批 DELETE;
|
||||
- CH:`ALTER TABLE ... MODIFY TTL toDateTime(...) + INTERVAL N DAY` + materialize(保留期由配置驱动,不再依赖 DDL 写死)。
|
||||
- 旧 key `database_auto_cleanup_*` 由 goose 迁移删除,前端同步清理。
|
||||
|
||||
## 7. 迁移任务「切换日志数据库」
|
||||
|
||||
**元数据**:Asynq `openflare:log_db_switch`,管理类型 `of_log_db_switch`,名称「切换日志数据库」,参数 `target`(`postgres`/`sqlite`/`clickhouse`),`Retryable: true`。UI 按当前日志主库只展示合法目标(当前=CH → 「主库」;当前=主库 → 「ClickHouse」)。
|
||||
|
||||
**执行流程(worker 进程)**:
|
||||
|
||||
1. **校验**:`target == 当前主库` → 拒绝;`target=clickhouse` 但 CH 未启用 / `target=postgres` 但 `database.enabled=false` / `target=sqlite` 但 `database.enabled=true` → 拒绝。
|
||||
2. **写冻结**:写 `log_db_migration = "migrating"`;先让 batchwriter 把在途批次 flush 完;此后 API 进程所有日志写入路径(`risk_control`、`chwriter` 队列、agent 上报落库)检查该 key → 返回明确错误(HTTP 503「日志数据库迁移中,暂不可写」),不排队积压。
|
||||
3. **复制**:6 张原始日志表逐表、按 id 分批(每批 ~1000)读源 → 写目标(CH→主库用 GORM `CreateInBatches`;主库→CH 用原生 `PrepareBatch`);ID 原样保留;每表/每批 `task.AppendLog` 进度。
|
||||
- **幂等前提**:开始复制前**清空目标库日志表**(任务参数「覆盖目标库已有日志」默认开启;目标库通常为空,仅「切回去」场景有旧数据)——保证失败重试可重跑不重复。
|
||||
4. **翻转**:全部成功 → 更新 `log_database = target`、清除迁移标记 → `logstore` 缓存失效自动切到新实现 → 写入恢复(走新库)。
|
||||
5. **失败**:返回错误触发 Asynq 重试;**失败时清除迁移标记**,写入继续走源库(不丢功能);重试时重新清空目标 + 复制。
|
||||
|
||||
**双进程一致性**:迁移标记与主库标记落在 `system_configs`(Redis 缓存,worker 更新后 API 进程自动失效重读)。
|
||||
|
||||
## 8. API 与前端
|
||||
|
||||
**后端**:
|
||||
|
||||
- `GET /api/v1/admin/status/log-database`(改造现有 `/clickhouse` 状态端点):返回当前日志主库、迁移状态(`idle`/`migrating`)、各库保留天数、当前合法迁移目标;CH 为主时附带现有 CH 运行指标,主库为主时附带 GORM 写入器状态。
|
||||
- 任务「切换日志数据库」走现有任务管理通用派发 API(`RegisterTaskMeta` + Params),无需新派发接口;执行记录/进度复用任务框架。
|
||||
- 系统配置:新增 3 个 `log_retention_days_*`(business)图形化 + 参数表可见;新增内部 `log_database`、`log_db_migration`(system、visibility=0、受保护);下线 `database_auto_cleanup_*`。
|
||||
|
||||
**前端**:
|
||||
|
||||
- 任务管理页:出现「切换日志数据库」,参数下拉只显示合法目标;页面展示当前日志主库与迁移状态。
|
||||
- `/admin/settings` 业务配置:新增「日志保留时间」分组(PG/SQLite/CH 三个数字输入)。
|
||||
- 状态/仪表盘:日志库状态卡片(当前库 + 迁移中提示)。
|
||||
|
||||
## 9. 测试与验证
|
||||
|
||||
- **import-lint 测试**:`internal/apps/**` 不得 import `internal/repository/analytics`、`internal/infra/persistence`(`batchwriter` 白名单除外),违规即失败。
|
||||
- **logstore 单测**:GORM 实现用 SQLite 全量跑;PG 专属(分区 DROP 等)走既有集成测试路径;CH 实现复用现有 analyticsrepo 测试。
|
||||
- **迁移任务测试**:目标/组合校验、批处理与 ID 保留、清空目标、翻转标记、失败清标记回退、冻结期写入拒绝——用 memory/sqlite 双端模拟,不依赖真实 CH。
|
||||
- **清理测试**:`system_cleanup` 日志清理步骤(PG 分区 DROP / SQLite 分批 DELETE / CH TTL 修改)与保留配置读取。
|
||||
- **迁移验证**:goose 空库 Up 全量(PG/SQLite/CH 三套)、`go test ./...`、`make swagger`(API 变更)、`make code-check`、`make format`。
|
||||
|
||||
## 10. 非目标(YAGNI)
|
||||
|
||||
- 不在 PG/SQLite 物理建聚合/物化视图表(查询实时聚合)。
|
||||
- 不做 PG ↔ SQLite 日志互迁(非法组合,启动校验拒绝)。
|
||||
- 迁移成功不自动删除源库数据(保留,后续提供手动清理入口)。
|
||||
- 不引入 PG COPY 协议(GORM `CreateInBatches` 对低流量足够)。
|
||||
- 不引入自定义跨进程迁移协议(`system_configs` + Redis 缓存即可)。
|
||||
|
||||
## 11. 里程碑建议(供实现计划分解)
|
||||
|
||||
1. **M1 抽象与改造**:`logstore` 接口 + PG/SQLite 实现 + `clickhouse_store` 包装 + import-lint 测试 + repository 委托改造 + apps 直连改造 + `log_database`/`log_db_migration` key 与启动校验。
|
||||
2. **M2 表与清理**:goose 双方言建表迁移 + 保留配置 key + `system_cleanup` 日志清理步骤 + 下线 `of_database_auto_cleanup` 与旧配置。
|
||||
3. **M3 迁移任务与展示**:迁移任务 Handler + 状态端点 + 任务管理页/业务配置前端 + 日志库状态卡片。
|
||||
4. **M4 收尾**:全量验证(goose 三套、单测、`make code-check`/`swagger`/`format`)、文档同步(中文)、changelog `[Unreleased]`。
|
||||
|
||||
## 12. 实现归档说明(Task 18,2026-08-08)
|
||||
|
||||
- 设计稿第 4 节 provider 入口写作 `Open(ctx)`,实现命名为 `Active(ctx)`(按 `log_database` 解析并缓存,配置翻转后重建),另导出 `Build(ctx, database)` / `BuildForMigration(ctx, database)` 供迁移任务构造目标库 store;`ActiveDatabase(ctx)` 供状态端点。
|
||||
- 设计稿第 4 节列出的 `CleanupStore` 接口未单独落地:清理实现为包级 `CleanupExpired(ctx)`(按当前激活库保留天数删除过期日志并预建 PG 分区),由 `system_cleanup` 每日任务调用。
|
||||
- 设计稿第 4 节列举的 `tasks/database_cleanup.go` 已随 M2 下线(`of_database_auto_cleanup` 配置与前端 UI 一并移除),日志清理职责并入 `system_cleanup`。
|
||||
- 迁移复制按 id 升序分页,`copyObservability` 以每批最后一条 id 作为下一批游标(修正计划中 `lastID += n` 的近似写法);失败回退由 `defer setMigrationFlag("")` 保证源库恢复可写,重试前先清空目标库保证幂等。
|
||||
- 其余实现决策(`SetConfigReader` 注入、`ensureWritable` 统一冻结、解析 helper 迁至 `model/analytics` 等)见计划「自检记录」,与本文档一致。
|
||||
@@ -1,117 +0,0 @@
|
||||
# Service Worker 离线兜底设计(issue #23)
|
||||
|
||||
- 日期:2026-08-08
|
||||
- 状态:设计已确认
|
||||
- 范围:Proxy Route(反代)+ Pages 静态托管 全覆盖
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当 CDN 域名被墙、浏览器对所有网络请求失败时,用户会直接流失。本功能通过给网站下发 Service Worker,缓存一个"联系站长"离线页;域名被墙后,SW 从缓存吐出该页,保留用户并引导联系站长。
|
||||
|
||||
核心约束:
|
||||
|
||||
- 平台一键批量下发,避免逐个 Agent 配置。
|
||||
- 不改源页代码,全部在 OpenResty 边缘层完成。
|
||||
- 覆盖反代(Proxy Route)与 Pages 静态托管两种网站类型。
|
||||
|
||||
## 2. 机制总览
|
||||
|
||||
采用「首次挑战页 + Cookie 放行 + UA 白名单」模式,替代 `sub_filter` 响应体重写。
|
||||
|
||||
| 环节 | 行为 |
|
||||
|---|---|
|
||||
| 真实浏览器 UA(含特征版本,如 `Chrome/120`)首次访问首页 | 返回 SW 挑战页(内嵌 `register('/sw.js')` 与离线页预缓存),设置长过期 Cookie |
|
||||
| 带 Cookie 的请求 | 直接放行到上游,正常返回真实页面 |
|
||||
| 未知 UA(爬虫、curl,无真实浏览器特征) | 直接放过,交给 WAF 处理,拿到真实内容 |
|
||||
|
||||
### 为什么不用 sub_filter
|
||||
|
||||
`sub_filter` 需处理上游 gzip / Content-Type / 大响应扫描 / 流式缓冲等多处坑。本方案不改上游 body,整体替换首次响应,以上问题全部规避;且爬虫(不匹配真实浏览器 UA)天然绕过挑战页,不伤 SEO。
|
||||
|
||||
## 3. 分层职责
|
||||
|
||||
```
|
||||
apps/proxy_route ─┐
|
||||
apps/pages ─┼─ model → repository → 渲染(pkg/render/openresty) → Agent(OpenResty)
|
||||
前端设置卡 ─┘ ↑ SW 挑战页 + sw.js/offline 落盘
|
||||
```
|
||||
|
||||
### 后端数据(全局 Option,与 origin error page 同模式)
|
||||
|
||||
`sw_offline` 相关配置作为**全局 SystemConfig / OpenRestyConfig snapshot 字段**,对所有启用 HTTPS 的路由生效,实现"一键批量下发"。新增字段:
|
||||
|
||||
- `sw_offline_enabled`:是否启用 SW 离线兜底
|
||||
- `sw_offline_html`:联系站长离线页 HTML 内容(默认提供内置模板)
|
||||
|
||||
### 渲染层(`pkg/render/openresty`)
|
||||
|
||||
新增 `renderServiceWorkerChallenger(cfg ConfigSnapshot)` 工具,为真实提供内容的 HTTPS server 块(`sw_offline_enabled` 且 `EnableHTTPS` 时)输出:
|
||||
|
||||
```nginx
|
||||
# SW 脚本 + 离线页(作为 support file 落盘)
|
||||
location = /sw.js { alias .../sw.js; add_header Service-Worker-Allowed /; }
|
||||
location = /offline.html { alias .../offline.html; }
|
||||
|
||||
# 仅首页拦截:真实浏览器 UA 且无 cookie → 返回 SW 挑战页
|
||||
# 否则(带 cookie / 未知 UA)→ 放行到上游
|
||||
location = / {
|
||||
if (真实浏览器UA && 无cookie) { content_by_lua 返回 SW 挑战页; }
|
||||
放行到上游;
|
||||
}
|
||||
```
|
||||
|
||||
- SW 逻辑:`install` 阶段缓存 `/offline.html`;`fetch` 事件在网络失败时返回 `caches.match('/offline.html')`。
|
||||
- 仅在 `EnableHTTPS` 时注入(SW 要求 HTTPS 安全上下文)。
|
||||
- 多域名 server 块:`/sw.js`、`/offline.html`、挑战页在各 `server_name` 下同源可达。
|
||||
- 仅对首页 `location = /` 触发;js/css/图片/API/子页面请求不拦,零额外开销。
|
||||
|
||||
## 4. 数据流
|
||||
|
||||
```
|
||||
用户首次访问首页(真实UA, 无cookie)
|
||||
→ OpenResty 判断:真实UA && 无cookie
|
||||
→ 返回 SW 挑战页 (内嵌 register + 预缓存 offline.html)
|
||||
→ 浏览器执行 → 注册 SW → 设置长过期 cookie
|
||||
→ 用户再次请求(带cookie)
|
||||
→ 放行到上游,正常返回真实页面
|
||||
域名被墙后
|
||||
→ 所有请求失败 → SW fetch 兜底 → 从缓存返回 /offline.html(联系页)
|
||||
```
|
||||
|
||||
## 5. 边界与风险
|
||||
|
||||
| 项 | 处理 |
|
||||
|---|---|
|
||||
| 首次即被墙的用户 | SW 未注册,兜底无效(所有 SW 方案共性,接受) |
|
||||
| HTTP-only 站点 | 跳过注入(SW 需 HTTPS) |
|
||||
| 反代多域名 | 各域名同源提供 sw.js / offline.html / 挑战页 |
|
||||
| Cookie 过期 | 设长过期(约 1 年),过期后重新走一次挑战页 |
|
||||
| 未知 UA | 放过并交给 WAF 处理,不重复拦截 |
|
||||
| 资源/API 请求 | 不拦,仅首页触发 |
|
||||
|
||||
## 6. 测试
|
||||
|
||||
- 渲染层单元测试:
|
||||
- `sw_offline_enabled` 时输出 sw.js / offline.html / 挑战页 location
|
||||
- 非 HTTPS 或未启用时不输出
|
||||
- 仅首页触发,子路径/资源不触发
|
||||
- UA 判定:真实浏览器 / 爬虫 / curl 三种 UA 的放行分支。
|
||||
- Cookie 有无的放行分支。
|
||||
- 现有 config snapshot checksum / rebind 测试不回归。
|
||||
|
||||
## 7. 前端命名与入口
|
||||
|
||||
离线联系页设置与现有 origin error page 设置合并为同一个功能模块,命名为**「响应页面」**(路由 `responses`),内含两个 tab:
|
||||
|
||||
- **错误页设置**:源站错误兜底页(现有 origin error page)
|
||||
- **联系页设置**:SW 离线兜底联系页(本功能)
|
||||
|
||||
两者同属「边缘层兜底展示页」语义,统一管理与入口。
|
||||
|
||||
## 8. 待实现确认项(写 plan 时细化)
|
||||
|
||||
- SW 挑战页与 sw.js 的具体 Lua 实现与落盘路径(对齐现有 support file 机制)。
|
||||
- `sw_offline_html` 默认内置模板样式(参考 origin error page 内置模板)。
|
||||
- 「响应页面」前端模块下错误页/联系页两个 tab 的具体位置与交互。
|
||||
- UA 白名单默认真实浏览器特征集合(Chrome / Firefox / Safari / Edge + 版本号正则)。
|
||||
- SW 落盘路径:sw.js / offline.html 通过 SupportFile 下发,Agent 替换占位符(类似 ErrorPageTmplPlaceholder 机制)。
|
||||
@@ -1,191 +0,0 @@
|
||||
# SW 离线兜底生效范围(域名作用域)设计
|
||||
|
||||
- 日期:2026-08-08
|
||||
- 状态:设计已确认
|
||||
- 前置:issue #23 Service Worker 离线兜底(`docs/superpowers/specs/2026-08-08-service-worker-offline-design.md`)
|
||||
- 范围:SW 注入从「全局所有 HTTPS 站点」细化为「总开关 + 域名作用域」
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
issue #23 实现后,`sw_offline_enabled` 为全局布尔开关:开启后对所有启用 HTTPS 的路由注入 Service Worker 离线兜底。本需求将其细化为可选的**生效域名范围**:
|
||||
|
||||
- 保留总开关(`sw_offline_enabled`)。
|
||||
- 新增作用域:管理员选择需要生效的域名,仅作用域内域名注入 SW。
|
||||
- 域名选择交互参考 `/cloudflare/groups/1` 的「添加域名成员」弹窗(搜索筛选、按 Zone 分组、批量勾选),但**与 Cloudflare 完全解耦**——仅复用交互模式,数据源为平台自身 zones/zone_domains,不涉及 A 记录同步。
|
||||
|
||||
核心约束:
|
||||
|
||||
- 语义为「总开关 && 域名 ∈ 作用域」交集:总开关关 → 全部不注入;总开关开 + 作用域空 → 不注入;总开关开 + 域名命中 → 注入。
|
||||
- 与 Cloudflare 指向分组(A 记录)无任何关联。
|
||||
- 联系页 HTML(`sw_offline_html`)仍为全局单份,不分域名定制。
|
||||
|
||||
## 2. 机制总览
|
||||
|
||||
```
|
||||
sw_offline_enabled (bool, 已有) 总开关
|
||||
sw_offline_html (string, 已有) 联系页 HTML(全局一份)
|
||||
sw_offline_domains (JSON 字符串数组, 新增) 生效域名作用域
|
||||
|
||||
渲染: routeSWEnabled(routeDomains, cfg)
|
||||
= SWOfflineEnabled && routeDomains ∩ SWOfflineDomains ≠ ∅
|
||||
命中 → HTTPS server 块注入 access 检查 + SW location
|
||||
未命中 → 与 feature 前字节一致
|
||||
```
|
||||
|
||||
Support files(`sw/sw.js`、`sw/offline.html`)仅在「总开关开 && 作用域非空」时下发,避免空作用域产生无用资源。
|
||||
|
||||
## 3. 数据层
|
||||
|
||||
### 3.1 配置 key
|
||||
|
||||
`model.ConfigKeySWOfflineDomains = "sw_offline_domains"`(business 类型,visibility 0),值存 JSON 域名字符串数组:
|
||||
|
||||
```json
|
||||
["example.com", "api.example.com"]
|
||||
```
|
||||
|
||||
### 3.2 goose 迁移(postgres + sqlite 各一份)
|
||||
|
||||
`INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) VALUES ('sw_offline_domains', '[]', 'business', 0, 'SW 离线兜底生效域名列表(JSON 数组,空则仅总开关无效)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT (key) DO NOTHING;`
|
||||
|
||||
Down 删除该 key。migrator 测试计数 92 → 93,并更新注释。
|
||||
|
||||
### 3.3 validator
|
||||
|
||||
`validateSWOfflineDomains(key, value string) error`,注册进 `openRestyOptionValidators`:
|
||||
|
||||
- JSON 解析为 `[]string`,失败报「必须为 JSON 字符串数组」
|
||||
- 元素去重(重复报错)
|
||||
- 元素非空、小写规范化校验(复用/对齐 zone `normalizeDomain` 的域名格式约束:无 `*`、无 `://` `/` `?` `#` `@`、`publicsuffix.EffectiveTLDPlusOne` 可解析)
|
||||
- 数量上限 `maxSWOfflineDomains = 1000`(防滥用)
|
||||
|
||||
### 3.4 config_version snapshot
|
||||
|
||||
- `openRestyConfigSnapshot`(`snapshot.go`)新增 `SWOfflineDomains []string json:"sw_offline_domains,omitempty"`。
|
||||
- `buildOpenRestyConfigSnapshot` 新增 `getStringSliceConfig(key string, defaultVal []string) []string`(解析 JSON 数组,失败回退默认),赋值 `SWOfflineDomains: getStringSliceConfig(model.ConfigKeySWOfflineDomains, nil)`。
|
||||
- `logics.go`:`diffOpenRestyOptionDetails` 追加 `appendIfChanged("SWOfflineDomains", ...)`;`openRestyOptionKeys()` 追加 `"SWOfflineDomains"`。
|
||||
|
||||
## 4. 渲染层(pkg/render/openresty)
|
||||
|
||||
### 4.1 ConfigSnapshot
|
||||
|
||||
`types.go` 的 `ConfigSnapshot` 新增:
|
||||
|
||||
```go
|
||||
// SWOfflineDomains restricts the offline fallback to matching HTTPS routes.
|
||||
SWOfflineDomains []string `json:"sw_offline_domains,omitempty"`
|
||||
```
|
||||
|
||||
### 4.2 作用域判断
|
||||
|
||||
```go
|
||||
// routeSWEnabled returns true when SW offline fallback applies to this route.
|
||||
func routeSWEnabled(routeDomains []string, cfg ConfigSnapshot) bool {
|
||||
if !cfg.SWOfflineEnabled || len(cfg.SWOfflineDomains) == 0 {
|
||||
return false
|
||||
}
|
||||
scope := make(map[string]struct{}, len(cfg.SWOfflineDomains))
|
||||
for _, d := range cfg.SWOfflineDomains {
|
||||
scope[d] = struct{}{}
|
||||
}
|
||||
for _, d := range routeDomains {
|
||||
if _, ok := scope[d]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
域名精确匹配(存储时已小写规范化)。
|
||||
|
||||
### 4.3 server 渲染签名扩展
|
||||
|
||||
- `RenderRouteConfig`:每 route 计算 `swEnabled := routeSWEnabled(domains, doc.OpenRestyConfig)`,传入 `renderProxyRoute` / `renderPagesRoute`(新增 `swEnabled bool` 参数)。
|
||||
- 下传链路:`renderProxyRouteHTTPS` / `renderPagesRouteHTTPS` / `renderHTTPSServer` / `renderHTTPSPagesServer` 均新增 `swEnabled bool` 参数。
|
||||
- `swEnabled=true` → `renderAccessBlockWithSW(siteName, powEnabled, cfg)` + 追加 `renderServiceWorkerChallenger(cfg)`(现行为,两函数内部不再判断 `SWOfflineEnabled`,条件已上移到 route 层)。
|
||||
- `swEnabled=false` → 纯 `renderAccessBlock`,无 challenger(与 feature 前字节一致)。
|
||||
- HTTP(80)server 块保持不注入(issue #23 已定 HTTPS-only)。
|
||||
- `renderAccessBlockWithSW` / `renderServiceWorkerChallenger` 保留 `cfg` 参数(HTML 内容来自 `cfg.SWOfflineHTML`),仅移除其内部开关判断。
|
||||
|
||||
### 4.4 Support files
|
||||
|
||||
`Render` 中生成条件从 `if doc.OpenRestyConfig.SWOfflineEnabled` 改为:
|
||||
|
||||
```go
|
||||
if doc.OpenRestyConfig.SWOfflineEnabled && len(doc.OpenRestyConfig.SWOfflineDomains) > 0 {
|
||||
files = append(files, ServiceWorkerSupportFiles(doc.OpenRestyConfig)...)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 测试
|
||||
|
||||
- `routeSWEnabled`:开关关 / 作用域空 / 无交集 / 单域名交集 / 多域名部分交集。
|
||||
- HTTPS server 渲染:命中 → 含 `require("sw.runtime").check()` + 三个 SW location;未命中 → 与旧输出字节一致。
|
||||
- `Render`:空作用域不下发 `sw/*` support files。
|
||||
- 现有 `TestRenderAccessBlockWithSWMergesSingleBlock` 等适配新签名(`cfg` 语义变化:禁用时不再由内部判断,改由上层传 `swEnabled`)。
|
||||
|
||||
## 5. 前端(frontend/app/(main)/responses)
|
||||
|
||||
### 5.1 联系页 tab 布局
|
||||
|
||||
联系页 tab 两张卡片:
|
||||
|
||||
**卡片 1:离线兜底(总开关)**
|
||||
- 标题「离线兜底」+ 描述。
|
||||
- 右上角「保存」按钮。
|
||||
- 「启用 Service Worker 离线兜底」Switch(`sw_offline_enabled`)。
|
||||
- 「生效范围」区块:当前已选域名 badge 列表(可移除)+「添加域名」按钮打开弹窗;开关关闭时整卡禁用/置灰。
|
||||
- 保存时 `updateBatch` 一次性提交三个 key:
|
||||
```ts
|
||||
{ key: KEY_SW_ENABLED, value: String(fields.enabled) },
|
||||
{ key: KEY_SW_HTML, value: fields.html },
|
||||
{ key: KEY_SW_DOMAINS, value: JSON.stringify(fields.domains) },
|
||||
```
|
||||
- 保存成功后 `invalidateResponseQueries`(toast 提示「请前往版本发布使配置生效」不变)。
|
||||
|
||||
**卡片 2:联系页 HTML**
|
||||
- 复用 `HtmlEditorWorkspace`(见 5.3),无占位符,实时预览原样 HTML。
|
||||
|
||||
### 5.2 域名选择弹窗(scope-domain-dialog.tsx)
|
||||
|
||||
- 交互复用 `member-add-dialog.tsx`:搜索框(域名/zone 模糊匹配)、按 Zone 分组折叠、组内勾选/取消、全选可见/清空、已选计数。
|
||||
- 无橙云开关、无 Cloudflare 依赖。
|
||||
- 数据源:`ZoneService.list()` + 每 zone `ZoneService.getOverview(id)` 并行拉取(`Promise.all`),zone 根域并入对应分组。**不新增后端 API**。
|
||||
- 弹窗预勾选当前已生效域名;确认后返回选中的域名字符串数组(覆盖式替换本地 fields.domains)。
|
||||
- 空态:无 zone 时提示「暂无可用域名,请先在 Zone 管理中注册」。
|
||||
|
||||
### 5.3 HtmlEditorWorkspace 复用(泛化)
|
||||
|
||||
`frontend/app/(main)/error-pages/components/html-editor-workspace.tsx` 泛化并移至 `frontend/components/common/html-editor-workspace.tsx`:
|
||||
|
||||
- Props 扩展:
|
||||
- `maxBytes?: number`(默认 `ORIGIN_ERROR_PAGE_HTML_MAX_BYTES` = 256 KiB,SW 同为 256 KiB 常量可共用)
|
||||
- `preview?: (html: string) => string`(默认 `previewOriginErrorPageHTML`;SW 传 `(html) => html` 原样预览)
|
||||
- `footerHint?: React.ReactNode`(预览 footer 提示文案,默认错误页的「`{{status}}`→502 · `{{host}}`→example.com」;SW 传 `null`)
|
||||
- 错误页 `edit/page.tsx` 改 import 路径,行为不变。
|
||||
- `frontend/components/common/` 若不存在则创建目录。
|
||||
|
||||
### 5.4 shared.ts 与表单
|
||||
|
||||
- `KEY_SW_DOMAINS = 'sw_offline_domains'`。
|
||||
- `ContactPageFields` 增加 `domains: string[]`;`defaultContactPageFields.domains = []`。
|
||||
- `mapOptionsToContactFields` 解析 `sw_offline_domains` JSON(容错:非法 JSON → `[]`)。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
- 后端:`go test ./pkg/render/openresty/... ./internal/apps/openflare/option/... ./internal/apps/openflare/config_version/... ./internal/infra/persistence/migrator/...`
|
||||
- 前端:`pnpm tsc --noEmit` + `eslint`(联系页新字段/弹窗/多 zone 并行拉取)
|
||||
- 全量:`go test ./...`、`make code-check`、`make format`
|
||||
- `make swagger`:无新 API(验证无变更即可)
|
||||
|
||||
## 7. Changelog
|
||||
|
||||
`docs/changelog/index.md` `[Unreleased]` 更新 SW 条目:新增「可指定生效域名范围(仅对选中的 HTTPS 域名生效)」。
|
||||
|
||||
## 8. 已知边界
|
||||
|
||||
- 作用域存域名字符串数组:域名从 zone/zone_domain 改名后需手动同步作用域(与 `route.Domains` 精确匹配)。
|
||||
- 联系页 HTML 全局单份,不分域名定制。
|
||||
- 空作用域 + 总开关开 → 不注入(前端置灰提示先选域名)。
|
||||
- 匹配为精确匹配,不跨子域通配(选 `example.com` 不自动覆盖 `api.example.com`,需显式加入)。
|
||||
Reference in New Issue
Block a user