Files
OpenFlare/pkg/render/openresty/render.go
T
ryan aa4faddade 补齐 131 个 .go 文件的 SPDX license 头(repo 自带 make license 约定,早于约定新增的文件含 2 个生产文件;纯注释插入零行为影响),make license-check 转绿。go mod tidy -diff 确认干净。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":81}
2026-08-16 20:09:24 +08:00

996 lines
41 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// 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"
"strconv"
"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))
}
if doc.OpenRestyConfig.SWOfflineEnabled && len(doc.OpenRestyConfig.SWOfflineDomains) > 0 {
files = append(files, ServiceWorkerSupportFiles(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}}", strconv.Itoa(cfg.WorkerConnections),
"{{OpenRestyWorkerRlimitNofile}}", strconv.Itoa(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}}", strconv.Itoa(cfg.KeepaliveTimeout),
"{{OpenRestyKeepaliveRequests}}", strconv.Itoa(cfg.KeepaliveRequests),
"{{OpenRestyClientHeaderTimeout}}", strconv.Itoa(cfg.ClientHeaderTimeout),
"{{OpenRestyClientBodyTimeout}}", strconv.Itoa(cfg.ClientBodyTimeout),
"{{OpenRestyClientMaxBodySize}}", cfg.ClientMaxBodySize,
"{{OpenRestyLargeClientHeaderBuffers}}", cfg.LargeClientHeaderBuffers,
"{{OpenRestySendTimeout}}", strconv.Itoa(cfg.SendTimeout),
"{{OpenRestyProxyConnectTimeout}}", strconv.Itoa(cfg.ProxyConnectTimeout),
"{{OpenRestyProxySendTimeout}}", strconv.Itoa(cfg.ProxySendTimeout),
"{{OpenRestyProxyReadTimeout}}", strconv.Itoa(cfg.ProxyReadTimeout),
"{{OpenRestyProxyRequestBuffering}}", onOff(cfg.ProxyRequestBuffering),
"{{OpenRestyProxyBuffering}}", onOff(cfg.ProxyBufferingEnabled),
"{{OpenRestyProxyBuffers}}", cfg.ProxyBuffers,
"{{OpenRestyProxyBufferSize}}", cfg.ProxyBufferSize,
"{{OpenRestyProxyBusyBuffersSize}}", cfg.ProxyBusyBuffersSize,
"{{OpenRestyGzip}}", onOff(cfg.GzipEnabled),
"{{OpenRestyGzipMinLength}}", strconv.Itoa(cfg.GzipMinLength),
"{{OpenRestyGzipCompLevel}}", strconv.Itoa(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, _ bool, 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, _ bool, _ ConfigSnapshot) 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, swEnabled bool, 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"
}
if swEnabled {
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%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlockWithSW(siteName, powEnabled, cfg), 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), renderServiceWorkerChallenger(cfg))
}
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, swEnabled bool, 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"
}
if swEnabled {
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%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlockWithSW(siteName, powEnabled, cfg), 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), renderServiceWorkerChallenger(cfg))
}
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.SplitSeq(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)
}
// renderAccessBlockWithSW emits the access phase directives for a server block,
// merging the Service Worker runtime check into the single access directive.
// nginx runs only the last access_by_lua* directive in a scope, so the SW check
// must never be emitted as a second directive; otherwise it would silently
// override (or be overridden by) the WAF/PoW check.
func renderAccessBlockWithSW(siteName string, powEnabled bool, _ ConfigSnapshot) string {
escapedSiteName := escapeNginxString(siteName)
if !powEnabled {
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("sw.runtime").check()
}
`, escapedSiteName, LuaDirPlaceholder, LuaDirPlaceholder, 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()
require("sw.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 {",
" listen 443 ssl default_server;" + 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 {
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
}
}
return false
}
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, 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
}
}