diff --git a/openflare_agent/internal/nginx/manager.go b/openflare_agent/internal/nginx/manager.go index 216a3285..5e6038bf 100644 --- a/openflare_agent/internal/nginx/manager.go +++ b/openflare_agent/internal/nginx/manager.go @@ -19,6 +19,8 @@ import ( "strings" "time" + openrestyrender "openflare/utils/render/openresty" + "openflare-agent/internal/protocol" ) @@ -223,6 +225,9 @@ func (m *Manager) writeTargetFiles(mainConfig string, routeConfig string, suppor if err := m.writeWAFConfig(supportFiles); err != nil { return err } + if err := m.writeSourceConfig(supportFiles); err != nil { + return err + } if err := m.ensureMimeTypes(); err != nil { return err } @@ -507,6 +512,8 @@ type backupState struct { RouteData []byte Files []protocol.SupportFile PowConfig *protocol.SupportFile + WAFConfig *protocol.SupportFile + SourceConfig *protocol.SupportFile } type managedFile struct { @@ -568,6 +575,16 @@ func (m *Manager) backup() (*backupState, error) { return nil, err } state.PowConfig = powConfig + wafConfig, err := m.readRuntimeConfigFile("waf_config.json") + if err != nil { + return nil, err + } + state.WAFConfig = wafConfig + sourceConfig, err := m.readRuntimeConfigFile(openrestyrender.SourceConfigFileName) + if err != nil { + return nil, err + } + state.SourceConfig = sourceConfig slog.Debug("backup captured", "main_exists", state.MainExisted, "route_exists", state.RouteExisted, "cert_files", len(state.Files)) return state, nil } @@ -596,7 +613,13 @@ func (m *Manager) restore(state *backupState) error { return err } } - return m.restorePowConfig(state) + if err := m.restoreRuntimeConfig(state.PowConfig, "pow_config.json"); err != nil { + return err + } + if err := m.restoreRuntimeConfig(state.WAFConfig, "waf_config.json"); err != nil { + return err + } + return m.restoreRuntimeConfig(state.SourceConfig, openrestyrender.SourceConfigFileName) } func (m *Manager) writeCertFiles(certFiles []protocol.SupportFile) error { @@ -652,10 +675,30 @@ func (m *Manager) writeWAFConfig(supportFiles []protocol.SupportFile) error { return nil } +func (m *Manager) writeSourceConfig(supportFiles []protocol.SupportFile) error { + if m.RuntimeConfigDir == "" { + return nil + } + configPath := filepath.Join(m.RuntimeConfigDir, openrestyrender.SourceConfigFileName) + for _, file := range supportFiles { + if file.Path == openrestyrender.SourceConfigFileName { + if err := os.WriteFile(configPath, []byte(file.Content), 0o644); err != nil { + return fmt.Errorf("write %s: %w", openrestyrender.SourceConfigFileName, err) + } + slog.Info("wrote openresty source config", "path", configPath, "size", len(file.Content)) + return nil + } + } + if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove %s: %w", openrestyrender.SourceConfigFileName, err) + } + return nil +} + func (m *Manager) writeManagedCertFiles(certFiles []protocol.SupportFile) error { files := make([]managedFile, 0, len(certFiles)) for _, file := range certFiles { - if file.Path == "pow_config.json" || file.Path == "waf_config.json" { + if file.Path == "pow_config.json" || file.Path == "waf_config.json" || file.Path == openrestyrender.SourceConfigFileName { continue } targetPath, err := m.certFileTargetPath(file.Path) @@ -720,10 +763,14 @@ func (m *Manager) readCertFiles() ([]protocol.SupportFile, error) { } func (m *Manager) readPowConfigFile() (*protocol.SupportFile, error) { + return m.readRuntimeConfigFile("pow_config.json") +} + +func (m *Manager) readRuntimeConfigFile(name string) (*protocol.SupportFile, error) { if m.RuntimeConfigDir == "" { return nil, nil } - configPath := filepath.Join(m.RuntimeConfigDir, "pow_config.json") + configPath := filepath.Join(m.RuntimeConfigDir, name) data, err := os.ReadFile(configPath) if err != nil { if os.IsNotExist(err) { @@ -732,7 +779,7 @@ func (m *Manager) readPowConfigFile() (*protocol.SupportFile, error) { return nil, err } return &protocol.SupportFile{ - Path: "pow_config.json", + Path: name, Content: string(data), }, nil } @@ -749,21 +796,28 @@ func (m *Manager) readManagedSupportFiles() ([]protocol.SupportFile, error) { if powConfig != nil { files = append(files, *powConfig) } + wafConfig, err := m.readRuntimeConfigFile("waf_config.json") + if err != nil { + return nil, err + } + if wafConfig != nil { + files = append(files, *wafConfig) + } return files, nil } -func (m *Manager) restorePowConfig(state *backupState) error { - if state == nil || m.RuntimeConfigDir == "" { +func (m *Manager) restoreRuntimeConfig(file *protocol.SupportFile, name string) error { + if m.RuntimeConfigDir == "" { return nil } - configPath := filepath.Join(m.RuntimeConfigDir, "pow_config.json") - if state.PowConfig == nil { + configPath := filepath.Join(m.RuntimeConfigDir, name) + if file == nil { if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { return err } return nil } - return os.WriteFile(configPath, []byte(state.PowConfig.Content), 0o644) + return os.WriteFile(configPath, []byte(file.Content), 0o644) } func (m *Manager) writeSafeDefaultFallbackFiles() error { diff --git a/openflare_agent/internal/protocol/agent_api.go b/openflare_agent/internal/protocol/agent_api.go index 15846178..c30d9d2b 100644 --- a/openflare_agent/internal/protocol/agent_api.go +++ b/openflare_agent/internal/protocol/agent_api.go @@ -165,13 +165,11 @@ type ApplyLogPayload struct { } type ActiveConfigResponse struct { - Version string `json:"version"` - Checksum string `json:"checksum"` - MainConfig string `json:"main_config"` - RouteConfig string `json:"route_config"` - RenderedConfig string `json:"rendered_config"` - SupportFiles []SupportFile `json:"support_files"` - CreatedAt string `json:"created_at"` + Version string `json:"version"` + Checksum string `json:"checksum"` + SourceConfigJSON string `json:"source_config_json"` + SupportFiles []SupportFile `json:"support_files"` + CreatedAt string `json:"created_at"` } type ActiveConfigMeta struct { diff --git a/openflare_agent/internal/sync/service.go b/openflare_agent/internal/sync/service.go index eeaa05d2..406db221 100644 --- a/openflare_agent/internal/sync/service.go +++ b/openflare_agent/internal/sync/service.go @@ -4,8 +4,10 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "log/slog" + openrestyrender "openflare/utils/render/openresty" "strings" "openflare-agent/internal/nginx" @@ -91,6 +93,14 @@ func (s *Service) sync(ctx context.Context, startup bool, target *protocol.Activ } if currentChecksum == target.Checksum { + if startup { + config, fetchErr := s.client.GetActiveConfig(ctx) + if fetchErr != nil { + slog.Error("fetch active config failed", "mode", mode, "error", fetchErr) + return fetchErr + } + return s.applyIfNeeded(ctx, mode, startup, snapshot, currentChecksum, target, config) + } slog.Debug("local openresty config already up to date", "mode", mode, "version", target.Version) shouldReport := shouldReportNoopApply(snapshot, target.Version, target.Checksum) if startup { @@ -152,11 +162,20 @@ func (s *Service) ForceSyncOnce(ctx context.Context, target *protocol.ActiveConf clearBlockedTarget(snapshot) _ = s.stateStore.Save(snapshot) } - return s.SyncOnce(ctx, target) + currentChecksum, err := s.nginxManager.CurrentChecksum() + if err != nil { + return err + } + config, err := s.client.GetActiveConfig(ctx) + if err != nil { + slog.Error("fetch active config failed", "mode", "force", "error", err) + return err + } + return s.applyIfNeeded(ctx, "force", true, snapshot, currentChecksum, target, config) } func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool, snapshot *state.Snapshot, currentChecksum string, target *protocol.ActiveConfigMeta, config *protocol.ActiveConfigResponse) error { - if currentChecksum == config.Checksum { + if currentChecksum == config.Checksum && !startup { slog.Debug("local openresty config already up to date", "mode", mode, "version", config.Version) shouldReport := shouldReportNoopApply(snapshot, config.Version, config.Checksum) if startup { @@ -172,11 +191,11 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool, snapshot.OpenrestyMessage = "" } if shouldReport { - routeConfig := config.RouteConfig - if routeConfig == "" { - routeConfig = config.RenderedConfig + rendered, renderErr := renderActiveConfig(config) + if renderErr != nil { + return renderErr } - if err := s.reportNoopApply(ctx, snapshot.NodeID, config.Version, config.Checksum, checksumString(config.MainConfig), checksumString(routeConfig), len(config.SupportFiles)); err != nil { + if err := s.reportNoopApply(ctx, snapshot.NodeID, config.Version, config.Checksum, checksumString(rendered.mainConfig), checksumString(rendered.routeConfig), len(rendered.supportFiles)); err != nil { return err } } @@ -207,14 +226,14 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool, slog.Debug("skipping apply because state already records target version/checksum", "version", config.Version, "checksum", config.Checksum) return s.stateStore.Save(snapshot) } - routeConfig := config.RouteConfig - if routeConfig == "" { - routeConfig = config.RenderedConfig + rendered, err := renderActiveConfig(config) + if err != nil { + return err } - mainConfigChecksum := checksumString(config.MainConfig) - routeConfigChecksum := checksumString(routeConfig) + mainConfigChecksum := checksumString(rendered.mainConfig) + routeConfigChecksum := checksumString(rendered.routeConfig) slog.Info("applying new openresty config", "mode", mode, "from_version", snapshot.CurrentVersion, "to_version", config.Version, "old_checksum", currentChecksum, "new_checksum", config.Checksum) - outcome := s.nginxManager.Apply(ctx, config.MainConfig, routeConfig, config.SupportFiles) + outcome := s.nginxManager.Apply(ctx, rendered.mainConfig, rendered.routeConfig, rendered.supportFiles) message := strings.TrimSpace(outcome.Message) if outcome.Status == "" { outcome.Status = nginx.ApplyStatusFatal @@ -269,7 +288,7 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool, Checksum: config.Checksum, MainConfigChecksum: mainConfigChecksum, RouteConfigChecksum: routeConfigChecksum, - SupportFileCount: len(config.SupportFiles), + SupportFileCount: len(rendered.supportFiles), }); err != nil { slog.Error("report apply log failed", "version", config.Version, "result", reportResult, "error", err) return err @@ -282,6 +301,55 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool, return nil } +type renderedActiveConfig struct { + mainConfig string + routeConfig string + supportFiles []protocol.SupportFile +} + +func renderActiveConfig(config *protocol.ActiveConfigResponse) (*renderedActiveConfig, error) { + if config == nil { + return nil, errors.New("active config is nil") + } + sourceJSON := strings.TrimSpace(config.SourceConfigJSON) + if sourceJSON == "" { + return nil, errors.New("active config source_config_json is empty") + } + rendered, err := openrestyrender.RenderJSON(sourceJSON, toOpenRestySupportFiles(config.SupportFiles)) + if err != nil { + return nil, err + } + files := fromOpenRestySupportFiles(rendered.SupportFiles) + files = append(files, protocol.SupportFile{Path: openrestyrender.SourceConfigFileName, Content: sourceJSON}) + return &renderedActiveConfig{ + mainConfig: rendered.MainConfig, + routeConfig: rendered.RouteConfig, + supportFiles: files, + }, nil +} + +func toOpenRestySupportFiles(files []protocol.SupportFile) []openrestyrender.SupportFile { + if len(files) == 0 { + return nil + } + result := make([]openrestyrender.SupportFile, 0, len(files)) + for _, file := range files { + result = append(result, openrestyrender.SupportFile{Path: file.Path, Content: file.Content}) + } + return result +} + +func fromOpenRestySupportFiles(files []openrestyrender.SupportFile) []protocol.SupportFile { + if len(files) == 0 { + return nil + } + result := make([]protocol.SupportFile, 0, len(files)) + for _, file := range files { + result = append(result, protocol.SupportFile{Path: file.Path, Content: file.Content}) + } + return result +} + func shouldReportNoopApply(snapshot *state.Snapshot, version string, checksum string) bool { if snapshot == nil { return false diff --git a/openflare_agent/internal/sync/service_test.go b/openflare_agent/internal/sync/service_test.go index 9cd3c64d..9a3a5cf8 100644 --- a/openflare_agent/internal/sync/service_test.go +++ b/openflare_agent/internal/sync/service_test.go @@ -2,8 +2,10 @@ package sync import ( "context" + "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -36,6 +38,10 @@ type fakeManager struct { applyFiles [][]protocol.SupportFile } +func testSourceConfigJSON(workerProcesses string, listen int) string { + return fmt.Sprintf(`{"routes":[{"id":1,"site_name":"example","domain":"example.com","domains":["example.com"],"origin_url":"http://127.0.0.1:%d","upstreams":["http://127.0.0.1:%d"],"enabled":true}],"openresty_config":{"worker_processes":"%s","worker_connections":1024,"worker_rlimit_nofile":65535,"events_multi_accept_enabled":true,"keepalive_timeout":20,"keepalive_requests":1000,"client_header_timeout":15,"client_body_timeout":15,"client_max_body_size":"64m","large_client_header_buffers":"4 16k","send_timeout":30,"proxy_connect_timeout":3,"proxy_send_timeout":60,"proxy_read_timeout":60,"websocket_enabled":true,"proxy_request_buffering":false,"proxy_buffering_enabled":true,"proxy_buffers":"16 16k","proxy_buffer_size":"8k","proxy_busy_buffers_size":"64k","gzip_enabled":true,"gzip_min_length":1024,"gzip_comp_level":5,"cache_enabled":false,"cache_levels":"1:2","cache_inactive":"30m","cache_max_size":"1g","cache_key_template":"$scheme$host$request_uri","cache_lock_enabled":true,"cache_lock_timeout":"5s","cache_use_stale":"error timeout updating http_500 http_502 http_503 http_504","main_config_template":"worker_processes {{OpenRestyWorkerProcesses}};"},"waf":{"rule_groups":[],"bindings":[]}}`, listen, listen, workerProcesses) +} + func (f *fakeExecutor) Test(ctx context.Context) error { return f.testErr } @@ -93,13 +99,11 @@ func (m *fakeManager) CurrentChecksum() (string, error) { func TestSyncOnceSuccess(t *testing.T) { client := &fakeClient{ config: protocol.ActiveConfigResponse{ - Version: "20260309-001", - Checksum: "checksum-1", - MainConfig: "worker_processes auto;", - RouteConfig: "server { listen 80; }", - RenderedConfig: "server { listen 80; }", - SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}}, - CreatedAt: time.Now().Format(time.RFC3339), + Version: "20260309-001", + Checksum: "checksum-1", + SourceConfigJSON: testSourceConfigJSON("auto", 80), + SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}}, + CreatedAt: time.Now().Format(time.RFC3339), }, } @@ -132,7 +136,7 @@ func TestSyncOnceSuccess(t *testing.T) { if err != nil { t.Fatalf("failed to read route config: %v", err) } - if string(data) != "server { listen 80; }" { + if !strings.Contains(string(data), "listen 80;") || !strings.Contains(string(data), "server_name example.com;") { t.Fatal("expected rendered config to be written to route file") } mainData, err := os.ReadFile(filepath.Join(filepath.Dir(routePath), "nginx.conf")) @@ -158,7 +162,7 @@ func TestSyncOnceSuccess(t *testing.T) { if client.reports[0].MainConfigChecksum == "" || client.reports[0].RouteConfigChecksum == "" { t.Fatal("expected main and route config checksums to be reported") } - if client.reports[0].SupportFileCount != 1 { + if client.reports[0].SupportFileCount != 4 { t.Fatalf("expected support file count to be reported, got %d", client.reports[0].SupportFileCount) } } @@ -166,13 +170,11 @@ func TestSyncOnceSuccess(t *testing.T) { func TestSyncOnceRollbackOnNginxFailure(t *testing.T) { client := &fakeClient{ config: protocol.ActiveConfigResponse{ - Version: "20260309-002", - Checksum: "checksum-2", - MainConfig: "worker_processes 2;", - RouteConfig: "server { listen 81; }", - RenderedConfig: "server { listen 81; }", - SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}}, - CreatedAt: time.Now().Format(time.RFC3339), + Version: "20260309-002", + Checksum: "checksum-2", + SourceConfigJSON: testSourceConfigJSON("2", 81), + SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}}, + CreatedAt: time.Now().Format(time.RFC3339), }, } @@ -225,7 +227,7 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) { if client.reports[0].MainConfigChecksum == "" || client.reports[0].RouteConfigChecksum == "" { t.Fatal("expected failed report to include main and route config checksums") } - if client.reports[0].SupportFileCount != 1 { + if client.reports[0].SupportFileCount != 4 { t.Fatalf("expected failed report to include support file count, got %d", client.reports[0].SupportFileCount) } } @@ -233,13 +235,11 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) { func TestSyncOnceReportsWarningWhenRollbackKeepsOpenrestyHealthy(t *testing.T) { client := &fakeClient{ config: protocol.ActiveConfigResponse{ - Version: "20260309-002", - Checksum: "checksum-2", - MainConfig: "worker_processes 2;", - RouteConfig: "server { listen 81; }", - RenderedConfig: "server { listen 81; }", - SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}}, - CreatedAt: time.Now().Format(time.RFC3339), + Version: "20260309-002", + Checksum: "checksum-2", + SourceConfigJSON: testSourceConfigJSON("2", 81), + SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}}, + CreatedAt: time.Now().Format(time.RFC3339), }, } @@ -294,13 +294,11 @@ func TestSyncOnceReportsWarningWhenRollbackKeepsOpenrestyHealthy(t *testing.T) { func TestSyncOnStartupRecreatesRuntimeWhenChecksumMatches(t *testing.T) { client := &fakeClient{ config: protocol.ActiveConfigResponse{ - Version: "20260309-003", - Checksum: "checksum-3", - MainConfig: "worker_processes auto;", - RouteConfig: "server { listen 82; }", - RenderedConfig: "server { listen 82; }", - SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}}, - CreatedAt: time.Now().Format(time.RFC3339), + Version: "20260309-003", + Checksum: "checksum-3", + SourceConfigJSON: testSourceConfigJSON("auto", 82), + SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}}, + CreatedAt: time.Now().Format(time.RFC3339), }, } stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json")) @@ -320,14 +318,11 @@ func TestSyncOnStartupRecreatesRuntimeWhenChecksumMatches(t *testing.T) { }); err != nil { t.Fatalf("SyncOnStartup failed: %v", err) } - if len(manager.ensureCalls) != 1 || !manager.ensureCalls[0] { - t.Fatal("expected startup sync to recreate runtime") + if len(manager.applyMainContents) != 1 { + t.Fatal("expected startup sync to re-render and apply local config") } if len(client.reports) != 1 || client.reports[0].Result != ApplyResultSuccess { - t.Fatal("expected startup sync to report noop success when state is refreshed") - } - if client.reports[0].Message != "local config already matches active version; apply skipped" { - t.Fatalf("unexpected noop apply message: %q", client.reports[0].Message) + t.Fatal("expected startup sync to report apply success when state is refreshed") } snapshot, err := stateStore.Load() if err != nil { @@ -416,12 +411,10 @@ func TestSyncOnceDoesNotRepeatNoopReportWhenStateAlreadyMatches(t *testing.T) { func TestSyncOnStartupRecordsRuntimeFailure(t *testing.T) { client := &fakeClient{ config: protocol.ActiveConfigResponse{ - Version: "20260309-004", - Checksum: "checksum-4", - MainConfig: "worker_processes 4;", - RouteConfig: "server { listen 83; }", - RenderedConfig: "server { listen 83; }", - CreatedAt: time.Now().Format(time.RFC3339), + Version: "20260309-004", + Checksum: "checksum-4", + SourceConfigJSON: testSourceConfigJSON("4", 83), + CreatedAt: time.Now().Format(time.RFC3339), }, } stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json")) @@ -435,7 +428,7 @@ func TestSyncOnStartupRecordsRuntimeFailure(t *testing.T) { manager := &fakeManager{ currentChecksum: "checksum-4", - ensureErr: context.DeadlineExceeded, + applyOutcome: nginx.ApplyOutcome{Status: nginx.ApplyStatusFatal, Message: context.DeadlineExceeded.Error()}, } service := New(client, manager, stateStore) if err = service.SyncOnStartup(context.Background(), &protocol.ActiveConfigMeta{ @@ -459,12 +452,10 @@ func TestSyncOnStartupRecordsRuntimeFailure(t *testing.T) { func TestSyncOnceSkipsPreviouslyBlockedVersion(t *testing.T) { client := &fakeClient{ config: protocol.ActiveConfigResponse{ - Version: "20260309-006", - Checksum: "checksum-6", - MainConfig: "worker_processes 6;", - RouteConfig: "server { listen 86; }", - RenderedConfig: "server { listen 86; }", - CreatedAt: time.Now().Format(time.RFC3339), + Version: "20260309-006", + Checksum: "checksum-6", + SourceConfigJSON: testSourceConfigJSON("6", 86), + CreatedAt: time.Now().Format(time.RFC3339), }, } stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json")) @@ -506,12 +497,10 @@ func TestSyncOnceSkipsPreviouslyBlockedVersion(t *testing.T) { func TestSyncOnStartupKeepsBlockedVersionSuppressedUntilNewTargetArrives(t *testing.T) { client := &fakeClient{ config: protocol.ActiveConfigResponse{ - Version: "20260309-007", - Checksum: "checksum-7", - MainConfig: "worker_processes 7;", - RouteConfig: "server { listen 87; }", - RenderedConfig: "server { listen 87; }", - CreatedAt: time.Now().Format(time.RFC3339), + Version: "20260309-007", + Checksum: "checksum-7", + SourceConfigJSON: testSourceConfigJSON("7", 87), + CreatedAt: time.Now().Format(time.RFC3339), }, } stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json")) @@ -565,12 +554,10 @@ func TestSyncOnStartupKeepsBlockedVersionSuppressedUntilNewTargetArrives(t *test func TestSyncOnStartupStartsFallbackWhenBlockedVersionHasNoLocalConfig(t *testing.T) { client := &fakeClient{ config: protocol.ActiveConfigResponse{ - Version: "20260309-007", - Checksum: "checksum-7", - MainConfig: "worker_processes 7;", - RouteConfig: "server { listen 87; }", - RenderedConfig: "server { listen 87; }", - CreatedAt: time.Now().Format(time.RFC3339), + Version: "20260309-007", + Checksum: "checksum-7", + SourceConfigJSON: testSourceConfigJSON("7", 87), + CreatedAt: time.Now().Format(time.RFC3339), }, } stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json")) @@ -625,12 +612,10 @@ func TestSyncOnStartupStartsFallbackWhenBlockedVersionHasNoLocalConfig(t *testin func TestSyncOnStartupStartsFallbackWhenResidualConfigCannotRecover(t *testing.T) { client := &fakeClient{ config: protocol.ActiveConfigResponse{ - Version: "20260309-007", - Checksum: "checksum-7", - MainConfig: "worker_processes 7;", - RouteConfig: "server { listen 87; }", - RenderedConfig: "server { listen 87; }", - CreatedAt: time.Now().Format(time.RFC3339), + Version: "20260309-007", + Checksum: "checksum-7", + SourceConfigJSON: testSourceConfigJSON("7", 87), + CreatedAt: time.Now().Format(time.RFC3339), }, } stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json")) @@ -679,12 +664,10 @@ func TestSyncOnStartupStartsFallbackWhenResidualConfigCannotRecover(t *testing.T func TestSyncOnceClearsBlockedTargetWhenNewVersionArrives(t *testing.T) { client := &fakeClient{ config: protocol.ActiveConfigResponse{ - Version: "20260309-008", - Checksum: "checksum-8", - MainConfig: "worker_processes 8;", - RouteConfig: "server { listen 88; }", - RenderedConfig: "server { listen 88; }", - CreatedAt: time.Now().Format(time.RFC3339), + Version: "20260309-008", + Checksum: "checksum-8", + SourceConfigJSON: testSourceConfigJSON("8", 88), + CreatedAt: time.Now().Format(time.RFC3339), }, } stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json")) @@ -732,12 +715,10 @@ func TestSyncOnceClearsBlockedTargetWhenNewVersionArrives(t *testing.T) { func TestSyncOnceSkipsFetchWhenHeartbeatChecksumMatches(t *testing.T) { client := &fakeClient{ config: protocol.ActiveConfigResponse{ - Version: "20260309-005", - Checksum: "checksum-5", - MainConfig: "worker_processes auto;", - RouteConfig: "server { listen 84; }", - RenderedConfig: "server { listen 84; }", - CreatedAt: time.Now().Format(time.RFC3339), + Version: "20260309-005", + Checksum: "checksum-5", + SourceConfigJSON: testSourceConfigJSON("auto", 84), + CreatedAt: time.Now().Format(time.RFC3339), }, } stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json")) diff --git a/openflare_server/router/api_phase1_test.go b/openflare_server/router/api_phase1_test.go index 0ed9a0e1..e1275af1 100644 --- a/openflare_server/router/api_phase1_test.go +++ b/openflare_server/router/api_phase1_test.go @@ -378,13 +378,13 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) { agentResp := performAgentJSONRequestWithToken(t, engine, common.AgentToken, http.MethodGet, "/api/agent/config-versions/active", nil) var activeConfig map[string]any decodeResponseData(t, agentResp, &activeConfig) - mainConfig, ok := activeConfig["main_config"].(string) - if !ok || !strings.Contains(mainConfig, "include __OPENFLARE_ROUTE_CONFIG__;") { - t.Fatalf("expected active config to expose main_config, got %#v", activeConfig["main_config"]) + sourceConfigJSON, ok := activeConfig["source_config_json"].(string) + if !ok || !strings.Contains(sourceConfigJSON, "secure.example.com") { + t.Fatalf("expected active config to expose source_config_json, got %#v", activeConfig["source_config_json"]) } supportFiles, ok := activeConfig["support_files"].([]any) - if !ok || len(supportFiles) != 4 { - t.Fatalf("expected active config to expose 4 support files, got %#v", activeConfig["support_files"]) + if !ok || len(supportFiles) != 2 { + t.Fatalf("expected active config to expose 2 certificate support files, got %#v", activeConfig["support_files"]) } } diff --git a/openflare_server/router/api_phase2_test.go b/openflare_server/router/api_phase2_test.go index fa06306a..2359d5d6 100644 --- a/openflare_server/router/api_phase2_test.go +++ b/openflare_server/router/api_phase2_test.go @@ -457,7 +457,7 @@ func TestPhase2AgentLifecycle(t *testing.T) { activeConfigResp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodGet, "/api/agent/config-versions/active", nil) var activeConfig service.AgentConfigResponse decodeResponseData(t, activeConfigResp, &activeConfig) - if activeConfig.Version == "" || activeConfig.RenderedConfig == "" || activeConfig.Checksum == "" { + if activeConfig.Version == "" || activeConfig.SourceConfigJSON == "" || activeConfig.Checksum == "" { t.Fatal("expected active config response to contain version payload") } diff --git a/openflare_server/service/agent.go b/openflare_server/service/agent.go index abf7b889..6adc5fbb 100644 --- a/openflare_server/service/agent.go +++ b/openflare_server/service/agent.go @@ -79,13 +79,11 @@ type ApplyLogCleanupResult struct { } type AgentConfigResponse struct { - Version string `json:"version"` - Checksum string `json:"checksum"` - MainConfig string `json:"main_config"` - RouteConfig string `json:"route_config"` - RenderedConfig string `json:"rendered_config"` - SupportFiles []SupportFile `json:"support_files"` - CreatedAt time.Time `json:"created_at"` + Version string `json:"version"` + Checksum string `json:"checksum"` + SourceConfigJSON string `json:"source_config_json"` + SupportFiles []SupportFile `json:"support_files"` + CreatedAt time.Time `json:"created_at"` } type AgentSettings struct { @@ -233,13 +231,11 @@ func GetActiveConfigForAgent() (*AgentConfigResponse, error) { } slog.Debug("agent fetched active config", "version", version.Version, "checksum", version.Checksum) return &AgentConfigResponse{ - Version: version.Version, - Checksum: version.Checksum, - MainConfig: version.MainConfig, - RouteConfig: version.RenderedConfig, - RenderedConfig: version.RenderedConfig, - SupportFiles: supportFiles, - CreatedAt: version.CreatedAt, + Version: version.Version, + Checksum: version.Checksum, + SourceConfigJSON: version.SnapshotJSON, + SupportFiles: sourceSupportFiles(supportFiles), + CreatedAt: version.CreatedAt, }, nil } @@ -426,10 +422,6 @@ func normalizeApplyLogPageSize(pageSize int) int { return pageSize } -func upsertNode(payload AgentNodePayload) (*model.Node, error) { - return nil, errors.New("不再支持匿名自动注册") -} - func computeNodeStatus(node *model.Node) string { if node == nil { return NodeStatusOffline diff --git a/openflare_server/service/agent_test.go b/openflare_server/service/agent_test.go index ad1653da..27f7a296 100644 --- a/openflare_server/service/agent_test.go +++ b/openflare_server/service/agent_test.go @@ -33,18 +33,13 @@ func TestGetActiveConfigForAgentIncludesPoWConfig(t *testing.T) { t.Fatalf("GetActiveConfigForAgent failed: %v", err) } - foundPowConfig := false for _, file := range activeConfig.SupportFiles { - if file.Path != "pow_config.json" { - continue - } - foundPowConfig = true - if file.Content == "" { - t.Fatal("expected pow_config.json content to be populated") + if file.Path == "pow_config.json" || file.Path == "waf_config.json" { + t.Fatalf("agent config should not receive rendered runtime config file %s", file.Path) } } - if !foundPowConfig { - t.Fatal("expected agent config to include pow_config.json support file") + if !strings.Contains(activeConfig.SourceConfigJSON, `"pow_enabled":true`) { + t.Fatal("expected agent config source json to include PoW source configuration") } } @@ -71,13 +66,12 @@ func TestGetActiveConfigForAgentIncludesWAFConfig(t *testing.T) { for _, file := range activeConfig.SupportFiles { if file.Path == "waf_config.json" { - if !strings.Contains(file.Content, `"rule_groups"`) { - t.Fatalf("expected waf_config.json content to include rule groups, got %s", file.Content) - } - return + t.Fatal("agent config should not receive rendered waf_config.json") } } - t.Fatal("expected agent config to include waf_config.json support file") + if !strings.Contains(activeConfig.SourceConfigJSON, `"waf"`) { + t.Fatal("expected agent config source json to include WAF source configuration") + } } func TestGetActiveConfigForAgentUsesTenMinutePoWSessionDefault(t *testing.T) { @@ -105,13 +99,12 @@ func TestGetActiveConfigForAgentUsesTenMinutePoWSessionDefault(t *testing.T) { for _, file := range activeConfig.SupportFiles { if file.Path == "pow_config.json" { - if !strings.Contains(file.Content, `"session_ttl":600`) { - t.Fatalf("expected default PoW session TTL to be 600 seconds, got %s", file.Content) - } - return + t.Fatal("agent config should not receive rendered pow_config.json") } } - t.Fatal("expected agent config to include pow_config.json support file") + if !strings.Contains(activeConfig.SourceConfigJSON, `"session_ttl":600`) { + t.Fatalf("expected default PoW session TTL to be in source json, got %s", activeConfig.SourceConfigJSON) + } } func TestRegisterNodeWithAgentToken(t *testing.T) { diff --git a/openflare_server/service/config_version.go b/openflare_server/service/config_version.go index f95beebe..c4b0bca2 100644 --- a/openflare_server/service/config_version.go +++ b/openflare_server/service/config_version.go @@ -1,16 +1,12 @@ package service import ( - "crypto/sha256" - "encoding/base64" - "encoding/hex" "encoding/json" "errors" "fmt" - "net/url" "openflare/common" "openflare/model" - "regexp" + openrestyrender "openflare/utils/render/openresty" "sort" "strconv" "strings" @@ -67,6 +63,7 @@ type ConfigOptionDiffItem struct { } type snapshotRoute struct { + ID uint `json:"id,omitempty"` SiteName string `json:"site_name,omitempty"` Domain string `json:"domain"` Domains []string `json:"domains,omitempty"` @@ -122,26 +119,6 @@ type snapshotWAFDocument struct { Bindings []snapshotWAFBinding `json:"bindings"` } -type routeCacheConfig struct { - Enabled bool - Policy string - Rules []string -} - -type routeLimitConfig struct { - LimitConnPerServer int - LimitConnPerIP int - LimitRate string -} - -type routeUpstreamConfig struct { - Name string - Scheme string - ProxyPassURI string - Servers []string - UsesNamedUpstream bool -} - type openRestyConfigSnapshot struct { WorkerProcesses string `json:"worker_processes"` WorkerConnections int `json:"worker_connections"` @@ -177,6 +154,7 @@ type openRestyConfigSnapshot struct { CacheLockEnabled bool `json:"cache_lock_enabled"` CacheLockTimeout string `json:"cache_lock_timeout"` CacheUseStale string `json:"cache_use_stale"` + MainConfigTemplate string `json:"main_config_template,omitempty"` } type snapshotDocument struct { @@ -198,46 +176,6 @@ type configBundle struct { ChangedOptionKeys []string } -const ( - nginxCertDirPlaceholder = "__OPENFLARE_CERT_DIR__" - nginxRouteConfigPlaceholder = "__OPENFLARE_ROUTE_CONFIG__" - nginxAccessLogPlaceholder = "__OPENFLARE_ACCESS_LOG__" - nginxLuaDirPlaceholder = "__OPENFLARE_LUA_DIR__" - nginxObservabilityListenPlaceholder = "__OPENFLARE_OBSERVABILITY_LISTEN__" - nginxObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__" -) - -var requiredMainConfigTemplatePlaceholders = []string{ - "{{OpenRestyWorkerProcesses}}", - "{{OpenRestyWorkerConnections}}", - "{{OpenRestyWorkerRlimitNofile}}", - "{{OpenRestyConnectionUpgradeMap}}", - "{{OpenRestyDefaultServerBlock}}", - "{{OpenRestyAccessLogPath}}", - "{{OpenRestyEventsUseDirective}}", - "{{OpenRestyEventsMultiAcceptDirective}}", - "{{OpenRestyKeepaliveTimeout}}", - "{{OpenRestyKeepaliveRequests}}", - "{{OpenRestyClientHeaderTimeout}}", - "{{OpenRestyClientBodyTimeout}}", - "{{OpenRestyClientMaxBodySize}}", - "{{OpenRestyLargeClientHeaderBuffers}}", - "{{OpenRestySendTimeout}}", - "{{OpenRestyProxyConnectTimeout}}", - "{{OpenRestyProxySendTimeout}}", - "{{OpenRestyProxyReadTimeout}}", - "{{OpenRestyProxyRequestBuffering}}", - "{{OpenRestyProxyBuffering}}", - "{{OpenRestyProxyBuffers}}", - "{{OpenRestyProxyBufferSize}}", - "{{OpenRestyProxyBusyBuffersSize}}", - "{{OpenRestyGzip}}", - "{{OpenRestyGzipMinLength}}", - "{{OpenRestyGzipCompLevel}}", - "{{OpenRestyCacheBlock}}", - "{{OpenRestyRouteConfigInclude}}", -} - func ListConfigVersions() ([]*ConfigVersionSummary, error) { return model.ListConfigVersionSummaries() } @@ -427,6 +365,29 @@ func PublishConfigVersion(createdBy string, force bool) (*ReleaseResult, error) }, nil } +func sourceSupportFiles(files []SupportFile) []SupportFile { + if len(files) == 0 { + return nil + } + result := make([]SupportFile, 0, len(files)) + for _, file := range files { + if isRuntimeGeneratedSupportFile(file.Path) { + continue + } + result = append(result, file) + } + return result +} + +func isRuntimeGeneratedSupportFile(path string) bool { + switch strings.TrimSpace(path) { + case "pow_config.json", "waf_config.json", openrestyrender.SourceConfigFileName: + return true + default: + return false + } +} + func ActivateConfigVersion(id uint) (*model.ConfigVersion, error) { version, err := model.GetConfigVersionByID(id) if err != nil { @@ -506,32 +467,24 @@ func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) { if err != nil { return nil, err } - routeConfig, supportFiles, err := renderRouteConfig(routes, openRestyConfig, wafSnapshot) + certificateFiles, err := buildCertificateSupportFiles(snapshotRoutes) if err != nil { return nil, err } - wafConfigJSON, err := renderWAFConfigBundle(wafSnapshot) + rendered, err := renderSnapshotConfig(string(snapshotJSON), certificateFiles) if err != nil { return nil, err } - powConfigJSON, powSupportFiles, err := renderPowConfigBundle(routes, wafSnapshot) - if err != nil { - return nil, err - } - supportFiles = append(supportFiles, powSupportFiles...) - mainConfig := renderMainConfig(openRestyConfig) - supportFiles = append(supportFiles, SupportFile{Path: "pow_config.json", Content: powConfigJSON}) - supportFiles = append(supportFiles, SupportFile{Path: "waf_config.json", Content: wafConfigJSON}) return &configBundle{ Routes: routes, SnapshotRoutes: snapshotRoutes, WAFSnapshot: wafSnapshot, OpenRestyConfig: openRestyConfig, SnapshotJSON: string(snapshotJSON), - MainConfig: mainConfig, - RouteConfig: routeConfig, - SupportFiles: supportFiles, - Checksum: checksumBundle(mainConfig, routeConfig, supportFiles), + MainConfig: rendered.MainConfig, + RouteConfig: rendered.RouteConfig, + SupportFiles: fromOpenRestySupportFiles(rendered.SupportFiles), + Checksum: rendered.Checksum, ChangedOptionKeys: openRestyOptionKeys(), }, nil } @@ -563,6 +516,7 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) { powConfig = nil } items = append(items, snapshotRoute{ + ID: route.ID, SiteName: normalizeProxyRouteSiteNameInput(route, route.SiteName, domains[0]), Domain: domains[0], Domains: domains, @@ -922,12 +876,68 @@ func buildOpenRestyConfigSnapshot() openRestyConfigSnapshot { CacheLockEnabled: common.OpenRestyCacheLockEnabled, CacheLockTimeout: common.OpenRestyCacheLockTimeout, CacheUseStale: common.OpenRestyCacheUseStale, + MainConfigTemplate: common.OpenRestyMainConfigTemplate, } } -func diffOpenRestyOptionKeys(left openRestyConfigSnapshot, right openRestyConfigSnapshot) []string { - details := diffOpenRestyOptionDetails(left, right) - return extractOptionDiffKeys(details) +func renderSnapshotConfig(sourceJSON string, certificateFiles []SupportFile) (*openrestyrender.Result, error) { + return openrestyrender.RenderJSON(sourceJSON, toOpenRestySupportFiles(certificateFiles)) +} + +func buildCertificateSupportFiles(routes []snapshotRoute) ([]SupportFile, error) { + certIDSet := make(map[uint]struct{}) + for _, route := range routes { + for _, certID := range route.CertIDs { + if certID != 0 { + certIDSet[certID] = struct{}{} + } + } + } + if len(certIDSet) == 0 { + return nil, nil + } + certIDs := make([]uint, 0, len(certIDSet)) + for certID := range certIDSet { + certIDs = append(certIDs, certID) + } + sort.Slice(certIDs, func(i, j int) bool { return certIDs[i] < certIDs[j] }) + certificates, err := loadTLSCertificates(certIDs) + if err != nil { + return nil, err + } + files := make([]SupportFile, 0, len(certificates)*2) + for _, certificate := range certificates { + if certificate == nil { + continue + } + files = append(files, + SupportFile{Path: certificateCertFileName(certificate.ID), Content: normalizePEM(certificate.CertPEM)}, + SupportFile{Path: certificateKeyFileName(certificate.ID), Content: normalizePEM(certificate.KeyPEM)}, + ) + } + return dedupeSupportFiles(files), nil +} + +func toOpenRestySupportFiles(files []SupportFile) []openrestyrender.SupportFile { + if len(files) == 0 { + return nil + } + result := make([]openrestyrender.SupportFile, 0, len(files)) + for _, file := range files { + result = append(result, openrestyrender.SupportFile{Path: file.Path, Content: file.Content}) + } + return result +} + +func fromOpenRestySupportFiles(files []openrestyrender.SupportFile) []SupportFile { + if len(files) == 0 { + return nil + } + result := make([]SupportFile, 0, len(files)) + for _, file := range files { + result = append(result, SupportFile{Path: file.Path, Content: file.Content}) + } + return result } func buildInitialOpenRestyOptionDiffs(current openRestyConfigSnapshot) []ConfigOptionDiffItem { @@ -1033,309 +1043,10 @@ func openRestyOptionKeys() []string { } } -func getPoWConfigForRoute(routeID uint, snapshot snapshotWAFDocument) (bool, *ProxyRoutePoWConfig) { - for _, binding := range snapshot.Bindings { - if binding.RouteID == routeID { - for _, groupID := range binding.RuleGroupIDs { - for _, group := range snapshot.RuleGroups { - if group.ID == groupID && group.PoWEnabled { - return true, group.PoWConfig - } - } - } - break - } - } - for _, group := range snapshot.RuleGroups { - if group.IsGlobal && group.PoWEnabled { - return true, group.PoWConfig - } - } - return false, nil -} - -func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot, wafSnapshot snapshotWAFDocument) (string, []SupportFile, error) { - var builder strings.Builder - builder.WriteString("# This file is generated by OpenFlare. Do not edit manually.\n") - supportFiles := make([]SupportFile, 0) - for _, route := range routes { - domains, err := decodeStoredDomains(route.Domains, route.Domain) - if err != nil { - return "", nil, fmt.Errorf("route %s domains are invalid", route.Domain) - } - serverNames := renderServerNames(domains) - displayName := route.SiteName - if strings.TrimSpace(displayName) == "" { - displayName = domains[0] - } - customHeaders, err := decodeStoredCustomHeaders(route.CustomHeaders) - if err != nil { - return "", nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain) - } - upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL) - if err != nil { - return "", nil, fmt.Errorf("路由 %s 上游配置无效", route.Domain) - } - cacheRules, err := decodeStoredCacheRules(route.CacheRules) - if err != nil { - return "", nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain) - } - cacheConfig := routeCacheConfig{ - Enabled: route.CacheEnabled, - Policy: route.CachePolicy, - Rules: cacheRules, - } - limitConfig := routeLimitConfig{ - LimitConnPerServer: route.LimitConnPerServer, - LimitConnPerIP: route.LimitConnPerIP, - LimitRate: route.LimitRate, - } - upstreamConfig := buildRouteUpstreamConfig(route, upstreams) - if upstreamConfig.UsesNamedUpstream { - builder.WriteString(renderNamedUpstreamBlock(upstreamConfig)) - } - powEnabled, _ := getPoWConfigForRoute(route.ID, wafSnapshot) - if route.PoWEnabled { - powEnabled = true - } - if !route.EnableHTTPS { - builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg)) - continue - } - certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID) - if err != nil { - return "", nil, fmt.Errorf("route %s cert_ids are invalid: %w", route.Domain, err) - } - domainCertIDs, err := resolveProxyRouteDomainCertIDs(route, domains, certIDs) - if err != nil { - return "", nil, fmt.Errorf("route %s domain_cert_ids are invalid: %w", route.Domain, err) - } - if route.CertID == nil || *route.CertID == 0 { - return "", nil, fmt.Errorf("路由 %s 未配置证书", route.Domain) - } - if len(certIDs) == 0 { - return "", nil, fmt.Errorf("路由 %s 未配置证书", route.Domain) - } - certificates, err := loadTLSCertificates(certIDs) - if err != nil { - return "", nil, fmt.Errorf("route %s certificate lookup failed: %w", route.Domain, err) - } - certificateByID := make(map[uint]*model.TLSCertificate, len(certificates)) - for _, certificate := range certificates { - if certificate == nil { - continue - } - certificateByID[certificate.ID] = certificate - supportFiles = append(supportFiles, - SupportFile{Path: certificateCertFileName(certificate.ID), Content: normalizePEM(certificate.CertPEM)}, - SupportFile{Path: certificateKeyFileName(certificate.ID), Content: normalizePEM(certificate.KeyPEM)}, - ) - } - - httpOnlyDomains := make([]string, 0, len(domains)) - domainsByCertID := make(map[uint][]string, len(certIDs)) - for index, domain := range domains { - if index >= len(domainCertIDs) || domainCertIDs[index] == 0 { - httpOnlyDomains = append(httpOnlyDomains, domain) - continue - } - domainsByCertID[domainCertIDs[index]] = append( - domainsByCertID[domainCertIDs[index]], - domain, - ) - } - for _, certID := range certIDs { - assignedDomains := domainsByCertID[certID] - if len(assignedDomains) == 0 { - continue - } - certificate := certificateByID[certID] - if certificate == nil { - return "", nil, fmt.Errorf("route %s certificate %d does not exist", route.Domain, certID) - } - if err := validateCertificateCoverage(certificate, assignedDomains); err != nil { - return "", nil, fmt.Errorf("site %s certificate validation failed: %w", displayName, err) - } - } - - if route.RedirectHTTP { - if len(httpOnlyDomains) > 0 { - builder.WriteString(renderHTTPProxyServer(renderServerNames(httpOnlyDomains), displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg)) - } - for _, certID := range certIDs { - assignedDomains := domainsByCertID[certID] - if len(assignedDomains) == 0 { - continue - } - builder.WriteString(renderHTTPRedirectServer(renderServerNames(assignedDomains), displayName)) - } - } else { - builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg)) - } - for _, certID := range certIDs { - assignedDomains := domainsByCertID[certID] - if len(assignedDomains) == 0 { - continue - } - builder.WriteString(renderHTTPSServer(renderServerNames(assignedDomains), displayName, route.OriginURL, route.OriginHost, certID, customHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg)) - } - } - return builder.String(), dedupeSupportFiles(supportFiles), nil -} - -func renderMainConfig(cfg openRestyConfigSnapshot) string { - templateText := common.OpenRestyMainConfigTemplate - if strings.TrimSpace(templateText) == "" { - templateText = defaultOpenRestyMainConfigTemplate() - } - return renderMainConfigTemplate(templateText, cfg) -} - func ValidateOpenRestyMainConfigTemplate(templateText string) error { - trimmed := strings.TrimSpace(templateText) - if trimmed == "" { - return errors.New("OpenRestyMainConfigTemplate 不能为空") - } - for _, placeholder := range requiredMainConfigTemplatePlaceholders { - if !strings.Contains(trimmed, placeholder) { - return fmt.Errorf("OpenRestyMainConfigTemplate 必须保留占位符 %s", placeholder) - } - } - return nil + return openrestyrender.ValidateMainConfigTemplate(templateText) } -func defaultOpenRestyMainConfigTemplate() string { - return common.OpenRestyMainConfigTemplate -} - -func renderMainConfigTemplate(templateText string, cfg openRestyConfigSnapshot) string { - replacer := strings.NewReplacer( - "{{OpenRestyWorkerProcesses}}", cfg.WorkerProcesses, - "{{OpenRestyWorkerConnections}}", fmt.Sprintf("%d", cfg.WorkerConnections), - "{{OpenRestyWorkerRlimitNofile}}", fmt.Sprintf("%d", cfg.WorkerRlimitNofile), - "{{OpenRestyConnectionUpgradeMap}}", renderConnectionUpgradeMap(), - "{{OpenRestyDefaultServerBlock}}", renderDefaultServerBlock(), - "{{OpenRestyAccessLogPath}}", nginxAccessLogPlaceholder, - "{{OpenRestyEventsUseDirective}}", renderTemplateDirective(cfg.EventsUse != "", fmt.Sprintf("use %s;", cfg.EventsUse)), - "{{OpenRestyEventsMultiAcceptDirective}}", renderTemplateDirective(cfg.EventsMultiAcceptEnabled, "multi_accept on;"), - "{{OpenRestyKeepaliveTimeout}}", fmt.Sprintf("%d", cfg.KeepaliveTimeout), - "{{OpenRestyKeepaliveRequests}}", fmt.Sprintf("%d", cfg.KeepaliveRequests), - "{{OpenRestyClientHeaderTimeout}}", fmt.Sprintf("%d", cfg.ClientHeaderTimeout), - "{{OpenRestyClientBodyTimeout}}", fmt.Sprintf("%d", cfg.ClientBodyTimeout), - "{{OpenRestyClientMaxBodySize}}", cfg.ClientMaxBodySize, - "{{OpenRestyLargeClientHeaderBuffers}}", cfg.LargeClientHeaderBuffers, - "{{OpenRestySendTimeout}}", fmt.Sprintf("%d", cfg.SendTimeout), - "{{OpenRestyProxyConnectTimeout}}", fmt.Sprintf("%d", cfg.ProxyConnectTimeout), - "{{OpenRestyProxySendTimeout}}", fmt.Sprintf("%d", cfg.ProxySendTimeout), - "{{OpenRestyProxyReadTimeout}}", fmt.Sprintf("%d", cfg.ProxyReadTimeout), - "{{OpenRestyProxyRequestBuffering}}", onOff(cfg.ProxyRequestBuffering), - "{{OpenRestyProxyBuffering}}", onOff(cfg.ProxyBufferingEnabled), - "{{OpenRestyProxyBuffers}}", cfg.ProxyBuffers, - "{{OpenRestyProxyBufferSize}}", cfg.ProxyBufferSize, - "{{OpenRestyProxyBusyBuffersSize}}", cfg.ProxyBusyBuffersSize, - "{{OpenRestyGzip}}", onOff(cfg.GzipEnabled), - "{{OpenRestyGzipMinLength}}", fmt.Sprintf("%d", cfg.GzipMinLength), - "{{OpenRestyGzipCompLevel}}", fmt.Sprintf("%d", cfg.GzipCompLevel), - "{{OpenRestyResolverDirective}}", renderTemplateDirective(cfg.Resolvers != "", fmt.Sprintf("resolver %s;", cfg.Resolvers)), - "{{OpenRestyCacheBlock}}", renderOpenRestyCacheTemplateBlock(cfg), - "{{OpenRestyRouteConfigInclude}}", nginxRouteConfigPlaceholder, - ) - return replacer.Replace(templateText) -} - -func renderTemplateDirective(enabled bool, statement string) string { - if !enabled { - return "" - } - return fmt.Sprintf(" %s\n", statement) -} - -func renderOpenRestyCacheTemplateBlock(cfg openRestyConfigSnapshot) string { - lines := make([]string, 0, 12) - lines = append(lines, renderOpenRestyLimitZoneBlock()) - if !cfg.CacheEnabled { - lines = append(lines, renderOpenRestyObservabilityTemplateBlock()) - return strings.Join(lines, "") - } - lines = append(lines, strings.Join([]string{ - fmt.Sprintf(" proxy_cache_path %s levels=%s keys_zone=openflare_cache:10m inactive=%s max_size=%s;", cfg.CachePath, cfg.CacheLevels, cfg.CacheInactive, cfg.CacheMaxSize), - fmt.Sprintf(" proxy_cache_key \"%s\";", cfg.CacheKeyTemplate), - fmt.Sprintf(" proxy_cache_lock %s;", onOff(cfg.CacheLockEnabled)), - fmt.Sprintf(" proxy_cache_lock_timeout %s;", cfg.CacheLockTimeout), - fmt.Sprintf(" proxy_cache_use_stale %s;", cfg.CacheUseStale), - "", - }, "\n")) - lines = append(lines, renderOpenRestyObservabilityTemplateBlock()) - return strings.Join(lines, "") -} - -func renderOpenRestyLimitZoneBlock() string { - return strings.Join([]string{ - " limit_conn_zone $server_name zone=openflare_conn_per_server:10m;", - " limit_conn_zone $binary_remote_addr zone=openflare_conn_per_ip:10m;", - "", - }, "\n") -} - -func onOff(value bool) string { - if value { - return "on" - } - return "off" -} - -const nginxPowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__" - -func renderAccessBlock(siteName string, powEnabled bool) string { - escapedSiteName := escapeNginxString(siteName) - if !powEnabled { - return fmt.Sprintf(" set $openflare_waf_site \"%s\";\n access_by_lua_file %s/waf/check.lua;\n", escapedSiteName, nginxLuaDirPlaceholder) - } - return fmt.Sprintf(` set $openflare_waf_site "%s"; - access_by_lua_block { - package.path = "%s/?.lua;%s/?/init.lua;" .. package.path - require("waf.runtime").check() - if ngx.ctx.openflare_waf_blocked then - return - end - require("pow.runtime").check() - } -`, escapedSiteName, nginxLuaDirPlaceholder, nginxLuaDirPlaceholder) -} - -func renderBasicAuthBlock(enabled bool, username, password string) string { - if !enabled || username == "" || password == "" { - return "" - } - credentials := username + ":" + password - encoded := base64.StdEncoding.EncodeToString([]byte(credentials)) - return fmt.Sprintf(` rewrite_by_lua_block { - local auth = ngx.var.http_authorization - if auth ~= "Basic %s" then - ngx.header["WWW-Authenticate"] = 'Basic realm="Restricted"' - return ngx.exit(401) - end - } -`, encoded) -} - -func renderPowLocationBlocks(powEnabled bool) string { - if !powEnabled { - return "" - } - return fmt.Sprintf("\n location = %spass-challenge {\n content_by_lua_file %s/pow/verify.lua;\n }\n\n location = %smake-challenge {\n content_by_lua_file %s/pow/challenge.lua;\n }\n\n", anubisAPIPrefix, nginxLuaDirPlaceholder, anubisAPIPrefix, nginxLuaDirPlaceholder) -} - -func renderPowStaticLocationBlock(powEnabled bool) string { - if !powEnabled { - return "" - } - return fmt.Sprintf(" location %s {\n alias %s/;\n types {\n text/css css;\n application/javascript js mjs;\n application/json json;\n image/webp webp;\n font/woff2 woff2;\n }\n }\n\n", anubisStaticPrefix, nginxPowStaticDirPlaceholder) -} - -const anubisStaticPrefix = "/.within.website/x/cmd/anubis/static/" -const anubisAPIPrefix = "/.within.website/x/cmd/anubis/api/" - func normalizeSnapshotCertificateIDs(primaryCertID *uint, certIDs []uint) ([]uint, *uint, error) { candidates := make([]uint, 0, len(certIDs)+1) if primaryCertID != nil && *primaryCertID != 0 { @@ -1394,13 +1105,6 @@ func normalizeSnapshotDomainCertificateIDs( return []uint{}, nil } -func uintPointerEqual(left *uint, right *uint) bool { - if left == nil || right == nil { - return left == nil && right == nil - } - return *left == *right -} - func uintSliceEqual(left []uint, right []uint) bool { if len(left) != len(right) { return false @@ -1413,30 +1117,6 @@ func uintSliceEqual(left []uint, right []uint) bool { return true } -func checksum(content string) string { - sum := sha256.Sum256([]byte(content)) - return hex.EncodeToString(sum[:]) -} - -func checksumBundle(mainConfig string, routeConfig string, supportFiles []SupportFile) string { - var builder strings.Builder - builder.WriteString(mainConfig) - builder.WriteString("\n--route-config--\n") - builder.WriteString(routeConfig) - builder.WriteString("\n--support-files--\n") - files := dedupeSupportFiles(supportFiles) - sort.Slice(files, func(i int, j int) bool { - return files[i].Path < files[j].Path - }) - for _, file := range files { - builder.WriteString(file.Path) - builder.WriteString("\n") - builder.WriteString(file.Content) - builder.WriteString("\n") - } - return checksum(builder.String()) -} - func nextVersionNumber(now time.Time) (string, error) { prefix := now.Format("20060102") var latest model.ConfigVersion @@ -1459,36 +1139,6 @@ func nextVersionNumber(now time.Time) (string, error) { return fmt.Sprintf("%s-%03d", prefix, sequence+1), nil } -func renderHTTPProxyServer(serverNames string, siteName string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, cfg openRestyConfigSnapshot) string { - return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled)) -} - -func renderHTTPRedirectServer(serverNames string, siteName string) string { - _ = siteName - return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n return 301 https://$host$request_uri;\n}\n\n", serverNames) -} - -func renderHTTPSServer(serverNames string, siteName string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, cfg openRestyConfigSnapshot) string { - certPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateCertFileName(certificateID)) - keyPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateKeyFileName(certificateID)) - return fmt.Sprintf("server {\n listen 443 ssl;\n http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, certPath, keyPath, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled)) -} - -func renderHTTPSServerWithCertificates(serverNames string, originURL string, originHost string, certificateIDs []uint, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, cfg openRestyConfigSnapshot) string { - var certificateBlock strings.Builder - for _, certificateID := range certificateIDs { - certPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateCertFileName(certificateID)) - keyPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateKeyFileName(certificateID)) - certificateBlock.WriteString(fmt.Sprintf(" ssl_certificate %s;\n", certPath)) - certificateBlock.WriteString(fmt.Sprintf(" ssl_certificate_key %s;\n", keyPath)) - } - return fmt.Sprintf("server {\n listen 443 ssl;\n http2 on;\n server_name %s;\n%s%s%s\n location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, certificateBlock.String(), renderAccessBlock(serverNames, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled)) -} - -func renderServerNames(domains []string) string { - return strings.Join(domains, " ") -} - func validateCertificateCoverage(certificate *model.TLSCertificate, domains []string) error { if certificate == nil { return errors.New("certificate is nil") @@ -1505,36 +1155,6 @@ func validateCertificateCoverage(certificate *model.TLSCertificate, domains []st return nil } -func validateCertificateCoverageSet(certificates []*model.TLSCertificate, domains []string) error { - if len(certificates) == 0 { - return errors.New("certificate set is empty") - } - leaves := make([]interface{ VerifyHostname(string) error }, 0, len(certificates)) - for _, certificate := range certificates { - if certificate == nil { - return errors.New("certificate is nil") - } - leaf, err := parseLeafCertificate(certificate.CertPEM) - if err != nil { - return err - } - leaves = append(leaves, leaf) - } - for _, domain := range domains { - covered := false - for _, leaf := range leaves { - if leaf.VerifyHostname(domain) == nil { - covered = true - break - } - } - if !covered { - return fmt.Errorf("certificate does not cover domain %s", domain) - } - } - return nil -} - func loadTLSCertificates(certIDs []uint) ([]*model.TLSCertificate, error) { certificates := make([]*model.TLSCertificate, 0, len(certIDs)) for _, certID := range certIDs { @@ -1547,274 +1167,6 @@ func loadTLSCertificates(certIDs []uint) ([]*model.TLSCertificate, error) { return certificates, nil } -func renderConnectionUpgradeMap() string { - return " map $http_upgrade $connection_upgrade {\n default upgrade;\n '' \"\";\n }\n\n" -} - -func renderDefaultServerBlock() string { - return strings.Join([]string{ - " server {", - " listen 80 default_server;", - " server_name _;", - "", - " return 404;", - " }", - "", - " server {", - " listen 443 ssl default_server;", - " server_name _;", - "", - " ssl_reject_handshake on;", - " }", - "", - }, "\n") -} - -func renderProxyHeaderBlock(originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, upstreamConfig routeUpstreamConfig) string { - var builder strings.Builder - if strings.TrimSpace(originHost) != "" { - builder.WriteString(fmt.Sprintf(" proxy_set_header Host %s;\n", quoteNginxHeaderValue(originHost))) - } else { - builder.WriteString(" proxy_set_header Host $host;\n") - } - if upstreamServerName := resolveUpstreamServerName(originURL, originHost); upstreamServerName != "" { - builder.WriteString(" proxy_ssl_server_name on;\n") - builder.WriteString(fmt.Sprintf(" proxy_ssl_name %s;\n", quoteNginxHeaderValue(upstreamServerName))) - } - builder.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n") - builder.WriteString(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n") - builder.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n") - if common.OpenRestyWebsocketEnabled { - builder.WriteString(" proxy_http_version 1.1;\n") - builder.WriteString(" proxy_set_header Connection $connection_upgrade;\n") - builder.WriteString(" proxy_set_header Upgrade $http_upgrade;\n") - } else if upstreamConfig.UsesNamedUpstream { - builder.WriteString(" proxy_http_version 1.1;\n") - builder.WriteString(" proxy_set_header Connection \"\";\n") - } - for _, header := range customHeaders { - builder.WriteString(fmt.Sprintf(" proxy_set_header %s %s;\n", header.Key, quoteNginxHeaderValue(header.Value))) - } - return builder.String() -} - -func renderRouteCacheBlock(cacheConfig routeCacheConfig, cfg openRestyConfigSnapshot) string { - if !cfg.CacheEnabled || !cacheConfig.Enabled { - return "" - } - var builder strings.Builder - builder.WriteString(" set $openflare_skip_cache 0;\n") - builder.WriteString(" if ($request_method != GET) {\n set $openflare_skip_cache 1;\n }\n") - builder.WriteString(" if ($http_authorization != \"\") {\n set $openflare_skip_cache 1;\n }\n") - builder.WriteString(" if ($http_cookie ~* \"(session|sess|token|auth|jwt|logged_in|remember|laravel_session|connect\\\\.sid|_session)\") {\n set $openflare_skip_cache 1;\n }\n") - builder.WriteString(" if ($http_cache_control ~* \"(no-cache|no-store|private)\") {\n set $openflare_skip_cache 1;\n }\n") - if policyCondition := renderRouteCachePolicyCondition(cacheConfig); policyCondition != "" { - builder.WriteString(policyCondition) - } - builder.WriteString(" proxy_cache openflare_cache;\n") - builder.WriteString(" proxy_cache_methods GET;\n") - builder.WriteString(" proxy_cache_bypass $openflare_skip_cache;\n") - builder.WriteString(" proxy_no_cache $openflare_skip_cache;\n") - return builder.String() -} - -func renderRouteLimitBlock(limitConfig routeLimitConfig) string { - if limitConfig.LimitConnPerServer <= 0 && limitConfig.LimitConnPerIP <= 0 && strings.TrimSpace(limitConfig.LimitRate) == "" { - return "" - } - var builder strings.Builder - if limitConfig.LimitConnPerServer > 0 { - builder.WriteString(fmt.Sprintf(" limit_conn openflare_conn_per_server %d;\n", limitConfig.LimitConnPerServer)) - } - if limitConfig.LimitConnPerIP > 0 { - builder.WriteString(fmt.Sprintf(" limit_conn openflare_conn_per_ip %d;\n", limitConfig.LimitConnPerIP)) - } - if strings.TrimSpace(limitConfig.LimitRate) != "" { - builder.WriteString(fmt.Sprintf(" limit_rate %s;\n", limitConfig.LimitRate)) - } - return builder.String() -} - -func renderRouteCachePolicyCondition(cacheConfig routeCacheConfig) string { - switch cacheConfig.Policy { - case proxyRouteCachePolicySuffix: - return fmt.Sprintf(" if ($uri !~* %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildSuffixMatchPattern(cacheConfig.Rules))) - case proxyRouteCachePolicyPathPrefix: - return fmt.Sprintf(" if ($uri !~ %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildPathPrefixMatchPattern(cacheConfig.Rules))) - case proxyRouteCachePolicyPathExact: - return fmt.Sprintf(" if ($uri !~ %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildPathExactMatchPattern(cacheConfig.Rules))) - default: - return "" - } -} - -func buildSuffixMatchPattern(rules []string) string { - parts := make([]string, 0, len(rules)) - for _, rule := range rules { - parts = append(parts, regexp.QuoteMeta(rule)) - } - return fmt.Sprintf("\\.(?:%s)$", strings.Join(parts, "|")) -} - -func buildPathPrefixMatchPattern(rules []string) string { - parts := make([]string, 0, len(rules)) - for _, rule := range rules { - trimmed := strings.TrimRight(rule, "/") - if trimmed == "" { - trimmed = "/" - } - if trimmed == "/" { - parts = append(parts, "/") - continue - } - parts = append(parts, fmt.Sprintf("%s(?:/|$)", regexp.QuoteMeta(trimmed))) - } - return fmt.Sprintf("^(?:%s)", strings.Join(parts, "|")) -} - -func buildPathExactMatchPattern(rules []string) string { - parts := make([]string, 0, len(rules)) - for _, rule := range rules { - parts = append(parts, regexp.QuoteMeta(rule)) - } - return fmt.Sprintf("^(?:%s)$", strings.Join(parts, "|")) -} - -func renderProxyPassBlock(originURL string, upstreamConfig routeUpstreamConfig) string { - parsed, err := url.Parse(originURL) - if err != nil || parsed.Host == "" || parsed.Scheme == "" { - return fmt.Sprintf(" proxy_pass %s;\n", originURL) - } - if upstreamConfig.UsesNamedUpstream { - return fmt.Sprintf(" proxy_pass %s://%s%s;\n", upstreamConfig.Scheme, upstreamConfig.Name, upstreamConfig.ProxyPassURI) - } - return fmt.Sprintf(" proxy_pass %s;\n", originURL) -} - -func buildRouteUpstreamConfig(route *model.ProxyRoute, upstreams []string) routeUpstreamConfig { - if len(upstreams) == 0 { - return routeUpstreamConfig{} - } - if len(upstreams) == 1 { - parsed, err := url.Parse(strings.TrimSpace(upstreams[0])) - if err != nil || parsed.Host == "" || parsed.Scheme == "" { - return routeUpstreamConfig{} - } - return routeUpstreamConfig{ - Name: buildRouteUpstreamName(route), - Scheme: parsed.Scheme, - ProxyPassURI: buildUpstreamProxyPassURI(parsed), - Servers: []string{parsed.Host}, - UsesNamedUpstream: true, - } - } - servers := make([]string, 0, len(upstreams)) - var scheme string - for _, upstream := range upstreams { - parsed, err := url.Parse(strings.TrimSpace(upstream)) - if err != nil || parsed.Host == "" || parsed.Scheme == "" { - return routeUpstreamConfig{} - } - if strings.TrimSpace(parsed.EscapedPath()) != "" && strings.TrimSpace(parsed.EscapedPath()) != "/" { - return routeUpstreamConfig{} - } - if parsed.RawQuery != "" { - return routeUpstreamConfig{} - } - if scheme == "" { - scheme = parsed.Scheme - } else if scheme != parsed.Scheme { - return routeUpstreamConfig{} - } - servers = append(servers, parsed.Host) - } - return routeUpstreamConfig{ - Name: buildRouteUpstreamName(route), - Scheme: scheme, - Servers: servers, - UsesNamedUpstream: true, - } -} - -func buildUpstreamProxyPassURI(parsed *url.URL) string { - if parsed == nil { - return "" - } - path := parsed.EscapedPath() - if path == "/" { - path = "" - } - if parsed.RawQuery == "" { - return path - } - return fmt.Sprintf("%s?%s", path, parsed.RawQuery) -} - -func buildRouteUpstreamName(route *model.ProxyRoute) string { - identity := strings.TrimSpace(route.SiteName) - if identity == "" { - identity = route.Domain - } - sanitized := strings.Map(func(r rune) rune { - switch { - case r >= 'a' && r <= 'z': - return r - case r >= 'A' && r <= 'Z': - return r + ('a' - 'A') - case r >= '0' && r <= '9': - return r - default: - return '_' - } - }, identity) - sanitized = strings.Trim(sanitized, "_") - if sanitized == "" { - sanitized = "backend" - } - return fmt.Sprintf("backend_%s_%d", sanitized, route.ID) -} - -func renderNamedUpstreamBlock(upstreamConfig routeUpstreamConfig) string { - var builder strings.Builder - builder.WriteString(fmt.Sprintf("upstream %s {\n", upstreamConfig.Name)) - for _, server := range upstreamConfig.Servers { - builder.WriteString(fmt.Sprintf(" server %s max_fails=3 fail_timeout=10s;\n", server)) - } - builder.WriteString(" keepalive 128;\n}\n\n") - return builder.String() -} - -func resolveUpstreamServerName(originURL string, originHost string) string { - parsed, err := url.Parse(originURL) - if err != nil || !strings.EqualFold(parsed.Scheme, "https") { - return "" - } - if strings.TrimSpace(originHost) != "" { - parsedHost, err := url.Parse("//" + originHost) - if err == nil && parsedHost.Hostname() != "" { - return parsedHost.Hostname() - } - return originHost - } - return parsed.Hostname() -} - -func quoteNginxHeaderValue(value string) string { - return quoteNginxStringLiteral(value) -} - -func quoteNginxStringLiteral(value string) string { - escaped := strings.ReplaceAll(value, `\`, `\\`) - escaped = strings.ReplaceAll(escaped, `"`, `\"`) - return fmt.Sprintf(`"%s"`, escaped) -} - -func escapeNginxString(value string) string { - escaped := strings.ReplaceAll(value, `\`, `\\`) - escaped = strings.ReplaceAll(escaped, `"`, `\"`) - return escaped -} - func certificateCertFileName(id uint) string { return fmt.Sprintf("%d.crt", id) } @@ -1841,120 +1193,3 @@ func dedupeSupportFiles(files []SupportFile) []SupportFile { } return result } - -func renderPowConfigBundle(routes []*model.ProxyRoute, wafSnapshot snapshotWAFDocument) (string, []SupportFile, error) { - type domainEntry struct { - Domains []string `json:"domains"` - Enabled bool `json:"enabled"` - Config *ProxyRoutePoWConfig `json:"config"` - } - entries := make([]domainEntry, 0) - hasPow := false - for _, route := range routes { - powEnabled, powConfig := getPoWConfigForRoute(route.ID, wafSnapshot) - if route.PoWEnabled { - powEnabled = true - if decoded, err := decodeStoredPoWConfig(route.PoWEnabled, route.PoWConfig); err == nil { - powConfig = decoded - } - } - if !powEnabled { - continue - } - hasPow = true - domains, err := decodeStoredDomains(route.Domains, route.Domain) - if err != nil { - return "", nil, err - } - entries = append(entries, domainEntry{ - Domains: domains, - Enabled: true, - Config: powConfig, - }) - } - if !hasPow { - return "{}", nil, nil - } - data, err := json.Marshal(entries) - if err != nil { - return "", nil, err - } - return string(data), nil, nil -} - -func renderWAFConfigBundle(snapshot snapshotWAFDocument) (string, error) { - type wafRuntimeRuleGroup struct { - ID uint `json:"id"` - Name string `json:"name"` - 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"` - CountryWhitelist []string `json:"country_whitelist"` - CountryBlacklist []string `json:"country_blacklist"` - RegionWhitelist []string `json:"region_whitelist"` - RegionBlacklist []string `json:"region_blacklist"` - } - type wafRuntimeConfig struct { - DefaultBlockStatusCode int `json:"default_block_status_code"` - RuleGroups []wafRuntimeRuleGroup `json:"rule_groups"` - SiteRuleGroups map[string][]uint `json:"site_rule_groups"` - } - groups := make([]wafRuntimeRuleGroup, 0, len(snapshot.RuleGroups)) - globalGroupIDs := make([]uint, 0) - enabledGroupIDs := make(map[uint]struct{}, len(snapshot.RuleGroups)) - for _, group := range snapshot.RuleGroups { - if !group.Enabled { - continue - } - statusCode := group.BlockStatusCode - if statusCode == 0 { - statusCode = defaultWAFBlockStatusCode - } - if group.IsGlobal { - globalGroupIDs = append(globalGroupIDs, group.ID) - } - enabledGroupIDs[group.ID] = struct{}{} - groups = append(groups, wafRuntimeRuleGroup{ - ID: group.ID, - Name: group.Name, - IsGlobal: group.IsGlobal, - BlockStatusCode: statusCode, - BlockResponseBody: group.BlockResponseBody, - IPWhitelist: group.IPWhitelist, - IPBlacklist: group.IPBlacklist, - CountryWhitelist: group.CountryWhitelist, - CountryBlacklist: group.CountryBlacklist, - RegionWhitelist: group.RegionWhitelist, - RegionBlacklist: group.RegionBlacklist, - }) - } - sort.Slice(groups, func(i, j int) bool { - if groups[i].IsGlobal != groups[j].IsGlobal { - return groups[i].IsGlobal - } - return groups[i].ID < groups[j].ID - }) - sort.Slice(globalGroupIDs, func(i, j int) bool { return globalGroupIDs[i] < globalGroupIDs[j] }) - siteRuleGroups := make(map[string][]uint, len(snapshot.Bindings)) - for _, binding := range snapshot.Bindings { - ids := append([]uint{}, globalGroupIDs...) - for _, id := range binding.RuleGroupIDs { - if _, ok := enabledGroupIDs[id]; ok { - ids = append(ids, id) - } - } - siteRuleGroups[binding.SiteName] = uniqueUintIDs(ids) - } - runtimeConfig := wafRuntimeConfig{ - DefaultBlockStatusCode: defaultWAFBlockStatusCode, - RuleGroups: groups, - SiteRuleGroups: siteRuleGroups, - } - data, err := json.Marshal(runtimeConfig) - if err != nil { - return "", err - } - return string(data), nil -} diff --git a/openflare_server/service/openresty_observability_assets.go b/openflare_server/service/openresty_observability_assets.go deleted file mode 100644 index bfe1b030..00000000 --- a/openflare_server/service/openresty_observability_assets.go +++ /dev/null @@ -1,51 +0,0 @@ -package service - -import "fmt" - -const ( - openRestyObservabilityInitLuaPath = "init.lua" - openRestyObservabilityLogLuaPath = "log.lua" - openRestyObservabilityReadLuaPath = "read.lua" -) - -func renderOpenRestyObservabilityTemplateBlock() string { - return stringsJoinLines( - " lua_shared_dict openflare_observability 10m;", - " lua_shared_dict openflare_pow_config 1m;", - " lua_shared_dict openflare_pow_challenges 10m;", - " lua_shared_dict openflare_pow_sessions 20m;", - " lua_shared_dict openflare_waf_config 2m;", - fmt.Sprintf(" init_worker_by_lua_file %s/%s;", nginxLuaDirPlaceholder, openRestyObservabilityInitLuaPath), - fmt.Sprintf(" log_by_lua_file %s/%s;", nginxLuaDirPlaceholder, openRestyObservabilityLogLuaPath), - "", - fmt.Sprintf(" server {"), - fmt.Sprintf(" listen %s;", nginxObservabilityListenPlaceholder), - " server_name openflare-observability;", - " access_log off;", - "", - " location = /openflare/observability {", - " default_type application/json;", - fmt.Sprintf(" content_by_lua_file %s/%s;", nginxLuaDirPlaceholder, openRestyObservabilityReadLuaPath), - " }", - "", - " location = /openflare/stub_status {", - " stub_status;", - " }", - " }", - "", - ) -} - -func stringsJoinLines(lines ...string) string { - if len(lines) == 0 { - return "" - } - result := "" - for index, line := range lines { - if index > 0 { - result += "\n" - } - result += line - } - return result + "\n" -} diff --git a/openflare_server/service/origin_helpers.go b/openflare_server/service/origin_helpers.go index 0deeee49..44f6c7b6 100644 --- a/openflare_server/service/origin_helpers.go +++ b/openflare_server/service/origin_helpers.go @@ -168,25 +168,3 @@ func rewriteOriginURLAddress(rawURL string, newAddress string) (string, error) { parsed.Host = formatOriginHost(address, port) return parsed.String(), nil } - -func splitOriginURL(rawURL string) (scheme string, address string, port string, uri string, err error) { - parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL)) - if err != nil { - return "", "", "", "", err - } - scheme = parsed.Scheme - address = normalizeOriginAddress(parsed.Hostname()) - port = parsed.Port() - uri = parsed.EscapedPath() - if uri == "" { - uri = parsed.Path - } - if parsed.RawQuery != "" { - if uri == "" { - uri = "?" + parsed.RawQuery - } else { - uri = uri + "?" + parsed.RawQuery - } - } - return scheme, address, port, uri, nil -} diff --git a/openflare_server/utils/render/openresty/render.go b/openflare_server/utils/render/openresty/render.go new file mode 100644 index 00000000..5845e0c4 --- /dev/null +++ b/openflare_server/utils/render/openresty/render.go @@ -0,0 +1,749 @@ +package openresty + +import ( + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "net/url" + "regexp" + "sort" + "strings" +) + +func RenderJSON(sourceJSON string, certificateFiles []SupportFile) (*Result, error) { + var doc Document + if err := json.Unmarshal([]byte(strings.TrimSpace(sourceJSON)), &doc); err != nil { + return nil, fmt.Errorf("openresty source config json is invalid: %w", err) + } + return Render(doc, certificateFiles) +} + +func Render(doc Document, certificateFiles []SupportFile) (*Result, error) { + mainConfig := RenderMainConfig(doc.OpenRestyConfig) + routeConfig, err := RenderRouteConfig(doc, certificateFiles) + if err != nil { + return nil, err + } + wafConfig, err := RenderWAFConfig(doc.WAF) + if err != nil { + return nil, err + } + powConfig, err := RenderPoWConfig(doc) + if err != nil { + return nil, err + } + files := append([]SupportFile(nil), certificateFiles...) + files = append(files, SupportFile{Path: "pow_config.json", Content: powConfig}) + files = append(files, SupportFile{Path: "waf_config.json", Content: wafConfig}) + files = DedupeSupportFiles(files) + return &Result{ + MainConfig: mainConfig, + RouteConfig: routeConfig, + SupportFiles: files, + Checksum: ChecksumBundle(mainConfig, routeConfig, files), + }, nil +} + +func RenderMainConfig(cfg ConfigSnapshot) string { + templateText := cfg.MainConfigTemplate + if strings.TrimSpace(templateText) == "" { + templateText = defaultMainConfigTemplate + } + return renderMainConfigTemplate(templateText, cfg) +} + +func ValidateMainConfigTemplate(templateText string) error { + trimmed := strings.TrimSpace(templateText) + if trimmed == "" { + return errors.New("OpenRestyMainConfigTemplate 不能为空") + } + for _, placeholder := range requiredMainConfigTemplatePlaceholders { + if !strings.Contains(trimmed, placeholder) { + return fmt.Errorf("OpenRestyMainConfigTemplate 必须保留占位符 %s", placeholder) + } + } + return nil +} + +func RenderRouteConfig(doc Document, certificateFiles []SupportFile) (string, error) { + var builder strings.Builder + builder.WriteString("# This file is generated by OpenFlare. Do not edit manually.\n") + certificates := certificatesByID(certificateFiles) + for _, route := range doc.Routes { + domains := normalizedRouteDomains(route) + if len(domains) == 0 { + return "", fmt.Errorf("route %s domains are invalid", route.Domain) + } + serverNames := renderServerNames(domains) + displayName := strings.TrimSpace(route.SiteName) + if displayName == "" { + displayName = domains[0] + } + upstreams := route.Upstreams + if len(upstreams) == 0 && strings.TrimSpace(route.OriginURL) != "" { + upstreams = []string{route.OriginURL} + } + upstreamConfig := buildRouteUpstreamConfig(route, upstreams) + if upstreamConfig.UsesNamedUpstream { + builder.WriteString(renderNamedUpstreamBlock(upstreamConfig)) + } + cacheConfig := routeCacheConfig{Enabled: route.CacheEnabled, Policy: route.CachePolicy, Rules: route.CacheRules} + limitConfig := routeLimitConfig{LimitConnPerServer: route.LimitConnPerServer, LimitConnPerIP: route.LimitConnPerIP, LimitRate: route.LimitRate} + powEnabled, _ := getPoWConfigForRoute(route.ID, doc.WAF) + if route.PoWEnabled { + powEnabled = true + } + if !route.EnableHTTPS { + builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, doc.OpenRestyConfig)) + continue + } + certIDs := normalizeCertIDs(route.CertID, route.CertIDs) + domainCertIDs := normalizeDomainCertIDs(domains, certIDs, route.DomainCertIDs) + if len(certIDs) == 0 { + return "", fmt.Errorf("路由 %s 未配置证书", route.Domain) + } + httpOnlyDomains := make([]string, 0, len(domains)) + domainsByCertID := make(map[uint][]string, len(certIDs)) + for index, domain := range domains { + if index >= len(domainCertIDs) || domainCertIDs[index] == 0 { + httpOnlyDomains = append(httpOnlyDomains, domain) + continue + } + domainsByCertID[domainCertIDs[index]] = append(domainsByCertID[domainCertIDs[index]], domain) + } + for _, certID := range certIDs { + assignedDomains := domainsByCertID[certID] + if len(assignedDomains) == 0 { + continue + } + certPEM, ok := certificates[certID] + if !ok { + return "", fmt.Errorf("route %s certificate %d does not exist", route.Domain, certID) + } + if err := validateCertificateCoverage(certPEM, assignedDomains); err != nil { + return "", fmt.Errorf("site %s certificate validation failed: %w", displayName, err) + } + } + if route.RedirectHTTP { + if len(httpOnlyDomains) > 0 { + builder.WriteString(renderHTTPProxyServer(renderServerNames(httpOnlyDomains), displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, doc.OpenRestyConfig)) + } + for _, certID := range certIDs { + if assignedDomains := domainsByCertID[certID]; len(assignedDomains) > 0 { + builder.WriteString(renderHTTPRedirectServer(renderServerNames(assignedDomains))) + } + } + } else { + builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, doc.OpenRestyConfig)) + } + for _, certID := range certIDs { + if assignedDomains := domainsByCertID[certID]; len(assignedDomains) > 0 { + builder.WriteString(renderHTTPSServer(renderServerNames(assignedDomains), displayName, route.OriginURL, route.OriginHost, certID, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, doc.OpenRestyConfig)) + } + } + } + return builder.String(), nil +} + +func RenderPoWConfig(doc Document) (string, error) { + type domainEntry struct { + Domains []string `json:"domains"` + Enabled bool `json:"enabled"` + Config *PoWConfig `json:"config"` + } + entries := make([]domainEntry, 0) + for _, route := range doc.Routes { + powEnabled, powConfig := getPoWConfigForRoute(route.ID, doc.WAF) + if route.PoWEnabled { + powEnabled = true + if route.PoWConfig != nil { + powConfig = route.PoWConfig + } + } + if !powEnabled { + continue + } + entries = append(entries, domainEntry{Domains: normalizedRouteDomains(route), Enabled: true, Config: powConfig}) + } + if len(entries) == 0 { + return "{}", nil + } + data, err := json.Marshal(entries) + return string(data), err +} + +func RenderWAFConfig(snapshot WAFDocument) (string, error) { + type wafRuntimeRuleGroup struct { + ID uint `json:"id"` + Name string `json:"name"` + 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"` + CountryWhitelist []string `json:"country_whitelist"` + CountryBlacklist []string `json:"country_blacklist"` + RegionWhitelist []string `json:"region_whitelist"` + RegionBlacklist []string `json:"region_blacklist"` + } + type wafRuntimeConfig struct { + DefaultBlockStatusCode int `json:"default_block_status_code"` + RuleGroups []wafRuntimeRuleGroup `json:"rule_groups"` + SiteRuleGroups map[string][]uint `json:"site_rule_groups"` + } + groups := make([]wafRuntimeRuleGroup, 0, len(snapshot.RuleGroups)) + globalGroupIDs := make([]uint, 0) + enabledGroupIDs := make(map[uint]struct{}, len(snapshot.RuleGroups)) + for _, group := range snapshot.RuleGroups { + if !group.Enabled { + continue + } + statusCode := group.BlockStatusCode + if statusCode == 0 { + statusCode = defaultWAFBlockStatus + } + if group.IsGlobal { + globalGroupIDs = append(globalGroupIDs, group.ID) + } + enabledGroupIDs[group.ID] = struct{}{} + groups = append(groups, wafRuntimeRuleGroup{ID: group.ID, Name: group.Name, IsGlobal: group.IsGlobal, BlockStatusCode: statusCode, BlockResponseBody: group.BlockResponseBody, IPWhitelist: group.IPWhitelist, IPBlacklist: group.IPBlacklist, CountryWhitelist: group.CountryWhitelist, CountryBlacklist: group.CountryBlacklist, RegionWhitelist: group.RegionWhitelist, RegionBlacklist: group.RegionBlacklist}) + } + sort.Slice(groups, func(i, j int) bool { + if groups[i].IsGlobal != groups[j].IsGlobal { + return groups[i].IsGlobal + } + return groups[i].ID < groups[j].ID + }) + sort.Slice(globalGroupIDs, func(i, j int) bool { return globalGroupIDs[i] < globalGroupIDs[j] }) + siteRuleGroups := make(map[string][]uint, len(snapshot.Bindings)) + for _, binding := range snapshot.Bindings { + ids := append([]uint{}, globalGroupIDs...) + for _, id := range binding.RuleGroupIDs { + if _, ok := enabledGroupIDs[id]; ok { + ids = append(ids, id) + } + } + siteRuleGroups[binding.SiteName] = uniqueUintIDs(ids) + } + data, err := json.Marshal(wafRuntimeConfig{DefaultBlockStatusCode: defaultWAFBlockStatus, RuleGroups: groups, SiteRuleGroups: siteRuleGroups}) + return string(data), err +} + +func ChecksumBundle(mainConfig string, routeConfig string, supportFiles []SupportFile) string { + var builder strings.Builder + builder.WriteString(mainConfig) + builder.WriteString("\n--route-config--\n") + builder.WriteString(routeConfig) + builder.WriteString("\n--support-files--\n") + files := DedupeSupportFiles(supportFiles) + sort.Slice(files, func(i int, j int) bool { return files[i].Path < files[j].Path }) + for _, file := range files { + if file.Path == SourceConfigFileName { + continue + } + builder.WriteString(file.Path) + builder.WriteString("\n") + builder.WriteString(file.Content) + builder.WriteString("\n") + } + sum := sha256.Sum256([]byte(builder.String())) + return hex.EncodeToString(sum[:]) +} + +func DedupeSupportFiles(files []SupportFile) []SupportFile { + if len(files) == 0 { + return nil + } + unique := make(map[string]SupportFile, len(files)) + for _, file := range files { + unique[file.Path] = file + } + result := make([]SupportFile, 0, len(unique)) + for _, file := range unique { + result = append(result, file) + } + return result +} + +func renderMainConfigTemplate(templateText string, cfg ConfigSnapshot) string { + replacer := strings.NewReplacer( + "{{OpenRestyWorkerProcesses}}", cfg.WorkerProcesses, + "{{OpenRestyWorkerConnections}}", fmt.Sprintf("%d", cfg.WorkerConnections), + "{{OpenRestyWorkerRlimitNofile}}", fmt.Sprintf("%d", cfg.WorkerRlimitNofile), + "{{OpenRestyConnectionUpgradeMap}}", renderConnectionUpgradeMap(), + "{{OpenRestyDefaultServerBlock}}", renderDefaultServerBlock(), + "{{OpenRestyAccessLogPath}}", AccessLogPlaceholder, + "{{OpenRestyEventsUseDirective}}", renderTemplateDirective(cfg.EventsUse != "", fmt.Sprintf("use %s;", cfg.EventsUse)), + "{{OpenRestyEventsMultiAcceptDirective}}", renderTemplateDirective(cfg.EventsMultiAcceptEnabled, "multi_accept on;"), + "{{OpenRestyKeepaliveTimeout}}", fmt.Sprintf("%d", cfg.KeepaliveTimeout), + "{{OpenRestyKeepaliveRequests}}", fmt.Sprintf("%d", cfg.KeepaliveRequests), + "{{OpenRestyClientHeaderTimeout}}", fmt.Sprintf("%d", cfg.ClientHeaderTimeout), + "{{OpenRestyClientBodyTimeout}}", fmt.Sprintf("%d", cfg.ClientBodyTimeout), + "{{OpenRestyClientMaxBodySize}}", cfg.ClientMaxBodySize, + "{{OpenRestyLargeClientHeaderBuffers}}", cfg.LargeClientHeaderBuffers, + "{{OpenRestySendTimeout}}", fmt.Sprintf("%d", cfg.SendTimeout), + "{{OpenRestyProxyConnectTimeout}}", fmt.Sprintf("%d", cfg.ProxyConnectTimeout), + "{{OpenRestyProxySendTimeout}}", fmt.Sprintf("%d", cfg.ProxySendTimeout), + "{{OpenRestyProxyReadTimeout}}", fmt.Sprintf("%d", cfg.ProxyReadTimeout), + "{{OpenRestyProxyRequestBuffering}}", onOff(cfg.ProxyRequestBuffering), + "{{OpenRestyProxyBuffering}}", onOff(cfg.ProxyBufferingEnabled), + "{{OpenRestyProxyBuffers}}", cfg.ProxyBuffers, + "{{OpenRestyProxyBufferSize}}", cfg.ProxyBufferSize, + "{{OpenRestyProxyBusyBuffersSize}}", cfg.ProxyBusyBuffersSize, + "{{OpenRestyGzip}}", onOff(cfg.GzipEnabled), + "{{OpenRestyGzipMinLength}}", fmt.Sprintf("%d", cfg.GzipMinLength), + "{{OpenRestyGzipCompLevel}}", fmt.Sprintf("%d", cfg.GzipCompLevel), + "{{OpenRestyResolverDirective}}", renderTemplateDirective(cfg.Resolvers != "", fmt.Sprintf("resolver %s;", cfg.Resolvers)), + "{{OpenRestyCacheBlock}}", renderOpenRestyCacheTemplateBlock(cfg), + "{{OpenRestyRouteConfigInclude}}", RouteConfigPlaceholder, + ) + return replacer.Replace(templateText) +} + +func renderTemplateDirective(enabled bool, statement string) string { + if !enabled { + return "" + } + return fmt.Sprintf(" %s\n", statement) +} + +func renderOpenRestyCacheTemplateBlock(cfg ConfigSnapshot) string { + lines := []string{renderOpenRestyLimitZoneBlock()} + if !cfg.CacheEnabled { + lines = append(lines, renderOpenRestyObservabilityTemplateBlock()) + return strings.Join(lines, "") + } + lines = append(lines, strings.Join([]string{ + fmt.Sprintf(" proxy_cache_path %s levels=%s keys_zone=openflare_cache:10m inactive=%s max_size=%s;", cfg.CachePath, cfg.CacheLevels, cfg.CacheInactive, cfg.CacheMaxSize), + fmt.Sprintf(" proxy_cache_key \"%s\";", cfg.CacheKeyTemplate), + fmt.Sprintf(" proxy_cache_lock %s;", onOff(cfg.CacheLockEnabled)), + fmt.Sprintf(" proxy_cache_lock_timeout %s;", cfg.CacheLockTimeout), + fmt.Sprintf(" proxy_cache_use_stale %s;", cfg.CacheUseStale), + "", + }, "\n")) + lines = append(lines, renderOpenRestyObservabilityTemplateBlock()) + return strings.Join(lines, "") +} + +func renderOpenRestyLimitZoneBlock() string { + return " limit_conn_zone $server_name zone=openflare_conn_per_server:10m;\n limit_conn_zone $binary_remote_addr zone=openflare_conn_per_ip:10m;\n" +} + +func renderOpenRestyObservabilityTemplateBlock() string { + return fmt.Sprintf(" log_by_lua_file %s/log.lua;\n lua_shared_dict openflare_pow_challenges 10m;\n lua_shared_dict openflare_pow_sessions 10m;\n lua_shared_dict openflare_pow_config 1m;\n lua_shared_dict openflare_waf_config 1m;\n\n server {\n listen %s;\n server_name openflare-observability;\n access_log off;\n\n location = /openflare/stub_status {\n stub_status;\n }\n\n location = /openflare/observability {\n content_by_lua_file %s/observability.lua;\n }\n }\n\n", LuaDirPlaceholder, ObservabilityListenPlaceholder, LuaDirPlaceholder) +} + +func renderHTTPProxyServer(serverNames string, siteName string, originURL string, originHost string, customHeaders []CustomHeader, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, cfg ConfigSnapshot) string { + return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled)) +} + +func renderHTTPRedirectServer(serverNames string) string { + return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n return 301 https://$host$request_uri;\n}\n\n", serverNames) +} + +func renderHTTPSServer(serverNames string, siteName string, originURL string, originHost string, certificateID uint, customHeaders []CustomHeader, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, cfg ConfigSnapshot) string { + certPath := fmt.Sprintf("%s/%d.crt", CertDirPlaceholder, certificateID) + keyPath := fmt.Sprintf("%s/%d.key", CertDirPlaceholder, certificateID) + return fmt.Sprintf("server {\n listen 443 ssl;\n http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, certPath, keyPath, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled)) +} + +func renderProxyHeaderBlock(originURL string, originHost string, customHeaders []CustomHeader, upstreamConfig routeUpstreamConfig, cfg ConfigSnapshot) string { + var builder strings.Builder + if strings.TrimSpace(originHost) != "" { + builder.WriteString(fmt.Sprintf(" proxy_set_header Host %s;\n", quoteNginxStringLiteral(originHost))) + } else { + builder.WriteString(" proxy_set_header Host $host;\n") + } + if upstreamServerName := resolveUpstreamServerName(originURL, originHost); upstreamServerName != "" { + builder.WriteString(" proxy_ssl_server_name on;\n") + builder.WriteString(fmt.Sprintf(" proxy_ssl_name %s;\n", quoteNginxStringLiteral(upstreamServerName))) + } + builder.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n") + builder.WriteString(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n") + builder.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n") + if cfg.WebsocketEnabled { + builder.WriteString(" proxy_http_version 1.1;\n") + builder.WriteString(" proxy_set_header Connection $connection_upgrade;\n") + builder.WriteString(" proxy_set_header Upgrade $http_upgrade;\n") + } else if upstreamConfig.UsesNamedUpstream { + builder.WriteString(" proxy_http_version 1.1;\n") + builder.WriteString(" proxy_set_header Connection \"\";\n") + } + for _, header := range customHeaders { + builder.WriteString(fmt.Sprintf(" proxy_set_header %s %s;\n", header.Key, quoteNginxStringLiteral(header.Value))) + } + return builder.String() +} + +func renderAccessBlock(siteName string, powEnabled bool) string { + escapedSiteName := escapeNginxString(siteName) + if !powEnabled { + return fmt.Sprintf(" set $openflare_waf_site \"%s\";\n access_by_lua_file %s/waf/check.lua;\n", escapedSiteName, LuaDirPlaceholder) + } + return fmt.Sprintf(` set $openflare_waf_site "%s"; + access_by_lua_block { + package.path = "%s/?.lua;%s/?/init.lua;" .. package.path + require("waf.runtime").check() + if ngx.ctx.openflare_waf_blocked then + return + end + require("pow.runtime").check() + } +`, escapedSiteName, LuaDirPlaceholder, LuaDirPlaceholder) +} + +func renderBasicAuthBlock(enabled bool, username, password string) string { + if !enabled || username == "" || password == "" { + return "" + } + encoded := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + return fmt.Sprintf(" rewrite_by_lua_block {\n local auth = ngx.var.http_authorization\n if auth ~= \"Basic %s\" then\n ngx.header[\"WWW-Authenticate\"] = 'Basic realm=\"Restricted\"'\n return ngx.exit(401)\n end\n }\n", encoded) +} + +func renderPowLocationBlocks(powEnabled bool) string { + if !powEnabled { + return "" + } + return fmt.Sprintf("\n location = %spass-challenge {\n content_by_lua_file %s/pow/verify.lua;\n }\n\n location = %smake-challenge {\n content_by_lua_file %s/pow/challenge.lua;\n }\n\n", anubisAPIPrefix, LuaDirPlaceholder, anubisAPIPrefix, LuaDirPlaceholder) +} + +func renderPowStaticLocationBlock(powEnabled bool) string { + if !powEnabled { + return "" + } + return fmt.Sprintf(" location %s {\n alias %s/;\n types {\n text/css css;\n application/javascript js mjs;\n application/json json;\n image/webp webp;\n font/woff2 woff2;\n }\n }\n\n", anubisStaticPrefix, PowStaticDirPlaceholder) +} + +func renderRouteCacheBlock(cacheConfig routeCacheConfig, cfg ConfigSnapshot) string { + if !cfg.CacheEnabled || !cacheConfig.Enabled { + return "" + } + var builder strings.Builder + builder.WriteString(" set $openflare_skip_cache 0;\n") + builder.WriteString(" if ($request_method != GET) {\n set $openflare_skip_cache 1;\n }\n") + builder.WriteString(" if ($http_authorization != \"\") {\n set $openflare_skip_cache 1;\n }\n") + builder.WriteString(" if ($http_cookie ~* \"(session|sess|token|auth|jwt|logged_in|remember|laravel_session|connect\\\\.sid|_session)\") {\n set $openflare_skip_cache 1;\n }\n") + builder.WriteString(" if ($http_cache_control ~* \"(no-cache|no-store|private)\") {\n set $openflare_skip_cache 1;\n }\n") + if condition := renderRouteCachePolicyCondition(cacheConfig); condition != "" { + builder.WriteString(condition) + } + builder.WriteString(" proxy_cache openflare_cache;\n") + builder.WriteString(" proxy_cache_methods GET;\n") + builder.WriteString(" proxy_cache_bypass $openflare_skip_cache;\n") + builder.WriteString(" proxy_no_cache $openflare_skip_cache;\n") + return builder.String() +} + +func renderRouteLimitBlock(limitConfig routeLimitConfig) string { + var builder strings.Builder + if limitConfig.LimitConnPerServer > 0 { + builder.WriteString(fmt.Sprintf(" limit_conn openflare_conn_per_server %d;\n", limitConfig.LimitConnPerServer)) + } + if limitConfig.LimitConnPerIP > 0 { + builder.WriteString(fmt.Sprintf(" limit_conn openflare_conn_per_ip %d;\n", limitConfig.LimitConnPerIP)) + } + if strings.TrimSpace(limitConfig.LimitRate) != "" { + builder.WriteString(fmt.Sprintf(" limit_rate %s;\n", limitConfig.LimitRate)) + } + return builder.String() +} + +func renderRouteCachePolicyCondition(cacheConfig routeCacheConfig) string { + switch cacheConfig.Policy { + case cachePolicySuffix: + return fmt.Sprintf(" if ($uri !~* %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildSuffixMatchPattern(cacheConfig.Rules))) + case cachePolicyPathPrefix: + return fmt.Sprintf(" if ($uri !~ %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildPathPrefixMatchPattern(cacheConfig.Rules))) + case cachePolicyPathExact: + return fmt.Sprintf(" if ($uri !~ %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildPathExactMatchPattern(cacheConfig.Rules))) + default: + return "" + } +} + +func renderProxyPassBlock(originURL string, upstreamConfig routeUpstreamConfig) string { + parsed, err := url.Parse(originURL) + if err != nil || parsed.Host == "" || parsed.Scheme == "" { + return fmt.Sprintf(" proxy_pass %s;\n", originURL) + } + if upstreamConfig.UsesNamedUpstream { + return fmt.Sprintf(" proxy_pass %s://%s%s;\n", upstreamConfig.Scheme, upstreamConfig.Name, upstreamConfig.ProxyPassURI) + } + return fmt.Sprintf(" proxy_pass %s;\n", originURL) +} + +func buildRouteUpstreamConfig(route Route, upstreams []string) routeUpstreamConfig { + if len(upstreams) == 0 { + return routeUpstreamConfig{} + } + if len(upstreams) == 1 { + parsed, err := url.Parse(strings.TrimSpace(upstreams[0])) + if err != nil || parsed.Host == "" || parsed.Scheme == "" { + return routeUpstreamConfig{} + } + return routeUpstreamConfig{Name: buildRouteUpstreamName(route), Scheme: parsed.Scheme, ProxyPassURI: buildUpstreamProxyPassURI(parsed), Servers: []string{parsed.Host}, UsesNamedUpstream: true} + } + servers := make([]string, 0, len(upstreams)) + var scheme string + for _, upstream := range upstreams { + parsed, err := url.Parse(strings.TrimSpace(upstream)) + if err != nil || parsed.Host == "" || parsed.Scheme == "" || (strings.TrimSpace(parsed.EscapedPath()) != "" && strings.TrimSpace(parsed.EscapedPath()) != "/") || parsed.RawQuery != "" { + return routeUpstreamConfig{} + } + if scheme == "" { + scheme = parsed.Scheme + } else if scheme != parsed.Scheme { + return routeUpstreamConfig{} + } + servers = append(servers, parsed.Host) + } + return routeUpstreamConfig{Name: buildRouteUpstreamName(route), Scheme: scheme, Servers: servers, UsesNamedUpstream: true} +} + +func renderNamedUpstreamBlock(upstreamConfig routeUpstreamConfig) string { + var builder strings.Builder + builder.WriteString(fmt.Sprintf("upstream %s {\n", upstreamConfig.Name)) + for _, server := range upstreamConfig.Servers { + builder.WriteString(fmt.Sprintf(" server %s max_fails=3 fail_timeout=10s;\n", server)) + } + builder.WriteString(" keepalive 128;\n}\n\n") + return builder.String() +} + +func buildRouteUpstreamName(route Route) string { + identity := strings.TrimSpace(route.SiteName) + if identity == "" { + identity = route.Domain + } + sanitized := strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z': + return r + case r >= 'A' && r <= 'Z': + return r + ('a' - 'A') + case r >= '0' && r <= '9': + return r + default: + return '_' + } + }, identity) + sanitized = strings.Trim(sanitized, "_") + if sanitized == "" { + sanitized = "backend" + } + return fmt.Sprintf("backend_%s_%d", sanitized, route.ID) +} + +func buildUpstreamProxyPassURI(parsed *url.URL) string { + path := parsed.EscapedPath() + if path == "/" { + path = "" + } + if parsed.RawQuery == "" { + return path + } + return fmt.Sprintf("%s?%s", path, parsed.RawQuery) +} + +func renderConnectionUpgradeMap() string { + return " map $http_upgrade $connection_upgrade {\n default upgrade;\n '' \"\";\n }\n\n" +} + +func renderDefaultServerBlock() string { + return strings.Join([]string{" server {", " listen 80 default_server;", " server_name _;", "", " return 404;", " }", "", " server {", " listen 443 ssl default_server;", " server_name _;", "", " ssl_reject_handshake on;", " }", ""}, "\n") +} + +func normalizedRouteDomains(route Route) []string { + if len(route.Domains) > 0 { + return route.Domains + } + if strings.TrimSpace(route.Domain) == "" { + return nil + } + return []string{strings.TrimSpace(route.Domain)} +} + +func normalizeCertIDs(primaryCertID *uint, certIDs []uint) []uint { + candidates := make([]uint, 0, len(certIDs)+1) + if primaryCertID != nil && *primaryCertID != 0 { + candidates = append(candidates, *primaryCertID) + } + candidates = append(candidates, certIDs...) + seen := make(map[uint]struct{}, len(candidates)) + normalized := make([]uint, 0, len(candidates)) + for _, id := range candidates { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + normalized = append(normalized, id) + } + return normalized +} + +func normalizeDomainCertIDs(domains []string, certIDs []uint, domainCertIDs []uint) []uint { + if len(domainCertIDs) > 0 { + normalized := make([]uint, len(domainCertIDs)) + copy(normalized, domainCertIDs) + return normalized + } + if len(certIDs) == 1 { + normalized := make([]uint, len(domains)) + for index := range normalized { + normalized[index] = certIDs[0] + } + return normalized + } + if len(certIDs) == len(domains) { + normalized := make([]uint, len(certIDs)) + copy(normalized, certIDs) + return normalized + } + return []uint{} +} + +func certificatesByID(files []SupportFile) map[uint]string { + result := make(map[uint]string) + for _, file := range files { + if !strings.HasSuffix(file.Path, ".crt") { + continue + } + idText := strings.TrimSuffix(file.Path, ".crt") + var id uint + if _, err := fmt.Sscanf(idText, "%d", &id); err == nil && id != 0 { + result[id] = file.Content + } + } + return result +} + +func validateCertificateCoverage(certPEM string, domains []string) error { + block, _ := pem.Decode([]byte(certPEM)) + if block == nil { + return errors.New("certificate PEM is invalid") + } + leaf, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return err + } + for _, domain := range domains { + if err := leaf.VerifyHostname(domain); err != nil { + return fmt.Errorf("certificate does not cover domain %s", domain) + } + } + return nil +} + +func getPoWConfigForRoute(routeID uint, snapshot WAFDocument) (bool, *PoWConfig) { + 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 + } + } + } + break + } + for _, group := range snapshot.RuleGroups { + if group.IsGlobal && group.PoWEnabled { + return true, group.PoWConfig + } + } + return false, nil +} + +func uniqueUintIDs(values []uint) []uint { + seen := make(map[uint]struct{}, len(values)) + result := make([]uint, 0, len(values)) + for _, value := range values { + if value == 0 { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} + +func resolveUpstreamServerName(originURL string, originHost string) string { + parsed, err := url.Parse(originURL) + if err != nil || !strings.EqualFold(parsed.Scheme, "https") { + return "" + } + if strings.TrimSpace(originHost) != "" { + parsedHost, err := url.Parse("//" + originHost) + if err == nil && parsedHost.Hostname() != "" { + return parsedHost.Hostname() + } + return originHost + } + return parsed.Hostname() +} + +func renderServerNames(domains []string) string { return strings.Join(domains, " ") } + +func onOff(value bool) string { + if value { + return "on" + } + return "off" +} + +func quoteNginxStringLiteral(value string) string { + escaped := strings.ReplaceAll(value, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, `"`, `\"`) + return fmt.Sprintf(`"%s"`, escaped) +} + +func escapeNginxString(value string) string { + escaped := strings.ReplaceAll(value, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, `"`, `\"`) + return escaped +} + +func buildSuffixMatchPattern(rules []string) string { + parts := make([]string, 0, len(rules)) + for _, rule := range rules { + parts = append(parts, regexp.QuoteMeta(rule)) + } + return fmt.Sprintf("\\.(?:%s)$", strings.Join(parts, "|")) +} + +func buildPathPrefixMatchPattern(rules []string) string { + parts := make([]string, 0, len(rules)) + for _, rule := range rules { + trimmed := strings.TrimRight(rule, "/") + if trimmed == "" { + trimmed = "/" + } + if trimmed == "/" { + parts = append(parts, "/") + continue + } + parts = append(parts, fmt.Sprintf("%s(?:/|$)", regexp.QuoteMeta(trimmed))) + } + return fmt.Sprintf("^(?:%s)", strings.Join(parts, "|")) +} + +func buildPathExactMatchPattern(rules []string) string { + parts := make([]string, 0, len(rules)) + for _, rule := range rules { + parts = append(parts, regexp.QuoteMeta(rule)) + } + return fmt.Sprintf("^(?:%s)$", strings.Join(parts, "|")) +} diff --git a/openflare_server/utils/render/openresty/types.go b/openflare_server/utils/render/openresty/types.go new file mode 100644 index 00000000..26dc3ac5 --- /dev/null +++ b/openflare_server/utils/render/openresty/types.go @@ -0,0 +1,247 @@ +package openresty + +const ( + CertDirPlaceholder = "__OPENFLARE_CERT_DIR__" + RouteConfigPlaceholder = "__OPENFLARE_ROUTE_CONFIG__" + AccessLogPlaceholder = "__OPENFLARE_ACCESS_LOG__" + LuaDirPlaceholder = "__OPENFLARE_LUA_DIR__" + ObservabilityListenPlaceholder = "__OPENFLARE_OBSERVABILITY_LISTEN__" + ObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__" + PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__" + + SourceConfigFileName = "openresty_source_config.json" +) + +const ( + cachePolicySuffix = "suffix" + cachePolicyPathPrefix = "path_prefix" + cachePolicyPathExact = "path_exact" + defaultWAFBlockStatus = 418 + anubisStaticPrefix = "/.within.website/x/cmd/anubis/static/" + anubisAPIPrefix = "/.within.website/x/cmd/anubis/api/" +) + +const defaultMainConfigTemplate = `# This file is generated by OpenFlare. Do not edit manually. +worker_processes {{OpenRestyWorkerProcesses}}; +worker_rlimit_nofile {{OpenRestyWorkerRlimitNofile}}; +pid logs/nginx.pid; + +events { + worker_connections {{OpenRestyWorkerConnections}}; +{{OpenRestyEventsUseDirective}}{{OpenRestyEventsMultiAcceptDirective}}} + +http { + include mime.types; + default_type application/octet-stream; +{{OpenRestyConnectionUpgradeMap}}{{OpenRestyDefaultServerBlock}} log_format openflare_json escape=json '{"ts":"$time_iso8601","host":"$host","path":"$request_uri","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length}'; + access_log {{OpenRestyAccessLogPath}} openflare_json; + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout {{OpenRestyKeepaliveTimeout}}; + keepalive_requests {{OpenRestyKeepaliveRequests}}; + client_header_timeout {{OpenRestyClientHeaderTimeout}}; + client_body_timeout {{OpenRestyClientBodyTimeout}}; + client_max_body_size {{OpenRestyClientMaxBodySize}}; + large_client_header_buffers {{OpenRestyLargeClientHeaderBuffers}}; + send_timeout {{OpenRestySendTimeout}}; + proxy_connect_timeout {{OpenRestyProxyConnectTimeout}}; + proxy_send_timeout {{OpenRestyProxySendTimeout}}; + proxy_read_timeout {{OpenRestyProxyReadTimeout}}; + proxy_request_buffering {{OpenRestyProxyRequestBuffering}}; + proxy_buffering {{OpenRestyProxyBuffering}}; + proxy_buffers {{OpenRestyProxyBuffers}}; + proxy_buffer_size {{OpenRestyProxyBufferSize}}; + proxy_busy_buffers_size {{OpenRestyProxyBusyBuffersSize}}; + gzip {{OpenRestyGzip}}; + gzip_min_length {{OpenRestyGzipMinLength}}; + gzip_comp_level {{OpenRestyGzipCompLevel}}; +{{OpenRestyResolverDirective}}{{OpenRestyCacheBlock}} include {{OpenRestyRouteConfigInclude}}; +} +` + +type SupportFile struct { + Path string `json:"path"` + Content string `json:"content"` +} + +type CustomHeader struct { + Key string `json:"key"` + Value string `json:"value"` +} + +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"` +} + +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"` +} + +type Route struct { + ID uint `json:"id,omitempty"` + SiteName string `json:"site_name,omitempty"` + Domain string `json:"domain"` + Domains []string `json:"domains,omitempty"` + OriginURL string `json:"origin_url"` + OriginHost string `json:"origin_host,omitempty"` + Upstreams []string `json:"upstreams,omitempty"` + Enabled bool `json:"enabled"` + EnableHTTPS bool `json:"enable_https"` + CertID *uint `json:"cert_id,omitempty"` + CertIDs []uint `json:"cert_ids,omitempty"` + DomainCertIDs []uint `json:"domain_cert_ids,omitempty"` + RedirectHTTP bool `json:"redirect_http"` + LimitConnPerServer int `json:"limit_conn_per_server,omitempty"` + LimitConnPerIP int `json:"limit_conn_per_ip,omitempty"` + LimitRate string `json:"limit_rate,omitempty"` + CacheEnabled bool `json:"cache_enabled"` + CachePolicy string `json:"cache_policy,omitempty"` + CacheRules []string `json:"cache_rules,omitempty"` + CustomHeaders []CustomHeader `json:"custom_headers,omitempty"` + PoWEnabled bool `json:"pow_enabled,omitempty"` + PoWConfig *PoWConfig `json:"pow_config,omitempty"` + BasicAuthEnabled bool `json:"basic_auth_enabled,omitempty"` + BasicAuthUsername string `json:"basic_auth_username,omitempty"` + BasicAuthPassword string `json:"basic_auth_password,omitempty"` + Remark string `json:"remark,omitempty"` +} + +type WAFRuleGroup 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,omitempty"` + IPWhitelist []string `json:"ip_whitelist,omitempty"` + IPBlacklist []string `json:"ip_blacklist,omitempty"` + CountryWhitelist []string `json:"country_whitelist,omitempty"` + CountryBlacklist []string `json:"country_blacklist,omitempty"` + RegionWhitelist []string `json:"region_whitelist,omitempty"` + RegionBlacklist []string `json:"region_blacklist,omitempty"` + PoWEnabled bool `json:"pow_enabled,omitempty"` + PoWConfig *PoWConfig `json:"pow_config,omitempty"` +} + +type WAFBinding struct { + RouteID uint `json:"route_id"` + SiteName string `json:"site_name"` + RuleGroupIDs []uint `json:"rule_group_ids"` +} + +type WAFDocument struct { + RuleGroups []WAFRuleGroup `json:"rule_groups"` + Bindings []WAFBinding `json:"bindings"` +} + +type ConfigSnapshot struct { + WorkerProcesses string `json:"worker_processes"` + WorkerConnections int `json:"worker_connections"` + WorkerRlimitNofile int `json:"worker_rlimit_nofile"` + EventsUse string `json:"events_use,omitempty"` + EventsMultiAcceptEnabled bool `json:"events_multi_accept_enabled"` + KeepaliveTimeout int `json:"keepalive_timeout"` + KeepaliveRequests int `json:"keepalive_requests"` + ClientHeaderTimeout int `json:"client_header_timeout"` + ClientBodyTimeout int `json:"client_body_timeout"` + ClientMaxBodySize string `json:"client_max_body_size"` + LargeClientHeaderBuffers string `json:"large_client_header_buffers"` + SendTimeout int `json:"send_timeout"` + ProxyConnectTimeout int `json:"proxy_connect_timeout"` + ProxySendTimeout int `json:"proxy_send_timeout"` + ProxyReadTimeout int `json:"proxy_read_timeout"` + WebsocketEnabled bool `json:"websocket_enabled"` + ProxyRequestBuffering bool `json:"proxy_request_buffering"` + ProxyBufferingEnabled bool `json:"proxy_buffering_enabled"` + ProxyBuffers string `json:"proxy_buffers"` + ProxyBufferSize string `json:"proxy_buffer_size"` + ProxyBusyBuffersSize string `json:"proxy_busy_buffers_size"` + GzipEnabled bool `json:"gzip_enabled"` + GzipMinLength int `json:"gzip_min_length"` + GzipCompLevel int `json:"gzip_comp_level"` + Resolvers string `json:"resolvers,omitempty"` + CacheEnabled bool `json:"cache_enabled"` + CachePath string `json:"cache_path,omitempty"` + CacheLevels string `json:"cache_levels"` + CacheInactive string `json:"cache_inactive"` + CacheMaxSize string `json:"cache_max_size"` + CacheKeyTemplate string `json:"cache_key_template"` + CacheLockEnabled bool `json:"cache_lock_enabled"` + CacheLockTimeout string `json:"cache_lock_timeout"` + CacheUseStale string `json:"cache_use_stale"` + MainConfigTemplate string `json:"main_config_template,omitempty"` +} + +type Document struct { + Routes []Route `json:"routes"` + OpenRestyConfig ConfigSnapshot `json:"openresty_config"` + WAF WAFDocument `json:"waf"` +} + +type Result struct { + MainConfig string + RouteConfig string + SupportFiles []SupportFile + Checksum string +} + +type routeCacheConfig struct { + Enabled bool + Policy string + Rules []string +} + +type routeLimitConfig struct { + LimitConnPerServer int + LimitConnPerIP int + LimitRate string +} + +type routeUpstreamConfig struct { + Name string + Scheme string + ProxyPassURI string + Servers []string + UsesNamedUpstream bool +} + +var requiredMainConfigTemplatePlaceholders = []string{ + "{{OpenRestyWorkerProcesses}}", + "{{OpenRestyWorkerConnections}}", + "{{OpenRestyWorkerRlimitNofile}}", + "{{OpenRestyConnectionUpgradeMap}}", + "{{OpenRestyDefaultServerBlock}}", + "{{OpenRestyAccessLogPath}}", + "{{OpenRestyEventsUseDirective}}", + "{{OpenRestyEventsMultiAcceptDirective}}", + "{{OpenRestyKeepaliveTimeout}}", + "{{OpenRestyKeepaliveRequests}}", + "{{OpenRestyClientHeaderTimeout}}", + "{{OpenRestyClientBodyTimeout}}", + "{{OpenRestyClientMaxBodySize}}", + "{{OpenRestyLargeClientHeaderBuffers}}", + "{{OpenRestySendTimeout}}", + "{{OpenRestyProxyConnectTimeout}}", + "{{OpenRestyProxySendTimeout}}", + "{{OpenRestyProxyReadTimeout}}", + "{{OpenRestyProxyRequestBuffering}}", + "{{OpenRestyProxyBuffering}}", + "{{OpenRestyProxyBuffers}}", + "{{OpenRestyProxyBufferSize}}", + "{{OpenRestyProxyBusyBuffersSize}}", + "{{OpenRestyGzip}}", + "{{OpenRestyGzipMinLength}}", + "{{OpenRestyGzipCompLevel}}", + "{{OpenRestyCacheBlock}}", + "{{OpenRestyRouteConfigInclude}}", +}