拆分为状态码解析、OpenResty 渲染、Option/快照、前端设置页与文档验收五步任务。
17 KiB
源站错误页 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 全局可配置源站/网关错误页:默认标签 500-599、Cloudflare 风格 HTML、可在线自定义;HTTP 状态码保持原值并在页面展示 {{status}}/{{host}};可关闭恢复透传。
Architecture: 三个 Option key 持久化 → 配置版本 openresty_config 快照 → pkg/render/openresty 对反代 server 生成 proxy_intercept_errors + error_page + internal location;模板 SupportFile + 轻量 content_by_lua_block 替换占位符。管理端 /error-pages 挂在侧栏「网站管理」。
Tech Stack: Go、goose、Option API、pkg/render/openresty、OpenResty/Lua、Next.js、Tags Input、OptionService
Spec: docs/design/origin-error-page.md · docs/superpowers/specs/2026-08-06-origin-error-page-design.md
Global Constraints
- 仅反代路由应用;Pages 路由不生成错误页指令
- HTTP status 保持原错误码,禁止统一改为 200
- 状态码标签:单码
522或闭区间500-599;范围 400–599;默认["500-599"] - 占位符:
{{status}}、{{host}};HTML 空 = 内置默认;最大 256 KiB - 保存走 Option
update-batch;需配置版本发布后边缘生效 - 完成后
make code-check;前端相关make format/ prettier;中文 changelog;不写英文文档 - 不新增独立业务路由注册(复用
/api/v1/d/option);侧栏仅前端导航
File map
| 文件 | 职责 |
|---|---|
pkg/render/openresty/status_codes.go |
标签解析/展开纯函数 |
pkg/render/openresty/status_codes_test.go |
解析单测 |
pkg/render/openresty/origin_error_page.go |
默认 HTML、渲染 error_page 片段、SupportFile 路径常量 |
pkg/render/openresty/origin_error_page_test.go |
渲染片段单测 |
pkg/render/openresty/types.go |
ConfigSnapshot 三字段 |
pkg/render/openresty/render.go / render_route.go |
接入 error 块到反代 server |
pkg/render/openresty/render_test.go |
集成渲染断言 |
internal/model/system_configs.go |
三个 ConfigKey 常量 |
internal/infra/persistence/migrator/goose/{postgres,sqlite}/202608060001_add_origin_error_page_options.sql |
seed |
internal/apps/openflare/option/openresty_validators.go |
校验 enabled / codes / html |
internal/apps/openflare/option 相关 test |
校验失败用例 |
internal/apps/openflare/config_version/snapshot.go |
快照读写字段 |
internal/apps/openflare/config_version/logics.go |
diffOpenRestyOptionDetails 含新字段 |
frontend/components/ui/tags-input.tsx |
shadcn-extension Tags Input(若缺失则添加) |
frontend/app/(main)/error-pages/page.tsx |
设置页 |
frontend/lib/navigation/openflare-nav.ts |
网站管理菜单 |
frontend/lib/utils/search-data.ts |
搜索 |
docs/reference/configuration.md |
配置键说明(中文) |
docs/changelog/index.md |
Unreleased |
docs/plan/index.md |
进行中索引 |
Task 1: 状态码标签解析(纯函数 TDD)
Files:
- Create:
pkg/render/openresty/status_codes.go - Create:
pkg/render/openresty/status_codes_test.go
Interfaces:
-
Produces:
func ExpandStatusCodeTags(tags []string) (codes []int, err error)func ParseStatusCodeTag(tag string) (lo, hi int, err error)— 单码时lo==hi- 常量:
StatusCodeMin = 400,StatusCodeMax = 599
-
规则:trim;
^\d{3}$或^\d{3}-\d{3}$;lo<=hi;均在 400–599;展开 inclusive;排序去重;空 tags → 空 slice + nil error(「启用且空」由校验层拒绝) -
Step 1: 写失败单测
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")
}
}
- Step 2: 运行确认失败
Run: go test ./pkg/render/openresty/ -run TestExpandStatusCodeTags -count=1
Expected: FAIL(函数未定义)
- Step 3: 实现
status_codes.go
package openresty
import (
"fmt"
"sort"
"strconv"
"strings"
)
const (
StatusCodeMin = 400
StatusCodeMax = 599
)
func ParseStatusCodeTag(tag string) (lo, hi int, err error) {
tag = strings.TrimSpace(tag)
if tag == "" {
return 0, 0, fmt.Errorf("状态码标签不能为空")
}
if i := strings.IndexByte(tag, '-'); i >= 0 {
lo, err = strconv.Atoi(tag[:i])
if err != nil {
return 0, 0, fmt.Errorf("无效状态码区间: %s", tag)
}
hi, err = strconv.Atoi(tag[i+1:])
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
}
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
}
- Step 4: 运行确认通过
Run: go test ./pkg/render/openresty/ -run TestExpandStatusCodeTags -count=1
Expected: PASS
- Step 5: Commit
git add pkg/render/openresty/status_codes.go pkg/render/openresty/status_codes_test.go
git commit -m "feat(openresty): add status code tag expand helper"
Task 2: ConfigSnapshot 字段 + 默认 HTML + error_page 渲染(TDD)
Files:
- Modify:
pkg/render/openresty/types.go—ConfigSnapshot增加:OriginErrorPageEnabled bool \json:"origin_error_page_enabled"``OriginErrorPageStatusCodes []string \json:"origin_error_page_status_codes,omitempty"``OriginErrorPageHTML string \json:"origin_error_page_html,omitempty"``
- Create:
pkg/render/openresty/origin_error_page.go— 默认 HTML 常量、路径常量、renderOriginErrorPageDirectives、originErrorPageSupportFile - Modify:
pkg/render/openresty/render.go—Render/RenderRouteConfig在 enabled 时 append SupportFile - Modify:
pkg/render/openresty/render_route.go(或render.go中renderHTTPProxyServer/renderHTTPSServer)— 在 proxy location 与 server 级写入 intercept + error_page + internal location - Test:
pkg/render/openresty/origin_error_page_test.go+ 扩展render_test.go
Interfaces:
- Produces:
const OriginErrorPageSupportPath = "error_pages/origin_error.html.tmpl"const DefaultOriginErrorPageHTML = \...`— CF 风格,含{{status}}{{host}}`func EffectiveOriginErrorPageHTML(cfg ConfigSnapshot) string— 空则默认func renderOriginErrorPageServerBits(cfg ConfigSnapshot) string— 若 disabled 或 expand 失败/空则"";否则error_page ...+ internal location 字符串- SupportFile content = Effective HTML(保留占位符)
Internal location 形状(必须 status 透传):
location = /__openflare_origin_error {
internal;
default_type text/html;
charset utf-8;
content_by_lua_block {
local f = io.open("__OPENFLARE_ERROR_PAGE_TMPL__", "r")
if not f then
ngx.status = ngx.status
ngx.say("Error ", ngx.status)
return
end
local body = f:read("*a")
f:close()
local status = tostring(ngx.status)
local host = ngx.var.host or ""
body = body:gsub("{{status}}", status, 1)
body = body:gsub("{{host}}", host, 1)
-- 全局替换剩余占位(若模板多处 status)
body = body:gsub("{{status}}", status)
body = body:gsub("{{host}}", host)
ngx.header["Content-Type"] = "text/html; charset=utf-8"
ngx.say(body)
}
}
占位路径:渲染时用常量如 ErrorPageTmplPlaceholder = "__OPENFLARE_ERROR_PAGE_TMPL__",Agent 落盘时与 SupportFile 绝对路径替换(若现有 Agent 已有 support file root 替换模式则复用;否则在 internal/apps/agent 同步路径处增加对该 placeholder 的替换,与 CertDirPlaceholder 同类)。
在每个反代 location / 内(proxy 块):
proxy_intercept_errors on;
在 server 块内(location 外):
error_page 500 501 ... = /__openflare_origin_error;
- internal location。
Pages 的 renderHTTPSPagesServer / pages HTTP 不调用。
- Step 1: 写失败单测
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, "{{status}}") == false {
// SupportFile 在 Render 全量结果中
}
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 !found {
t.Fatal("missing support file")
}
}
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")
}
}
- Step 2: 运行确认失败 → 实现默认 HTML + 渲染接入 → 运行 PASS
默认 HTML 要求:大号 {{status}}、展示 {{host}}、中性「源站暂时无法提供服务」类文案、无 Cloudflare 商标、可单文件 inline CSS。
- Step 3: Agent placeholder 替换
搜索 Agent 写 conf 时如何替换 __OPENFLARE_CERT_DIR__ 等,为 __OPENFLARE_ERROR_PAGE_TMPL__ 增加指向 support 目录下 error_pages/origin_error.html.tmpl 的绝对路径。
若 conf 内 lua 无法可靠 io.open 绝对路径,可改为 content_by_lua_file + 小 lua 读固定相对路径;优先与现有 pow/waf 资源部署方式一致。
- Step 4: Commit
git commit -m "feat(openresty): render origin error page directives"
Task 3: Option keys、迁移、校验、快照
Files:
- Modify:
internal/model/system_configs.go— 常量:ConfigKeyOriginErrorPageEnabled = "origin_error_page_enabled"ConfigKeyOriginErrorPageStatusCodes = "origin_error_page_status_codes"ConfigKeyOriginErrorPageHTML = "origin_error_page_html"
- Create goose(postgres + sqlite 同名版本号)
202608060001_add_origin_error_page_options.sql:
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('origin_error_page_enabled', 'true', 'business', 0, '是否启用源站错误页', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('origin_error_page_status_codes', '["500-599"]', 'business', 0, '源站错误页触发状态码标签 JSON 数组', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('origin_error_page_html', '', 'business', 0, '源站错误页自定义 HTML,空则使用内置默认', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key IN (
'origin_error_page_enabled',
'origin_error_page_status_codes',
'origin_error_page_html'
);
(SQLite 若无 ON CONFLICT 同现有迁移方言对齐。)
-
Modify:
openresty_validators.go:- enabled →
validateBooleanOption - status_codes → JSON 数组
[]string,对每项ParseStatusCodeTag;若enabled==true(跨 key 时可用 state,或在 batch 校验后单独检查:若本 key 合法但 enabled 为 true 且 expand 为空则失败)。实现建议:validateOriginErrorPageStatusCodes只校验标签可解析且 expand 非空(即使 disabled 也要求非空列表,避免脏数据);html 长度<= 256*1024 - html →
len(value) <= 256<<10
- enabled →
-
Modify:
snapshot.go的openRestyConfigSnapshot+buildOpenRestyConfigSnapshot:- Enabled:
getBoolConfig(..., true) - StatusCodes: 解析 JSON 数组,失败则默认
[]string{"500-599"} - HTML:
getStringConfig(..., "")
- Enabled:
-
Modify:
logics.godiffOpenRestyOptionDetails比较新字段(否则发布 diff 不显示) -
Step 1: 校验单测(
option包)非法["abc"]、超大 html、合法["522","500-502"] -
Step 2: 实现迁移与 snapshot 填充
-
Step 3: 手动或单测确认 snapshot JSON 含字段
-
Step 4: Commit
git commit -m "feat(option): seed and validate origin error page options"
Task 4: 前端 Tags Input + /error-pages 页
Files:
- Create/Modify:
frontend/components/ui/tags-input.tsx(若无:用 shadcn skill /npx shadcn@latest add社区 Tags Input;API:value: string[],onChange,placeholder) - Create:
frontend/app/(main)/error-pages/page.tsx(及可选components/拆分若超 600 行) - Modify:
frontend/lib/navigation/openflare-nav.ts—openflareWebsiteNavGroup.items增加{ title: '错误页', url: '/error-pages' };openflareWebsiteSubNav同步 - Modify:
frontend/lib/utils/search-data.ts— 关键词:错误页、源站、502、error page - Reuse:
OptionService.list/updateBatch(同 performance 页)
UI 行为:
- 管理员加载 options → 映射三字段
- Switch 启用
- TagsInput:默认展示解析后的 JSON 数组
- Textarea HTML;按钮「加载默认模板」「恢复默认」
- 预览:客户端把
{{status}}→502、{{host}}→example.com后 iframesrcDoc - 保存:
updateBatch三条;toast 提示去版本发布 - 前端校验:标签用与后端相同规则(可抽
lib/openflare/status-code-tags.ts轻量实现或仅保存时依赖后端错误)
默认 HTML:从前端常量复制与 Go DefaultOriginErrorPageHTML 内容一致(计划实现时两处同一字符串;可在 PR 说明需人工对齐)。
-
Step 1: 添加 Tags Input 组件并在 demo 或本页使用
-
Step 2: 实现 page.tsx
-
Step 3: 导航与搜索
-
Step 4: 本地 UI 走查(开关、标签区间、预览、保存错误提示)
-
Step 5: Commit
git commit -m "feat(frontend): add origin error page settings under websites"
Task 5: 文档、changelog、收尾门禁
Files:
-
Modify:
docs/reference/configuration.md— 三 key 说明 -
Modify:
docs/changelog/index.md[unreleased]改进:用户可读中文 -
Modify:
docs/plan/index.md— 本计划列入进行中 -
Step 1: 写配置说明与 changelog
示例 changelog:
- 新增全局源站错误页:可在「网站管理 → 错误页」配置触发状态码(支持 500-599 区间与单码)与自定义 HTML;默认 Cloudflare 风格页面并保持真实 HTTP 状态码,关闭后恢复透传。
-
Step 2:
make code-check与前端 format -
Step 3: 手动验收清单(对照设计 §7.2)
- 默认启用 + 源站宕机 → 错误页 + 真实 status
- 源站 503 → 替换
- 仅
522→ 其它 5xx 透传 - 关闭并发布 → 透传
- 自定义
{{status}}/{{host}} - Pages 不受影响
- Step 4: Final commit
git commit -m "docs: origin error page configuration and changelog"
Spec coverage checklist
| Spec 要求 | Task |
|---|---|
| 全局开关 | 3, 4 |
默认 500-599 / 单码+区间 |
1, 3, 4 |
状态码透传 + {{status}} |
2 |
| 默认 CF 风格 / 自定义 HTML | 2, 4 |
| Option + 配置版本 | 3 |
| 仅反代 | 2 |
| 侧栏网站管理 | 4 |
| Tags Input | 4 |
| 256KB 限制 | 3 |
| 测试与 changelog | 1–5 |
Placeholder scan
无 TBD;Agent 路径替换若与现网 placeholder 机制不一致,在 Task 2 Step 3 内对齐现有 CertDirPlaceholder 模式,不另开悬空任务。