feat(option): seed and validate origin error page options

This commit is contained in:
ryan
2026-08-06 13:59:54 +08:00
parent 68d8f786cc
commit 7d47db1f34
10 changed files with 398 additions and 82 deletions
+1
View File
@@ -24,6 +24,7 @@ sidebar: false
### 新增 ### 新增
- 新增源站错误页全局配置(开关、状态码标签、自定义 HTML),写入配置版本快照后随发布下发到边缘;默认拦截 5xx 并使用内置错误页模板。
- 新增 Cloudflare DNS 指向管理:可复用现有 Cloudflare DNS 账号或配置独立 Token,按分组将 ZoneDomain 的单条 A 记录异步同步到边缘节点 IPv4,并支持成员橙云、同步状态与节点 IP 变更联动。 - 新增 Cloudflare DNS 指向管理:可复用现有 Cloudflare DNS 账号或配置独立 Token,按分组将 ZoneDomain 的单条 A 记录异步同步到边缘节点 IPv4,并支持成员橙云、同步状态与节点 IP 变更联动。
### 改进 ### 改进
@@ -534,9 +534,23 @@ func diffOpenRestyOptionDetails(left openRestyConfigSnapshot, right openRestyCon
appendIfChanged("OpenRestyDefaultLimitConnPerIP", fmt.Sprintf("%d", left.DefaultLimitConnPerIP), fmt.Sprintf("%d", right.DefaultLimitConnPerIP)) appendIfChanged("OpenRestyDefaultLimitConnPerIP", fmt.Sprintf("%d", left.DefaultLimitConnPerIP), fmt.Sprintf("%d", right.DefaultLimitConnPerIP))
appendIfChanged("OpenRestyDefaultLimitRate", left.DefaultLimitRate, right.DefaultLimitRate) appendIfChanged("OpenRestyDefaultLimitRate", left.DefaultLimitRate, right.DefaultLimitRate)
appendIfChanged("OpenRestyDefaultLimitReqPerIP", left.DefaultLimitReqPerIP, right.DefaultLimitReqPerIP) appendIfChanged("OpenRestyDefaultLimitReqPerIP", left.DefaultLimitReqPerIP, right.DefaultLimitReqPerIP)
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)
return changes return changes
} }
func encodeOriginErrorPageStatusCodes(tags []string) string {
if len(tags) == 0 {
return ""
}
payload, err := json.Marshal(tags)
if err != nil {
return strings.Join(tags, ",")
}
return string(payload)
}
func extractOptionDiffKeys(details []ConfigOptionDiffItem) []string { func extractOptionDiffKeys(details []ConfigOptionDiffItem) []string {
keys := make([]string, 0, len(details)) keys := make([]string, 0, len(details))
for _, item := range details { for _, item := range details {
@@ -586,5 +600,8 @@ func openRestyOptionKeys() []string {
"OpenRestyDefaultLimitConnPerIP", "OpenRestyDefaultLimitConnPerIP",
"OpenRestyDefaultLimitRate", "OpenRestyDefaultLimitRate",
"OpenRestyDefaultLimitReqPerIP", "OpenRestyDefaultLimitReqPerIP",
"OriginErrorPageEnabled",
"OriginErrorPageStatusCodes",
"OriginErrorPageHTML",
} }
} }
@@ -0,0 +1,103 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package config_version
import (
"context"
"encoding/json"
"testing"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupOriginErrorPageSnapshotDB(t *testing.T) func() {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(&model.SystemConfig{}))
db.SetDB(sqliteDB)
return func() {
db.SetDB(nil)
}
}
func TestBuildOpenRestyConfigSnapshotOriginErrorPageDefaults(t *testing.T) {
cleanup := setupOriginErrorPageSnapshotDB(t)
defer cleanup()
snapshot := buildOpenRestyConfigSnapshot(context.Background())
assert.True(t, snapshot.OriginErrorPageEnabled)
assert.Equal(t, []string{"500-599"}, snapshot.OriginErrorPageStatusCodes)
assert.Empty(t, snapshot.OriginErrorPageHTML)
payload, err := json.Marshal(snapshot)
require.NoError(t, err)
assert.Contains(t, string(payload), `"origin_error_page_enabled":true`)
assert.Contains(t, string(payload), `"origin_error_page_status_codes":["500-599"]`)
}
func TestBuildOpenRestyConfigSnapshotOriginErrorPageCustom(t *testing.T) {
cleanup := setupOriginErrorPageSnapshotDB(t)
defer cleanup()
ctx := context.Background()
require.NoError(t, db.DB(ctx).Create(&model.SystemConfig{
Key: model.ConfigKeyOriginErrorPageEnabled, Value: "false", Type: "business",
}).Error)
require.NoError(t, db.DB(ctx).Create(&model.SystemConfig{
Key: model.ConfigKeyOriginErrorPageStatusCodes, Value: `["522","500-502"]`, Type: "business",
}).Error)
require.NoError(t, db.DB(ctx).Create(&model.SystemConfig{
Key: model.ConfigKeyOriginErrorPageHTML, Value: "<h1>{{status}}</h1>", Type: "business",
}).Error)
snapshot := buildOpenRestyConfigSnapshot(ctx)
assert.False(t, snapshot.OriginErrorPageEnabled)
assert.Equal(t, []string{"522", "500-502"}, snapshot.OriginErrorPageStatusCodes)
assert.Equal(t, "<h1>{{status}}</h1>", snapshot.OriginErrorPageHTML)
}
func TestParseOriginErrorPageStatusCodesFallback(t *testing.T) {
t.Parallel()
assert.Equal(t, []string{"500-599"}, parseOriginErrorPageStatusCodes(""))
assert.Equal(t, []string{"500-599"}, parseOriginErrorPageStatusCodes("not-json"))
assert.Equal(t, []string{"500-599"}, parseOriginErrorPageStatusCodes("[]"))
assert.Equal(t, []string{"502"}, parseOriginErrorPageStatusCodes(`["502"]`))
}
func TestDiffOpenRestyOptionDetailsOriginErrorPage(t *testing.T) {
t.Parallel()
left := openRestyConfigSnapshot{
OriginErrorPageEnabled: true,
OriginErrorPageStatusCodes: []string{"500-599"},
OriginErrorPageHTML: "",
}
right := openRestyConfigSnapshot{
OriginErrorPageEnabled: false,
OriginErrorPageStatusCodes: []string{"522"},
OriginErrorPageHTML: "<p>x</p>",
}
details := diffOpenRestyOptionDetails(left, right)
keys := make(map[string]ConfigOptionDiffItem, len(details))
for _, item := range details {
keys[item.Key] = item
}
assert.Equal(t, "true", keys["OriginErrorPageEnabled"].PreviousValue)
assert.Equal(t, "false", keys["OriginErrorPageEnabled"].CurrentValue)
assert.Equal(t, `["500-599"]`, keys["OriginErrorPageStatusCodes"].PreviousValue)
assert.Equal(t, `["522"]`, keys["OriginErrorPageStatusCodes"].CurrentValue)
assert.Equal(t, "", keys["OriginErrorPageHTML"].PreviousValue)
assert.Equal(t, "<p>x</p>", keys["OriginErrorPageHTML"].CurrentValue)
}
@@ -140,6 +140,9 @@ type openRestyConfigSnapshot struct {
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"` DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
DefaultLimitRate string `json:"default_limit_rate,omitempty"` DefaultLimitRate string `json:"default_limit_rate,omitempty"`
DefaultLimitReqPerIP string `json:"default_limit_req_per_ip,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"`
} }
type snapshotDocument struct { type snapshotDocument struct {
@@ -552,6 +555,9 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot {
DefaultLimitConnPerIP: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerIP, 0), DefaultLimitConnPerIP: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerIP, 0),
DefaultLimitRate: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitRate, ""))), DefaultLimitRate: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitRate, ""))),
DefaultLimitReqPerIP: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitReqPerIP, ""))), DefaultLimitReqPerIP: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitReqPerIP, ""))),
OriginErrorPageEnabled: getBoolConfig(model.ConfigKeyOriginErrorPageEnabled, true),
OriginErrorPageStatusCodes: parseOriginErrorPageStatusCodes(getStringConfig(model.ConfigKeyOriginErrorPageStatusCodes, `["500-599"]`)),
OriginErrorPageHTML: getStringConfig(model.ConfigKeyOriginErrorPageHTML, ""),
} }
if snapshot.DefaultLimitRate == "0" { if snapshot.DefaultLimitRate == "0" {
snapshot.DefaultLimitRate = "" snapshot.DefaultLimitRate = ""
@@ -563,6 +569,19 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot {
return snapshot return snapshot
} }
func parseOriginErrorPageStatusCodes(raw string) []string {
const defaultTag = "500-599"
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return []string{defaultTag}
}
var tags []string
if err := json.Unmarshal([]byte(trimmed), &tags); err != nil || len(tags) == 0 {
return []string{defaultTag}
}
return tags
}
func normalizeProxyCachePathForSnapshot(cacheEnabled bool, cachePath string) string { func normalizeProxyCachePathForSnapshot(cacheEnabled bool, cachePath string) string {
if !cacheEnabled { if !cacheEnabled {
return strings.TrimSpace(cachePath) return strings.TrimSpace(cachePath)
@@ -4,14 +4,18 @@
package option package option
import ( import (
"encoding/json"
"fmt" "fmt"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/model"
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
) )
const maxOriginErrorPageHTMLBytes = 256 << 10 // 256 KiB
var openRestyOptionValidators = map[string]func(key, value string) error{ var openRestyOptionValidators = map[string]func(key, value string) error{
model.ConfigKeyOpenRestyDefaultServerReturnStatus: validateOpenRestyDefaultServerReturnStatus, model.ConfigKeyOpenRestyDefaultServerReturnStatus: validateOpenRestyDefaultServerReturnStatus,
model.ConfigKeyOpenRestyWorkerProcesses: validateOpenRestyWorkerProcesses, model.ConfigKeyOpenRestyWorkerProcesses: validateOpenRestyWorkerProcesses,
@@ -54,11 +58,18 @@ var openRestyOptionValidators = map[string]func(key, value string) error{
model.ConfigKeyOpenRestyDefaultLimitConnPerIP: validateNonNegativeIntegerOption, model.ConfigKeyOpenRestyDefaultLimitConnPerIP: validateNonNegativeIntegerOption,
model.ConfigKeyOpenRestyDefaultLimitRate: validateOpenRestyDefaultLimitRate, model.ConfigKeyOpenRestyDefaultLimitRate: validateOpenRestyDefaultLimitRate,
model.ConfigKeyOpenRestyDefaultLimitReqPerIP: validateOpenRestyDefaultLimitReqPerIP, model.ConfigKeyOpenRestyDefaultLimitReqPerIP: validateOpenRestyDefaultLimitReqPerIP,
model.ConfigKeyOriginErrorPageEnabled: validateBooleanOption,
model.ConfigKeyOriginErrorPageStatusCodes: validateOriginErrorPageStatusCodes,
model.ConfigKeyOriginErrorPageHTML: validateOriginErrorPageHTML,
} }
var openRestyDefaultLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`) var openRestyDefaultLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`)
func validateOpenRestyOption(key, value string) error { func validateOpenRestyOption(key, value string) error {
// HTML 按原始字节长度校验,避免 TrimSpace 影响上限判断
if key == model.ConfigKeyOriginErrorPageHTML {
return validateOriginErrorPageHTML(key, value)
}
trimmed := strings.TrimSpace(value) trimmed := strings.TrimSpace(value)
if validator, ok := openRestyOptionValidators[key]; ok { if validator, ok := openRestyOptionValidators[key]; ok {
return validator(key, trimmed) return validator(key, trimmed)
@@ -207,3 +218,31 @@ func validateOpenRestyDefaultLimitReqPerIP(key, trimmed string) error {
} }
return nil return nil
} }
func validateOriginErrorPageStatusCodes(key, trimmed string) error {
if trimmed == "" {
return fmt.Errorf("%s 不能为空", key)
}
var tags []string
if err := json.Unmarshal([]byte(trimmed), &tags); err != nil {
return fmt.Errorf("%s 必须为 JSON 字符串数组", key)
}
if len(tags) == 0 {
return fmt.Errorf("%s 至少包含一个状态码标签", key)
}
codes, err := openrestyrender.ExpandStatusCodeTags(tags)
if err != nil {
return fmt.Errorf("%s: %v", key, err)
}
if len(codes) == 0 {
return fmt.Errorf("%s 展开后不能为空", key)
}
return nil
}
func validateOriginErrorPageHTML(key, value string) error {
if len(value) > maxOriginErrorPageHTMLBytes {
return fmt.Errorf("%s 长度不能超过 %d 字节(256 KiB)", key, maxOriginErrorPageHTMLBytes)
}
return nil
}
@@ -0,0 +1,96 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package option
import (
"strings"
"testing"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateOriginErrorPageStatusCodes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
wantErr string
}{
{
name: "合法单码与区间",
value: `["522","500-502"]`,
},
{
name: "默认区间",
value: `["500-599"]`,
},
{
name: "非法标签",
value: `["abc"]`,
wantErr: "无效状态码",
},
{
name: "非 JSON 数组",
value: `500-599`,
wantErr: "必须为 JSON 字符串数组",
},
{
name: "空数组",
value: `[]`,
wantErr: "至少包含一个状态码标签",
},
{
name: "越界状态码",
value: `["399"]`,
wantErr: "状态码须在",
},
{
name: "空字符串",
value: "",
wantErr: "不能为空",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateOpenRestyOption(model.ConfigKeyOriginErrorPageStatusCodes, tt.value)
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
})
}
}
func TestValidateOriginErrorPageHTML(t *testing.T) {
t.Parallel()
require.NoError(t, validateOpenRestyOption(model.ConfigKeyOriginErrorPageHTML, ""))
require.NoError(t, validateOpenRestyOption(model.ConfigKeyOriginErrorPageHTML, "<html>ok</html>"))
oversized := strings.Repeat("a", maxOriginErrorPageHTMLBytes+1)
err := validateOpenRestyOption(model.ConfigKeyOriginErrorPageHTML, oversized)
require.Error(t, err)
assert.Contains(t, err.Error(), "长度不能超过")
// 恰好上限应通过
atLimit := strings.Repeat("b", maxOriginErrorPageHTMLBytes)
require.NoError(t, validateOpenRestyOption(model.ConfigKeyOriginErrorPageHTML, atLimit))
}
func TestValidateOriginErrorPageEnabled(t *testing.T) {
t.Parallel()
require.NoError(t, validateOpenRestyOption(model.ConfigKeyOriginErrorPageEnabled, "true"))
require.NoError(t, validateOpenRestyOption(model.ConfigKeyOriginErrorPageEnabled, "false"))
err := validateOpenRestyOption(model.ConfigKeyOriginErrorPageEnabled, "yes")
require.Error(t, err)
assert.Contains(t, err.Error(), "true 或 false")
}
@@ -0,0 +1,14 @@
-- +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'
);
@@ -0,0 +1,14 @@
-- +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'
);
+5
View File
@@ -109,6 +109,11 @@ const (
ConfigKeyOpenRestyDefaultLimitConnPerIP = "openresty_default_limit_conn_per_ip" // 默认单 IP 并发连接 ConfigKeyOpenRestyDefaultLimitConnPerIP = "openresty_default_limit_conn_per_ip" // 默认单 IP 并发连接
ConfigKeyOpenRestyDefaultLimitRate = "openresty_default_limit_rate" // 默认单请求带宽 ConfigKeyOpenRestyDefaultLimitRate = "openresty_default_limit_rate" // 默认单请求带宽
ConfigKeyOpenRestyDefaultLimitReqPerIP = "openresty_default_limit_req_per_ip" // 默认单 IP 请求频率限制 ConfigKeyOpenRestyDefaultLimitReqPerIP = "openresty_default_limit_req_per_ip" // 默认单 IP 请求频率限制
// 源站错误页
ConfigKeyOriginErrorPageEnabled = "origin_error_page_enabled" // 是否启用源站错误页
ConfigKeyOriginErrorPageStatusCodes = "origin_error_page_status_codes" // 源站错误页触发状态码标签 JSON 数组
ConfigKeyOriginErrorPageHTML = "origin_error_page_html" // 源站错误页自定义 HTML(空则内置默认)
) )
const ( const (
+8
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package openresty package openresty
import ( import (
@@ -8,10 +11,14 @@ import (
) )
const ( const (
// StatusCodeMin is the lowest HTTP status code accepted for origin error pages.
StatusCodeMin = 400 StatusCodeMin = 400
// StatusCodeMax is the highest HTTP status code accepted for origin error pages.
StatusCodeMax = 599 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) { func ParseStatusCodeTag(tag string) (lo, hi int, err error) {
tag = strings.TrimSpace(tag) tag = strings.TrimSpace(tag)
if tag == "" { if tag == "" {
@@ -42,6 +49,7 @@ func ParseStatusCodeTag(tag string) (lo, hi int, err error) {
return lo, hi, nil return lo, hi, nil
} }
// ExpandStatusCodeTags expands status code tags into a sorted unique list of integers.
func ExpandStatusCodeTags(tags []string) ([]int, error) { func ExpandStatusCodeTags(tags []string) ([]int, error) {
set := map[int]struct{}{} set := map[int]struct{}{}
for _, tag := range tags { for _, tag := range tags {