mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
b76f707c8b
Result: {"status":"keep","total_issues":74,"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":73,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38}
992 lines
41 KiB
Go
992 lines
41 KiB
Go
// 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))
|
||
}
|
||
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}}", 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, _ 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 {",
|
||
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
|
||
}
|
||
}
|