fix: 修复 WAF 规则组保存/绑定网站时报 of_waf_rule_group_bindings_pkey 冲突

This commit is contained in:
ryan
2026-06-20 21:52:33 +08:00
parent 99f6f3231a
commit 117d473c27
11 changed files with 194 additions and 29 deletions
+8
View File
@@ -16,14 +16,22 @@ sidebar: false
## [Unreleased]
### 变更
- 前端页面鉴权改为默认私域:除 `/login`、`/register`、`/callback` 外,未登录访问任意页面(含数据看板 `/`)均重定向至登录页。
### 修复
- 配置版本列表按 `created_at` 倒序展示,最新发布版本固定显示在列表顶部。
- 修复 WAF 规则组保存/绑定网站时报 `of_waf_rule_group_bindings_pkey` 冲突:PostgreSQL 在迁移导入显式 ID 后同步绑定表序列,并在写入前自动校正序列。
- 修复 PostgreSQL 启动迁移失败:`of_waf_rule_group_bindings` 序列表为空时 `setval(0)` 越界,改为空表重置为 1、有数据时对齐 `MAX(id)`。
- 修复 WAF 规则组 PoW 策略发布后边缘不生效:统一 WAF 绑定站点名与 OpenResty 路由 `site_name` 解析逻辑,并为所有已启用网站生成 `site_rule_groups` 条目(含仅依赖全局规则组的站点)。
- 修复 WAF PoW 已启用但挑战页不弹出:`pow_enabled=true` 且 `pow_config` 为空时补齐默认配置写入 `waf_config.json`,OpenResty 按全局+已绑定规则组解析 PoW 注入,Lua 对空配置使用运行时默认值。
- 收敛子代理站点标识双轨逻辑:新增 `routeidentity` 统一包,`proxy_route`、`config_version`、`uptimekuma`、`flared` 与 OpenResty 渲染共用 `ResolveSiteName` / `DecodeDomains`;移除废弃 `RenderPoWConfig`;PoW Lua 与 WAF 一致仅依赖 `$openflare_waf_site`。
- 修复全球态势板在仅有 `geo_name`(如 mmdb 的 Germany)而无经纬度时误用美国 fallback 坐标的问题;按国家名/ISO 匹配地图质心。
+4 -9
View File
@@ -78,7 +78,7 @@ if (typeof setInterval !== 'undefined') {
export function proxy(request: NextRequest) {
const { pathname, search } = request.nextUrl
const sessionCookieName = process.env.WAVELET_SESSION_COOKIE_NAME || 'wavelet_session_id'
const sessionCookieName = process.env.WAVELET_SESSION_COOKIE_NAME || 'openflare_session_id'
const sessionCookie = request.cookies.get(sessionCookieName)
/* API 请求:速率限制后放行 */
@@ -102,15 +102,10 @@ export function proxy(request: NextRequest) {
return NextResponse.next()
}
/* 页面请求:公共路由放行 */
const publicRoutes = ['/', '/login', '/register', '/callback', '/privacy', '/terms', '/icon']
const publicPrefixes = ['/docs/', '/epay/']
/* 页面请求:默认私域,仅登录流程入口无需 session */
const authEntryRoutes = ['/login', '/register', '/callback']
if (publicRoutes.includes(pathname) || publicPrefixes.some(p => pathname.startsWith(p))) {
return NextResponse.next()
}
if (!sessionCookie) {
if (!authEntryRoutes.includes(pathname) && !sessionCookie) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('callbackUrl', pathname + search)
return NextResponse.redirect(loginUrl)
+3 -3
View File
@@ -71,7 +71,7 @@ local function load_pow_config()
local groups = {}
for _, group in ipairs(decoded.rule_groups) do
if group.pow_enabled then
groups[tostring(group.id)] = group.pow_config
groups[tostring(group.id)] = group.pow_config or {}
end
end
-- Build site name to pow_config map
@@ -88,12 +88,12 @@ local function load_pow_config()
if not pow_config then
for _, group in ipairs(decoded.rule_groups) do
if group.is_global and group.pow_enabled then
pow_config = group.pow_config
pow_config = group.pow_config or {}
break
end
end
end
if pow_config then
if pow_config ~= nil then
pow_config_dict:set(site, cjson.encode({enabled = true, config = pow_config}), 0)
domain_keys[#domain_keys+1] = site
end
@@ -177,3 +177,52 @@ func TestBuildSnapshotWAFDocumentUsesNormalizedSiteNames(t *testing.T) {
assert.Contains(t, bundle.RouteConfig, `set $openflare_waf_site "example.com"`)
assert.Contains(t, bundle.RouteConfig, `require("pow.runtime").check()`)
}
func TestBuildCurrentConfigBundleEnablesGlobalPoWWithoutExplicitBinding(t *testing.T) {
cleanup := setupConfigVersionTestDB(t)
defer cleanup()
ctx := context.Background()
route := &model.ProxyRoute{
Domain: "pow-global.example.com",
Domains: `["pow-global.example.com"]`,
OriginURL: "http://origin.example.com:8080",
Upstreams: `["http://origin.example.com:8080"]`,
Enabled: true,
}
require.NoError(t, model.CreateProxyRouteRecord(ctx, route))
require.NoError(t, waf.EnsureDefaultRuleGroup(ctx))
globalGroup, err := model.GetGlobalOpenFlareWAFRuleGroup(ctx)
require.NoError(t, err)
globalGroup.PoWEnabled = true
globalGroup.PoWConfig = `{"difficulty":4,"algorithm":"fast","session_ttl":600,"challenge_ttl":300}`
require.NoError(t, model.UpdateOpenFlareWAFRuleGroup(ctx, globalGroup))
bundle, err := buildCurrentConfigBundle(ctx, true)
require.NoError(t, err)
assert.Contains(t, bundle.RouteConfig, `require("pow.runtime").check()`)
var wafRuntime struct {
RuleGroups []struct {
ID uint `json:"id"`
PoWEnabled bool `json:"pow_enabled"`
PoWConfig *struct {
Difficulty int `json:"difficulty"`
} `json:"pow_config"`
} `json:"rule_groups"`
SiteRuleGroups map[string][]uint `json:"site_rule_groups"`
}
for _, file := range bundle.SupportFiles {
if file.Path != "waf_config.json" {
continue
}
require.NoError(t, json.Unmarshal([]byte(file.Content), &wafRuntime))
}
require.Contains(t, wafRuntime.SiteRuleGroups, "pow-global.example.com")
require.Contains(t, wafRuntime.SiteRuleGroups["pow-global.example.com"], globalGroup.ID)
require.NotEmpty(t, wafRuntime.RuleGroups)
assert.True(t, wafRuntime.RuleGroups[0].PoWEnabled)
require.NotNil(t, wafRuntime.RuleGroups[0].PoWConfig)
assert.Equal(t, 4, wafRuntime.RuleGroups[0].PoWConfig.Difficulty)
}
@@ -308,7 +308,7 @@ func buildSnapshotWAFDocument(ctx context.Context, routes []*model.ProxyRoute) (
RegionWhitelist: view.RegionWhitelist,
RegionBlacklist: view.RegionBlacklist,
PoWEnabled: view.PoWEnabled,
PoWConfig: convertPoWConfig(view.PoWConfig),
PoWConfig: convertPoWConfig(view.PoWEnabled, view.PoWConfig),
})
}
ipGroups, err := buildSnapshotWAFIPGroups(ctx, ruleGroups)
@@ -415,10 +415,14 @@ func decodeIPList(raw string) ([]string, error) {
return items, nil
}
func convertPoWConfig(config *waf.PoWConfig) *openrestyrender.PoWConfig {
if config == nil {
func convertPoWConfig(enabled bool, config *waf.PoWConfig) *openrestyrender.PoWConfig {
if !enabled {
return nil
}
if config == nil {
defaultConfig := openrestyrender.DefaultPoWConfig()
return &defaultConfig
}
return &openrestyrender.PoWConfig{
Difficulty: config.Difficulty,
Algorithm: config.Algorithm,
@@ -102,7 +102,8 @@ func up202606200006(ctx context.Context, tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `
SELECT setval(
pg_get_serial_sequence('of_waf_rule_group_bindings', 'id'),
COALESCE((SELECT MAX(id) FROM of_waf_rule_group_bindings), 0)
GREATEST(COALESCE((SELECT MAX(id) FROM of_waf_rule_group_bindings), 0), 1),
COALESCE((SELECT MAX(id) FROM of_waf_rule_group_bindings), 0) > 0
)
`); err != nil {
return fmt.Errorf("sync of_waf_rule_group_bindings sequence failed: %w", err)
@@ -1,7 +1,8 @@
-- +goose Up
SELECT setval(
pg_get_serial_sequence('of_waf_rule_group_bindings', 'id'),
COALESCE((SELECT MAX(id) FROM of_waf_rule_group_bindings), 0)
GREATEST(COALESCE((SELECT MAX(id) FROM of_waf_rule_group_bindings), 0), 1),
COALESCE((SELECT MAX(id) FROM of_waf_rule_group_bindings), 0) > 0
);
-- +goose Down
+2 -1
View File
@@ -319,7 +319,8 @@ func syncWAFBindingIDSequence(tx *gorm.DB) error {
return tx.Exec(`
SELECT setval(
pg_get_serial_sequence('of_waf_rule_group_bindings', 'id'),
COALESCE((SELECT MAX(id) FROM of_waf_rule_group_bindings), 0)
GREATEST(COALESCE((SELECT MAX(id) FROM of_waf_rule_group_bindings), 0), 1),
COALESCE((SELECT MAX(id) FROM of_waf_rule_group_bindings), 0) > 0
)
`).Error
}
+35 -11
View File
@@ -149,10 +149,7 @@ func RenderWAFConfig(snapshot WAFDocument) (string, error) {
globalGroupIDs = append(globalGroupIDs, group.ID)
}
enabledGroupIDs[group.ID] = struct{}{}
powConfig := group.PoWConfig
if !group.PoWEnabled {
powConfig = nil
}
powConfig := ensurePoWConfig(group.PoWEnabled, group.PoWConfig)
groups = append(groups, wafRuntimeRuleGroup{
ID: group.ID,
Name: group.Name,
@@ -773,27 +770,54 @@ func validateCertificateCoverage(certPEM string, domains []string) error {
}
func getPoWConfigForRoute(routeID uint, snapshot WAFDocument) (bool, *PoWConfig) {
enabledGroups := make(map[uint]WAFRuleGroup, len(snapshot.RuleGroups))
globalGroupIDs := make([]uint, 0)
for _, group := range snapshot.RuleGroups {
if !group.Enabled {
continue
}
enabledGroups[group.ID] = group
if group.IsGlobal {
globalGroupIDs = append(globalGroupIDs, group.ID)
}
}
sort.Slice(globalGroupIDs, func(i, j int) bool { return globalGroupIDs[i] < globalGroupIDs[j] })
var boundGroupIDs []uint
for _, binding := range snapshot.Bindings {
if binding.RouteID != routeID {
continue
}
for _, groupID := range binding.RuleGroupIDs {
for _, group := range snapshot.RuleGroups {
if group.ID == groupID && group.PoWEnabled {
return true, group.PoWConfig
}
if _, ok := enabledGroups[groupID]; ok {
boundGroupIDs = append(boundGroupIDs, groupID)
}
}
break
}
for _, group := range snapshot.RuleGroups {
if group.IsGlobal && group.PoWEnabled {
return true, group.PoWConfig
activeGroupIDs := uniqueUintIDs(append(append([]uint{}, globalGroupIDs...), boundGroupIDs...))
for _, groupID := range activeGroupIDs {
group := enabledGroups[groupID]
if group.PoWEnabled {
config := ensurePoWConfig(true, group.PoWConfig)
return true, config
}
}
return false, nil
}
func ensurePoWConfig(enabled bool, config *PoWConfig) *PoWConfig {
if !enabled {
return nil
}
if config != nil {
return config
}
defaultConfig := DefaultPoWConfig()
return &defaultConfig
}
func uniqueUintIDs(values []uint) []uint {
seen := make(map[uint]struct{}, len(values))
result := make([]uint, 0, len(values))
+69
View File
@@ -60,6 +60,75 @@ func TestRenderWAFConfigIncludesAllRouteSiteNames(t *testing.T) {
}
}
func TestRenderWAFConfigUsesDefaultPoWConfigWhenEnabledWithoutPayload(t *testing.T) {
doc := WAFDocument{
RuleGroups: []WAFRuleGroup{
{
ID: 1,
Name: "global",
Enabled: true,
IsGlobal: true,
PoWEnabled: true,
},
},
Bindings: []WAFBinding{
{RouteID: 1, SiteName: "example.com", RuleGroupIDs: []uint{}},
},
}
wafConfig, err := RenderWAFConfig(doc)
if err != nil {
t.Fatalf("RenderWAFConfig() error = %v", err)
}
var decoded struct {
RuleGroups []struct {
PoWEnabled bool `json:"pow_enabled"`
PoWConfig *PoWConfig `json:"pow_config"`
} `json:"rule_groups"`
}
if err := json.Unmarshal([]byte(wafConfig), &decoded); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if len(decoded.RuleGroups) != 1 {
t.Fatalf("expected 1 rule group, got %d", len(decoded.RuleGroups))
}
if !decoded.RuleGroups[0].PoWEnabled {
t.Fatal("expected pow_enabled=true")
}
if decoded.RuleGroups[0].PoWConfig == nil {
t.Fatal("expected default pow_config to be emitted")
}
if decoded.RuleGroups[0].PoWConfig.Difficulty != 4 {
t.Fatalf("expected default difficulty 4, got %d", decoded.RuleGroups[0].PoWConfig.Difficulty)
}
}
func TestGetPoWConfigForRouteUsesGlobalGroupWithoutExplicitBinding(t *testing.T) {
snapshot := WAFDocument{
RuleGroups: []WAFRuleGroup{
{
ID: 1,
Name: "global",
Enabled: true,
IsGlobal: true,
PoWEnabled: true,
},
},
Bindings: []WAFBinding{
{RouteID: 42, SiteName: "example.com", RuleGroupIDs: []uint{}},
},
}
enabled, config := getPoWConfigForRoute(42, snapshot)
if !enabled {
t.Fatal("expected pow to be enabled via global rule group")
}
if config == nil || config.Difficulty != 4 {
t.Fatalf("expected default pow config, got %#v", config)
}
}
func TestRenderPagesAPIProxyLocationBlock(t *testing.T) {
tests := []struct {
name string
+13
View File
@@ -100,6 +100,19 @@ type PoWConfig struct {
Blacklist PoWListConfig `json:"blacklist"`
}
// DefaultPoWConfig returns the canonical PoW defaults used when pow_enabled is
// true but no explicit pow_config payload is available.
func DefaultPoWConfig() PoWConfig {
return PoWConfig{
Difficulty: 4,
Algorithm: "fast",
SessionTTL: 600,
ChallengeTTL: 300,
Whitelist: PoWListConfig{IPs: []string{}, IPCidrs: []string{}, Paths: []string{}, PathRegexes: []string{}, UserAgents: []string{}},
Blacklist: PoWListConfig{IPs: []string{}, IPCidrs: []string{}, Paths: []string{}, PathRegexes: []string{}, UserAgents: []string{}},
}
}
// Route describes a single proxy or pages site entry in the OpenFlare config
// document, including upstream, TLS, caching, rate-limiting and WAF settings.
type Route struct {