mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
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:
@@ -35,6 +35,7 @@ sidebar: false
|
||||
### 新增
|
||||
|
||||
- 新增全局源站错误页:可在「网站管理 → 错误页」配置开关、触发状态码(支持 `500-599` 区间与单码)与自定义 HTML;默认启用 OpenFlare 极简错误页并保持真实 HTTP 状态码,修改后随配置版本发布下发到边缘,关闭后恢复透传。
|
||||
- 源站错误页支持「仅针对 GET 请求」:开启后仅对 GET 的匹配错误状态码返回自定义错误页,其它 HTTP 方法透传源站响应。
|
||||
- 新增 Cloudflare DNS 指向管理:可复用现有 Cloudflare DNS 账号或配置独立 Token,按分组将 ZoneDomain 的单条 A 记录异步同步到边缘节点 IPv4,并支持成员橙云、同步状态与节点 IP 变更联动。
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -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] ?? '',
|
||||
};
|
||||
|
||||
@@ -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]?$`)
|
||||
|
||||
+8
@@ -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';
|
||||
+8
@@ -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';
|
||||
@@ -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 (
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user