Files
OpenFlare/docs/plan/20260713-waf-orchestration.md
T
ryan 33a1c32cf8 refactor(structure): group platform, infra, and shared packages
Reorganize internal packages into platform/infra/shared layers and update
imports, docs, and seed-count tests to match current system configs.
2026-07-24 15:41:59 +08:00

30 KiB
Raw Blame History

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. 数据与控制流

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、绑定顺序稳定。测试核心断言:

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: 实现乐观锁与有序绑定模型方法
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 范围和超限图。

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: 定义带判别联合的图类型
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 去重排序。

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 与安全错误映射
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 模块初始化
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 刷新
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 和名称对话框
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 全部通过。