// Package openresty renders OpenResty configuration from proxy route definitions. package openresty import ( "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/hex" "encoding/json" "encoding/pem" "errors" "fmt" "net/url" "path" "regexp" "sort" "strings" ) const ( routeUpstreamTypePages = "pages" indexHTML = "/index.html" ) // RenderJSON parses the given JSON string as a Document and renders the full // OpenResty configuration bundle, injecting the provided certificate support files. 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) } // Render produces a complete OpenResty configuration Result from a Document and // a set of certificate support files. func Render(doc Document, certificateFiles []SupportFile) (*Result, error) { mainConfig := RenderMainConfig(doc) routeConfig, err := RenderRouteConfig(doc, certificateFiles) if err != nil { return nil, err } wafConfig, err := RenderWAFConfig(doc.WAF) if err != nil { return nil, err } files := append([]SupportFile(nil), certificateFiles...) files = append(files, SupportFile{Path: "waf_config.json", Content: wafConfig}) if doc.OpenRestyConfig.OriginErrorPageEnabled { files = append(files, originErrorPageSupportFile(doc.OpenRestyConfig)) } files = DedupeSupportFiles(files) return &Result{ MainConfig: mainConfig, RouteConfig: routeConfig, SupportFiles: files, Checksum: ChecksumBundle(mainConfig, routeConfig, files), }, nil } // RenderMainConfig renders the nginx main configuration string from the given // Document, falling back to the built-in default template when none is set. // Limit-req zones are derived from each route's effective rate after merge. func RenderMainConfig(doc Document) string { cfg := doc.OpenRestyConfig templateText := cfg.MainConfigTemplate if strings.TrimSpace(templateText) == "" { templateText = defaultMainConfigTemplate } return renderMainConfigTemplate(templateText, cfg, collectEffectiveLimitReqRates(doc.Routes, cfg)) } // ValidateMainConfigTemplate checks that the provided template text is non-empty // and contains all required OpenResty placeholder tokens. 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 } // RenderRouteConfig generates the nginx server-block configuration for all // routes in the Document, resolving certificate files as needed. 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.SiteName) } serverNames := renderServerNames(domains) displayName := resolveRouteSiteName(route) cacheConfig := routeCacheConfig{Enabled: route.CacheEnabled, Policy: route.CachePolicy, Rules: route.CacheRules} limitConfig := mergeRouteLimitConfig(route, doc.OpenRestyConfig) powEnabled, _ := getPoWConfigForRoute(route.ID, doc.WAF) if normalizeRouteUpstreamType(route.UpstreamType) == routeUpstreamTypePages { if err := renderPagesRoute(&builder, route, displayName, serverNames, certificates, limitConfig, powEnabled, doc.OpenRestyConfig); err != nil { return "", err } continue } if err := renderProxyRoute(&builder, route, displayName, serverNames, certificates, cacheConfig, limitConfig, powEnabled, doc.OpenRestyConfig); err != nil { return "", err } } return builder.String(), nil } // RenderWAFConfig serialises the WAF runtime configuration (rule groups and // per-site bindings) as a JSON string consumed by the OpenResty Lua runtime. func RenderWAFConfig(snapshot WAFDocument) (string, error) { data, err := json.Marshal(snapshot) return string(data), err } // ChecksumBundle returns a stable SHA-256 hex digest over the combined content // of the main config, route config, and deduplicated support files, excluding // the source config JSON file itself. 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[:]) } // DedupeSupportFiles returns a new slice with duplicate paths removed, keeping // the last occurrence of each path. 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, limitReqRates []string) string { replacer := strings.NewReplacer( "{{OpenRestyWorkerProcesses}}", cfg.WorkerProcesses, "{{OpenRestyWorkerConnections}}", fmt.Sprintf("%d", cfg.WorkerConnections), "{{OpenRestyWorkerRlimitNofile}}", fmt.Sprintf("%d", cfg.WorkerRlimitNofile), "{{OpenRestyConnectionUpgradeMap}}", renderConnectionUpgradeMap(), "{{OpenRestyDefaultServerBlock}}", renderDefaultServerBlock(cfg.DefaultServerReturnStatus, cfg.HTTP3Enabled), "{{OpenRestyAccessLogPath}}", AccessLogPlaceholder, "{{OpenRestyErrorLogPath}}", ErrorLogPlaceholder, "{{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, limitReqRates), "{{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, limitReqRates []string) string { lines := []string{renderOpenRestyLimitZoneBlock(limitReqRates)} if !cfg.CacheEnabled { lines = append(lines, renderOpenRestyObservabilityTemplateBlock()) return strings.Join(lines, "") } cachePath := strings.TrimSpace(cfg.CachePath) if cachePath == "" || strings.HasPrefix(cachePath, "/var/") { cachePath = ProxyCachePathPlaceholder } lines = append(lines, strings.Join([]string{ fmt.Sprintf(" proxy_cache_path %s levels=%s keys_zone=openflare_cache:10m inactive=%s max_size=%s;", 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(limitReqRates []string) string { var builder strings.Builder builder.WriteString(" limit_conn_zone $server_name zone=openflare_conn_per_server:10m;\n") builder.WriteString(" limit_conn_zone $binary_remote_addr zone=openflare_conn_per_ip:10m;\n") for _, rate := range limitReqRates { fmt.Fprintf( &builder, " limit_req_zone $openflare_waf_site$binary_remote_addr zone=%s:10m rate=%s;\n", limitReqZoneName(rate), rate, ) } return builder.String() } func collectEffectiveLimitReqRates(routes []Route, cfg ConfigSnapshot) []string { seen := make(map[string]struct{}, len(routes)) for _, route := range routes { rate := strings.TrimSpace(mergeRouteLimitConfig(route, cfg).LimitReqPerIP) if rate == "" { continue } seen[rate] = struct{}{} } if len(seen) == 0 { return nil } rates := make([]string, 0, len(seen)) for rate := range seen { rates = append(rates, rate) } sort.Strings(rates) return rates } func limitReqZoneName(rate string) string { normalized := strings.ToLower(strings.TrimSpace(rate)) normalized = strings.ReplaceAll(normalized, "/", "") return "openflare_req_" + normalized } func renderOpenRestyObservabilityTemplateBlock() string { return fmt.Sprintf(" lua_shared_dict openflare_observability 10m;\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 lua_shared_dict openflare_waf_ip_groups 64m;\n init_worker_by_lua_file %s/observability/init.lua;\n log_by_lua_file %s/observability/log.lua;\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 default_type application/json;\n content_by_lua_file %s/observability/read.lua;\n }\n }\n\n", LuaDirPlaceholder, 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%s }\n%s%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderOriginErrorPageIntercept(cfg), renderProxyPassBlock(originURL, upstreamConfig), renderOriginErrorPageServerBits(cfg), renderPowStaticLocationBlock(powEnabled)) } func renderPagesAPIProxyLocationBlock(deployment *PagesDeployment) string { if deployment == nil || !deployment.APIProxyEnabled { return "" } path := strings.TrimSpace(deployment.APIProxyPath) pass := strings.TrimSpace(deployment.APIProxyPass) rewrite := strings.TrimSpace(deployment.APIProxyRewrite) if path == "" || pass == "" { return "" } if !strings.HasPrefix(path, "/") { path = "/" + path } cleanPath := strings.TrimSuffix(path, "/") var builder strings.Builder // 使用 fmt.Fprintf 替代 WriteString(fmt.Sprintf(...))(QF1012) fmt.Fprintf(&builder, "\n location %s {\n", cleanPath) if rewrite != "" { if !strings.HasPrefix(rewrite, "/") { rewrite = "/" + rewrite } cleanRewrite := strings.TrimSuffix(rewrite, "/") if cleanRewrite == "" { fmt.Fprintf(&builder, " rewrite ^%s/(.*)$ /$1 break;\n", regexp.QuoteMeta(cleanPath)) fmt.Fprintf(&builder, " rewrite ^%s$ / break;\n", regexp.QuoteMeta(cleanPath)) } else { fmt.Fprintf(&builder, " rewrite ^%s/(.*)$ %s/$1 break;\n", regexp.QuoteMeta(cleanPath), cleanRewrite) fmt.Fprintf(&builder, " rewrite ^%s$ %s break;\n", regexp.QuoteMeta(cleanPath), cleanRewrite) } } fmt.Fprintf(&builder, " proxy_pass %s;\n", pass) builder.WriteString(" proxy_http_version 1.1;\n") builder.WriteString(" proxy_set_header Host $http_host;\n") 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") builder.WriteString(" proxy_set_header Upgrade $http_upgrade;\n") builder.WriteString(" proxy_set_header Connection $connection_upgrade;\n") builder.WriteString(" }\n") return builder.String() } func renderHTTPPagesServer(serverNames string, siteName string, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string) string { return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s root %s;\n index %s;%s%s\n\n location / {\n%s%s }\n%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderPagesAPIProxyLocationBlock(deployment), renderPagesRootLocationBlock(deployment, limitConfig, basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), 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) var h3Listen string var h3Header string if cfg.HTTP3Enabled { h3Listen = " listen 443 quic;\n" h3Header = " add_header Alt-Svc 'h3=\":443\"; ma=86400';\n" } return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s location / {\n%s%s%s%s%s%s }\n%s%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderOriginErrorPageIntercept(cfg), renderProxyPassBlock(originURL, upstreamConfig), renderOriginErrorPageServerBits(cfg), renderPowStaticLocationBlock(powEnabled)) } func renderHTTPSPagesServer(serverNames string, siteName string, certificateID uint, deployment *PagesDeployment, limitConfig routeLimitConfig, 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) var h3Listen string var h3Header string if cfg.HTTP3Enabled { h3Listen = " listen 443 quic;\n" h3Header = " add_header Alt-Svc 'h3=\":443\"; ma=86400';\n" } return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s root %s;\n index %s;%s%s\n\n location / {\n%s%s }\n%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderPagesAPIProxyLocationBlock(deployment), renderPagesRootLocationBlock(deployment, limitConfig, basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled)) } func renderPagesRootLocationBlock(deployment *PagesDeployment, limitConfig routeLimitConfig, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string) string { tryFile := pagesRootTryFile(deployment) var builder strings.Builder builder.WriteString("\n location = / {\n") builder.WriteString(renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword)) builder.WriteString(renderRouteLimitBlock(limitConfig)) fmt.Fprintf(&builder, " try_files %s =404;\n", tryFile) builder.WriteString(" }\n") return builder.String() } func pagesRootTryFile(deployment *PagesDeployment) string { if deployment != nil && deployment.SPAFallbackEnabled { return pagesFallbackPath(deployment) } return "/" + pagesEntryFile(deployment) } func renderPagesLocationBlock(deployment *PagesDeployment, limitConfig routeLimitConfig) string { var builder strings.Builder builder.WriteString(renderRouteLimitBlock(limitConfig)) if deployment != nil && deployment.SPAFallbackEnabled { fmt.Fprintf(&builder, " try_files $uri $uri/ %s;\n", pagesFallbackPath(deployment)) } else { builder.WriteString(" try_files $uri $uri/ =404;\n") } return builder.String() } func pagesDeploymentRoot(deployment *PagesDeployment) string { if deployment == nil || strings.TrimSpace(deployment.LocalRoot) == "" { return PagesDirPlaceholder } return filepathToNginxPath(deployment.LocalRoot) } func pagesEntryFile(deployment *PagesDeployment) string { if deployment == nil || strings.TrimSpace(deployment.EntryFile) == "" { return "index.html" } return strings.TrimPrefix(filepathToNginxPath(deployment.EntryFile), "/") } func pagesFallbackPath(deployment *PagesDeployment) string { if deployment == nil || strings.TrimSpace(deployment.SPAFallbackPath) == "" { return indexHTML } value := filepathToNginxPath(strings.TrimSpace(deployment.SPAFallbackPath)) if !strings.HasPrefix(value, "/") { value = "/" + value } if value == "/" || strings.HasSuffix(value, "/") || strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") || strings.ContainsAny(value, " \t\r\n") { return indexHTML } for _, segment := range strings.Split(value, "/") { if segment == "." || segment == ".." { return indexHTML } } cleaned := path.Clean(value) if cleaned == "/" || strings.HasSuffix(cleaned, "/") { return "/index.html" } return cleaned } func renderProxyHeaderBlock(originURL string, originHost string, customHeaders []CustomHeader, upstreamConfig routeUpstreamConfig, cfg ConfigSnapshot) string { var builder strings.Builder if strings.TrimSpace(originHost) != "" { fmt.Fprintf(&builder, " 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") fmt.Fprintf(&builder, " 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 { fmt.Fprintf(&builder, " 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 { if not string.find(package.path, "%s/?.lua", 1, true) then package.path = "%s/?.lua;%s/?/init.lua;" .. package.path end require("waf.runtime").check() if ngx.ctx.openflare_waf_blocked then return end require("pow.runtime").check() } `, escapedSiteName, LuaDirPlaceholder, 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") 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 $upstream_http_set_cookie;\n") builder.WriteString(" proxy_cache_valid 200 206 301 120m;\n") builder.WriteString(" proxy_cache_valid 302 303 20m;\n") builder.WriteString(" proxy_cache_valid 404 410 3m;\n") return builder.String() } func renderRouteLimitBlock(limitConfig routeLimitConfig) string { var builder strings.Builder if limitConfig.LimitConnPerServer > 0 { fmt.Fprintf(&builder, " limit_conn openflare_conn_per_server %d;\n", limitConfig.LimitConnPerServer) } if limitConfig.LimitConnPerIP > 0 { fmt.Fprintf(&builder, " limit_conn openflare_conn_per_ip %d;\n", limitConfig.LimitConnPerIP) } if strings.TrimSpace(limitConfig.LimitRate) != "" { fmt.Fprintf(&builder, " limit_rate %s;\n", limitConfig.LimitRate) } if strings.TrimSpace(limitConfig.LimitReqPerIP) != "" { rate := strings.TrimSpace(limitConfig.LimitReqPerIP) burst := calculateBurst(rate) fmt.Fprintf(&builder, " limit_req zone=%s burst=%d nodelay;\n", limitReqZoneName(rate), burst) fmt.Fprintf(&builder, " limit_req_status 429;\n") } return builder.String() } func mergeRouteLimitConfig(route Route, cfg ConfigSnapshot) routeLimitConfig { return routeLimitConfig{ LimitConnPerServer: mergeLimitConn(route.LimitConnPerServer, cfg.DefaultLimitConnPerServer), LimitConnPerIP: mergeLimitConn(route.LimitConnPerIP, cfg.DefaultLimitConnPerIP), LimitRate: mergeLimitRate(route.LimitRate, cfg.DefaultLimitRate), LimitReqPerIP: mergeLimitRate(route.LimitReqPerIP, cfg.DefaultLimitReqPerIP), } } func mergeLimitConn(route, def int) int { if route == -1 { return 0 } if route > 0 { return route } if def > 0 { return def } return 0 } func mergeLimitRate(route, def string) string { r := strings.ToLower(strings.TrimSpace(route)) if r == "-1" { return "" } if r != "" && r != "0" { return r } d := strings.ToLower(strings.TrimSpace(def)) if d != "" && d != "0" { return d } return "" } func renderRouteCachePolicyCondition(cacheConfig routeCacheConfig) string { policy := normalizeRenderCachePolicy(cacheConfig.Policy) switch policy { case cachePolicyStatic: return fmt.Sprintf(" if ($uri !~* %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildSuffixMatchPattern(DefaultStaticCacheExtensions))) 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))) case cachePolicyAll, cachePolicyURL: return "" default: // Unknown policy: treat as static for safety (do not cache everything). return fmt.Sprintf(" if ($uri !~* %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildSuffixMatchPattern(DefaultStaticCacheExtensions))) } } // normalizeRenderCachePolicy maps stored policy for OpenResty generation. // Legacy empty and "url" mean "all GETs after security bypass" (pre-static default). // Explicit "static" uses the built-in extension allowlist. Unknown policies fall back to static. func normalizeRenderCachePolicy(raw string) string { policy := strings.TrimSpace(strings.ToLower(raw)) switch policy { case "", cachePolicyURL, cachePolicyAll: return cachePolicyAll case cachePolicyStatic: return cachePolicyStatic case cachePolicySuffix, cachePolicyPathPrefix, cachePolicyPathExact: return policy default: return policy } } 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 normalizeRouteUpstreamType(raw string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case routeUpstreamTypePages: return routeUpstreamTypePages default: return "direct" } } func renderNamedUpstreamBlock(upstreamConfig routeUpstreamConfig) string { var builder strings.Builder fmt.Fprintf(&builder, "upstream %s {\n", upstreamConfig.Name) for _, server := range upstreamConfig.Servers { fmt.Fprintf(&builder, " server %s max_fails=3 fail_timeout=10s;\n", server) } builder.WriteString(" keepalive 128;\n}\n\n") return builder.String() } func resolveRouteSiteName(route Route) string { if name := strings.TrimSpace(route.SiteName); name != "" { return name } if domains := normalizedRouteDomains(route); len(domains) > 0 { return domains[0] } return "" } func buildRouteUpstreamName(route Route) string { identity := resolveRouteSiteName(route) 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(statusCode int, http3Enabled bool) string { if statusCode <= 0 { statusCode = 421 } var h3Default string if http3Enabled { h3Default = "\n listen 443 quic reuseport default_server;" } return strings.Join([]string{ " server {", " listen 80 default_server;", " server_name _;", "", fmt.Sprintf(" return %d;", statusCode), " }", "", " server {", fmt.Sprintf(" listen 443 ssl default_server;%s", h3Default), " server_name _;", "", " ssl_reject_handshake on;", " }", "", }, "\n") } func normalizedRouteDomains(route Route) []string { return route.Domains } func certificateIDsFromDomainCertIDs(domainCertIDs []uint) []uint { seen := make(map[uint]struct{}, len(domainCertIDs)) normalized := make([]uint, 0, len(domainCertIDs)) for _, id := range domainCertIDs { if id == 0 { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} normalized = append(normalized, id) } return normalized } 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) { enabledGroups := make(map[uint]WAFRuleGroup, len(snapshot.RuleGroups)) globalGroupIDs := make([]uint, 0) for _, group := range snapshot.RuleGroups { if !group.Enabled { continue } enabledGroups[group.ID] = group if group.IsGlobal { globalGroupIDs = append(globalGroupIDs, group.ID) } } var boundGroupIDs []uint for _, binding := range snapshot.Bindings { if binding.RouteID != routeID { continue } for _, groupID := range binding.RuleGroupIDs { if _, ok := enabledGroups[groupID]; ok { boundGroupIDs = append(boundGroupIDs, groupID) } } break } activeGroupIDs := uniqueUintIDs(append(append([]uint{}, globalGroupIDs...), boundGroupIDs...)) for _, groupID := range activeGroupIDs { group := enabledGroups[groupID] if graphContainsNodeType(group.Graph, "pow") { return true, nil } } return false, nil } func graphContainsNodeType(graph WAFRuleGraph, nodeType string) bool { for _, node := range graph.Nodes { if node.Type == nodeType { return true } } return false } 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 filepathToNginxPath(value string) string { return strings.ReplaceAll(strings.TrimSpace(value), `\`, `/`) } 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, "|")) } const ( limitReqDefaultBurst = 5 limitReqPerSecondBurstMul = 2 limitReqPerMinuteBurstDiv = 5 ) func calculateBurst(rateStr string) int { rateStr = strings.ToLower(strings.TrimSpace(rateStr)) if rateStr == "" { return 0 } var val int var unit string _, err := fmt.Sscanf(rateStr, "%dr/%s", &val, &unit) if err != nil || val <= 0 { return limitReqDefaultBurst } switch unit { case "s": return val * limitReqPerSecondBurstMul case "m": b := val / limitReqPerMinuteBurstDiv if b < limitReqDefaultBurst { return limitReqDefaultBurst } return b default: return limitReqDefaultBurst } }