mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 23:06:36 +08:00
feat(waf): complete composable rule orchestration
Add the React Flow rule editor, ordered graph APIs and runtime DAG execution.\n\nPublish rules only on OpenResty reload and reconcile checksum-driven IP group snapshots in bounded shared memory.
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -42,7 +43,8 @@ type NginxManager interface {
|
||||
EnsureSafeFallbackRuntime(ctx context.Context, reason string) error
|
||||
CurrentChecksum() (string, error)
|
||||
WAFIPGroupChecksums() (map[string]string, error)
|
||||
SyncWAFIPGroups(groups []protocol.WAFIPGroup) error
|
||||
ReconcileWAFIPGroups(targetIDs []uint, changed []protocol.WAFIPGroup) error
|
||||
UpdateExistingWAFIPGroups(changed []protocol.WAFIPGroup) error
|
||||
EnsureWorkerReadAccess() error
|
||||
}
|
||||
|
||||
@@ -132,12 +134,12 @@ func (s *Service) WAFIPGroupChecksums() (map[string]string, error) {
|
||||
return s.nginxManager.WAFIPGroupChecksums()
|
||||
}
|
||||
|
||||
// ApplyWAFIPGroups writes the given WAF IP groups to the nginx manager.
|
||||
// ApplyWAFIPGroups applies real-time changes only to groups already in the local authoritative snapshot.
|
||||
func (s *Service) ApplyWAFIPGroups(_ context.Context, groups []protocol.WAFIPGroup) error {
|
||||
if len(groups) == 0 || s.nginxManager == nil {
|
||||
return nil
|
||||
}
|
||||
return s.nginxManager.SyncWAFIPGroups(groups)
|
||||
return s.nginxManager.UpdateExistingWAFIPGroups(groups)
|
||||
}
|
||||
|
||||
func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool, snapshot *state.Snapshot, currentChecksum string, target *protocol.ActiveConfigMeta, config *protocol.ActiveConfigResponse) error {
|
||||
@@ -218,25 +220,42 @@ func (s *Service) applyRenderedConfig(ctx context.Context, mode string, snapshot
|
||||
}
|
||||
|
||||
func (s *Service) syncReferencedWAFIPGroups(ctx context.Context, supportFiles []protocol.SupportFile) error {
|
||||
ids := referencedWAFIPGroupIDs(supportFiles)
|
||||
ids, err := referencedWAFIPGroupIDs(supportFiles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
if s.nginxManager == nil {
|
||||
return nil
|
||||
}
|
||||
return s.nginxManager.ReconcileWAFIPGroups([]uint{}, nil)
|
||||
}
|
||||
checksums, err := s.WAFIPGroupChecksums()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetChecksums := make(map[string]string, len(ids))
|
||||
for _, id := range ids {
|
||||
key := strconv.FormatUint(uint64(id), 10)
|
||||
if value := strings.TrimSpace(checksums[key]); value != "" {
|
||||
targetChecksums[key] = value
|
||||
}
|
||||
}
|
||||
response, err := s.client.SyncWAFIPGroups(ctx, protocol.WAFIPGroupSyncRequest{
|
||||
IDs: ids,
|
||||
Checksums: checksums,
|
||||
Checksums: targetChecksums,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response == nil || len(response.Groups) == 0 {
|
||||
if s.nginxManager == nil {
|
||||
return nil
|
||||
}
|
||||
return s.ApplyWAFIPGroups(ctx, response.Groups)
|
||||
var changed []protocol.WAFIPGroup
|
||||
if response != nil {
|
||||
changed = response.Groups
|
||||
}
|
||||
return s.nginxManager.ReconcileWAFIPGroups(ids, changed)
|
||||
}
|
||||
|
||||
type renderedActiveConfig struct {
|
||||
@@ -288,7 +307,7 @@ func fromOpenRestySupportFiles(files []openrestyrender.SupportFile) []protocol.S
|
||||
return result
|
||||
}
|
||||
|
||||
func referencedWAFIPGroupIDs(supportFiles []protocol.SupportFile) []uint {
|
||||
func referencedWAFIPGroupIDs(supportFiles []protocol.SupportFile) ([]uint, error) {
|
||||
var content string
|
||||
for _, file := range supportFiles {
|
||||
if file.Path == "waf_config.json" {
|
||||
@@ -297,28 +316,38 @@ func referencedWAFIPGroupIDs(supportFiles []protocol.SupportFile) []uint {
|
||||
}
|
||||
}
|
||||
if content == "" {
|
||||
return []uint{}
|
||||
}
|
||||
var payload struct {
|
||||
RuleGroups []struct {
|
||||
IPWhitelistGroups []uint `json:"ip_whitelist_group_ids"`
|
||||
IPBlacklistGroups []uint `json:"ip_blacklist_group_ids"`
|
||||
} `json:"rule_groups"`
|
||||
return []uint{}, nil
|
||||
}
|
||||
var payload openrestyrender.WAFDocument
|
||||
if err := json.Unmarshal([]byte(content), &payload); err != nil {
|
||||
slog.Debug("decode waf_config.json for ip group references failed", "error", err)
|
||||
return []uint{}
|
||||
return nil, fmt.Errorf("decode waf_config.json for ip group references: %w", err)
|
||||
}
|
||||
seen := make(map[uint]struct{})
|
||||
for _, group := range payload.RuleGroups {
|
||||
for _, id := range group.IPWhitelistGroups {
|
||||
if id > 0 {
|
||||
seen[id] = struct{}{}
|
||||
for _, legacyIDs := range [][]uint{group.IPWhitelistGroups, group.IPBlacklistGroups} {
|
||||
for _, id := range legacyIDs {
|
||||
if id > 0 {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, id := range group.IPBlacklistGroups {
|
||||
if id > 0 {
|
||||
seen[id] = struct{}{}
|
||||
for nodeID, node := range group.Graph.Nodes {
|
||||
if node.Type != "ip_match" {
|
||||
continue
|
||||
}
|
||||
var config *struct {
|
||||
IPGroupIDs []uint `json:"ip_group_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(node.Config, &config); err != nil || config == nil {
|
||||
if err == nil {
|
||||
err = errors.New("config must be a JSON object")
|
||||
}
|
||||
return nil, fmt.Errorf("decode ip_match config for rule group %d node %s: %w", group.ID, nodeID, err)
|
||||
}
|
||||
for _, id := range config.IPGroupIDs {
|
||||
if id > 0 {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,7 +356,7 @@ func referencedWAFIPGroupIDs(supportFiles []protocol.SupportFile) []uint {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
return ids
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func shouldReportNoopApply(snapshot *state.Snapshot, version string, checksum string) bool {
|
||||
|
||||
@@ -30,8 +30,10 @@ func testPagesSourceConfigJSON(deploymentID uint, checksum string) string {
|
||||
type fakeClient struct {
|
||||
config protocol.ActiveConfigResponse
|
||||
reports []protocol.ApplyLogPayload
|
||||
wafSyncCalls []protocol.WAFIPGroupSyncRequest
|
||||
pagesPackages map[uint][]byte
|
||||
pagesHashes map[uint]string
|
||||
wafSyncResult protocol.WAFIPGroupSyncResponse
|
||||
fetchCalls int
|
||||
hashCalls int
|
||||
}
|
||||
@@ -47,6 +49,12 @@ type fakeManager struct {
|
||||
applyMainContents []string
|
||||
applyRouteContents []string
|
||||
applyFiles [][]protocol.SupportFile
|
||||
wafChecksums map[string]string
|
||||
wafReconcileIDs []uint
|
||||
wafReconcileGroups []protocol.WAFIPGroup
|
||||
wafUpdatedGroups []protocol.WAFIPGroup
|
||||
wafReconcileErr error
|
||||
wafReconcileCalls int
|
||||
}
|
||||
|
||||
func testSourceConfigJSON(workerProcesses string, listen int) string {
|
||||
@@ -106,7 +114,9 @@ func (f *fakeClient) ReportApplyLog(ctx context.Context, payload protocol.ApplyL
|
||||
}
|
||||
|
||||
func (f *fakeClient) SyncWAFIPGroups(ctx context.Context, payload protocol.WAFIPGroupSyncRequest) (*protocol.WAFIPGroupSyncResponse, error) {
|
||||
return &protocol.WAFIPGroupSyncResponse{}, nil
|
||||
f.wafSyncCalls = append(f.wafSyncCalls, payload)
|
||||
result := f.wafSyncResult
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (m *fakeManager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) nginx.ApplyOutcome {
|
||||
@@ -134,10 +144,18 @@ func (m *fakeManager) CurrentChecksum() (string, error) {
|
||||
}
|
||||
|
||||
func (m *fakeManager) WAFIPGroupChecksums() (map[string]string, error) {
|
||||
return map[string]string{}, nil
|
||||
return m.wafChecksums, nil
|
||||
}
|
||||
|
||||
func (m *fakeManager) SyncWAFIPGroups(groups []protocol.WAFIPGroup) error {
|
||||
func (m *fakeManager) ReconcileWAFIPGroups(ids []uint, groups []protocol.WAFIPGroup) error {
|
||||
m.wafReconcileCalls++
|
||||
m.wafReconcileIDs = append([]uint(nil), ids...)
|
||||
m.wafReconcileGroups = append([]protocol.WAFIPGroup(nil), groups...)
|
||||
return m.wafReconcileErr
|
||||
}
|
||||
|
||||
func (m *fakeManager) UpdateExistingWAFIPGroups(groups []protocol.WAFIPGroup) error {
|
||||
m.wafUpdatedGroups = append([]protocol.WAFIPGroup(nil), groups...)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -145,6 +163,143 @@ func (m *fakeManager) EnsureWorkerReadAccess() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestReferencedWAFIPGroupIDsSyncsCompiledDAGReferences(t *testing.T) {
|
||||
client := &fakeClient{wafSyncResult: protocol.WAFIPGroupSyncResponse{Groups: []protocol.WAFIPGroup{{ID: 7, Checksum: "new-7"}}}}
|
||||
manager := &fakeManager{wafChecksums: map[string]string{"2": "sum-2", "7": "old-7", "99": "stale"}}
|
||||
service := New(client, manager, nil)
|
||||
supportFiles := []protocol.SupportFile{{Path: "waf_config.json", Content: `{
|
||||
"rule_groups":[
|
||||
{"id":1,"ip_whitelist_group_ids":[0,11,7],"graph":{"entry":"start","nodes":{
|
||||
"start":{"type":"start","config":{}},
|
||||
"first":{"type":"ip_match","config":{"ip_group_ids":[7,0,2,7]}},
|
||||
"geo":{"type":"geo_match","config":{"countries":["US"],"ip_group_ids":[700]}},
|
||||
"pow":{"type":"pow","config":{"difficulty":4,"ip_group_ids":[800]}},
|
||||
"second":{"type":"ip_match","config":{"ip_group_ids":[9,2]}}
|
||||
}}}
|
||||
],
|
||||
"ip_groups":[{"id":2},{"id":7},{"id":9},{"id":11},{"id":404}],
|
||||
"bindings":[]
|
||||
}`}}
|
||||
|
||||
if err := service.syncReferencedWAFIPGroups(context.Background(), supportFiles); err != nil {
|
||||
t.Fatalf("syncReferencedWAFIPGroups failed: %v", err)
|
||||
}
|
||||
if len(client.wafSyncCalls) != 1 {
|
||||
t.Fatalf("expected one WAF IP group sync request, got %d", len(client.wafSyncCalls))
|
||||
}
|
||||
if got, want := fmt.Sprint(client.wafSyncCalls[0].IDs), "[2 7 9 11]"; got != want {
|
||||
t.Fatalf("referenced IDs = %s, want %s", got, want)
|
||||
}
|
||||
if got, want := fmt.Sprint(client.wafSyncCalls[0].Checksums), "map[2:sum-2 7:old-7]"; got != want {
|
||||
t.Fatalf("request checksums = %s, want target-only %s", got, want)
|
||||
}
|
||||
if got, want := fmt.Sprint(manager.wafReconcileIDs), "[2 7 9 11]"; got != want {
|
||||
t.Fatalf("reconcile IDs = %s, want %s", got, want)
|
||||
}
|
||||
if len(manager.wafReconcileGroups) != 1 || manager.wafReconcileGroups[0].ID != 7 {
|
||||
t.Fatalf("changed groups not passed to reconcile: %#v", manager.wafReconcileGroups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferencedWAFIPGroupIDsReconcilesEmptyResponseAndSurfacesMissingLocal(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
manager := &fakeManager{
|
||||
wafChecksums: map[string]string{"7": "mistaken-match"},
|
||||
wafReconcileErr: fmt.Errorf("missing referenced WAF IP group 7"),
|
||||
}
|
||||
service := New(client, manager, nil)
|
||||
err := service.syncReferencedWAFIPGroups(context.Background(), []protocol.SupportFile{{
|
||||
Path: "waf_config.json", Content: `{"rule_groups":[{"graph":{"nodes":{"match":{"type":"ip_match","config":{"ip_group_ids":[7]}}}}}]}`,
|
||||
}})
|
||||
if err == nil || !strings.Contains(err.Error(), "missing referenced WAF IP group 7") {
|
||||
t.Fatalf("expected missing local group error after empty delta, got %v", err)
|
||||
}
|
||||
if len(client.wafSyncCalls) != 1 || len(manager.wafReconcileIDs) != 1 || manager.wafReconcileIDs[0] != 7 {
|
||||
t.Fatalf("empty response did not reach authoritative reconcile: calls=%#v ids=%#v", client.wafSyncCalls, manager.wafReconcileIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferencedWAFIPGroupIDsEmptyTargetClearsWithoutRequest(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
manager := &fakeManager{}
|
||||
service := New(client, manager, nil)
|
||||
if err := service.syncReferencedWAFIPGroups(context.Background(), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(client.wafSyncCalls) != 0 {
|
||||
t.Fatalf("empty target performed request I/O: %#v", client.wafSyncCalls)
|
||||
}
|
||||
if manager.wafReconcileCalls != 1 {
|
||||
t.Fatal("empty authoritative target was not reconciled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWAFIPGroupsUsesExistingOnlyUpdatePath(t *testing.T) {
|
||||
manager := &fakeManager{}
|
||||
service := New(nil, manager, nil)
|
||||
groups := []protocol.WAFIPGroup{{ID: 99, Checksum: "broadcast"}}
|
||||
if err := service.ApplyWAFIPGroups(context.Background(), groups); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(manager.wafUpdatedGroups) != 1 || manager.wafUpdatedGroups[0].ID != 99 {
|
||||
t.Fatalf("broadcast did not use existing-only update path: %#v", manager.wafUpdatedGroups)
|
||||
}
|
||||
if manager.wafReconcileCalls != 0 {
|
||||
t.Fatal("broadcast must not use authoritative reconciliation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferencedWAFIPGroupIDsRejectsMalformedRuntimeConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "document", content: `{`, wantErr: "decode waf_config.json"},
|
||||
{
|
||||
name: "ip match config",
|
||||
content: `{"rule_groups":[{"id":1,"graph":{"nodes":{"match":{"type":"ip_match","config":{"ip_group_ids":"bad"}}}}}],"bindings":[]}`,
|
||||
wantErr: "decode ip_match config",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
service := New(client, &fakeManager{}, nil)
|
||||
err := service.syncReferencedWAFIPGroups(context.Background(), []protocol.SupportFile{{
|
||||
Path: "waf_config.json", Content: test.content,
|
||||
}})
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("expected %q error, got %v", test.wantErr, err)
|
||||
}
|
||||
if len(client.wafSyncCalls) != 0 {
|
||||
t.Fatalf("malformed WAF config must not send sync request, got %#v", client.wafSyncCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWAFIPGroupChecksumServicePublishesSidecar(t *testing.T) {
|
||||
runtimeDir := t.TempDir()
|
||||
manager := &nginx.Manager{RuntimeConfigDir: runtimeDir}
|
||||
if err := manager.ReconcileWAFIPGroups([]uint{3}, []protocol.WAFIPGroup{{
|
||||
ID: 3, Enabled: true, IPList: []string{"203.0.113.3"}, Checksum: "sum-3",
|
||||
}}); err != nil {
|
||||
t.Fatalf("ReconcileWAFIPGroups failed: %v", err)
|
||||
}
|
||||
jsonData, err := os.ReadFile(filepath.Join(runtimeDir, nginx.WAFIPGroupsConfigFileName))
|
||||
if err != nil {
|
||||
t.Fatalf("read IP group JSON: %v", err)
|
||||
}
|
||||
checksumData, err := os.ReadFile(filepath.Join(runtimeDir, nginx.WAFIPGroupsChecksumFileName))
|
||||
if err != nil {
|
||||
t.Fatalf("read IP group checksum: %v", err)
|
||||
}
|
||||
if got, want := strings.TrimSpace(string(checksumData)), testBytesChecksum(jsonData); got != want {
|
||||
t.Fatalf("published checksum mismatch: got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncOnceSuccess(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
|
||||
Reference in New Issue
Block a user