Files
OpenFlare/internal/apps/openflare/waf/logics.go
T
ryan b89dc9ec7e fix(waf): correct whitelist logic to bypass and add config/IP-group edit broadcasts
- Transition WAF whitelist filter from strict block-on-miss to bypass-on-hit logic

- Hook up broadcastIPGroupToAgents to CreateIPGroup and UpdateIPGroup WAF logics

- Hook up BroadcastActiveConfig to PublishConfigVersion and ActivateConfigVersion version logics

- Update WAF Lua tests in manager_test.go
2026-06-26 20:47:30 +08:00

1151 lines
36 KiB
Go

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package waf
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/netip"
"net/url"
"sort"
"strings"
"time"
"unicode"
"github.com/Rain-kl/Wavelet/internal/model"
exprlang "github.com/expr-lang/expr"
"gorm.io/gorm"
)
const (
defaultWAFBlockStatusCode = 418
maxWAFBlockBodyBytes = 16 * 1024
wafIPGroupTypeManual = "manual"
wafIPGroupTypeAutomatic = "automatic"
wafIPGroupTypeSubscription = "subscription"
wafIPGroupSubscriptionFormatText = "text"
wafIPGroupSubscriptionFormatJSON = "json"
defaultWAFIPGroupSyncIntervalMinutes = 1440
defaultWAFIPGroupAutoLookbackMinutes = 60
minWAFIPGroupSyncIntervalMinutes = 5
maxWAFIPGroupSyncIntervalMinutes = 43200
minPoWSessionTTLSeconds = 60
minPoWChallengeTTLSeconds = 30
)
// RuleGroupInput is the create/update payload for WAF rule groups.
type RuleGroupInput struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
BlockStatusCode int `json:"block_status_code"`
BlockResponseBody string `json:"block_response_body"`
IPWhitelist []string `json:"ip_whitelist"`
IPBlacklist []string `json:"ip_blacklist"`
IPWhitelistGroups []uint `json:"ip_whitelist_group_ids"`
IPBlacklistGroups []uint `json:"ip_blacklist_group_ids"`
CountryWhitelist []string `json:"country_whitelist"`
CountryBlacklist []string `json:"country_blacklist"`
RegionWhitelist []string `json:"region_whitelist"`
RegionBlacklist []string `json:"region_blacklist"`
Remark string `json:"remark"`
PoWEnabled bool `json:"pow_enabled"`
PoWConfig json.RawMessage `json:"pow_config"`
}
// PoWListConfig stores PoW whitelist/blacklist dimensions.
type PoWListConfig struct {
IPs []string `json:"ips"`
IPCidrs []string `json:"ip_cidrs"`
Paths []string `json:"paths"`
PathRegexes []string `json:"path_regexes"`
UserAgents []string `json:"user_agents"`
}
// PoWConfig stores proof-of-work settings for a rule group.
type PoWConfig struct {
Difficulty int `json:"difficulty"`
Algorithm string `json:"algorithm"`
SessionTTL int `json:"session_ttl"`
ChallengeTTL int `json:"challenge_ttl"`
Whitelist PoWListConfig `json:"whitelist"`
Blacklist PoWListConfig `json:"blacklist"`
}
// RuleGroupView is the API view for a WAF rule group.
type RuleGroupView struct {
ID uint `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
IsGlobal bool `json:"is_global"`
BlockStatusCode int `json:"block_status_code"`
BlockResponseBody string `json:"block_response_body"`
IPWhitelist []string `json:"ip_whitelist"`
IPBlacklist []string `json:"ip_blacklist"`
IPWhitelistGroups []uint `json:"ip_whitelist_group_ids"`
IPBlacklistGroups []uint `json:"ip_blacklist_group_ids"`
CountryWhitelist []string `json:"country_whitelist"`
CountryBlacklist []string `json:"country_blacklist"`
RegionWhitelist []string `json:"region_whitelist"`
RegionBlacklist []string `json:"region_blacklist"`
Remark string `json:"remark"`
PoWEnabled bool `json:"pow_enabled"`
PoWConfig *PoWConfig `json:"pow_config"`
AppliedSiteIDs []uint `json:"applied_site_ids"`
AppliedSiteCount int `json:"applied_site_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// SiteRuleGroupsView is the site-level WAF binding view.
type SiteRuleGroupsView struct {
RouteID uint `json:"route_id"`
GlobalRuleGroup *RuleGroupView `json:"global_rule_group"`
RuleGroups []RuleGroupView `json:"rule_groups"`
AppliedRuleGroups []RuleGroupView `json:"applied_rule_groups"`
AppliedIDs []uint `json:"applied_ids"`
}
// IDsRequest carries a list of numeric ids.
type IDsRequest struct {
IDs []uint `json:"ids"`
}
// IPGroupInput is the create/update payload for WAF IP groups.
type IPGroupInput struct {
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
IPList []string `json:"ip_list"`
AutoConfig json.RawMessage `json:"auto_config"`
SubscriptionURL string `json:"subscription_url"`
SubscriptionFormat string `json:"subscription_format"`
SubscriptionMappingRule string `json:"subscription_mapping_rule"`
SyncIntervalMinutes int `json:"sync_interval_minutes"`
Remark string `json:"remark"`
}
// IPGroupExtIPView is an external IP entry in API responses.
type IPGroupExtIPView struct {
IP string `json:"ip"`
CapturedAt string `json:"captured_at"`
}
// IPGroupView is the API view for a WAF IP group.
type IPGroupView struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
IPList []string `json:"ip_list"`
AutoConfig json.RawMessage `json:"auto_config"`
ExtIPs []IPGroupExtIPView `json:"ext_ips"`
SubscriptionURL string `json:"subscription_url"`
SubscriptionFormat string `json:"subscription_format"`
SubscriptionMappingRule string `json:"subscription_mapping_rule"`
SyncIntervalMinutes int `json:"sync_interval_minutes"`
LastSyncedAt string `json:"last_synced_at,omitempty"`
NextSyncAt string `json:"next_sync_at,omitempty"`
LastSyncStatus string `json:"last_sync_status"`
LastSyncMessage string `json:"last_sync_message"`
Remark string `json:"remark"`
ReferencedByRuleCount int `json:"referenced_by_rule_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// IPGroupSyncResult is the response for manual IP group sync.
type IPGroupSyncResult struct {
Group IPGroupView `json:"group"`
IPCount int `json:"ip_count"`
SyncedAt string `json:"synced_at"`
NextSyncAt string `json:"next_sync_at"`
Status string `json:"status"`
Message string `json:"message"`
}
// IPGroupAutoTestInput tests automatic IP group configuration.
type IPGroupAutoTestInput struct {
AutoConfig json.RawMessage `json:"auto_config"`
}
// IPGroupAutoTestResult is the response for automatic IP group test.
type IPGroupAutoTestResult struct {
MatchedIPs []string `json:"matched_ips"`
MatchedCount int `json:"matched_count"`
LookbackMinutes int `json:"lookback_minutes"`
RuleCount int `json:"rule_count"`
TestedAt string `json:"tested_at"`
}
type ipGroupAutoConfig struct {
LookbackMinutes int `json:"lookback_minutes"`
TTL int `json:"ttl"`
Rules []ipGroupAutoRule `json:"rules"`
}
type ipGroupAutoRule struct {
Name string `json:"name"`
Expr string `json:"expr"`
}
type ipGroupExtIP struct {
IP string `json:"ip"`
CapturedAt time.Time `json:"captured_at"`
}
var powAlgorithmValues = map[string]bool{"fast": true, "slow": true}
// ListRuleGroups returns all WAF rule groups.
func ListRuleGroups(ctx context.Context) ([]RuleGroupView, error) {
if err := EnsureDefaultRuleGroup(ctx); err != nil {
return nil, err
}
groups, err := model.ListOpenFlareWAFRuleGroups(ctx)
if err != nil {
return nil, err
}
bindings, err := loadRuleGroupBindings(ctx)
if err != nil {
return nil, err
}
views := make([]RuleGroupView, 0, len(groups))
for _, group := range groups {
view, buildErr := buildRuleGroupView(group, bindings[group.ID])
if buildErr != nil {
return nil, buildErr
}
views = append(views, view)
}
return views, nil
}
// GetRuleGroup returns a WAF rule group by id.
func GetRuleGroup(ctx context.Context, id uint) (*RuleGroupView, error) {
group, err := model.GetOpenFlareWAFRuleGroupByID(ctx, id)
if err != nil {
return nil, err
}
bindings, err := loadRuleGroupBindings(ctx)
if err != nil {
return nil, err
}
view, err := buildRuleGroupView(group, bindings[group.ID])
if err != nil {
return nil, err
}
return &view, nil
}
// CreateRuleGroup creates a custom WAF rule group.
func CreateRuleGroup(ctx context.Context, input RuleGroupInput) (*RuleGroupView, error) {
group, err := buildRuleGroup(ctx, nil, input)
if err != nil {
return nil, err
}
group.IsGlobal = false
if err = model.CreateOpenFlareWAFRuleGroup(ctx, group); err != nil {
return nil, err
}
return GetRuleGroup(ctx, group.ID)
}
// UpdateRuleGroup updates a WAF rule group.
func UpdateRuleGroup(ctx context.Context, id uint, input RuleGroupInput) (*RuleGroupView, error) {
group, err := model.GetOpenFlareWAFRuleGroupByID(ctx, id)
if err != nil {
return nil, err
}
isGlobal := group.IsGlobal
group, err = buildRuleGroup(ctx, group, input)
if err != nil {
return nil, err
}
group.IsGlobal = isGlobal
if isGlobal && strings.TrimSpace(group.Name) == "" {
group.Name = "全局规则组"
}
if err = model.UpdateOpenFlareWAFRuleGroup(ctx, group); err != nil {
return nil, err
}
return GetRuleGroup(ctx, group.ID)
}
// DeleteRuleGroup deletes a non-global WAF rule group.
func DeleteRuleGroup(ctx context.Context, id uint) error {
group, err := model.GetOpenFlareWAFRuleGroupByID(ctx, id)
if err != nil {
return err
}
if group.IsGlobal {
return errors.New("全局 WAF 规则组不能删除")
}
return model.DeleteOpenFlareWAFRuleGroupWithBindings(ctx, group.ID)
}
// ReplaceRuleGroupSites replaces site bindings for a rule group.
func ReplaceRuleGroupSites(ctx context.Context, groupID uint, routeIDs []uint) (*RuleGroupView, error) {
group, err := model.GetOpenFlareWAFRuleGroupByID(ctx, groupID)
if err != nil {
return nil, err
}
if group.IsGlobal {
return nil, errors.New("全局 WAF 规则组默认应用到所有网站,不能手动绑定")
}
normalized, err := normalizeRouteIDs(ctx, routeIDs)
if err != nil {
return nil, err
}
if err = model.ReplaceOpenFlareWAFRuleGroupBindings(ctx, groupID, normalized); err != nil {
return nil, err
}
return GetRuleGroup(ctx, groupID)
}
// GetSiteRuleGroups returns WAF rule groups for a proxy route.
func GetSiteRuleGroups(ctx context.Context, routeID uint) (*SiteRuleGroupsView, error) {
if _, err := model.GetOpenFlareProxyRouteByID(ctx, routeID); err != nil {
return nil, err
}
groups, err := ListRuleGroups(ctx)
if err != nil {
return nil, err
}
appliedIDs, err := ListSiteRuleGroupIDs(ctx, routeID)
if err != nil {
return nil, err
}
appliedSet := make(map[uint]struct{}, len(appliedIDs))
for _, id := range appliedIDs {
appliedSet[id] = struct{}{}
}
var global *RuleGroupView
custom := make([]RuleGroupView, 0, len(groups))
applied := make([]RuleGroupView, 0, len(appliedIDs))
for index := range groups {
group := groups[index]
if group.IsGlobal {
item := group
global = &item
continue
}
custom = append(custom, group)
if _, ok := appliedSet[group.ID]; ok {
applied = append(applied, group)
}
}
return &SiteRuleGroupsView{
RouteID: routeID,
GlobalRuleGroup: global,
RuleGroups: custom,
AppliedRuleGroups: applied,
AppliedIDs: appliedIDs,
}, nil
}
// ReplaceSiteRuleGroups replaces rule group bindings for a proxy route.
func ReplaceSiteRuleGroups(ctx context.Context, routeID uint, groupIDs []uint) (*SiteRuleGroupsView, error) {
if _, err := model.GetOpenFlareProxyRouteByID(ctx, routeID); err != nil {
return nil, err
}
normalized, err := normalizeRuleGroupIDs(ctx, groupIDs)
if err != nil {
return nil, err
}
if err = model.ReplaceOpenFlareWAFSiteRuleGroupBindings(ctx, routeID, normalized); err != nil {
return nil, err
}
return GetSiteRuleGroups(ctx, routeID)
}
// ListSiteRuleGroupIDs returns rule group ids bound to a proxy route.
func ListSiteRuleGroupIDs(ctx context.Context, routeID uint) ([]uint, error) {
bindings, err := model.ListOpenFlareWAFRuleGroupBindingsByRouteID(ctx, routeID)
if err != nil {
return nil, err
}
ids := make([]uint, 0, len(bindings))
for _, binding := range bindings {
ids = append(ids, binding.RuleGroupID)
}
return ids, nil
}
// EnsureDefaultRuleGroup ensures the global WAF rule group exists.
func EnsureDefaultRuleGroup(ctx context.Context) error {
_, err := model.GetGlobalOpenFlareWAFRuleGroup(ctx)
if err == nil {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
group := &model.OpenFlareWAFRuleGroup{
Name: "全局规则组",
Enabled: true,
IsGlobal: true,
BlockStatusCode: defaultWAFBlockStatusCode,
IPWhitelist: "[]",
IPBlacklist: "[]",
IPWhitelistGroups: "[]",
IPBlacklistGroups: "[]",
CountryWhitelist: "[]",
CountryBlacklist: "[]",
RegionWhitelist: "[]",
RegionBlacklist: "[]",
PoWEnabled: false,
PoWConfig: "{}",
BlockResponseBody: "",
}
return model.CreateOpenFlareWAFRuleGroup(ctx, group)
}
// ListIPGroups returns all WAF IP groups.
func ListIPGroups(ctx context.Context) ([]IPGroupView, error) {
groups, err := model.ListOpenFlareWAFIPGroups(ctx)
if err != nil {
return nil, err
}
referenceCounts, err := loadIPGroupReferenceCounts(ctx)
if err != nil {
return nil, err
}
views := make([]IPGroupView, 0, len(groups))
for _, group := range groups {
view, buildErr := buildIPGroupView(group, referenceCounts[group.ID])
if buildErr != nil {
return nil, buildErr
}
views = append(views, view)
}
return views, nil
}
// GetIPGroup returns a WAF IP group by id.
func GetIPGroup(ctx context.Context, id uint) (*IPGroupView, error) {
group, err := model.GetOpenFlareWAFIPGroupByID(ctx, id)
if err != nil {
return nil, err
}
referenceCounts, err := loadIPGroupReferenceCounts(ctx)
if err != nil {
return nil, err
}
view, err := buildIPGroupView(group, referenceCounts[group.ID])
if err != nil {
return nil, err
}
return &view, nil
}
// CreateIPGroup creates a WAF IP group.
func CreateIPGroup(ctx context.Context, input IPGroupInput) (*IPGroupView, error) {
group, err := buildIPGroup(nil, input)
if err != nil {
return nil, err
}
if err = model.CreateOpenFlareWAFIPGroup(ctx, group); err != nil {
return nil, err
}
broadcastIPGroupToAgents(ctx, group.ID)
return GetIPGroup(ctx, group.ID)
}
// UpdateIPGroup updates a WAF IP group.
func UpdateIPGroup(ctx context.Context, id uint, input IPGroupInput) (*IPGroupView, error) {
group, err := model.GetOpenFlareWAFIPGroupByID(ctx, id)
if err != nil {
return nil, err
}
group, err = buildIPGroup(group, input)
if err != nil {
return nil, err
}
if err = model.UpdateOpenFlareWAFIPGroup(ctx, group); err != nil {
return nil, err
}
broadcastIPGroupToAgents(ctx, group.ID)
return GetIPGroup(ctx, group.ID)
}
// DeleteIPGroup deletes a WAF IP group when not referenced.
func DeleteIPGroup(ctx context.Context, id uint) error {
group, err := model.GetOpenFlareWAFIPGroupByID(ctx, id)
if err != nil {
return err
}
counts, err := loadIPGroupReferenceCounts(ctx)
if err != nil {
return err
}
if counts[group.ID] > 0 {
return errors.New("IP 组已被 WAF 规则组引用,请先移除引用")
}
return model.DeleteOpenFlareWAFIPGroup(ctx, group.ID)
}
// SyncIPGroup synchronizes a subscription or automatic WAF IP group.
func SyncIPGroup(ctx context.Context, id uint) (*IPGroupSyncResult, error) {
group, err := model.GetOpenFlareWAFIPGroupByID(ctx, id)
if err != nil {
return nil, err
}
return syncOpenFlareWAFIPGroup(ctx, group, time.Now().UTC())
}
// TestIPGroupAutoConfig evaluates automatic IP group rules against recent access logs.
func TestIPGroupAutoConfig(ctx context.Context, input IPGroupAutoTestInput) (*IPGroupAutoTestResult, error) {
config, err := parseIPGroupAutoConfig(input.AutoConfig)
if err != nil {
return nil, err
}
now := time.Now().UTC()
ips, err := evaluateParsedIPGroupAutoConfig(ctx, config, now)
if err != nil {
return nil, err
}
return &IPGroupAutoTestResult{
MatchedIPs: ips,
MatchedCount: len(ips),
LookbackMinutes: config.LookbackMinutes,
RuleCount: len(config.Rules),
TestedAt: now.Format(time.RFC3339),
}, nil
}
func buildRuleGroup(ctx context.Context, group *model.OpenFlareWAFRuleGroup, input RuleGroupInput) (*model.OpenFlareWAFRuleGroup, error) {
name := strings.TrimSpace(input.Name)
if name == "" {
return nil, errors.New("规则组名称不能为空")
}
statusCode := input.BlockStatusCode
if statusCode == 0 {
statusCode = defaultWAFBlockStatusCode
}
if statusCode < 400 || statusCode > 599 {
return nil, errors.New("拦截状态码必须在 400-599 之间")
}
if len([]byte(input.BlockResponseBody)) > maxWAFBlockBodyBytes {
return nil, fmt.Errorf("拦截页面内容不能超过 %d 字节", maxWAFBlockBodyBytes)
}
ipWhitelist, err := normalizeIPList(input.IPWhitelist)
if err != nil {
return nil, fmt.Errorf("IP 白名单无效: %w", err)
}
ipBlacklist, err := normalizeIPList(input.IPBlacklist)
if err != nil {
return nil, fmt.Errorf("IP 黑名单无效: %w", err)
}
ipWhitelistGroups, err := normalizeIPGroupIDs(ctx, input.IPWhitelistGroups)
if err != nil {
return nil, fmt.Errorf("IP 白名单引用无效: %w", err)
}
ipBlacklistGroups, err := normalizeIPGroupIDs(ctx, input.IPBlacklistGroups)
if err != nil {
return nil, fmt.Errorf("IP 黑名单引用无效: %w", err)
}
countryWhitelist, err := normalizeCountryList(input.CountryWhitelist)
if err != nil {
return nil, fmt.Errorf("地域白名单无效: %w", err)
}
countryBlacklist, err := normalizeCountryList(input.CountryBlacklist)
if err != nil {
return nil, fmt.Errorf("地域黑名单无效: %w", err)
}
regionWhitelist := normalizeStringList(input.RegionWhitelist)
regionBlacklist := normalizeStringList(input.RegionBlacklist)
powConfigRaw := strings.TrimSpace(string(input.PoWConfig))
if powConfigRaw == "" {
powConfigRaw = "{}"
}
powConfig, err := normalizePoWConfig(input.PoWEnabled, powConfigRaw)
if err != nil {
return nil, err
}
powConfigJSON, _ := json.Marshal(powConfig)
ipWhitelistJSON, _ := json.Marshal(ipWhitelist)
ipBlacklistJSON, _ := json.Marshal(ipBlacklist)
ipWhitelistGroupsJSON, _ := json.Marshal(ipWhitelistGroups)
ipBlacklistGroupsJSON, _ := json.Marshal(ipBlacklistGroups)
countryWhitelistJSON, _ := json.Marshal(countryWhitelist)
countryBlacklistJSON, _ := json.Marshal(countryBlacklist)
regionWhitelistJSON, _ := json.Marshal(regionWhitelist)
regionBlacklistJSON, _ := json.Marshal(regionBlacklist)
if group == nil {
group = &model.OpenFlareWAFRuleGroup{}
}
group.Name = name
group.Enabled = input.Enabled
group.BlockStatusCode = statusCode
group.BlockResponseBody = input.BlockResponseBody
group.IPWhitelist = string(ipWhitelistJSON)
group.IPBlacklist = string(ipBlacklistJSON)
group.IPWhitelistGroups = string(ipWhitelistGroupsJSON)
group.IPBlacklistGroups = string(ipBlacklistGroupsJSON)
group.CountryWhitelist = string(countryWhitelistJSON)
group.CountryBlacklist = string(countryBlacklistJSON)
group.RegionWhitelist = string(regionWhitelistJSON)
group.RegionBlacklist = string(regionBlacklistJSON)
group.PoWEnabled = input.PoWEnabled
group.PoWConfig = string(powConfigJSON)
group.Remark = strings.TrimSpace(input.Remark)
return group, nil
}
func buildRuleGroupView(group *model.OpenFlareWAFRuleGroup, appliedSiteIDs []uint) (RuleGroupView, error) {
if group == nil {
return RuleGroupView{}, errors.New("waf rule group is nil")
}
sort.Slice(appliedSiteIDs, func(i, j int) bool { return appliedSiteIDs[i] < appliedSiteIDs[j] })
view := RuleGroupView{
ID: group.ID,
Name: group.Name,
Enabled: group.Enabled,
IsGlobal: group.IsGlobal,
BlockStatusCode: group.BlockStatusCode,
BlockResponseBody: group.BlockResponseBody,
Remark: group.Remark,
PoWEnabled: group.PoWEnabled,
AppliedSiteIDs: appliedSiteIDs,
AppliedSiteCount: len(appliedSiteIDs),
CreatedAt: group.CreatedAt.Format(time.RFC3339),
UpdatedAt: group.UpdatedAt.Format(time.RFC3339),
}
var err error
if view.IPWhitelist, err = decodeStringList(group.IPWhitelist); err != nil {
return view, err
}
if view.IPBlacklist, err = decodeStringList(group.IPBlacklist); err != nil {
return view, err
}
view.IPWhitelistGroups = mustDecodeUintList(group.IPWhitelistGroups)
view.IPBlacklistGroups = mustDecodeUintList(group.IPBlacklistGroups)
if view.CountryWhitelist, err = decodeStringList(group.CountryWhitelist); err != nil {
return view, err
}
if view.CountryBlacklist, err = decodeStringList(group.CountryBlacklist); err != nil {
return view, err
}
if view.RegionWhitelist, err = decodeStringList(group.RegionWhitelist); err != nil {
return view, err
}
if view.RegionBlacklist, err = decodeStringList(group.RegionBlacklist); err != nil {
return view, err
}
if view.PoWConfig, err = decodeStoredPoWConfig(group.PoWEnabled, group.PoWConfig); err != nil {
return view, err
}
return view, nil
}
func buildIPGroup(group *model.OpenFlareWAFIPGroup, input IPGroupInput) (*model.OpenFlareWAFIPGroup, error) {
name := strings.TrimSpace(input.Name)
if name == "" {
return nil, errors.New("IP 组名称不能为空")
}
groupType := normalizeIPGroupType(input.Type)
if groupType == "" {
return nil, errors.New("IP 组类型无效")
}
ipList := input.IPList
subscriptionURL := ""
subscriptionFormat := normalizeIPGroupSubscriptionFormat(input.SubscriptionFormat)
mappingRule := strings.TrimSpace(input.SubscriptionMappingRule)
syncInterval := normalizeIPGroupSyncInterval(input.SyncIntervalMinutes)
autoConfig := "{}"
switch groupType {
case wafIPGroupTypeManual:
subscriptionFormat = wafIPGroupSubscriptionFormatText
mappingRule = ""
case wafIPGroupTypeAutomatic:
normalizedConfig, err := normalizeIPGroupAutoConfig(input.AutoConfig)
if err != nil {
return nil, err
}
autoConfig = normalizedConfig
subscriptionFormat = wafIPGroupSubscriptionFormatText
mappingRule = ""
case wafIPGroupTypeSubscription:
subscriptionURL = strings.TrimSpace(input.SubscriptionURL)
if err := validateSubscriptionURL(subscriptionURL); err != nil {
return nil, err
}
if subscriptionFormat == "" {
subscriptionFormat = wafIPGroupSubscriptionFormatText
}
}
normalizedIPs, err := normalizeIPList(ipList)
if err != nil {
return nil, err
}
ipListJSON, _ := json.Marshal(normalizedIPs)
if group == nil {
group = &model.OpenFlareWAFIPGroup{}
group.ExtIPs = "[]"
}
group.Name = name
group.Type = groupType
group.Enabled = input.Enabled
group.IPList = string(ipListJSON)
if groupType == wafIPGroupTypeAutomatic {
if err := pruneIPGroupExtIPs(group, normalizedIPs); err != nil {
return nil, err
}
}
group.AutoConfig = autoConfig
group.SubscriptionURL = subscriptionURL
group.SubscriptionFormat = subscriptionFormat
group.SubscriptionMappingRule = mappingRule
group.SyncIntervalMinutes = syncInterval
group.NextSyncAt = nextIPGroupSyncAt(group.Type, group.Enabled, syncInterval, group.NextSyncAt)
group.Remark = strings.TrimSpace(input.Remark)
return group, nil
}
func buildIPGroupView(group *model.OpenFlareWAFIPGroup, referenceCount int) (IPGroupView, error) {
if group == nil {
return IPGroupView{}, errors.New("waf ip group is nil")
}
ips, err := decodeStringList(group.IPList)
if err != nil {
return IPGroupView{}, err
}
autoConfig := json.RawMessage(strings.TrimSpace(group.AutoConfig))
if len(autoConfig) == 0 {
autoConfig = json.RawMessage("{}")
}
var extIPs []ipGroupExtIP
if group.ExtIPs != "" && group.ExtIPs != "[]" {
_ = json.Unmarshal([]byte(group.ExtIPs), &extIPs)
}
viewExtIPs := make([]IPGroupExtIPView, 0, len(extIPs))
for _, extIP := range extIPs {
viewExtIPs = append(viewExtIPs, IPGroupExtIPView{
IP: extIP.IP,
CapturedAt: extIP.CapturedAt.Format(time.RFC3339),
})
}
view := IPGroupView{
ID: group.ID,
Name: group.Name,
Type: group.Type,
Enabled: group.Enabled,
IPList: ips,
AutoConfig: autoConfig,
ExtIPs: viewExtIPs,
SubscriptionURL: group.SubscriptionURL,
SubscriptionFormat: group.SubscriptionFormat,
SubscriptionMappingRule: group.SubscriptionMappingRule,
SyncIntervalMinutes: group.SyncIntervalMinutes,
LastSyncStatus: group.LastSyncStatus,
LastSyncMessage: group.LastSyncMessage,
Remark: group.Remark,
ReferencedByRuleCount: referenceCount,
CreatedAt: group.CreatedAt.Format(time.RFC3339),
UpdatedAt: group.UpdatedAt.Format(time.RFC3339),
}
if group.LastSyncedAt != nil {
view.LastSyncedAt = group.LastSyncedAt.Format(time.RFC3339)
}
if group.NextSyncAt != nil {
view.NextSyncAt = group.NextSyncAt.Format(time.RFC3339)
}
return view, nil
}
func loadRuleGroupBindings(ctx context.Context) (map[uint][]uint, error) {
bindings, err := model.ListOpenFlareWAFRuleGroupBindings(ctx)
if err != nil {
return nil, err
}
result := make(map[uint][]uint, len(bindings))
for _, binding := range bindings {
result[binding.RuleGroupID] = append(result[binding.RuleGroupID], binding.ProxyRouteID)
}
return result, nil
}
func loadIPGroupReferenceCounts(ctx context.Context) (map[uint]int, error) {
groups, err := model.ListOpenFlareWAFRuleGroups(ctx)
if err != nil {
return nil, err
}
counts := make(map[uint]int)
for _, group := range groups {
for _, id := range mustDecodeUintList(group.IPWhitelistGroups) {
counts[id]++
}
for _, id := range mustDecodeUintList(group.IPBlacklistGroups) {
counts[id]++
}
}
return counts, nil
}
func pruneIPGroupExtIPs(group *model.OpenFlareWAFIPGroup, ipList []string) error {
if group == nil {
return nil
}
allowed := make(map[string]struct{}, len(ipList))
for _, ip := range ipList {
allowed[ip] = struct{}{}
}
var extIPs []ipGroupExtIP
if group.ExtIPs != "" && group.ExtIPs != "[]" {
if err := json.Unmarshal([]byte(group.ExtIPs), &extIPs); err != nil {
return err
}
}
pruned := make([]ipGroupExtIP, 0, len(extIPs))
for _, extIP := range extIPs {
if _, ok := allowed[extIP.IP]; ok {
pruned = append(pruned, extIP)
}
}
extIPsJSON, err := json.Marshal(pruned)
if err != nil {
return err
}
group.ExtIPs = string(extIPsJSON)
return nil
}
func normalizeIPList(items []string) ([]string, error) {
normalized := make([]string, 0, len(items))
for _, raw := range items {
item := strings.TrimSpace(raw)
if item == "" {
continue
}
if strings.Contains(item, "/") {
prefix, err := netip.ParsePrefix(item)
if err != nil {
return nil, fmt.Errorf("%s 不是合法 IP 段", item)
}
item = prefix.Masked().String()
} else {
addr, err := netip.ParseAddr(item)
if err != nil {
return nil, fmt.Errorf("%s 不是合法 IP", item)
}
item = addr.String()
}
normalized = append(normalized, item)
}
normalized = uniqueStrings(normalized)
sort.Strings(normalized)
return normalized, nil
}
func normalizeCountryList(items []string) ([]string, error) {
normalized := make([]string, 0, len(items))
for _, raw := range items {
item := strings.ToUpper(strings.TrimSpace(raw))
if item == "" {
continue
}
if len(item) != 2 || !unicode.IsLetter(rune(item[0])) || !unicode.IsLetter(rune(item[1])) {
return nil, fmt.Errorf("%s 不是合法国家代码", item)
}
normalized = append(normalized, item)
}
normalized = uniqueStrings(normalized)
sort.Strings(normalized)
return normalized, nil
}
func normalizeStringList(items []string) []string {
normalized := make([]string, 0, len(items))
for _, raw := range items {
item := strings.TrimSpace(raw)
if item == "" {
continue
}
normalized = append(normalized, item)
}
normalized = uniqueStrings(normalized)
sort.Strings(normalized)
return normalized
}
func decodeStringList(raw string) ([]string, error) {
text := strings.TrimSpace(raw)
if text == "" {
return []string{}, nil
}
var items []string
if err := json.Unmarshal([]byte(text), &items); err != nil {
return nil, err
}
return items, nil
}
func normalizeRouteIDs(ctx context.Context, routeIDs []uint) ([]uint, error) {
normalized := uniqueUintIDs(routeIDs)
for _, routeID := range normalized {
if _, err := model.GetOpenFlareProxyRouteByID(ctx, routeID); err != nil {
return nil, fmt.Errorf("网站 %d 不存在", routeID)
}
}
return normalized, nil
}
func normalizeRuleGroupIDs(ctx context.Context, groupIDs []uint) ([]uint, error) {
normalized := uniqueUintIDs(groupIDs)
for _, groupID := range normalized {
group, err := model.GetOpenFlareWAFRuleGroupByID(ctx, groupID)
if err != nil {
return nil, fmt.Errorf("WAF 规则组 %d 不存在", groupID)
}
if group.IsGlobal {
return nil, errors.New("全局 WAF 规则组不需要手动绑定")
}
}
return normalized, nil
}
func normalizeIPGroupIDs(ctx context.Context, ids []uint) ([]uint, error) {
normalized := uniqueUintIDs(ids)
for _, id := range normalized {
if _, err := model.GetOpenFlareWAFIPGroupByID(ctx, id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("IP 组 %d 不存在", id)
}
return nil, err
}
}
return normalized, nil
}
func uniqueUintIDs(ids []uint) []uint {
normalized := make([]uint, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
normalized = append(normalized, id)
}
normalized = uniqueUints(normalized)
sort.Slice(normalized, func(i, j int) bool { return normalized[i] < normalized[j] })
return normalized
}
func uniqueUints(items []uint) []uint {
seen := make(map[uint]struct{}, len(items))
result := make([]uint, 0, len(items))
for _, item := range items {
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
result = append(result, item)
}
return result
}
func uniqueStrings(items []string) []string {
seen := make(map[string]struct{}, len(items))
result := make([]string, 0, len(items))
for _, item := range items {
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
result = append(result, item)
}
return result
}
func mustDecodeUintList(raw string) []uint {
var values []uint
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &values); err != nil {
return []uint{}
}
values = uniqueUintIDs(values)
sort.Slice(values, func(i, j int) bool { return values[i] < values[j] })
return values
}
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{}},
}
}
func normalizePoWConfig(enabled bool, raw string) (PoWConfig, error) {
cfg, err := parsePoWConfigRaw(enabled, raw)
if err != nil {
return cfg, err
}
if err := validatePoWCoreSettings(cfg); err != nil {
return cfg, err
}
if err := validatePoWCIDRs(cfg.Whitelist.IPCidrs, "白名单"); err != nil {
return cfg, err
}
if err := validatePoWCIDRs(cfg.Blacklist.IPCidrs, "黑名单"); err != nil {
return cfg, err
}
if err := validatePoWPathRegexes(cfg.Whitelist.PathRegexes, "白名单"); err != nil {
return cfg, err
}
if err := validatePoWPathRegexes(cfg.Blacklist.PathRegexes, "黑名单"); err != nil {
return cfg, err
}
if err := validatePoWIPs(cfg.Whitelist.IPs, "白名单"); err != nil {
return cfg, err
}
if err := validatePoWIPs(cfg.Blacklist.IPs, "黑名单"); err != nil {
return cfg, err
}
if err := validatePoWListMutualExclusion(cfg); err != nil {
return cfg, err
}
return cfg, nil
}
func decodeStoredPoWConfig(enabled bool, raw string) (*PoWConfig, error) {
if !enabled {
cfg := defaultPoWConfig()
return &cfg, nil
}
text := strings.TrimSpace(raw)
if text == "" || text == "{}" {
cfg := defaultPoWConfig()
return &cfg, nil
}
var cfg PoWConfig
if err := json.Unmarshal([]byte(text), &cfg); err != nil {
return nil, errors.New("pow_config 格式无效")
}
return &cfg, nil
}
func normalizeIPGroupAutoConfig(raw json.RawMessage) (string, error) {
text := strings.TrimSpace(string(raw))
if text == "" {
text = "{}"
}
config, err := parseIPGroupAutoConfig(json.RawMessage(text))
if err != nil {
return "", err
}
normalized, _ := json.Marshal(config)
return string(normalized), nil
}
func parseIPGroupAutoConfig(raw json.RawMessage) (ipGroupAutoConfig, error) {
text := strings.TrimSpace(string(raw))
if text == "" {
text = "{}"
}
var config ipGroupAutoConfig
if err := json.Unmarshal([]byte(text), &config); err != nil {
return ipGroupAutoConfig{}, errors.New("自动 IP 组配置必须是 JSON 对象")
}
var object map[string]any
if err := json.Unmarshal([]byte(text), &object); err != nil || object == nil {
return ipGroupAutoConfig{}, errors.New("自动 IP 组配置必须是 JSON 对象")
}
if config.LookbackMinutes <= 0 {
config.LookbackMinutes = defaultWAFIPGroupAutoLookbackMinutes
}
if config.LookbackMinutes < minWAFIPGroupSyncIntervalMinutes {
config.LookbackMinutes = minWAFIPGroupSyncIntervalMinutes
}
if config.LookbackMinutes > maxWAFIPGroupSyncIntervalMinutes {
config.LookbackMinutes = maxWAFIPGroupSyncIntervalMinutes
}
if config.TTL == 0 {
config.TTL = -1
}
if config.Rules == nil {
config.Rules = []ipGroupAutoRule{}
}
for i, rule := range config.Rules {
rule.Name = strings.TrimSpace(rule.Name)
rule.Expr = strings.TrimSpace(rule.Expr)
if rule.Expr == "" {
return ipGroupAutoConfig{}, fmt.Errorf("自动规则 %d 的 Expr 表达式不能为空", i+1)
}
if _, err := exprlang.Compile(rule.Expr, exprlang.Env(ipGroupAutoRuleEnv{}), exprlang.AsBool()); err != nil {
return ipGroupAutoConfig{}, fmt.Errorf("自动规则 %s Expr 无效: %w", displayIPGroupAutoRuleName(rule, i), err)
}
config.Rules[i] = rule
}
return config, nil
}
func validateSubscriptionURL(rawURL string) error {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Host == "" {
return errors.New("订阅 URL 无效")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return errors.New("订阅 URL 仅支持 http 或 https")
}
return nil
}
func normalizeIPGroupType(value string) string {
switch strings.TrimSpace(value) {
case wafIPGroupTypeManual, "":
return wafIPGroupTypeManual
case wafIPGroupTypeAutomatic:
return wafIPGroupTypeAutomatic
case wafIPGroupTypeSubscription:
return wafIPGroupTypeSubscription
default:
return ""
}
}
func normalizeIPGroupSubscriptionFormat(value string) string {
switch strings.TrimSpace(value) {
case wafIPGroupSubscriptionFormatJSON:
return wafIPGroupSubscriptionFormatJSON
default:
return wafIPGroupSubscriptionFormatText
}
}
func normalizeIPGroupSyncInterval(value int) int {
if value <= 0 {
return defaultWAFIPGroupSyncIntervalMinutes
}
if value < minWAFIPGroupSyncIntervalMinutes {
return minWAFIPGroupSyncIntervalMinutes
}
if value > maxWAFIPGroupSyncIntervalMinutes {
return maxWAFIPGroupSyncIntervalMinutes
}
return value
}
func nextIPGroupSyncAt(groupType string, enabled bool, interval int, current *time.Time) *time.Time {
if (groupType != wafIPGroupTypeSubscription && groupType != wafIPGroupTypeAutomatic) || !enabled {
return nil
}
if current != nil && current.After(time.Now().UTC()) {
return current
}
next := time.Now().UTC().Add(time.Duration(normalizeIPGroupSyncInterval(interval)) * time.Minute)
return &next
}