mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 16:16:37 +08:00
refactor(backend): rename OpenFlare directory to lowercase openflare
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package openresty
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// OriginErrorPageSupportPath is the SupportFile path for the origin error HTML template.
|
||||
OriginErrorPageSupportPath = "error_pages/origin_error.html.tmpl"
|
||||
|
||||
// OriginErrorPageInternalLocation is the named nginx location that serves the error body.
|
||||
// OriginErrorPageInternalLocation is the named nginx location that serves the error body
|
||||
// for the all-methods mode (get_only disabled). Must be a NAMED location (@...), not a
|
||||
// URI internal redirect: error_page URI redirects rewrite the request method to GET, so a
|
||||
// method check inside the location could never distinguish POST/PUT. Named locations keep
|
||||
// the original method and (without the `=` form) the original error status.
|
||||
//
|
||||
// When get_only is enabled this location is NOT emitted: GET-only mode replaces the body
|
||||
// via Lua header/body filters inside the proxy location, so non-GET responses pass through
|
||||
// with their original status and body.
|
||||
OriginErrorPageInternalLocation = "@__openflare_origin_error"
|
||||
defaultOriginErrorPageStatusTag = "500-599"
|
||||
)
|
||||
|
||||
// DefaultOriginErrorPageHTML is the built-in default (aligned with frontend minimalist).
|
||||
// Placeholders {{status}} and {{host}} are substituted at request time by Lua.
|
||||
const DefaultOriginErrorPageHTML = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{status}} | OpenFlare</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #ffffff;
|
||||
color: #333333;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
.error-code {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: #333333;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.error-description {
|
||||
font-size: 20px;
|
||||
line-height: 1.6;
|
||||
color: #666666;
|
||||
max-width: 480px;
|
||||
}
|
||||
.host {
|
||||
font-size: 14px;
|
||||
color: #999999;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #999999;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.brand-icon { width: 24px; height: 24px; fill: currentColor; display: block; }
|
||||
@media (max-width: 480px) {
|
||||
.error-code { font-size: 36px; }
|
||||
.error-description { font-size: 18px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1 class="error-code" aria-label="HTTP status">{{status}}</h1>
|
||||
<p class="error-description">
|
||||
The upstream server is unreachable. Please try again later or contact the site administrator if the problem persists.
|
||||
</p>
|
||||
<p class="host">{{host}}</p>
|
||||
<div class="footer">
|
||||
<svg class="brand-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" />
|
||||
</svg>
|
||||
<span>OpenFlare</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
// EffectiveOriginErrorPageHTML returns custom HTML when set, otherwise the built-in default.
|
||||
func EffectiveOriginErrorPageHTML(cfg ConfigSnapshot) string {
|
||||
if strings.TrimSpace(cfg.OriginErrorPageHTML) == "" {
|
||||
return DefaultOriginErrorPageHTML
|
||||
}
|
||||
return cfg.OriginErrorPageHTML
|
||||
}
|
||||
|
||||
func effectiveOriginErrorPageStatusTags(cfg ConfigSnapshot) []string {
|
||||
if len(cfg.OriginErrorPageStatusCodes) == 0 {
|
||||
return []string{defaultOriginErrorPageStatusTag}
|
||||
}
|
||||
return cfg.OriginErrorPageStatusCodes
|
||||
}
|
||||
|
||||
func originErrorPageSupportFile(cfg ConfigSnapshot) SupportFile {
|
||||
return SupportFile{
|
||||
Path: OriginErrorPageSupportPath,
|
||||
Content: EffectiveOriginErrorPageHTML(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
func renderOriginErrorPageIntercept(cfg ConfigSnapshot) string {
|
||||
if !cfg.OriginErrorPageEnabled {
|
||||
return ""
|
||||
}
|
||||
codes, err := ExpandStatusCodeTags(effectiveOriginErrorPageStatusTags(cfg))
|
||||
if err != nil || len(codes) == 0 {
|
||||
return ""
|
||||
}
|
||||
if cfg.OriginErrorPageGetOnly {
|
||||
// GET-only mode must NOT use proxy_intercept_errors: interception discards
|
||||
// the upstream error body, so non-GET requests could never receive the
|
||||
// original response (nginx would serve its own default error page instead).
|
||||
// The body is replaced by Lua header/body filters that only fire for GET;
|
||||
// non-GET responses pass through with status, headers and body untouched.
|
||||
return renderOriginErrorPageLuaFilterBlock(codes)
|
||||
}
|
||||
// Intercept at the proxy level for all methods. nginx does not allow
|
||||
// proxy_intercept_errors inside limit_except (only allow/deny are valid
|
||||
// there), so the custom HTML is served by the named error location.
|
||||
return " proxy_intercept_errors on;\n"
|
||||
}
|
||||
|
||||
// renderOriginErrorPageLuaFilterBlock emits the GET-only body replacement inside the
|
||||
// proxy location. header_filter decides whether the response should be replaced and
|
||||
// reads the template once into ngx.ctx; body_filter swaps the upstream body for the
|
||||
// custom HTML and forces end-of-body so remaining upstream chunks are discarded.
|
||||
// Non-GET requests (or statuses outside the configured set) are never touched.
|
||||
func renderOriginErrorPageLuaFilterBlock(codes []int) string {
|
||||
codeList := make([]string, len(codes))
|
||||
for i, code := range codes {
|
||||
codeList[i] = strconv.Itoa(code)
|
||||
}
|
||||
return fmt.Sprintf(` header_filter_by_lua_block {
|
||||
local codes = {%s}
|
||||
local function match(code)
|
||||
for _, c in ipairs(codes) do
|
||||
if c == code then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local status = ngx.status
|
||||
if match(status) and ngx.req.get_method() == "GET" then
|
||||
ngx.header.content_length = nil
|
||||
ngx.header["Content-Type"] = "text/html; charset=utf-8"
|
||||
local f = io.open("%s", "r")
|
||||
local body = f and f:read("*a")
|
||||
if f then
|
||||
f:close()
|
||||
end
|
||||
if not body then
|
||||
body = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>" .. tostring(status) .. "</title></head><body><h1>" .. tostring(status) .. "</h1></body></html>"
|
||||
end
|
||||
body = body:gsub("{{status}}", function() return tostring(status) end)
|
||||
body = body:gsub("{{host}}", function() return ngx.var.host or "" end)
|
||||
ngx.ctx.openflare_error_html = body
|
||||
end
|
||||
}
|
||||
body_filter_by_lua_block {
|
||||
local html = ngx.ctx.openflare_error_html
|
||||
if html then
|
||||
ngx.arg[1] = html
|
||||
ngx.arg[2] = true
|
||||
ngx.ctx.openflare_error_html = nil
|
||||
end
|
||||
}
|
||||
`, strings.Join(codeList, ", "), ErrorPageTmplPlaceholder)
|
||||
}
|
||||
|
||||
// renderOriginErrorPageServerBits emits server-level error_page + named error location
|
||||
// for the all-methods mode. Returns empty string when disabled, expand fails, no codes
|
||||
// remain, or get_only is enabled (GET-only mode replaces the body via Lua filters inside
|
||||
// the proxy location, see renderOriginErrorPageIntercept).
|
||||
//
|
||||
// IMPORTANT: do NOT use `error_page CODE = @name` (equals without response code).
|
||||
// That form adopts the status returned by the error URI; content_by_lua defaults
|
||||
// to 200 and ngx.status is often 0, so clients saw 200 with body "{{status}}"→"0".
|
||||
// Without `=`, nginx keeps the original error status for the redirect.
|
||||
func renderOriginErrorPageServerBits(cfg ConfigSnapshot) string {
|
||||
if !cfg.OriginErrorPageEnabled || cfg.OriginErrorPageGetOnly {
|
||||
return ""
|
||||
}
|
||||
codes, err := ExpandStatusCodeTags(effectiveOriginErrorPageStatusTags(cfg))
|
||||
if err != nil || len(codes) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, len(codes))
|
||||
for i, code := range codes {
|
||||
parts[i] = strconv.Itoa(code)
|
||||
}
|
||||
var builder strings.Builder
|
||||
// No `=` — preserve original error status (502 stays 502).
|
||||
fmt.Fprintf(&builder, " error_page %s %s;\n", strings.Join(parts, " "), OriginErrorPageInternalLocation)
|
||||
builder.WriteString(renderOriginErrorPageInternalLocation())
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func renderOriginErrorPageInternalLocation() string {
|
||||
// Resolve status from $status (set by error_page redirect), then
|
||||
// upstream_status, then ngx.status. Force ngx.status so the client receives
|
||||
// the real error code. Use function replacers so host/status with `%` are safe.
|
||||
//
|
||||
// Note: fmt.Sprintf is used only for the path placeholders; Lua `%` must be
|
||||
// written as `%%` so Sprintf does not treat them as format verbs.
|
||||
//
|
||||
// The location is NAMED (@...), not a URI internal redirect: URI redirects
|
||||
// (location = /uri) rewrite the request method to GET. Named locations keep
|
||||
// the original method and (without `=`) the original error status.
|
||||
return fmt.Sprintf(` location %s {
|
||||
default_type text/html;
|
||||
charset utf-8;
|
||||
content_by_lua_block {
|
||||
local function resolve_error_status()
|
||||
local code = tonumber(ngx.var.status)
|
||||
if code and code >= 400 then
|
||||
return code
|
||||
end
|
||||
local upstream = ngx.var.upstream_status or ""
|
||||
-- multi-upstream: "502, 502" or failed connect "0"
|
||||
local first = upstream:match("(%%d+)")
|
||||
code = tonumber(first)
|
||||
if code and code >= 400 then
|
||||
return code
|
||||
end
|
||||
code = tonumber(ngx.status)
|
||||
if code and code >= 400 then
|
||||
return code
|
||||
end
|
||||
return 502
|
||||
end
|
||||
|
||||
local code = resolve_error_status()
|
||||
ngx.status = code
|
||||
|
||||
local f = io.open("%s", "r")
|
||||
if not f then
|
||||
ngx.header["Content-Type"] = "text/html; charset=utf-8"
|
||||
ngx.say("Error ", tostring(code))
|
||||
return
|
||||
end
|
||||
local body = f:read("*a")
|
||||
f:close()
|
||||
local status = tostring(code)
|
||||
local host = ngx.var.host or ""
|
||||
-- function replacer: plain insert, no percent pattern side effects
|
||||
body = body:gsub("{{status}}", function() return status end)
|
||||
body = body:gsub("{{host}}", function() return host end)
|
||||
ngx.header["Content-Type"] = "text/html; charset=utf-8"
|
||||
ngx.say(body)
|
||||
}
|
||||
}
|
||||
`, OriginErrorPageInternalLocation, ErrorPageTmplPlaceholder)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package openresty
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderOriginErrorPageEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
ID: 1, SiteName: "ex", Domains: []string{"ex.test"},
|
||||
OriginURL: "http://127.0.0.1:9", Enabled: true,
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{
|
||||
OriginErrorPageEnabled: true,
|
||||
OriginErrorPageStatusCodes: []string{"500-599"},
|
||||
},
|
||||
}
|
||||
out, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "proxy_intercept_errors on") {
|
||||
t.Fatal("missing intercept")
|
||||
}
|
||||
if !strings.Contains(out, "error_page") || !strings.Contains(out, "@__openflare_origin_error") {
|
||||
t.Fatal("missing error_page")
|
||||
}
|
||||
if !strings.Contains(out, "error_page 500") {
|
||||
t.Fatalf("expected expanded status codes in error_page, got:\n%s", out)
|
||||
}
|
||||
// Must NOT use `error_page … = @name` (adopts error-URI status → often 200).
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "error_page ") && strings.Contains(trimmed, " = ") {
|
||||
t.Fatalf("error_page must not use '=' form, got: %s", trimmed)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, "error_page ") || !strings.Contains(out, " @__openflare_origin_error;") {
|
||||
t.Fatal("error_page must redirect to the named error location without '='")
|
||||
}
|
||||
if !strings.Contains(out, "location @__openflare_origin_error {") {
|
||||
t.Fatal("error location must be a named location (@...) that preserves the request method")
|
||||
}
|
||||
if strings.Contains(out, "location = /__openflare_origin_error") {
|
||||
t.Fatal("error location must NOT be a URI internal redirect (error_page URI redirects rewrite the method to GET, breaking the get_only gate)")
|
||||
}
|
||||
if !strings.Contains(out, "resolve_error_status") || !strings.Contains(out, "ngx.status = code") {
|
||||
t.Fatal("internal location must resolve and set ngx.status to the original error code")
|
||||
}
|
||||
if !strings.Contains(out, ErrorPageTmplPlaceholder) {
|
||||
t.Fatal("missing error page template placeholder")
|
||||
}
|
||||
res, err := Render(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, f := range res.SupportFiles {
|
||||
if f.Path == OriginErrorPageSupportPath {
|
||||
found = true
|
||||
if !strings.Contains(f.Content, "{{status}}") {
|
||||
t.Fatal("template missing placeholder")
|
||||
}
|
||||
if !strings.Contains(f.Content, "{{host}}") {
|
||||
t.Fatal("template missing host placeholder")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing support file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderOriginErrorPageGetOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
ID: 1, SiteName: "ex", Domains: []string{"ex.test"},
|
||||
OriginURL: "http://127.0.0.1:9", Enabled: true,
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{
|
||||
OriginErrorPageEnabled: true,
|
||||
OriginErrorPageStatusCodes: []string{"500-599"},
|
||||
OriginErrorPageGetOnly: true,
|
||||
},
|
||||
}
|
||||
out, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Regression: GET-only must NOT intercept at the proxy level.
|
||||
// proxy_intercept_errors discards the upstream error body, so non-GET requests
|
||||
// would receive nginx's own default error page instead of the original
|
||||
// response (this was the reported bug: POST 503 returned OpenResty's page).
|
||||
if strings.Contains(out, "proxy_intercept_errors") {
|
||||
t.Fatal("get_only must not emit proxy_intercept_errors (it discards the upstream body for non-GET)")
|
||||
}
|
||||
// The body replacement must happen in Lua filters that only fire for GET.
|
||||
if !strings.Contains(out, "header_filter_by_lua_block") {
|
||||
t.Fatal("get_only must emit header_filter_by_lua_block inside the proxy location")
|
||||
}
|
||||
if !strings.Contains(out, "body_filter_by_lua_block") {
|
||||
t.Fatal("get_only must emit body_filter_by_lua_block inside the proxy location")
|
||||
}
|
||||
if !strings.Contains(out, `ngx.req.get_method() == "GET"`) {
|
||||
t.Fatal("Lua filter must replace the body only for GET requests")
|
||||
}
|
||||
if !strings.Contains(out, `ngx.ctx.openflare_error_html`) {
|
||||
t.Fatal("Lua filter must stash the error HTML in ngx.ctx for the body filter")
|
||||
}
|
||||
if !strings.Contains(out, `local codes = {500`) {
|
||||
t.Fatal("Lua filter must carry the expanded status codes")
|
||||
}
|
||||
if !strings.Contains(out, ErrorPageTmplPlaceholder) {
|
||||
t.Fatal("missing error page template placeholder")
|
||||
}
|
||||
// No error_page / named location machinery in GET-only mode.
|
||||
if strings.Contains(out, "error_page") {
|
||||
t.Fatal("get_only must not emit error_page (named-location path can only serve HTML or an empty status, never the original body)")
|
||||
}
|
||||
if strings.Contains(out, "@__openflare_origin_error") {
|
||||
t.Fatal("get_only must not emit the named error location")
|
||||
}
|
||||
// nginx rejects proxy_intercept_errors inside limit_except (only allow/deny
|
||||
// are valid there); GET-only must rely on Lua filters instead.
|
||||
if strings.Contains(out, "limit_except") {
|
||||
t.Fatal("get_only must not emit limit_except (proxy_intercept_errors is not allowed there)")
|
||||
}
|
||||
if strings.Contains(out, "location = /__openflare_origin_error") {
|
||||
t.Fatal("must not use URI internal redirect (rewrites method to GET, breaking the GET gate)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderOriginErrorPageDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
ID: 1, SiteName: "ex", Domains: []string{"ex.test"},
|
||||
OriginURL: "http://127.0.0.1:9", Enabled: true,
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{OriginErrorPageEnabled: false},
|
||||
}
|
||||
out, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, "proxy_intercept_errors") {
|
||||
t.Fatal("should not intercept when disabled")
|
||||
}
|
||||
if strings.Contains(out, "@__openflare_origin_error") {
|
||||
t.Fatal("should not emit error location when disabled")
|
||||
}
|
||||
res, err := Render(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range res.SupportFiles {
|
||||
if f.Path == OriginErrorPageSupportPath {
|
||||
t.Fatal("should not emit support file when disabled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderOriginErrorPageDefaultsEmptyHTMLAndStatusCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
ID: 1, SiteName: "ex", Domains: []string{"ex.test"},
|
||||
OriginURL: "http://127.0.0.1:9", Enabled: true,
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{
|
||||
OriginErrorPageEnabled: true,
|
||||
},
|
||||
}
|
||||
out, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "error_page 500") {
|
||||
t.Fatalf("empty status codes should default to 500-599, got:\n%s", out)
|
||||
}
|
||||
html := EffectiveOriginErrorPageHTML(doc.OpenRestyConfig)
|
||||
if html != DefaultOriginErrorPageHTML {
|
||||
t.Fatal("empty HTML should use default template")
|
||||
}
|
||||
if !strings.Contains(html, "{{status}}") || !strings.Contains(html, "{{host}}") {
|
||||
t.Fatal("default HTML must include placeholders")
|
||||
}
|
||||
if !strings.Contains(html, "OpenFlare") || !strings.Contains(html, "upstream server is unreachable") {
|
||||
t.Fatal("default HTML missing minimalist copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderOriginErrorPageCustomHTMLInSupportFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
custom := "<html><body>custom {{status}} @ {{host}}</body></html>"
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
ID: 1, SiteName: "ex", Domains: []string{"ex.test"},
|
||||
OriginURL: "http://127.0.0.1:9", Enabled: true,
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{
|
||||
OriginErrorPageEnabled: true,
|
||||
OriginErrorPageStatusCodes: []string{"502"},
|
||||
OriginErrorPageHTML: custom,
|
||||
},
|
||||
}
|
||||
res, err := Render(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, f := range res.SupportFiles {
|
||||
if f.Path == OriginErrorPageSupportPath {
|
||||
found = true
|
||||
if f.Content != custom {
|
||||
t.Fatalf("support file content = %q, want custom HTML", f.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing support file")
|
||||
}
|
||||
out, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "error_page 502 @__openflare_origin_error;") {
|
||||
t.Fatalf("expected single 502 error_page without '=', got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderOriginErrorPageSkipsPagesRoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
ID: 1, SiteName: "pages", Domains: []string{"pages.test"},
|
||||
UpstreamType: "pages", Enabled: true,
|
||||
PagesDeployment: &PagesDeployment{
|
||||
ProjectID: 1, LocalRoot: PagesDirPlaceholder + "/projects/1/current",
|
||||
EntryFile: "index.html",
|
||||
},
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{
|
||||
OriginErrorPageEnabled: true,
|
||||
OriginErrorPageStatusCodes: []string{"500-599"},
|
||||
},
|
||||
}
|
||||
out, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, "proxy_intercept_errors") {
|
||||
t.Fatal("pages routes must not get proxy_intercept_errors")
|
||||
}
|
||||
if strings.Contains(out, "@__openflare_origin_error") {
|
||||
t.Fatal("pages routes must not get origin error location")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,995 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package openresty
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type routeCertPartition struct {
|
||||
httpOnlyDomains []string
|
||||
domainsByCertID map[uint][]string
|
||||
}
|
||||
|
||||
func partitionRouteDomainsByCert(domains []string, certIDs, domainCertIDs []uint) routeCertPartition {
|
||||
httpOnlyDomains := make([]string, 0, len(domains))
|
||||
domainsByCertID := make(map[uint][]string, len(certIDs))
|
||||
for index, domain := range domains {
|
||||
if index >= len(domainCertIDs) || domainCertIDs[index] == 0 {
|
||||
httpOnlyDomains = append(httpOnlyDomains, domain)
|
||||
continue
|
||||
}
|
||||
domainsByCertID[domainCertIDs[index]] = append(domainsByCertID[domainCertIDs[index]], domain)
|
||||
}
|
||||
return routeCertPartition{
|
||||
httpOnlyDomains: httpOnlyDomains,
|
||||
domainsByCertID: domainsByCertID,
|
||||
}
|
||||
}
|
||||
|
||||
func validateRouteCertificates(route Route, displayName string, certIDs []uint, partition routeCertPartition, certificates map[uint]string) error {
|
||||
for _, certID := range certIDs {
|
||||
assignedDomains := partition.domainsByCertID[certID]
|
||||
if len(assignedDomains) == 0 {
|
||||
continue
|
||||
}
|
||||
certPEM, ok := certificates[certID]
|
||||
if !ok {
|
||||
return fmt.Errorf("route %s certificate %d does not exist", route.SiteName, certID)
|
||||
}
|
||||
if err := validateCertificateCoverage(certPEM, assignedDomains); err != nil {
|
||||
return fmt.Errorf("site %s certificate validation failed: %w", displayName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderPagesRouteHTTPS(
|
||||
builder *strings.Builder,
|
||||
serverNames, displayName string,
|
||||
route Route,
|
||||
partition routeCertPartition,
|
||||
certIDs []uint,
|
||||
limitConfig routeLimitConfig,
|
||||
powEnabled bool,
|
||||
cfg ConfigSnapshot,
|
||||
) {
|
||||
if route.RedirectHTTP {
|
||||
if len(partition.httpOnlyDomains) > 0 {
|
||||
builder.WriteString(renderHTTPPagesServer(renderServerNames(partition.httpOnlyDomains), displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, false, cfg))
|
||||
}
|
||||
for _, certID := range certIDs {
|
||||
if assignedDomains := partition.domainsByCertID[certID]; len(assignedDomains) > 0 {
|
||||
builder.WriteString(renderHTTPRedirectServer(renderServerNames(assignedDomains)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
builder.WriteString(renderHTTPPagesServer(serverNames, displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, false, cfg))
|
||||
}
|
||||
for _, certID := range certIDs {
|
||||
if assignedDomains := partition.domainsByCertID[certID]; len(assignedDomains) > 0 {
|
||||
builder.WriteString(renderHTTPSPagesServer(renderServerNames(assignedDomains), displayName, certID, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, routeSWEnabled(assignedDomains, cfg), cfg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renderProxyRouteHTTPS(
|
||||
builder *strings.Builder,
|
||||
serverNames, displayName string,
|
||||
route Route,
|
||||
partition routeCertPartition,
|
||||
certIDs []uint,
|
||||
cacheConfig routeCacheConfig,
|
||||
limitConfig routeLimitConfig,
|
||||
upstreamConfig routeUpstreamConfig,
|
||||
powEnabled bool,
|
||||
cfg ConfigSnapshot,
|
||||
) {
|
||||
if route.RedirectHTTP {
|
||||
if len(partition.httpOnlyDomains) > 0 {
|
||||
builder.WriteString(renderHTTPProxyServer(renderServerNames(partition.httpOnlyDomains), displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, false, cfg))
|
||||
}
|
||||
for _, certID := range certIDs {
|
||||
if assignedDomains := partition.domainsByCertID[certID]; len(assignedDomains) > 0 {
|
||||
builder.WriteString(renderHTTPRedirectServer(renderServerNames(assignedDomains)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, false, cfg))
|
||||
}
|
||||
for _, certID := range certIDs {
|
||||
if assignedDomains := partition.domainsByCertID[certID]; len(assignedDomains) > 0 {
|
||||
builder.WriteString(renderHTTPSServer(renderServerNames(assignedDomains), displayName, route.OriginURL, route.OriginHost, certID, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, routeSWEnabled(assignedDomains, cfg), cfg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renderPagesRoute(builder *strings.Builder, route Route, displayName, serverNames string, certificates map[uint]string, limitConfig routeLimitConfig, powEnabled bool, cfg ConfigSnapshot) error {
|
||||
if route.PagesDeployment == nil {
|
||||
return fmt.Errorf("route %s pages deployment is missing", route.SiteName)
|
||||
}
|
||||
if !route.EnableHTTPS {
|
||||
builder.WriteString(renderHTTPPagesServer(serverNames, displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, false, cfg))
|
||||
return nil
|
||||
}
|
||||
certIDs := certificateIDsFromDomainCertIDs(route.DomainCertIDs)
|
||||
domainCertIDs := route.DomainCertIDs
|
||||
if len(certIDs) == 0 {
|
||||
return fmt.Errorf("路由 %s 未配置证书", route.SiteName)
|
||||
}
|
||||
partition := partitionRouteDomainsByCert(normalizedRouteDomains(route), certIDs, domainCertIDs)
|
||||
if err := validateRouteCertificates(route, displayName, certIDs, partition, certificates); err != nil {
|
||||
return err
|
||||
}
|
||||
renderPagesRouteHTTPS(builder, serverNames, displayName, route, partition, certIDs, limitConfig, powEnabled, cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderProxyRoute(builder *strings.Builder, route Route, displayName, serverNames string, certificates map[uint]string, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, powEnabled bool, cfg ConfigSnapshot) error {
|
||||
upstreams := route.Upstreams
|
||||
if len(upstreams) == 0 && strings.TrimSpace(route.OriginURL) != "" {
|
||||
upstreams = []string{route.OriginURL}
|
||||
}
|
||||
upstreamConfig := buildRouteUpstreamConfig(route, upstreams)
|
||||
if upstreamConfig.UsesNamedUpstream {
|
||||
builder.WriteString(renderNamedUpstreamBlock(upstreamConfig))
|
||||
}
|
||||
if !route.EnableHTTPS {
|
||||
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, false, cfg))
|
||||
return nil
|
||||
}
|
||||
certIDs := certificateIDsFromDomainCertIDs(route.DomainCertIDs)
|
||||
domainCertIDs := route.DomainCertIDs
|
||||
if len(certIDs) == 0 {
|
||||
return fmt.Errorf("路由 %s 未配置证书", route.SiteName)
|
||||
}
|
||||
partition := partitionRouteDomainsByCert(normalizedRouteDomains(route), certIDs, domainCertIDs)
|
||||
if err := validateRouteCertificates(route, displayName, certIDs, partition, certificates); err != nil {
|
||||
return err
|
||||
}
|
||||
renderProxyRouteHTTPS(builder, serverNames, displayName, route, partition, certIDs, cacheConfig, limitConfig, upstreamConfig, powEnabled, cfg)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package openresty
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderOpenRestyUsesDedicatedWAFIPGroupSharedDict(t *testing.T) {
|
||||
block := renderOpenRestyObservabilityTemplateBlock()
|
||||
if !strings.Contains(block, "lua_shared_dict openflare_waf_config 1m;") {
|
||||
t.Fatal("expected general WAF coordination dictionary to remain available")
|
||||
}
|
||||
if !strings.Contains(block, "lua_shared_dict openflare_waf_ip_groups 64m;") {
|
||||
t.Fatalf("expected dedicated 64m WAF IP group dictionary, got:\n%s", block)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderWAFConfigIncludesAllRouteSiteNames(t *testing.T) {
|
||||
doc := Document{
|
||||
Routes: []Route{
|
||||
{ID: 1, SiteName: "example.com", Domains: []string{"example.com", "www.example.com"}},
|
||||
{ID: 2, SiteName: "named-site", Domains: []string{"other.example.com"}},
|
||||
},
|
||||
WAF: WAFDocument{
|
||||
RuleGroups: []WAFRuleGroup{
|
||||
{
|
||||
ID: 1, Name: "pow-group", Enabled: true,
|
||||
Graph: WAFRuleGraph{Entry: "pow", Nodes: map[string]WAFRuleNode{"pow": {Type: "pow"}}},
|
||||
},
|
||||
},
|
||||
Bindings: []WAFBinding{
|
||||
{RouteID: 1, SiteName: "example.com", RuleGroupIDs: []uint{1}},
|
||||
{RouteID: 2, SiteName: "named-site", RuleGroupIDs: []uint{1}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
wafConfig, err := RenderWAFConfig(doc.WAF)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderWAFConfig() error = %v", err)
|
||||
}
|
||||
|
||||
var decoded WAFDocument
|
||||
if err := json.Unmarshal([]byte(wafConfig), &decoded); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
if len(decoded.Bindings) != 2 || decoded.Bindings[0].SiteName != "example.com" || decoded.Bindings[1].SiteName != "named-site" {
|
||||
t.Fatalf("bindings did not preserve route site names: %#v", decoded.Bindings)
|
||||
}
|
||||
|
||||
routeConfig, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRouteConfig() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(routeConfig, `set $openflare_waf_site "example.com"`) {
|
||||
t.Fatalf("expected route config to use normalized site name example.com, got:\n%s", routeConfig)
|
||||
}
|
||||
if !strings.Contains(routeConfig, `require("pow.runtime").check()`) {
|
||||
t.Fatalf("expected route config to enable pow runtime, got:\n%s", routeConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderWAFConfigDoesNotSynthesizeLegacyPoWConfig(t *testing.T) {
|
||||
doc := WAFDocument{
|
||||
RuleGroups: []WAFRuleGroup{
|
||||
{
|
||||
ID: 1,
|
||||
Name: "global",
|
||||
Enabled: true,
|
||||
IsGlobal: true,
|
||||
PoWEnabled: true,
|
||||
},
|
||||
},
|
||||
Bindings: []WAFBinding{
|
||||
{RouteID: 1, SiteName: "example.com", RuleGroupIDs: []uint{}},
|
||||
},
|
||||
}
|
||||
|
||||
wafConfig, err := RenderWAFConfig(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderWAFConfig() error = %v", err)
|
||||
}
|
||||
|
||||
var decoded WAFDocument
|
||||
if err := json.Unmarshal([]byte(wafConfig), &decoded); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(decoded.RuleGroups) != 1 {
|
||||
t.Fatalf("expected 1 rule group, got %d", len(decoded.RuleGroups))
|
||||
}
|
||||
if decoded.RuleGroups[0].PoWConfig != nil {
|
||||
t.Fatalf("expected renderer not to synthesize legacy PoW config, got %#v", decoded.RuleGroups[0].PoWConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPoWConfigForRouteUsesGlobalGroupWithoutExplicitBinding(t *testing.T) {
|
||||
snapshot := WAFDocument{
|
||||
RuleGroups: []WAFRuleGroup{
|
||||
{
|
||||
ID: 1, Name: "global", Enabled: true, IsGlobal: true,
|
||||
Graph: WAFRuleGraph{Entry: "pow", Nodes: map[string]WAFRuleNode{"pow": {Type: "pow"}}},
|
||||
},
|
||||
},
|
||||
Bindings: []WAFBinding{
|
||||
{RouteID: 42, SiteName: "example.com", RuleGroupIDs: []uint{}},
|
||||
},
|
||||
}
|
||||
|
||||
enabled := getPoWConfigForRoute(42, snapshot)
|
||||
if !enabled {
|
||||
t.Fatal("expected pow to be enabled via global rule group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRouteConfigEnablesPoWLocationsFromRuntimeGraph(t *testing.T) {
|
||||
doc := Document{
|
||||
Routes: []Route{{ID: 1, SiteName: "pow.example.com", Domains: []string{"pow.example.com"}, OriginURL: "http://127.0.0.1:8080", Enabled: true}},
|
||||
WAF: WAFDocument{
|
||||
RuleGroups: []WAFRuleGroup{{
|
||||
ID: 1, Name: "graph-pow", Enabled: true, IsGlobal: true,
|
||||
Graph: WAFRuleGraph{Entry: "start", Nodes: map[string]WAFRuleNode{
|
||||
"start": {Type: "start", Next: map[string]string{"next": "pow"}},
|
||||
"pow": {Type: "pow", Config: json.RawMessage(`{"algorithm":"fast","difficulty":4,"session_ttl":600,"challenge_ttl":300}`), Next: map[string]string{"next": "allow"}},
|
||||
"allow": {Type: "allow"},
|
||||
}},
|
||||
}},
|
||||
Bindings: []WAFBinding{{RouteID: 1, SiteName: "pow.example.com", RuleGroupIDs: []uint{}}},
|
||||
},
|
||||
}
|
||||
|
||||
rendered, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRouteConfig() error = %v", err)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
`location = /.within.website/x/cmd/anubis/api/make-challenge`,
|
||||
`location = /.within.website/x/cmd/anubis/api/pass-challenge`,
|
||||
`location /.within.website/x/cmd/anubis/static/`,
|
||||
} {
|
||||
if !strings.Contains(rendered, expected) {
|
||||
t.Fatalf("expected graph PoW route to contain %q, got:\n%s", expected, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderWAFConfigPreservesRuntimeGraphAndBindingOrder(t *testing.T) {
|
||||
doc := WAFDocument{
|
||||
RuleGroups: []WAFRuleGroup{{
|
||||
ID: 9, Name: "graph", Enabled: true,
|
||||
Graph: WAFRuleGraph{Entry: "start", Nodes: map[string]WAFRuleNode{
|
||||
"start": {Type: "start", Next: map[string]string{"next": "allow"}},
|
||||
"allow": {Type: "allow"},
|
||||
}},
|
||||
}},
|
||||
Bindings: []WAFBinding{{RouteID: 3, SiteName: "ordered.example.com", RuleGroupIDs: []uint{9, 4, 7}}},
|
||||
}
|
||||
|
||||
raw, err := RenderWAFConfig(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderWAFConfig() error = %v", err)
|
||||
}
|
||||
var decoded WAFDocument
|
||||
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if decoded.RuleGroups[0].Graph.Entry != "start" {
|
||||
t.Fatalf("runtime graph was not preserved: %#v", decoded.RuleGroups[0].Graph)
|
||||
}
|
||||
if got := decoded.Bindings[0].RuleGroupIDs; len(got) != 3 || got[0] != 9 || got[1] != 4 || got[2] != 7 {
|
||||
t.Fatalf("binding order changed: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPagesAPIProxyLocationBlock(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deployment *PagesDeployment
|
||||
expected []string
|
||||
unexpected []string
|
||||
}{
|
||||
{
|
||||
name: "nil deployment",
|
||||
deployment: nil,
|
||||
expected: []string{""},
|
||||
},
|
||||
{
|
||||
name: "disabled proxy",
|
||||
deployment: &PagesDeployment{
|
||||
APIProxyEnabled: false,
|
||||
APIProxyPath: "/api",
|
||||
APIProxyPass: "http://127.0.0.1:8080",
|
||||
},
|
||||
expected: []string{""},
|
||||
},
|
||||
{
|
||||
name: "enabled proxy without rewrite",
|
||||
deployment: &PagesDeployment{
|
||||
APIProxyEnabled: true,
|
||||
APIProxyPath: "/api",
|
||||
APIProxyPass: "http://127.0.0.1:8080",
|
||||
APIProxyRewrite: "",
|
||||
},
|
||||
expected: []string{
|
||||
"location /api {",
|
||||
"proxy_pass http://127.0.0.1:8080;",
|
||||
"proxy_http_version 1.1;",
|
||||
"proxy_set_header Host $http_host;",
|
||||
},
|
||||
unexpected: []string{
|
||||
"rewrite",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "enabled proxy with rewrite to root",
|
||||
deployment: &PagesDeployment{
|
||||
APIProxyEnabled: true,
|
||||
APIProxyPath: "/api",
|
||||
APIProxyPass: "http://127.0.0.1:8080",
|
||||
APIProxyRewrite: "/",
|
||||
},
|
||||
expected: []string{
|
||||
"location /api {",
|
||||
"rewrite ^/api/(.*)$ /$1 break;",
|
||||
"rewrite ^/api$ / break;",
|
||||
"proxy_pass http://127.0.0.1:8080;",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "enabled proxy with rewrite to subpath",
|
||||
deployment: &PagesDeployment{
|
||||
APIProxyEnabled: true,
|
||||
APIProxyPath: "/api",
|
||||
APIProxyPass: "http://127.0.0.1:8080",
|
||||
APIProxyRewrite: "/v2",
|
||||
},
|
||||
expected: []string{
|
||||
"location /api {",
|
||||
"rewrite ^/api/(.*)$ /v2/$1 break;",
|
||||
"rewrite ^/api$ /v2 break;",
|
||||
"proxy_pass http://127.0.0.1:8080;",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := renderPagesAPIProxyLocationBlock(tt.deployment)
|
||||
if len(tt.expected) == 1 && tt.expected[0] == "" {
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty output, got: %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, exp := range tt.expected {
|
||||
if !strings.Contains(got, exp) {
|
||||
t.Errorf("expected output to contain %q, but got:\n%s", exp, got)
|
||||
}
|
||||
}
|
||||
for _, unexp := range tt.unexpected {
|
||||
if strings.Contains(got, unexp) {
|
||||
t.Errorf("expected output NOT to contain %q, but got:\n%s", unexp, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPagesRootLocationBlock(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deployment *PagesDeployment
|
||||
expected []string
|
||||
unexpected []string
|
||||
}{
|
||||
{
|
||||
name: "spa fallback disabled serves entry file at root",
|
||||
deployment: &PagesDeployment{
|
||||
SPAFallbackEnabled: false,
|
||||
EntryFile: "index.html",
|
||||
},
|
||||
expected: []string{
|
||||
"location = / {",
|
||||
"try_files /index.html =404;",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "spa fallback disabled with custom entry file",
|
||||
deployment: &PagesDeployment{
|
||||
SPAFallbackEnabled: false,
|
||||
EntryFile: "app.html",
|
||||
},
|
||||
expected: []string{
|
||||
"location = / {",
|
||||
"try_files /app.html =404;",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "spa fallback enabled serves fallback file at root",
|
||||
deployment: &PagesDeployment{
|
||||
SPAFallbackEnabled: true,
|
||||
SPAFallbackPath: "/index.html",
|
||||
},
|
||||
expected: []string{
|
||||
"location = / {",
|
||||
"try_files /index.html =404;",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := renderPagesRootLocationBlock(tt.deployment, routeLimitConfig{}, false, "", "")
|
||||
if len(tt.expected) == 1 && tt.expected[0] == "" {
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty output, got: %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, exp := range tt.expected {
|
||||
if !strings.Contains(got, exp) {
|
||||
t.Errorf("expected output to contain %q, but got:\n%s", exp, got)
|
||||
}
|
||||
}
|
||||
for _, unexp := range tt.unexpected {
|
||||
if strings.Contains(got, unexp) {
|
||||
t.Errorf("expected output NOT to contain %q, but got:\n%s", unexp, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRouteConfigPagesWithoutSPAFallbackServesRoot(t *testing.T) {
|
||||
doc := Document{
|
||||
Routes: []Route{
|
||||
{
|
||||
ID: 1,
|
||||
SiteName: "speedtest.example.com",
|
||||
Domains: []string{"speedtest.example.com"},
|
||||
UpstreamType: "pages",
|
||||
EnableHTTPS: false,
|
||||
PagesDeployment: &PagesDeployment{
|
||||
LocalRoot: "/data/var/lib/openflare/pages/projects/1/current",
|
||||
EntryFile: "index.html",
|
||||
SPAFallbackEnabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
routeConfig, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRouteConfig() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(routeConfig, "location = / {") {
|
||||
t.Fatalf("expected root location block, got:\n%s", routeConfig)
|
||||
}
|
||||
if !strings.Contains(routeConfig, "try_files /index.html =404;") {
|
||||
t.Fatalf("expected root try_files for entry file, got:\n%s", routeConfig)
|
||||
}
|
||||
if !strings.Contains(routeConfig, "try_files $uri $uri/ =404;") {
|
||||
t.Fatalf("expected static file try_files in location /, got:\n%s", routeConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRouteConfigPagesWithSPAFallbackServesRoot(t *testing.T) {
|
||||
doc := Document{
|
||||
Routes: []Route{
|
||||
{
|
||||
ID: 1,
|
||||
SiteName: "speedtest.example.com",
|
||||
Domains: []string{"speedtest.example.com"},
|
||||
UpstreamType: "pages",
|
||||
EnableHTTPS: false,
|
||||
PagesDeployment: &PagesDeployment{
|
||||
LocalRoot: "/data/var/lib/openflare/pages/projects/1/current",
|
||||
EntryFile: "index.html",
|
||||
SPAFallbackEnabled: true,
|
||||
SPAFallbackPath: "/index.html",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
routeConfig, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRouteConfig() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(routeConfig, "location = / {") {
|
||||
t.Fatalf("expected root location block for spa fallback, got:\n%s", routeConfig)
|
||||
}
|
||||
if !strings.Contains(routeConfig, "try_files $uri $uri/ /index.html;") {
|
||||
t.Fatalf("expected spa fallback try_files in location /, got:\n%s", routeConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRouteCachePolicyConditionStaticDefault(t *testing.T) {
|
||||
staticBlock := renderRouteCachePolicyCondition(routeCacheConfig{Enabled: true, Policy: "static"})
|
||||
if staticBlock == "" {
|
||||
t.Fatal("static policy should emit a path condition")
|
||||
}
|
||||
if !strings.Contains(staticBlock, "css") || !strings.Contains(staticBlock, "woff2") {
|
||||
t.Fatalf("static policy should include default extensions, got:\n%s", staticBlock)
|
||||
}
|
||||
if !strings.Contains(staticBlock, "map") || !strings.Contains(staticBlock, "mjs") {
|
||||
t.Fatalf("static policy should include map and mjs, got:\n%s", staticBlock)
|
||||
}
|
||||
if strings.Contains(staticBlock, "html") {
|
||||
t.Fatalf("static policy must not include html, got:\n%s", staticBlock)
|
||||
}
|
||||
// Pattern is \.(?:css|js|...)$ — reject bare "json" as an alternation token.
|
||||
if strings.Contains(staticBlock, "|json|") || strings.Contains(staticBlock, "|json)") || strings.Contains(staticBlock, "(?:json|") {
|
||||
t.Fatalf("static policy must not include json (CF default), got:\n%s", staticBlock)
|
||||
}
|
||||
|
||||
// Legacy empty/url = all (wide cache after method bypass).
|
||||
emptyPolicy := renderRouteCachePolicyCondition(routeCacheConfig{Enabled: true, Policy: ""})
|
||||
if emptyPolicy != "" {
|
||||
t.Fatalf("empty policy should map to all (no path filter), got %q", emptyPolicy)
|
||||
}
|
||||
|
||||
allBlock := renderRouteCachePolicyCondition(routeCacheConfig{Enabled: true, Policy: "all"})
|
||||
if allBlock != "" {
|
||||
t.Fatalf("all policy should not add path condition, got %q", allBlock)
|
||||
}
|
||||
urlBlock := renderRouteCachePolicyCondition(routeCacheConfig{Enabled: true, Policy: "url"})
|
||||
if urlBlock != "" {
|
||||
t.Fatalf("legacy url policy should map to all, got %q", urlBlock)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRouteCacheBlockAlignsCloudflareDefaults(t *testing.T) {
|
||||
block := renderRouteCacheBlock(
|
||||
routeCacheConfig{Enabled: true, Policy: "static"},
|
||||
ConfigSnapshot{CacheEnabled: true},
|
||||
)
|
||||
if !strings.Contains(block, "proxy_cache openflare_cache") {
|
||||
t.Fatalf("expected proxy_cache, got:\n%s", block)
|
||||
}
|
||||
if !strings.Contains(block, "\\.(?:") {
|
||||
t.Fatalf("expected static suffix pattern, got:\n%s", block)
|
||||
}
|
||||
if !strings.Contains(block, "request_method != GET") {
|
||||
t.Fatalf("expected method bypass for non-GET, got:\n%s", block)
|
||||
}
|
||||
if strings.Contains(block, "$http_authorization") {
|
||||
t.Fatalf("must not bypass on Authorization (CF-aligned), got:\n%s", block)
|
||||
}
|
||||
if strings.Contains(block, "$http_cookie") {
|
||||
t.Fatalf("must not bypass on Cookie (CF-aligned), got:\n%s", block)
|
||||
}
|
||||
if strings.Contains(block, "$http_cache_control") {
|
||||
t.Fatalf("must not bypass on request Cache-Control (CF-aligned), got:\n%s", block)
|
||||
}
|
||||
if !strings.Contains(block, "proxy_no_cache $openflare_skip_cache $upstream_http_set_cookie") {
|
||||
t.Fatalf("expected Set-Cookie no-cache gate, got:\n%s", block)
|
||||
}
|
||||
if !strings.Contains(block, "proxy_cache_valid 200 206 301 120m") {
|
||||
t.Fatalf("expected default Edge TTL for 200/206/301, got:\n%s", block)
|
||||
}
|
||||
if !strings.Contains(block, "proxy_cache_valid 302 303 20m") {
|
||||
t.Fatalf("expected default Edge TTL for 302/303, got:\n%s", block)
|
||||
}
|
||||
if !strings.Contains(block, "proxy_cache_valid 404 410 3m") {
|
||||
t.Fatalf("expected default Edge TTL for 404/410, got:\n%s", block)
|
||||
}
|
||||
if !strings.Contains(block, "proxy_cache_bypass $openflare_skip_cache") {
|
||||
t.Fatalf("expected proxy_cache_bypass on skip flag only, got:\n%s", block)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeRouteLimitConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
route Route
|
||||
cfg ConfigSnapshot
|
||||
want routeLimitConfig
|
||||
}{
|
||||
{
|
||||
name: "both zero off",
|
||||
route: Route{},
|
||||
cfg: ConfigSnapshot{},
|
||||
want: routeLimitConfig{},
|
||||
},
|
||||
{
|
||||
name: "inherit all defaults",
|
||||
route: Route{},
|
||||
cfg: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 100,
|
||||
DefaultLimitConnPerIP: 10,
|
||||
DefaultLimitRate: "512k",
|
||||
DefaultLimitReqPerIP: "10r/s",
|
||||
},
|
||||
want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 10, LimitRate: "512k", LimitReqPerIP: "10r/s"},
|
||||
},
|
||||
{
|
||||
name: "explicit off ignores default",
|
||||
route: Route{LimitConnPerServer: -1, LimitConnPerIP: -1, LimitRate: "-1", LimitReqPerIP: "-1"},
|
||||
cfg: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 100,
|
||||
DefaultLimitConnPerIP: 10,
|
||||
DefaultLimitRate: "512k",
|
||||
DefaultLimitReqPerIP: "10r/s",
|
||||
},
|
||||
want: routeLimitConfig{},
|
||||
},
|
||||
{
|
||||
name: "route overrides default",
|
||||
route: Route{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m"},
|
||||
cfg: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 100,
|
||||
DefaultLimitConnPerIP: 10,
|
||||
DefaultLimitRate: "512k",
|
||||
DefaultLimitReqPerIP: "10r/s",
|
||||
},
|
||||
want: routeLimitConfig{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m", LimitReqPerIP: "10r/s"},
|
||||
},
|
||||
{
|
||||
name: "partial inherit",
|
||||
route: Route{LimitConnPerServer: 0, LimitConnPerIP: -1, LimitRate: ""},
|
||||
cfg: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 100,
|
||||
DefaultLimitConnPerIP: 10,
|
||||
DefaultLimitRate: "256k",
|
||||
DefaultLimitReqPerIP: "10r/s",
|
||||
},
|
||||
want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 0, LimitRate: "256k", LimitReqPerIP: "10r/s"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := mergeRouteLimitConfig(tc.route, tc.cfg)
|
||||
if got != tc.want {
|
||||
t.Fatalf("mergeRouteLimitConfig() = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRouteConfigAppliesDefaultLimits(t *testing.T) {
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
SiteName: "example.com",
|
||||
Domains: []string{"example.com"},
|
||||
Enabled: true,
|
||||
OriginURL: "http://127.0.0.1:8080",
|
||||
Upstreams: []string{"http://127.0.0.1:8080"},
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 120,
|
||||
DefaultLimitConnPerIP: 12,
|
||||
DefaultLimitRate: "512k",
|
||||
DefaultLimitReqPerIP: "10r/s",
|
||||
},
|
||||
}
|
||||
rendered, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRouteConfig() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"limit_conn openflare_conn_per_server 120;",
|
||||
"limit_conn openflare_conn_per_ip 12;",
|
||||
"limit_rate 512k;",
|
||||
"limit_req zone=openflare_req_10rs burst=20 nodelay;",
|
||||
"limit_req_status 429;",
|
||||
} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("expected %q in route config, got:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMainConfigEmitsLimitReqZonesByEffectiveRate(t *testing.T) {
|
||||
doc := Document{
|
||||
Routes: []Route{
|
||||
{
|
||||
SiteName: "a.example.com",
|
||||
Domains: []string{"a.example.com"},
|
||||
Enabled: true,
|
||||
OriginURL: "http://127.0.0.1:8080",
|
||||
Upstreams: []string{"http://127.0.0.1:8080"},
|
||||
},
|
||||
{
|
||||
SiteName: "b.example.com",
|
||||
Domains: []string{"b.example.com"},
|
||||
Enabled: true,
|
||||
OriginURL: "http://127.0.0.1:8081",
|
||||
Upstreams: []string{"http://127.0.0.1:8081"},
|
||||
LimitReqPerIP: "5r/s",
|
||||
},
|
||||
{
|
||||
SiteName: "c.example.com",
|
||||
Domains: []string{"c.example.com"},
|
||||
Enabled: true,
|
||||
OriginURL: "http://127.0.0.1:8082",
|
||||
Upstreams: []string{"http://127.0.0.1:8082"},
|
||||
LimitReqPerIP: "-1",
|
||||
},
|
||||
},
|
||||
OpenRestyConfig: ConfigSnapshot{
|
||||
DefaultLimitReqPerIP: "10r/s",
|
||||
},
|
||||
}
|
||||
mainConfig := RenderMainConfig(doc)
|
||||
for _, want := range []string{
|
||||
"limit_req_zone $openflare_waf_site$binary_remote_addr zone=openflare_req_10rs:10m rate=10r/s;",
|
||||
"limit_req_zone $openflare_waf_site$binary_remote_addr zone=openflare_req_5rs:10m rate=5r/s;",
|
||||
} {
|
||||
if !strings.Contains(mainConfig, want) {
|
||||
t.Fatalf("expected %q in main config, got:\n%s", want, mainConfig)
|
||||
}
|
||||
}
|
||||
if strings.Contains(mainConfig, "openflare_req_per_ip") {
|
||||
t.Fatalf("unexpected legacy zone name in main config:\n%s", mainConfig)
|
||||
}
|
||||
|
||||
routeConfig, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRouteConfig() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(routeConfig, "limit_req zone=openflare_req_10rs burst=20 nodelay;") {
|
||||
t.Fatalf("expected inherited zone on route a, got:\n%s", routeConfig)
|
||||
}
|
||||
if !strings.Contains(routeConfig, "limit_req zone=openflare_req_5rs burst=10 nodelay;") {
|
||||
t.Fatalf("expected custom zone on route b, got:\n%s", routeConfig)
|
||||
}
|
||||
// route c is off: count limit_req lines should equal 2 routes * (http+https? depends) — assert c server has no limit_req by site name block is hard; ensure -1 route does not force extra zones
|
||||
if strings.Count(mainConfig, "limit_req_zone") != 2 {
|
||||
t.Fatalf("expected exactly 2 limit_req_zone lines, got main:\n%s", mainConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRouteConfigExplicitOffSkipsDefaultLimits(t *testing.T) {
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
SiteName: "example.com",
|
||||
Domains: []string{"example.com"},
|
||||
Enabled: true,
|
||||
OriginURL: "http://127.0.0.1:8080",
|
||||
Upstreams: []string{"http://127.0.0.1:8080"},
|
||||
LimitConnPerServer: -1,
|
||||
LimitConnPerIP: -1,
|
||||
LimitRate: "-1",
|
||||
LimitReqPerIP: "-1",
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 120,
|
||||
DefaultLimitConnPerIP: 12,
|
||||
DefaultLimitRate: "512k",
|
||||
DefaultLimitReqPerIP: "10r/s",
|
||||
},
|
||||
}
|
||||
rendered, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRouteConfig() error = %v", err)
|
||||
}
|
||||
if strings.Contains(rendered, "limit_conn") || strings.Contains(rendered, "limit_rate") || strings.Contains(rendered, "limit_req") {
|
||||
t.Fatalf("expected no limit directives, got:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package openresty
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SW location strings and Lua module paths used by the Service Worker offline fallback.
|
||||
const (
|
||||
SWJSLocation = "location = /sw.js"
|
||||
SWOfflineLocation = "location = /offline.html"
|
||||
SWChallengeLua = "sw/challenge.lua"
|
||||
SWRuntimeLua = "sw/runtime.lua"
|
||||
swDirPrefix = "sw/"
|
||||
)
|
||||
|
||||
// DefaultSWOfflineHTML is the built-in contact page shown when the domain is blocked.
|
||||
const DefaultSWOfflineHTML = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>网站暂时无法访问 | 联系站长</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #ffffff; color: #333333; height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; padding: 48px 24px; }
|
||||
h1 { font-size: 28px; font-weight: 700; margin-bottom: 16px; }
|
||||
p { font-size: 16px; line-height: 1.7; color: #666666; max-width: 520px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>网站暂时无法访问</h1>
|
||||
<p>当前域名暂时无法从网络访问。请通过其他方式联系网站管理员获取最新访问入口。</p>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
// EffectiveSWOfflineHTML returns custom HTML when set, otherwise the built-in default.
|
||||
func EffectiveSWOfflineHTML(cfg ConfigSnapshot) string {
|
||||
if strings.TrimSpace(cfg.SWOfflineHTML) == "" {
|
||||
return DefaultSWOfflineHTML
|
||||
}
|
||||
return cfg.SWOfflineHTML
|
||||
}
|
||||
|
||||
// ServiceWorkerSupportFiles returns the sw.js script and offline contact page.
|
||||
// The sw.js content is derived from the offline HTML (see defaultSWJS) so that
|
||||
// HTML-only edits change the script, forcing browsers to re-install the worker
|
||||
// and re-cache the updated page.
|
||||
func ServiceWorkerSupportFiles(cfg ConfigSnapshot) []SupportFile {
|
||||
if !cfg.SWOfflineEnabled {
|
||||
return nil
|
||||
}
|
||||
html := EffectiveSWOfflineHTML(cfg)
|
||||
return []SupportFile{
|
||||
{Path: swDirPrefix + "sw.js", Content: defaultSWJS(html)},
|
||||
{Path: swDirPrefix + "offline.html", Content: html},
|
||||
}
|
||||
}
|
||||
|
||||
// swJSTemplate is the service worker body. The cache name is replaced with a
|
||||
// version derived from the offline HTML: editing the HTML changes the cache
|
||||
// name, which changes the sw.js bytes, which makes the browser re-install the
|
||||
// worker (sw.js is served with Cache-Control: no-cache) and fetch the new
|
||||
// /offline.html into the fresh cache during install.
|
||||
const swJSTemplate = `var CACHE = "__CACHE_NAME__";
|
||||
var OFFLINE = "/offline.html";
|
||||
self.addEventListener("install", function (e) {
|
||||
e.waitUntil(caches.open(CACHE).then(function (c) { return c.addAll([OFFLINE]); }));
|
||||
self.skipWaiting();
|
||||
});
|
||||
self.addEventListener("activate", function (e) {
|
||||
e.waitUntil(caches.keys().then(function (keys) {
|
||||
return Promise.all(keys.filter(function (k) { return k.indexOf("openflare-offline-") === 0 && k !== CACHE; }).map(function (k) { return caches.delete(k); }));
|
||||
}));
|
||||
self.clients.claim();
|
||||
});
|
||||
self.addEventListener("fetch", function (e) {
|
||||
if (e.request.method !== "GET" || e.request.mode !== "navigate") { return; }
|
||||
e.respondWith(
|
||||
fetch(e.request).catch(function () {
|
||||
return caches.match(e.request).then(function (r) { return r || caches.match(OFFLINE); });
|
||||
})
|
||||
);
|
||||
});
|
||||
`
|
||||
|
||||
func defaultSWJS(offlineHTML string) string {
|
||||
sum := sha256.Sum256([]byte(offlineHTML))
|
||||
version := hex.EncodeToString(sum[:])[:12]
|
||||
return strings.ReplaceAll(swJSTemplate, "__CACHE_NAME__", "openflare-offline-"+version)
|
||||
}
|
||||
|
||||
// routeSWEnabled returns true when SW offline fallback applies to this route.
|
||||
func routeSWEnabled(routeDomains []string, cfg ConfigSnapshot) bool {
|
||||
if !cfg.SWOfflineEnabled || len(cfg.SWOfflineDomains) == 0 {
|
||||
return false
|
||||
}
|
||||
scope := make(map[string]struct{}, len(cfg.SWOfflineDomains))
|
||||
for _, d := range cfg.SWOfflineDomains {
|
||||
scope[d] = struct{}{}
|
||||
}
|
||||
for _, d := range routeDomains {
|
||||
if _, ok := scope[d]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// renderServiceWorkerChallenger emits SW static locations and the homepage
|
||||
// challenge intercept for HTTPS server blocks.
|
||||
func renderServiceWorkerChallenger(_ ConfigSnapshot) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("\n location = /sw.js {\n")
|
||||
builder.WriteString(" alias " + SWDirPlaceholder + "/sw.js;\n")
|
||||
builder.WriteString(" default_type application/javascript;\n")
|
||||
builder.WriteString(" add_header Service-Worker-Allowed /;\n")
|
||||
builder.WriteString(" add_header Cache-Control \"no-cache\";\n")
|
||||
builder.WriteString(" }\n\n")
|
||||
builder.WriteString(" location = /offline.html {\n")
|
||||
builder.WriteString(" alias " + SWDirPlaceholder + "/offline.html;\n")
|
||||
builder.WriteString(" default_type text/html;\n")
|
||||
builder.WriteString(" add_header Cache-Control \"no-cache\";\n")
|
||||
builder.WriteString(" }\n\n")
|
||||
builder.WriteString(" location = /__openflare_sw_challenge {\n")
|
||||
builder.WriteString(" internal;\n")
|
||||
builder.WriteString(" # hit when sw.runtime.check() intercepts the homepage in the access phase\n")
|
||||
builder.WriteString(" content_by_lua_file " + SWDirPlaceholder + "/challenge.lua;\n")
|
||||
builder.WriteString(" }\n")
|
||||
return builder.String()
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package openresty
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEffectiveSWOfflineHTML(t *testing.T) {
|
||||
if got := EffectiveSWOfflineHTML(ConfigSnapshot{}); got != DefaultSWOfflineHTML {
|
||||
t.Fatalf("default mismatch")
|
||||
}
|
||||
custom := "<html>custom</html>"
|
||||
if got := EffectiveSWOfflineHTML(ConfigSnapshot{SWOfflineHTML: custom}); got != custom {
|
||||
t.Fatalf("custom mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceWorkerSupportFiles(t *testing.T) {
|
||||
disabled := ServiceWorkerSupportFiles(ConfigSnapshot{})
|
||||
if disabled != nil {
|
||||
t.Fatalf("expected nil when disabled, got %v", disabled)
|
||||
}
|
||||
enabled := ServiceWorkerSupportFiles(ConfigSnapshot{SWOfflineEnabled: true})
|
||||
if len(enabled) != 2 {
|
||||
t.Fatalf("expected 2 support files, got %d", len(enabled))
|
||||
}
|
||||
paths := map[string]string{}
|
||||
for _, f := range enabled {
|
||||
paths[f.Path] = f.Content
|
||||
}
|
||||
if _, ok := paths["sw/sw.js"]; !ok {
|
||||
t.Fatalf("missing sw/sw.js")
|
||||
}
|
||||
if _, ok := paths["sw/offline.html"]; !ok {
|
||||
t.Fatalf("missing sw/offline.html")
|
||||
}
|
||||
if paths["sw/offline.html"] != DefaultSWOfflineHTML {
|
||||
t.Fatalf("expected built-in offline html, got %q", paths["sw/offline.html"])
|
||||
}
|
||||
if !strings.Contains(paths["sw/sw.js"], `var OFFLINE = "/offline.html";`) {
|
||||
t.Fatalf("offline path must stay stable (exact location match), got:\n%s", paths["sw/sw.js"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSWJSCacheNameTracksOfflineHTML(t *testing.T) {
|
||||
htmlA := "<html>page-a</html>"
|
||||
htmlB := "<html>page-b</html>"
|
||||
jsA := defaultSWJS(htmlA)
|
||||
jsB := defaultSWJS(htmlB)
|
||||
if jsA == jsB {
|
||||
t.Fatal("sw.js content must change when the offline HTML changes")
|
||||
}
|
||||
extractCache := func(js string) string {
|
||||
const prefix = `var CACHE = "`
|
||||
start := strings.Index(js, prefix)
|
||||
if start < 0 {
|
||||
t.Fatalf("missing cache name in:\n%s", js)
|
||||
}
|
||||
rest := js[start+len(prefix):]
|
||||
end := strings.Index(rest, `"`)
|
||||
if end < 0 {
|
||||
t.Fatalf("unterminated cache name in:\n%s", js)
|
||||
}
|
||||
return rest[:end]
|
||||
}
|
||||
cacheA := extractCache(jsA)
|
||||
cacheB := extractCache(jsB)
|
||||
if cacheA == cacheB {
|
||||
t.Fatalf("cache names must differ per HTML, got %q", cacheA)
|
||||
}
|
||||
if !strings.HasPrefix(cacheA, "openflare-offline-") {
|
||||
t.Fatalf("unexpected cache name %q", cacheA)
|
||||
}
|
||||
for _, js := range []string{jsA, jsB} {
|
||||
if strings.Contains(js, "openflare-offline-v1") {
|
||||
t.Fatalf("static cache name must not remain, got:\n%s", js)
|
||||
}
|
||||
if strings.Contains(js, "__CACHE_NAME__") {
|
||||
t.Fatalf("template placeholder leaked into sw.js:\n%s", js)
|
||||
}
|
||||
}
|
||||
// Same HTML must produce identical sw.js (deterministic checksum).
|
||||
if defaultSWJS(htmlA) != jsA {
|
||||
t.Fatal("sw.js must be deterministic for identical HTML")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAccessBlockWithSWMergesSingleBlock(t *testing.T) {
|
||||
for _, powEnabled := range []bool{false, true} {
|
||||
name := "pow-disabled"
|
||||
if powEnabled {
|
||||
name = "pow-enabled"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := renderAccessBlockWithSW("example.com", powEnabled, ConfigSnapshot{})
|
||||
if n := strings.Count(got, "access_by_lua_block"); n != 1 {
|
||||
t.Fatalf("expected exactly 1 access_by_lua_block, got %d:\n%s", n, got)
|
||||
}
|
||||
if !strings.Contains(got, `require("sw.runtime").check()`) {
|
||||
t.Fatalf("expected sw.runtime check, got:\n%s", got)
|
||||
}
|
||||
wafIdx := strings.Index(got, `require("waf.runtime").check()`)
|
||||
swIdx := strings.Index(got, `require("sw.runtime").check()`)
|
||||
if wafIdx < 0 || swIdx < 0 || wafIdx > swIdx {
|
||||
t.Fatalf("expected waf.runtime before sw.runtime, got:\n%s", got)
|
||||
}
|
||||
if powEnabled {
|
||||
powIdx := strings.Index(got, `require("pow.runtime").check()`)
|
||||
if powIdx < 0 || wafIdx > powIdx || powIdx > swIdx {
|
||||
t.Fatalf("expected waf.runtime before pow.runtime before sw.runtime, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderServiceWorkerChallengerHTTPExclusion(t *testing.T) {
|
||||
cfg := ConfigSnapshot{SWOfflineEnabled: true}
|
||||
for name, rendered := range map[string]string{
|
||||
"proxy": renderHTTPProxyServer("example.com", "example.com", "http://127.0.0.1:8080", "", nil, routeCacheConfig{}, routeLimitConfig{}, routeUpstreamConfig{}, false, false, "", "", false, cfg),
|
||||
"pages": renderHTTPPagesServer("example.com", "example.com", nil, routeLimitConfig{}, false, false, "", "", false, cfg),
|
||||
"https": renderHTTPSServer("example.com", "example.com", "http://127.0.0.1:8080", "", 1, nil, routeCacheConfig{}, routeLimitConfig{}, routeUpstreamConfig{}, false, false, "", "", true, cfg),
|
||||
"hpages": renderHTTPSPagesServer("example.com", "example.com", 1, nil, routeLimitConfig{}, false, false, "", "", true, cfg),
|
||||
} {
|
||||
if strings.Contains(rendered, "access_by_lua_block") && strings.Count(rendered, "access_by_lua_block") != 1 {
|
||||
t.Fatalf("%s: expected at most one access block, got:\n%s", name, rendered)
|
||||
}
|
||||
}
|
||||
httpProxy := renderHTTPProxyServer("example.com", "example.com", "http://127.0.0.1:8080", "", nil, routeCacheConfig{}, routeLimitConfig{}, routeUpstreamConfig{}, false, false, "", "", false, cfg)
|
||||
if strings.Contains(httpProxy, "sw.runtime") || strings.Contains(httpProxy, "openflare_sw_challenge") || strings.Contains(httpProxy, "location = /sw.js") {
|
||||
t.Fatalf("HTTP proxy server must not carry SW intercept, got:\n%s", httpProxy)
|
||||
}
|
||||
httpPages := renderHTTPPagesServer("example.com", "example.com", nil, routeLimitConfig{}, false, false, "", "", false, cfg)
|
||||
if strings.Contains(httpPages, "sw.runtime") || strings.Contains(httpPages, "openflare_sw_challenge") || strings.Contains(httpPages, "location = /sw.js") {
|
||||
t.Fatalf("HTTP pages server must not carry SW intercept, got:\n%s", httpPages)
|
||||
}
|
||||
httpsProxy := renderHTTPSServer("example.com", "example.com", "http://127.0.0.1:8080", "", 1, nil, routeCacheConfig{}, routeLimitConfig{}, routeUpstreamConfig{}, false, false, "", "", true, cfg)
|
||||
for _, want := range []string{"sw.runtime", "location = /sw.js", "location = /offline.html", "__openflare_sw_challenge"} {
|
||||
if !strings.Contains(httpsProxy, want) {
|
||||
t.Fatalf("HTTPS proxy server missing %q, got:\n%s", want, httpsProxy)
|
||||
}
|
||||
}
|
||||
httpsPages := renderHTTPSPagesServer("example.com", "example.com", 1, nil, routeLimitConfig{}, false, false, "", "", true, cfg)
|
||||
for _, want := range []string{"sw.runtime", "location = /sw.js", "location = /offline.html", "__openflare_sw_challenge"} {
|
||||
if !strings.Contains(httpsPages, want) {
|
||||
t.Fatalf("HTTPS pages server missing %q, got:\n%s", want, httpsPages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteSWEnabled(t *testing.T) {
|
||||
cfgOff := ConfigSnapshot{SWOfflineEnabled: false, SWOfflineDomains: []string{"example.com"}}
|
||||
if routeSWEnabled([]string{"example.com"}, cfgOff) {
|
||||
t.Fatal("expected false when master switch off")
|
||||
}
|
||||
cfgEmpty := ConfigSnapshot{SWOfflineEnabled: true, SWOfflineDomains: nil}
|
||||
if routeSWEnabled([]string{"example.com"}, cfgEmpty) {
|
||||
t.Fatal("expected false when scope empty")
|
||||
}
|
||||
cfgHit := ConfigSnapshot{SWOfflineEnabled: true, SWOfflineDomains: []string{"example.com", "other.com"}}
|
||||
if !routeSWEnabled([]string{"api.example.com", "example.com"}, cfgHit) {
|
||||
t.Fatal("expected true on single domain intersection")
|
||||
}
|
||||
if routeSWEnabled([]string{"api.example.com", "third.com"}, cfgHit) {
|
||||
t.Fatal("expected false on no intersection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHTTPSServerSWScope(t *testing.T) {
|
||||
render := func(swEnabled bool) string {
|
||||
return renderHTTPSServer("example.com", "example.com", "http://127.0.0.1:8080", "", 1, nil, routeCacheConfig{}, routeLimitConfig{}, routeUpstreamConfig{}, false, false, "", "", swEnabled, ConfigSnapshot{SWOfflineEnabled: true})
|
||||
}
|
||||
hit := render(routeSWEnabled([]string{"example.com"}, ConfigSnapshot{SWOfflineEnabled: true, SWOfflineDomains: []string{"example.com"}}))
|
||||
for _, want := range []string{`require("sw.runtime").check()`, "location = /sw.js", "location = /offline.html", "__openflare_sw_challenge"} {
|
||||
if !strings.Contains(hit, want) {
|
||||
t.Fatalf("scoped HTTPS server missing %q, got:\n%s", want, hit)
|
||||
}
|
||||
}
|
||||
miss := render(routeSWEnabled([]string{"example.com"}, ConfigSnapshot{SWOfflineEnabled: true, SWOfflineDomains: []string{"other.com"}}))
|
||||
for _, notWant := range []string{`require("sw.runtime").check()`, "location = /sw.js", "location = /offline.html", "__openflare_sw_challenge"} {
|
||||
if strings.Contains(miss, notWant) {
|
||||
t.Fatalf("out-of-scope HTTPS server must not carry %q, got:\n%s", notWant, miss)
|
||||
}
|
||||
}
|
||||
if miss != renderHTTPSServer("example.com", "example.com", "http://127.0.0.1:8080", "", 1, nil, routeCacheConfig{}, routeLimitConfig{}, routeUpstreamConfig{}, false, false, "", "", false, ConfigSnapshot{}) {
|
||||
t.Fatalf("out-of-scope HTTPS server must match pre-feature bytes, got:\n%s", miss)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRouteConfigSWSCOPEPerCertPartition(t *testing.T) {
|
||||
doc := Document{
|
||||
OpenRestyConfig: ConfigSnapshot{
|
||||
SWOfflineEnabled: true,
|
||||
SWOfflineDomains: []string{"a.com"},
|
||||
},
|
||||
Routes: []Route{{
|
||||
ID: 1,
|
||||
SiteName: "multi.example.com",
|
||||
Domains: []string{"a.com", "b.com"},
|
||||
OriginURL: "http://127.0.0.1:8080",
|
||||
EnableHTTPS: true,
|
||||
DomainCertIDs: []uint{11, 22},
|
||||
}},
|
||||
}
|
||||
certFiles := []SupportFile{
|
||||
{Path: "11.crt", Content: testCertificatePEMForDomain(t, "a.com")},
|
||||
{Path: "22.crt", Content: testCertificatePEMForDomain(t, "b.com")},
|
||||
}
|
||||
rendered, err := RenderRouteConfig(doc, certFiles)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRouteConfig() error = %v", err)
|
||||
}
|
||||
inScope := httpsServerBlockForCert(t, rendered, 11)
|
||||
if !strings.Contains(inScope, "server_name a.com;") {
|
||||
t.Fatalf("cert 11 block must serve a.com, got:\n%s", inScope)
|
||||
}
|
||||
for _, want := range []string{`require("sw.runtime").check()`, "location = /sw.js", "location = /offline.html", "__openflare_sw_challenge"} {
|
||||
if !strings.Contains(inScope, want) {
|
||||
t.Fatalf("in-scope cert partition (a.com) missing %q, got:\n%s", want, inScope)
|
||||
}
|
||||
}
|
||||
outOfScope := httpsServerBlockForCert(t, rendered, 22)
|
||||
if !strings.Contains(outOfScope, "server_name b.com;") {
|
||||
t.Fatalf("cert 22 block must serve b.com, got:\n%s", outOfScope)
|
||||
}
|
||||
for _, notWant := range []string{`require("sw.runtime").check()`, "location = /sw.js", "location = /offline.html", "__openflare_sw_challenge"} {
|
||||
if strings.Contains(outOfScope, notWant) {
|
||||
t.Fatalf("out-of-scope cert partition (b.com) must not carry %q, got:\n%s", notWant, outOfScope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func httpsServerBlockForCert(t *testing.T, rendered string, certID uint) string {
|
||||
t.Helper()
|
||||
marker := fmt.Sprintf("ssl_certificate %s/%d.crt;", CertDirPlaceholder, certID)
|
||||
for _, block := range strings.Split(rendered, "server {") {
|
||||
if strings.Contains(block, marker) {
|
||||
return "server {" + block
|
||||
}
|
||||
}
|
||||
t.Fatalf("no server block found for cert %d in:\n%s", certID, rendered)
|
||||
return ""
|
||||
}
|
||||
|
||||
func testCertificatePEMForDomain(t *testing.T, domain string) string {
|
||||
t.Helper()
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa.GenerateKey() error = %v", err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: domain},
|
||||
DNSNames: []string{domain},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("x509.CreateCertificate() error = %v", err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||
}
|
||||
|
||||
func TestRenderServiceWorkerChallenger(t *testing.T) {
|
||||
got := renderServiceWorkerChallenger(ConfigSnapshot{SWOfflineEnabled: true})
|
||||
for _, want := range []string{"location = /sw.js", "location = /offline.html", "challenge.lua", "content_by_lua"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("challenger missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package openresty
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// StatusCodeMin is the lowest HTTP status code accepted for origin error pages.
|
||||
StatusCodeMin = 400
|
||||
// StatusCodeMax is the highest HTTP status code accepted for origin error pages.
|
||||
StatusCodeMax = 599
|
||||
)
|
||||
|
||||
// ParseStatusCodeTag parses a single tag such as "502" or "500-599".
|
||||
// Bounds must fall within StatusCodeMin–StatusCodeMax inclusive.
|
||||
func ParseStatusCodeTag(tag string) (lo, hi int, err error) {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
return 0, 0, errors.New("状态码标签不能为空")
|
||||
}
|
||||
if before, after, ok := strings.Cut(tag, "-"); ok {
|
||||
lo, err = strconv.Atoi(before)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("无效状态码区间: %s", tag)
|
||||
}
|
||||
hi, err = strconv.Atoi(after)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("无效状态码区间: %s", tag)
|
||||
}
|
||||
} else {
|
||||
lo, err = strconv.Atoi(tag)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("无效状态码: %s", tag)
|
||||
}
|
||||
hi = lo
|
||||
}
|
||||
if lo > hi {
|
||||
return 0, 0, fmt.Errorf("状态码区间左右端点反序: %s", tag)
|
||||
}
|
||||
if lo < StatusCodeMin || hi > StatusCodeMax {
|
||||
return 0, 0, fmt.Errorf("状态码须在 %d–%d: %s", StatusCodeMin, StatusCodeMax, tag)
|
||||
}
|
||||
return lo, hi, nil
|
||||
}
|
||||
|
||||
// ExpandStatusCodeTags expands status code tags into a sorted unique list of integers.
|
||||
func ExpandStatusCodeTags(tags []string) ([]int, error) {
|
||||
set := map[int]struct{}{}
|
||||
for _, tag := range tags {
|
||||
lo, hi, err := ParseStatusCodeTag(tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for c := lo; c <= hi; c++ {
|
||||
set[c] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]int, 0, len(set))
|
||||
for c := range set {
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package openresty
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExpandStatusCodeTags(t *testing.T) {
|
||||
t.Parallel()
|
||||
codes, err := ExpandStatusCodeTags([]string{"500-502", "522", "501"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// want sorted unique: 500,501,502,522
|
||||
if len(codes) != 4 || codes[0] != 500 || codes[3] != 522 {
|
||||
t.Fatalf("got %v", codes)
|
||||
}
|
||||
_, err = ExpandStatusCodeTags([]string{"399"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
_, err = ExpandStatusCodeTags([]string{"503-500"})
|
||||
if err == nil {
|
||||
t.Fatal("expected reverse range error")
|
||||
}
|
||||
_, err = ExpandStatusCodeTags([]string{"5xx"})
|
||||
if err == nil {
|
||||
t.Fatal("expected syntax error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package openresty
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Placeholder constants used as sentinel values in rendered OpenResty config
|
||||
// files; the deploy process replaces them with real paths before reload.
|
||||
const (
|
||||
CertDirPlaceholder = "__OPENFLARE_CERT_DIR__"
|
||||
RouteConfigPlaceholder = "__OPENFLARE_ROUTE_CONFIG__"
|
||||
AccessLogPlaceholder = "__OPENFLARE_ACCESS_LOG__"
|
||||
ErrorLogPlaceholder = "__OPENFLARE_ERROR_LOG__"
|
||||
PIDPathPlaceholder = "__OPENFLARE_PID_PATH__"
|
||||
NginxCacheDirPlaceholder = "__OPENFLARE_NGINX_CACHE_DIR__"
|
||||
ProxyCachePathPlaceholder = "__OPENFLARE_PROXY_CACHE_PATH__"
|
||||
LuaDirPlaceholder = "__OPENFLARE_LUA_DIR__"
|
||||
ObservabilityListenPlaceholder = "__OPENFLARE_OBSERVABILITY_LISTEN__"
|
||||
ObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__"
|
||||
PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
|
||||
PagesDirPlaceholder = "__OPENFLARE_PAGES_DIR__"
|
||||
ErrorPageTmplPlaceholder = "__OPENFLARE_ERROR_PAGE_TMPL__"
|
||||
SWDirPlaceholder = "__OPENFLARE_SW_DIR__"
|
||||
|
||||
SourceConfigFileName = "openresty_config.json"
|
||||
)
|
||||
|
||||
const (
|
||||
cachePolicyStatic = "static"
|
||||
cachePolicyAll = "all"
|
||||
cachePolicyURL = "url" // legacy alias of all
|
||||
cachePolicySuffix = "suffix"
|
||||
cachePolicyPathPrefix = "path_prefix"
|
||||
cachePolicyPathExact = "path_exact"
|
||||
defaultWAFBlockStatus = 418
|
||||
anubisStaticPrefix = "/.within.website/x/cmd/anubis/static/"
|
||||
anubisAPIPrefix = "/.within.website/x/cmd/anubis/api/"
|
||||
)
|
||||
|
||||
// DefaultStaticCacheExtensions is the built-in suffix allowlist for cache_policy=static.
|
||||
// HTML and JSON are excluded (Cloudflare default). map/mjs/wasm are intentional extras.
|
||||
var DefaultStaticCacheExtensions = []string{
|
||||
"css", "js", "mjs", "map",
|
||||
"ico", "cur", "gif", "jpg", "jpeg", "png", "webp", "avif", "svg", "svgz",
|
||||
"ttf", "otf", "woff", "woff2", "eot",
|
||||
"mp3", "mp4", "webm", "ogg", "flac",
|
||||
"wasm", "pdf",
|
||||
"zip", "7z", "gz", "tar",
|
||||
}
|
||||
|
||||
// OpenFlareRuntimeUser is the dedicated service account shared by the agent
|
||||
// process and OpenResty worker processes.
|
||||
const OpenFlareRuntimeUser = "openflare"
|
||||
|
||||
// OpenRestyWorkerUser is kept as an alias for existing call sites.
|
||||
const OpenRestyWorkerUser = OpenFlareRuntimeUser
|
||||
|
||||
const defaultMainConfigTemplate = `# This file is generated by OpenFlare. Do not edit manually.
|
||||
user ` + OpenFlareRuntimeUser + `;
|
||||
worker_processes {{OpenRestyWorkerProcesses}};
|
||||
worker_rlimit_nofile {{OpenRestyWorkerRlimitNofile}};
|
||||
pid __OPENFLARE_PID_PATH__;
|
||||
error_log {{OpenRestyErrorLogPath}} warn;
|
||||
|
||||
events {
|
||||
worker_connections {{OpenRestyWorkerConnections}};
|
||||
{{OpenRestyEventsUseDirective}}{{OpenRestyEventsMultiAcceptDirective}}}
|
||||
|
||||
http {
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
server_tokens off;
|
||||
client_body_temp_path __OPENFLARE_NGINX_CACHE_DIR__/client_temp;
|
||||
proxy_temp_path __OPENFLARE_NGINX_CACHE_DIR__/proxy_temp;
|
||||
fastcgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/fastcgi_temp;
|
||||
uwsgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/uwsgi_temp;
|
||||
scgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/scgi_temp;
|
||||
{{OpenRestyConnectionUpgradeMap}}{{OpenRestyDefaultServerBlock}} log_format openflare_json escape=json '{"ts":"$time_iso8601","host":"$host","path":"$request_uri","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length,"user_agent":"$http_user_agent","cache_status":"$upstream_cache_status"}';
|
||||
access_log {{OpenRestyAccessLogPath}} openflare_json;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout {{OpenRestyKeepaliveTimeout}};
|
||||
keepalive_requests {{OpenRestyKeepaliveRequests}};
|
||||
client_header_timeout {{OpenRestyClientHeaderTimeout}};
|
||||
client_body_timeout {{OpenRestyClientBodyTimeout}};
|
||||
client_max_body_size {{OpenRestyClientMaxBodySize}};
|
||||
large_client_header_buffers {{OpenRestyLargeClientHeaderBuffers}};
|
||||
send_timeout {{OpenRestySendTimeout}};
|
||||
proxy_connect_timeout {{OpenRestyProxyConnectTimeout}};
|
||||
proxy_send_timeout {{OpenRestyProxySendTimeout}};
|
||||
proxy_read_timeout {{OpenRestyProxyReadTimeout}};
|
||||
proxy_request_buffering {{OpenRestyProxyRequestBuffering}};
|
||||
proxy_buffering {{OpenRestyProxyBuffering}};
|
||||
proxy_buffers {{OpenRestyProxyBuffers}};
|
||||
proxy_buffer_size {{OpenRestyProxyBufferSize}};
|
||||
proxy_busy_buffers_size {{OpenRestyProxyBusyBuffersSize}};
|
||||
gzip {{OpenRestyGzip}};
|
||||
gzip_min_length {{OpenRestyGzipMinLength}};
|
||||
gzip_comp_level {{OpenRestyGzipCompLevel}};
|
||||
{{OpenRestyResolverDirective}}{{OpenRestyCacheBlock}} include {{OpenRestyRouteConfigInclude}};
|
||||
}
|
||||
`
|
||||
|
||||
// SupportFile represents an auxiliary file (certificate, WAF config, etc.)
|
||||
// that is written alongside the main OpenResty configuration.
|
||||
type SupportFile struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// CustomHeader is a key/value pair injected as an additional proxy_set_header
|
||||
// directive for a specific route.
|
||||
type CustomHeader struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// PoWListConfig holds the IP, CIDR, path, and user-agent lists used by the
|
||||
// Proof-of-Work whitelist or blacklist filter.
|
||||
type PoWListConfig struct {
|
||||
IPs []string `json:"ips"`
|
||||
IPCidrs []string `json:"ip_cidrs"`
|
||||
Paths []string `json:"paths"`
|
||||
PathRegexes []string `json:"path_regexes"`
|
||||
UserAgents []string `json:"user_agents"`
|
||||
}
|
||||
|
||||
// PoWConfig holds the full Proof-of-Work challenge parameters for a route,
|
||||
// including difficulty, algorithm, TTLs, and allow/block lists.
|
||||
type PoWConfig struct {
|
||||
Difficulty int `json:"difficulty"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
SessionTTL int `json:"session_ttl"`
|
||||
ChallengeTTL int `json:"challenge_ttl"`
|
||||
Whitelist PoWListConfig `json:"whitelist"`
|
||||
Blacklist PoWListConfig `json:"blacklist"`
|
||||
}
|
||||
|
||||
// DefaultPoWConfig returns the canonical PoW defaults used when pow_enabled is
|
||||
// true but no explicit pow_config payload is available.
|
||||
func DefaultPoWConfig() PoWConfig {
|
||||
return PoWConfig{
|
||||
Difficulty: 4,
|
||||
Algorithm: "fast",
|
||||
SessionTTL: 600,
|
||||
ChallengeTTL: 300,
|
||||
Whitelist: PoWListConfig{IPs: []string{}, IPCidrs: []string{}, Paths: []string{}, PathRegexes: []string{}, UserAgents: []string{}},
|
||||
Blacklist: PoWListConfig{IPs: []string{}, IPCidrs: []string{}, Paths: []string{}, PathRegexes: []string{}, UserAgents: []string{}},
|
||||
}
|
||||
}
|
||||
|
||||
// Route describes a single proxy or pages site entry in the OpenFlare config
|
||||
// document, including upstream, TLS, caching, rate-limiting and WAF settings.
|
||||
type Route struct {
|
||||
ID uint `json:"id,omitempty"`
|
||||
SiteName string `json:"site_name,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
OriginURL string `json:"origin_url"`
|
||||
OriginHost string `json:"origin_host,omitempty"`
|
||||
Upstreams []string `json:"upstreams,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EnableHTTPS bool `json:"enable_https"`
|
||||
DomainCertIDs []uint `json:"domain_cert_ids,omitempty"`
|
||||
RedirectHTTP bool `json:"redirect_http"`
|
||||
LimitConnPerServer int `json:"limit_conn_per_server,omitempty"`
|
||||
LimitConnPerIP int `json:"limit_conn_per_ip,omitempty"`
|
||||
LimitRate string `json:"limit_rate,omitempty"`
|
||||
LimitReqPerIP string `json:"limit_req_per_ip,omitempty"`
|
||||
CacheEnabled bool `json:"cache_enabled"`
|
||||
CachePolicy string `json:"cache_policy,omitempty"`
|
||||
CacheRules []string `json:"cache_rules,omitempty"`
|
||||
CustomHeaders []CustomHeader `json:"custom_headers,omitempty"`
|
||||
PoWEnabled bool `json:"pow_enabled,omitempty"`
|
||||
PoWConfig *PoWConfig `json:"pow_config,omitempty"`
|
||||
BasicAuthEnabled bool `json:"basic_auth_enabled,omitempty"`
|
||||
BasicAuthUsername string `json:"basic_auth_username,omitempty"`
|
||||
BasicAuthPassword string `json:"basic_auth_password,omitempty"`
|
||||
UpstreamType string `json:"upstream_type,omitempty"`
|
||||
PagesDeployment *PagesDeployment `json:"pages_deployment,omitempty"`
|
||||
}
|
||||
|
||||
// PagesDeployment holds the static-site deployment parameters for a Pages-type
|
||||
// route, including local root, entry file, SPA fallback, and API proxy options.
|
||||
//
|
||||
// LocalRoot is anchored on ProjectID (projects/{id}/current), not a specific
|
||||
// deployment ID, so Agents can switch active packages without re-publishing
|
||||
// main config / reloading OpenResty root paths.
|
||||
type PagesDeployment struct {
|
||||
ProjectID uint `json:"project_id"`
|
||||
ProjectSlug string `json:"project_slug"`
|
||||
DeploymentID uint `json:"deployment_id"`
|
||||
DeploymentNumber int `json:"deployment_number"`
|
||||
Checksum string `json:"checksum"`
|
||||
EntryFile string `json:"entry_file"`
|
||||
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
|
||||
SPAFallbackPath string `json:"spa_fallback_path"`
|
||||
APIProxyEnabled bool `json:"api_proxy_enabled"`
|
||||
APIProxyPath string `json:"api_proxy_path"`
|
||||
APIProxyPass string `json:"api_proxy_pass"`
|
||||
APIProxyRewrite string `json:"api_proxy_rewrite"`
|
||||
LocalRoot string `json:"local_root"`
|
||||
}
|
||||
|
||||
// PagesProjectLocalRoot returns the Agent-local root for a Pages project.
|
||||
func PagesProjectLocalRoot(projectID uint) string {
|
||||
if projectID == 0 {
|
||||
return PagesDirPlaceholder
|
||||
}
|
||||
return fmt.Sprintf("%s/projects/%d/current", PagesDirPlaceholder, projectID)
|
||||
}
|
||||
|
||||
// WAFRuleGraph is the compact graph executed by the OpenResty WAF runtime.
|
||||
type WAFRuleGraph struct {
|
||||
Entry string `json:"entry"`
|
||||
Nodes map[string]WAFRuleNode `json:"nodes"`
|
||||
}
|
||||
|
||||
// WAFRuleNode contains one compiled node and its handle-to-target edges.
|
||||
type WAFRuleNode struct {
|
||||
Type string `json:"type"`
|
||||
Config json.RawMessage `json:"config,omitempty"`
|
||||
Next map[string]string `json:"next,omitempty"`
|
||||
}
|
||||
|
||||
// WAFRuleGroup defines one enabled runtime graph. Legacy flattened fields are
|
||||
// retained only for decoding older stored snapshots during rolling upgrades.
|
||||
type WAFRuleGroup struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
IsGlobal bool `json:"is_global"`
|
||||
BlockStatusCode int `json:"block_status_code"`
|
||||
BlockResponseBody string `json:"block_response_body,omitempty"`
|
||||
IPWhitelist []string `json:"ip_whitelist,omitempty"`
|
||||
IPBlacklist []string `json:"ip_blacklist,omitempty"`
|
||||
IPWhitelistGroups []uint `json:"ip_whitelist_group_ids,omitempty"`
|
||||
IPBlacklistGroups []uint `json:"ip_blacklist_group_ids,omitempty"`
|
||||
CountryWhitelist []string `json:"country_whitelist,omitempty"`
|
||||
CountryBlacklist []string `json:"country_blacklist,omitempty"`
|
||||
RegionWhitelist []string `json:"region_whitelist,omitempty"`
|
||||
RegionBlacklist []string `json:"region_blacklist,omitempty"`
|
||||
PoWEnabled bool `json:"pow_enabled,omitempty"`
|
||||
PoWConfig *PoWConfig `json:"pow_config,omitempty"`
|
||||
Graph WAFRuleGraph `json:"graph"`
|
||||
}
|
||||
|
||||
// WAFIPGroup is a named, reusable list of IP addresses or CIDRs that can be
|
||||
// referenced by multiple WAF rule groups as a whitelist or blacklist.
|
||||
type WAFIPGroup struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
IPList []string `json:"ip_list,omitempty"`
|
||||
}
|
||||
|
||||
// WAFBinding associates a route (by site name) with the WAF rule groups that
|
||||
// should be enforced for that site.
|
||||
type WAFBinding struct {
|
||||
RouteID uint `json:"route_id"`
|
||||
SiteName string `json:"site_name"`
|
||||
RuleGroupIDs []uint `json:"rule_group_ids"`
|
||||
}
|
||||
|
||||
// WAFDocument is the top-level WAF configuration snapshot containing rule
|
||||
// groups, IP groups, and per-site bindings.
|
||||
type WAFDocument struct {
|
||||
RuleGroups []WAFRuleGroup `json:"rule_groups"`
|
||||
IPGroups []WAFIPGroup `json:"ip_groups,omitempty"`
|
||||
Bindings []WAFBinding `json:"bindings"`
|
||||
}
|
||||
|
||||
// ConfigSnapshot holds the full set of OpenResty tuning parameters that are
|
||||
// rendered into the nginx main configuration template.
|
||||
type ConfigSnapshot struct {
|
||||
DefaultServerReturnStatus int `json:"default_server_return_status"`
|
||||
WorkerProcesses string `json:"worker_processes"`
|
||||
WorkerConnections int `json:"worker_connections"`
|
||||
WorkerRlimitNofile int `json:"worker_rlimit_nofile"`
|
||||
EventsUse string `json:"events_use,omitempty"`
|
||||
EventsMultiAcceptEnabled bool `json:"events_multi_accept_enabled"`
|
||||
KeepaliveTimeout int `json:"keepalive_timeout"`
|
||||
KeepaliveRequests int `json:"keepalive_requests"`
|
||||
ClientHeaderTimeout int `json:"client_header_timeout"`
|
||||
ClientBodyTimeout int `json:"client_body_timeout"`
|
||||
ClientMaxBodySize string `json:"client_max_body_size"`
|
||||
LargeClientHeaderBuffers string `json:"large_client_header_buffers"`
|
||||
SendTimeout int `json:"send_timeout"`
|
||||
ProxyConnectTimeout int `json:"proxy_connect_timeout"`
|
||||
ProxySendTimeout int `json:"proxy_send_timeout"`
|
||||
ProxyReadTimeout int `json:"proxy_read_timeout"`
|
||||
WebsocketEnabled bool `json:"websocket_enabled"`
|
||||
HTTP3Enabled bool `json:"http3_enabled"`
|
||||
ProxyRequestBuffering bool `json:"proxy_request_buffering"`
|
||||
ProxyBufferingEnabled bool `json:"proxy_buffering_enabled"`
|
||||
ProxyBuffers string `json:"proxy_buffers"`
|
||||
ProxyBufferSize string `json:"proxy_buffer_size"`
|
||||
ProxyBusyBuffersSize string `json:"proxy_busy_buffers_size"`
|
||||
GzipEnabled bool `json:"gzip_enabled"`
|
||||
GzipMinLength int `json:"gzip_min_length"`
|
||||
GzipCompLevel int `json:"gzip_comp_level"`
|
||||
Resolvers string `json:"resolvers,omitempty"`
|
||||
CacheEnabled bool `json:"cache_enabled"`
|
||||
CachePath string `json:"cache_path,omitempty"`
|
||||
CacheLevels string `json:"cache_levels"`
|
||||
CacheInactive string `json:"cache_inactive"`
|
||||
CacheMaxSize string `json:"cache_max_size"`
|
||||
CacheKeyTemplate string `json:"cache_key_template"`
|
||||
CacheLockEnabled bool `json:"cache_lock_enabled"`
|
||||
CacheLockTimeout string `json:"cache_lock_timeout"`
|
||||
CacheUseStale string `json:"cache_use_stale"`
|
||||
MainConfigTemplate string `json:"main_config_template,omitempty"`
|
||||
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
|
||||
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
|
||||
DefaultLimitRate string `json:"default_limit_rate,omitempty"`
|
||||
DefaultLimitReqPerIP string `json:"default_limit_req_per_ip,omitempty"`
|
||||
OriginErrorPageEnabled bool `json:"origin_error_page_enabled"`
|
||||
OriginErrorPageStatusCodes []string `json:"origin_error_page_status_codes,omitempty"`
|
||||
OriginErrorPageHTML string `json:"origin_error_page_html,omitempty"`
|
||||
// OriginErrorPageGetOnly limits custom error HTML to GET requests; other methods pass through.
|
||||
OriginErrorPageGetOnly bool `json:"origin_error_page_get_only,omitempty"`
|
||||
// SWOfflineEnabled enables the Service Worker offline fallback for HTTPS routes.
|
||||
SWOfflineEnabled bool `json:"sw_offline_enabled,omitempty"`
|
||||
// SWOfflineHTML is the contact-page HTML served offline; empty uses the built-in default.
|
||||
SWOfflineHTML string `json:"sw_offline_html,omitempty"`
|
||||
// SWOfflineDomains restricts the offline fallback to matching HTTPS routes.
|
||||
SWOfflineDomains []string `json:"sw_offline_domains,omitempty"`
|
||||
}
|
||||
|
||||
// Document is the top-level input structure for the OpenResty renderer,
|
||||
// combining routes, OpenResty tuning, and WAF configuration.
|
||||
type Document struct {
|
||||
Routes []Route `json:"routes"`
|
||||
OpenRestyConfig ConfigSnapshot `json:"openresty_config"`
|
||||
WAF WAFDocument `json:"waf"`
|
||||
}
|
||||
|
||||
// Result is the output produced by Render, containing the rendered main
|
||||
// config, route config, support files, and a content checksum.
|
||||
type Result struct {
|
||||
MainConfig string
|
||||
RouteConfig string
|
||||
SupportFiles []SupportFile
|
||||
Checksum string
|
||||
}
|
||||
|
||||
type routeCacheConfig struct {
|
||||
Enabled bool
|
||||
Policy string
|
||||
Rules []string
|
||||
}
|
||||
|
||||
type routeLimitConfig struct {
|
||||
LimitConnPerServer int
|
||||
LimitConnPerIP int
|
||||
LimitRate string
|
||||
LimitReqPerIP string
|
||||
}
|
||||
|
||||
type routeUpstreamConfig struct {
|
||||
Name string
|
||||
Scheme string
|
||||
ProxyPassURI string
|
||||
Servers []string
|
||||
UsesNamedUpstream bool
|
||||
}
|
||||
|
||||
var requiredMainConfigTemplatePlaceholders = []string{
|
||||
"{{OpenRestyWorkerProcesses}}",
|
||||
"{{OpenRestyWorkerConnections}}",
|
||||
"{{OpenRestyWorkerRlimitNofile}}",
|
||||
"{{OpenRestyConnectionUpgradeMap}}",
|
||||
"{{OpenRestyDefaultServerBlock}}",
|
||||
"{{OpenRestyAccessLogPath}}",
|
||||
"{{OpenRestyErrorLogPath}}",
|
||||
"{{OpenRestyEventsUseDirective}}",
|
||||
"{{OpenRestyEventsMultiAcceptDirective}}",
|
||||
"{{OpenRestyKeepaliveTimeout}}",
|
||||
"{{OpenRestyKeepaliveRequests}}",
|
||||
"{{OpenRestyClientHeaderTimeout}}",
|
||||
"{{OpenRestyClientBodyTimeout}}",
|
||||
"{{OpenRestyClientMaxBodySize}}",
|
||||
"{{OpenRestyLargeClientHeaderBuffers}}",
|
||||
"{{OpenRestySendTimeout}}",
|
||||
"{{OpenRestyProxyConnectTimeout}}",
|
||||
"{{OpenRestyProxySendTimeout}}",
|
||||
"{{OpenRestyProxyReadTimeout}}",
|
||||
"{{OpenRestyProxyRequestBuffering}}",
|
||||
"{{OpenRestyProxyBuffering}}",
|
||||
"{{OpenRestyProxyBuffers}}",
|
||||
"{{OpenRestyProxyBufferSize}}",
|
||||
"{{OpenRestyProxyBusyBuffersSize}}",
|
||||
"{{OpenRestyGzip}}",
|
||||
"{{OpenRestyGzipMinLength}}",
|
||||
"{{OpenRestyGzipCompLevel}}",
|
||||
"{{OpenRestyCacheBlock}}",
|
||||
"{{OpenRestyRouteConfigInclude}}",
|
||||
}
|
||||
Reference in New Issue
Block a user