feat(openresty): add origin error page GET-only option

Allow restricting custom origin error HTML to GET requests so other
methods pass through origin responses. Adds option seed, snapshot field,
edge limit_except/Lua handling, and admin UI switch.
This commit is contained in:
ryan
2026-08-06 20:22:44 +08:00
parent d17d8457f3
commit 6738abdec1
13 changed files with 118 additions and 7 deletions
+1
View File
@@ -35,6 +35,7 @@ sidebar: false
### 新增
- 新增全局源站错误页:可在「网站管理 → 错误页」配置开关、触发状态码(支持 `500-599` 区间与单码)与自定义 HTML;默认启用 OpenFlare 极简错误页并保持真实 HTTP 状态码,修改后随配置版本发布下发到边缘,关闭后恢复透传。
- 源站错误页支持「仅针对 GET 请求」:开启后仅对 GET 的匹配错误状态码返回自定义错误页,其它 HTTP 方法透传源站响应。
- 新增 Cloudflare DNS 指向管理:可复用现有 Cloudflare DNS 账号或配置独立 Token,按分组将 ZoneDomain 的单条 A 记录异步同步到边缘节点 IPv4,并支持成员橙云、同步状态与节点 IP 变更联动。
### 修复
+1
View File
@@ -266,6 +266,7 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环
| 配置键 (Key) | 数据类型 | 作用说明 | 默认值 |
| --- | --- | --- | --- |
| `origin_error_page_enabled` | `bool` | 是否启用全局源站错误页。开启后,源站或网关返回的匹配状态码由自定义/默认 HTML 替换,**HTTP 状态码保持原值**;关闭后不生成相关指令,恢复透传。修改后需发布配置版本生效 | `true` |
| `origin_error_page_get_only` | `bool` | 是否仅对 **GET** 请求生效。开启后仅 GET 的匹配错误状态码返回自定义错误页;POST/PUT 等其它方法透传源站响应 | `false` |
| `origin_error_page_status_codes` | `json` | 触发错误页的状态码标签 JSON 数组。支持单码(如 `522`)与闭区间(如 `500-599`);单码与区间两端均须在 **400–599**,且 `lo ≤ hi`。启用时展开结果不能为空 | `["500-599"]` |
| `origin_error_page_html` | `string` | 错误页自定义 HTML。空字符串表示使用内置 OpenFlare 默认模板(极简白底);支持占位符 `{{status}}`(与 HTTP 状态码一致)、`{{host}}`(请求 Host)。最大 **256 KiB**(按字节)。勿嵌入不可信第三方脚本 | 空 |
@@ -10,15 +10,18 @@ export const OPTIONS_QUERY_KEY = ['openflare', 'options'] as const;
export const KEY_ENABLED = 'origin_error_page_enabled';
export const KEY_STATUS_CODES = 'origin_error_page_status_codes';
export const KEY_HTML = 'origin_error_page_html';
export const KEY_GET_ONLY = 'origin_error_page_get_only';
export type ErrorPageFields = {
enabled: boolean;
getOnly: boolean;
statusCodes: string[];
html: string;
};
export const defaultErrorPageFields: ErrorPageFields = {
enabled: true,
getOnly: false,
statusCodes: [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS],
html: '',
};
@@ -34,8 +37,10 @@ export function mapOptionsToFields(
optionMap: Record<string, string>,
): ErrorPageFields {
const enabledRaw = optionMap[KEY_ENABLED];
const getOnlyRaw = optionMap[KEY_GET_ONLY];
return {
enabled: enabledRaw === undefined ? true : enabledRaw === 'true',
getOnly: getOnlyRaw === undefined ? false : getOnlyRaw === 'true',
statusCodes: parseStatusCodeTagsJSON(optionMap[KEY_STATUS_CODES]),
html: optionMap[KEY_HTML] ?? '',
};
+25 -2
View File
@@ -30,6 +30,7 @@ import {
defaultErrorPageFields,
invalidateErrorPageQueries,
KEY_ENABLED,
KEY_GET_ONLY,
KEY_STATUS_CODES,
mapOptionsToFields,
OPTIONS_QUERY_KEY,
@@ -60,12 +61,13 @@ export default function ErrorPagesPage() {
[fields.html],
);
/** 仅保存启用开关 + 触发状态码(HTML 在编辑页单独保存) */
/** 仅保存策略项(HTML 在编辑页单独保存) */
const savePolicyMutation = useMutation({
mutationFn: async () => {
validateStatusCodeTags(fields.statusCodes);
await OptionService.updateBatch([
{ key: KEY_ENABLED, value: String(fields.enabled) },
{ key: KEY_GET_ONLY, value: String(fields.getOnly) },
{
key: KEY_STATUS_CODES,
value: JSON.stringify(fields.statusCodes),
@@ -124,13 +126,34 @@ export default function ErrorPagesPage() {
</CardHeader>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'>
<div>
<CardTitle className='text-base'>仅针对 GET 请求</CardTitle>
<CardDescription>
开启后仅对 GET
请求的匹配错误状态码返回自定义错误页;POST/PUT
等其它方法直接透传源站响应。
</CardDescription>
</div>
<Switch
checked={fields.getOnly}
disabled={!fields.enabled}
onCheckedChange={(getOnly) =>
setFields((prev) => ({ ...prev, getOnly }))
}
aria-label='仅针对 GET 请求'
/>
</CardHeader>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between space-y-0'>
<div className='space-y-1.5'>
<CardTitle className='text-base'>触发状态码</CardTitle>
<CardDescription>
支持单码(如 502)或闭区间(如 500-599),范围 400–599。默认
500-599。修改启用开关或状态码后需点击保存。
500-599。修改策略开关或状态码后需点击保存。
</CardDescription>
</div>
<Button
@@ -537,6 +537,7 @@ func diffOpenRestyOptionDetails(left openRestyConfigSnapshot, right openRestyCon
appendIfChanged("OriginErrorPageEnabled", fmt.Sprintf("%t", left.OriginErrorPageEnabled), fmt.Sprintf("%t", right.OriginErrorPageEnabled))
appendIfChanged("OriginErrorPageStatusCodes", encodeOriginErrorPageStatusCodes(left.OriginErrorPageStatusCodes), encodeOriginErrorPageStatusCodes(right.OriginErrorPageStatusCodes))
appendIfChanged("OriginErrorPageHTML", left.OriginErrorPageHTML, right.OriginErrorPageHTML)
appendIfChanged("OriginErrorPageGetOnly", fmt.Sprintf("%t", left.OriginErrorPageGetOnly), fmt.Sprintf("%t", right.OriginErrorPageGetOnly))
return changes
}
@@ -603,5 +604,6 @@ func openRestyOptionKeys() []string {
"OriginErrorPageEnabled",
"OriginErrorPageStatusCodes",
"OriginErrorPageHTML",
"OriginErrorPageGetOnly",
}
}
@@ -143,6 +143,7 @@ type openRestyConfigSnapshot struct {
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 bool `json:"origin_error_page_get_only,omitempty"`
}
type snapshotDocument struct {
@@ -558,6 +559,7 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot {
OriginErrorPageEnabled: getBoolConfig(model.ConfigKeyOriginErrorPageEnabled, true),
OriginErrorPageStatusCodes: parseOriginErrorPageStatusCodes(getStringConfig(model.ConfigKeyOriginErrorPageStatusCodes, `["500-599"]`)),
OriginErrorPageHTML: getStringConfig(model.ConfigKeyOriginErrorPageHTML, ""),
OriginErrorPageGetOnly: getBoolConfig(model.ConfigKeyOriginErrorPageGetOnly, false),
}
if snapshot.DefaultLimitRate == "0" {
snapshot.DefaultLimitRate = ""
@@ -61,6 +61,7 @@ var openRestyOptionValidators = map[string]func(key, value string) error{
model.ConfigKeyOriginErrorPageEnabled: validateBooleanOption,
model.ConfigKeyOriginErrorPageStatusCodes: validateOriginErrorPageStatusCodes,
model.ConfigKeyOriginErrorPageHTML: validateOriginErrorPageHTML,
model.ConfigKeyOriginErrorPageGetOnly: validateBooleanOption,
}
var openRestyDefaultLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`)
@@ -0,0 +1,8 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('origin_error_page_get_only', 'false', 'business', 0, '源站错误页是否仅对 GET 请求生效(其它方法透传)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'origin_error_page_get_only';
@@ -0,0 +1,8 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('origin_error_page_get_only', 'false', 'business', 0, '源站错误页是否仅对 GET 请求生效(其它方法透传)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'origin_error_page_get_only';
+1
View File
@@ -114,6 +114,7 @@ const (
ConfigKeyOriginErrorPageEnabled = "origin_error_page_enabled" // 是否启用源站错误页
ConfigKeyOriginErrorPageStatusCodes = "origin_error_page_status_codes" // 源站错误页触发状态码标签 JSON 数组
ConfigKeyOriginErrorPageHTML = "origin_error_page_html" // 源站错误页自定义 HTML(空则内置默认)
ConfigKeyOriginErrorPageGetOnly = "origin_error_page_get_only" // 是否仅对 GET 请求返回自定义错误页
)
const (
+28 -5
View File
@@ -130,7 +130,17 @@ func renderOriginErrorPageIntercept(cfg ConfigSnapshot) string {
if _, err := ExpandStatusCodeTags(effectiveOriginErrorPageStatusTags(cfg)); err != nil {
return ""
}
return " proxy_intercept_errors on;\n"
// Always enable intercept for GET (and all methods when get_only is false).
// limit_except GET applies to non-GET methods: turn intercept off so origin
// error bodies (e.g. JSON 5xx) pass through unchanged.
var builder strings.Builder
builder.WriteString(" proxy_intercept_errors on;\n")
if cfg.OriginErrorPageGetOnly {
builder.WriteString(" limit_except GET {\n")
builder.WriteString(" proxy_intercept_errors off;\n")
builder.WriteString(" }\n")
}
return builder.String()
}
// renderOriginErrorPageServerBits emits server-level error_page + internal location.
@@ -155,22 +165,30 @@ func renderOriginErrorPageServerBits(cfg ConfigSnapshot) string {
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())
builder.WriteString(renderOriginErrorPageInternalLocation(cfg.OriginErrorPageGetOnly))
return builder.String()
}
func renderOriginErrorPageInternalLocation() string {
func renderOriginErrorPageInternalLocation(getOnly bool) string {
// Resolve status from $status (set by error_page internal 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 two path placeholders; Lua `%` must be
// Note: fmt.Sprintf is used only for the path placeholders; Lua `%` must be
// written as `%%` so Sprintf does not treat them as format verbs.
//
// When getOnly is true, non-GET that still hit this location (e.g. nginx-local
// 502 without upstream body) exit with the original status and no HTML body.
getOnlyLua := "false"
if getOnly {
getOnlyLua = "true"
}
return fmt.Sprintf(` location = %s {
internal;
default_type text/html;
charset utf-8;
content_by_lua_block {
local get_only = %s
local function resolve_error_status()
local code = tonumber(ngx.var.status)
if code and code >= 400 then
@@ -193,6 +211,11 @@ func renderOriginErrorPageInternalLocation() string {
local code = resolve_error_status()
ngx.status = code
if get_only and ngx.req.get_method() ~= "GET" then
-- Non-GET: do not replace with HTML; exit with status only.
return ngx.exit(code)
end
local f = io.open("%s", "r")
if not f then
ngx.header["Content-Type"] = "text/html; charset=utf-8"
@@ -210,5 +233,5 @@ func renderOriginErrorPageInternalLocation() string {
ngx.say(body)
}
}
`, OriginErrorPageInternalLocation, ErrorPageTmplPlaceholder)
`, OriginErrorPageInternalLocation, getOnlyLua, ErrorPageTmplPlaceholder)
}
@@ -68,6 +68,40 @@ func TestRenderOriginErrorPageEnabled(t *testing.T) {
}
}
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)
}
if !strings.Contains(out, "proxy_intercept_errors on") {
t.Fatal("missing intercept on")
}
if !strings.Contains(out, "limit_except GET") {
t.Fatal("get_only must emit limit_except GET")
}
if !strings.Contains(out, "proxy_intercept_errors off") {
t.Fatal("get_only must turn intercept off for non-GET")
}
if !strings.Contains(out, `get_only = true`) {
t.Fatal("internal location must set get_only = true")
}
if !strings.Contains(out, `ngx.req.get_method() ~= "GET"`) {
t.Fatal("internal location must skip HTML for non-GET")
}
}
func TestRenderOriginErrorPageDisabled(t *testing.T) {
t.Parallel()
doc := Document{
+2
View File
@@ -318,6 +318,8 @@ type ConfigSnapshot struct {
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"`
}
// Document is the top-level input structure for the OpenResty renderer,