diff --git a/docs/changelog/index.md b/docs/changelog/index.md index b8a4d2a8..15fc94b1 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -37,13 +37,6 @@ sidebar: false - 移除了 WAF 旧版固定名单与人机验证配置;升级后请在发布前使用新的可视化规则重新编排防护策略。 -### 修复 - -- 修复 WAF 站点没有启用的自定义规则时,空绑定被编码为 `null` 并导致 OpenResty Lua 对 `ngx.null` 执行 `ipairs`、使请求返回 500 的问题;新快照固定输出空数组,运行时同时兼容旧快照。 -- 调整了生产环境的访问和数据库日志级别,减少心跳、轮询等高频请求刷屏,同时避免将查询内容写入常规日志。 -- 修复了 WAF 编辑器在拖动、删除节点或连线时可能闪烁或显示异常的问题,使规则编辑更稳定。 -- 修复了 Agent 同步 WAF IP 组时可能遗漏新引用或保留失效引用的问题,确保已发布的防护规则能够及时生效。 -- 修复了 Pages 部署文件清单请求失败的问题,避免部署详情无法正常加载。 ## [v3.2.0] - 2026-07-12 diff --git a/docs/design/waf-orchestration-design.md b/docs/design/waf-orchestration-design.md index 5d87a581..02058e5b 100644 --- a/docs/design/waf-orchestration-design.md +++ b/docs/design/waf-orchestration-design.md @@ -113,7 +113,7 @@ WAF 列表展示规则名称、启用状态、节点数量、应用路由数量 新 Worker 只接受完整且可解析的规则运行态配置。旧 Worker 在 OpenResty 优雅 reload 期间继续使用旧内存图,新 Worker 使用新图,因此请求不会观察到半更新状态。 -地域数据库不可用时,地域匹配返回 `false` 并限频告警,保持现有行为。IP 组刷新失败时保留旧内存快照。PoW 未完成由挑战模块接管请求,不视为执行错误。发布快照中的空规则绑定必须编码为 JSON 空数组;运行时将旧快照中的 `null` 可选数组按空数组处理,禁止因 `cjson` 的 `ngx.null` userdata 中断请求。 +地域数据库不可用时,地域匹配返回 `false` 并限频告警,保持现有行为。IP 组刷新失败时保留旧内存快照。PoW 未完成由挑战模块接管请求,不视为执行错误;PoW 节点配置先以短期键写入 OpenResty 共享内存,再通过 `ngx.exec` 的显式参数传给内部挑战处理器,不能依赖内部重定向保留 `ngx.ctx` 或隐式继承请求参数。发布快照中的空规则绑定必须编码为 JSON 空数组;运行时将旧快照中的 `null` 可选数组按空数组处理,禁止因 `cjson` 的 `ngx.null` userdata 中断请求。 ## 测试与验收 diff --git a/docs/plan/20260713-waf-orchestration.md b/docs/plan/20260713-waf-orchestration.md index fd2b0f1f..d3bbfd46 100644 --- a/docs/plan/20260713-waf-orchestration.md +++ b/docs/plan/20260713-waf-orchestration.md @@ -10,7 +10,7 @@ ## 实现状态(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。 +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 ...`,未返回编译错误或成功状态,故不计为通过。 diff --git a/internal/apps/agent/nginx/manager_test.go b/internal/apps/agent/nginx/manager_test.go index ac5ece51..888f3df9 100644 --- a/internal/apps/agent/nginx/manager_test.go +++ b/internal/apps/agent/nginx/manager_test.go @@ -731,7 +731,7 @@ func TestManagerCurrentChecksumIncludesPowConfig(t *testing.T) { } func TestManagedPowLuaFilesUseInternalChallengeFlow(t *testing.T) { - if !strings.Contains(openRestyPowRuntimeLua, `ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge")`) { + if !strings.Contains(openRestyPowRuntimeLua, `ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge", challenge_args)`) { t.Fatal("expected pow runtime lua to internally execute make-challenge instead of issuing a 302 redirect") } if strings.Contains(openRestyPowRuntimeLua, "ngx.redirect(") { diff --git a/internal/apps/agent/nginx/pow_assets.go b/internal/apps/agent/nginx/pow_assets.go index bda22b89..a1fe5aa9 100644 --- a/internal/apps/agent/nginx/pow_assets.go +++ b/internal/apps/agent/nginx/pow_assets.go @@ -68,12 +68,13 @@ function _M.evaluate(config) local config_key = "_request_config:" .. (ngx.var.request_id or ngx.md5(host .. uri .. tostring(ngx.now()))) pow_config_dict:set(config_key, cjson.encode(config), config.challenge_ttl or 300) - ngx.req.set_uri_args({ + local challenge_args = { redir = ngx.var.scheme .. "://" .. host .. uri .. (ngx.var.args and ("?" .. ngx.var.args) or ""), host = host, openflare_pow_config_key = config_key, - }) - ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge") + } + ngx.req.set_uri_args(challenge_args) + ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge", challenge_args) return false end diff --git a/internal/apps/agent/nginx/pow_assets_test.go b/internal/apps/agent/nginx/pow_assets_test.go new file mode 100644 index 00000000..5ba80af8 --- /dev/null +++ b/internal/apps/agent/nginx/pow_assets_test.go @@ -0,0 +1,91 @@ +package nginx + +import ( + "testing" + + lua "github.com/yuin/gopher-lua" +) + +func TestPowRuntimePassesConfigKeyToInternalChallenge(t *testing.T) { + state := lua.NewState() + defer state.Close() + + if err := state.DoString(` +package.preload["pow.policy"] = function() + return { + match_any = function() return false end, + has_entries = function() return false end, + } +end +package.preload["cjson.safe"] = function() + return { encode = function() return "{}" end } +end + +local config_values = {} +local config_dict = {} +function config_dict:set(key, value) config_values[key] = value return true end +function config_dict:get(key) return config_values[key] end + +local sessions = {} +function sessions:get() return nil end +function sessions:set() return true end + +ngx = { + var = { + host = "pow.example.com", + uri = "/protected", + scheme = "https", + remote_addr = "192.0.2.1", + http_user_agent = "test", + request_id = "request-1", + }, + ctx = {}, + header = {}, + shared = { + openflare_pow_sessions = sessions, + openflare_pow_config = config_dict, + }, + req = {}, + now = function() return 1 end, +} +function ngx.req.set_uri_args(args) captured_uri_args = args end +function ngx.exec(uri, args) + captured_exec_uri = uri + captured_exec_args = args +end +`); err != nil { + t.Fatalf("prepare Lua runtime: %v", err) + } + + chunk, err := state.LoadString(openRestyPowRuntimeLua) + if err != nil { + t.Fatalf("load PoW runtime: %v", err) + } + if err := state.CallByParam(lua.P{Fn: chunk, NRet: 1, Protect: true}); err != nil { + t.Fatalf("initialize PoW runtime: %v", err) + } + runtimeModule := state.Get(-1) + state.Pop(1) + + evaluate := state.GetField(runtimeModule, "evaluate") + config := state.NewTable() + config.RawSetString("challenge_ttl", lua.LNumber(300)) + if err := state.CallByParam(lua.P{Fn: evaluate, NRet: 1, Protect: true}, config); err != nil { + t.Fatalf("evaluate PoW node: %v", err) + } + state.Pop(1) + + if got := state.GetGlobal("captured_exec_uri").String(); got != "/.within.website/x/cmd/anubis/api/make-challenge" { + t.Fatalf("unexpected internal challenge URI: %q", got) + } + execArgs, ok := state.GetGlobal("captured_exec_args").(*lua.LTable) + if !ok { + t.Fatal("expected ngx.exec to receive explicit challenge arguments") + } + if got := execArgs.RawGetString("openflare_pow_config_key").String(); got != "_request_config:request-1" { + t.Fatalf("unexpected PoW config key: %q", got) + } + if state.GetGlobal("captured_uri_args") != execArgs { + t.Fatal("expected URI arguments and internal redirect arguments to use the same table") + } +}