Files
OpenFlare/docs/plan/20260806-origin-error-page.md
T
ryan 1f5bebd18a docs(plan): add origin error page implementation plan
拆分为状态码解析、OpenResty 渲染、Option/快照、前端设置页与文档验收五步任务。
2026-08-06 13:44:45 +08:00

17 KiB
Raw Blame History

源站错误页 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
  • Modify: snapshot.go 的 openRestyConfigSnapshot + buildOpenRestyConfigSnapshot:

    • Enabled: getBoolConfig(..., true)
    • StatusCodes: 解析 JSON 数组,失败则默认 []string{"500-599"}
    • HTML: getStringConfig(..., "")
  • Modify: logics.go diffOpenRestyOptionDetails 比较新字段(否则发布 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 行为:

  1. 管理员加载 options → 映射三字段
  2. Switch 启用
  3. TagsInput:默认展示解析后的 JSON 数组
  4. Textarea HTML;按钮「加载默认模板」「恢复默认」
  5. 预览:客户端把 {{status}}→502、{{host}}→example.com 后 iframe srcDoc
  6. 保存:updateBatch 三条;toast 提示去版本发布
  7. 前端校验:标签用与后端相同规则(可抽 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)

  1. 默认启用 + 源站宕机 → 错误页 + 真实 status
  2. 源站 503 → 替换
  3. 仅 522 → 其它 5xx 透传
  4. 关闭并发布 → 透传
  5. 自定义 {{status}}/{{host}}
  6. 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 模式,不另开悬空任务。