mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 00:26:37 +08:00
feat(option): seed and validate origin error page options
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
@@ -99,47 +99,50 @@ type snapshotWAFDocument struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type openRestyConfigSnapshot struct {
|
type openRestyConfigSnapshot struct {
|
||||||
DefaultServerReturnStatus int `json:"default_server_return_status"`
|
DefaultServerReturnStatus int `json:"default_server_return_status"`
|
||||||
WorkerProcesses string `json:"worker_processes"`
|
WorkerProcesses string `json:"worker_processes"`
|
||||||
WorkerConnections int `json:"worker_connections"`
|
WorkerConnections int `json:"worker_connections"`
|
||||||
WorkerRlimitNofile int `json:"worker_rlimit_nofile"`
|
WorkerRlimitNofile int `json:"worker_rlimit_nofile"`
|
||||||
EventsUse string `json:"events_use,omitempty"`
|
EventsUse string `json:"events_use,omitempty"`
|
||||||
EventsMultiAcceptEnabled bool `json:"events_multi_accept_enabled"`
|
EventsMultiAcceptEnabled bool `json:"events_multi_accept_enabled"`
|
||||||
KeepaliveTimeout int `json:"keepalive_timeout"`
|
KeepaliveTimeout int `json:"keepalive_timeout"`
|
||||||
KeepaliveRequests int `json:"keepalive_requests"`
|
KeepaliveRequests int `json:"keepalive_requests"`
|
||||||
ClientHeaderTimeout int `json:"client_header_timeout"`
|
ClientHeaderTimeout int `json:"client_header_timeout"`
|
||||||
ClientBodyTimeout int `json:"client_body_timeout"`
|
ClientBodyTimeout int `json:"client_body_timeout"`
|
||||||
ClientMaxBodySize string `json:"client_max_body_size"`
|
ClientMaxBodySize string `json:"client_max_body_size"`
|
||||||
LargeClientHeaderBuffers string `json:"large_client_header_buffers"`
|
LargeClientHeaderBuffers string `json:"large_client_header_buffers"`
|
||||||
SendTimeout int `json:"send_timeout"`
|
SendTimeout int `json:"send_timeout"`
|
||||||
ProxyConnectTimeout int `json:"proxy_connect_timeout"`
|
ProxyConnectTimeout int `json:"proxy_connect_timeout"`
|
||||||
ProxySendTimeout int `json:"proxy_send_timeout"`
|
ProxySendTimeout int `json:"proxy_send_timeout"`
|
||||||
ProxyReadTimeout int `json:"proxy_read_timeout"`
|
ProxyReadTimeout int `json:"proxy_read_timeout"`
|
||||||
WebsocketEnabled bool `json:"websocket_enabled"`
|
WebsocketEnabled bool `json:"websocket_enabled"`
|
||||||
HTTP3Enabled bool `json:"http3_enabled"`
|
HTTP3Enabled bool `json:"http3_enabled"`
|
||||||
ProxyRequestBuffering bool `json:"proxy_request_buffering"`
|
ProxyRequestBuffering bool `json:"proxy_request_buffering"`
|
||||||
ProxyBufferingEnabled bool `json:"proxy_buffering_enabled"`
|
ProxyBufferingEnabled bool `json:"proxy_buffering_enabled"`
|
||||||
ProxyBuffers string `json:"proxy_buffers"`
|
ProxyBuffers string `json:"proxy_buffers"`
|
||||||
ProxyBufferSize string `json:"proxy_buffer_size"`
|
ProxyBufferSize string `json:"proxy_buffer_size"`
|
||||||
ProxyBusyBuffersSize string `json:"proxy_busy_buffers_size"`
|
ProxyBusyBuffersSize string `json:"proxy_busy_buffers_size"`
|
||||||
GzipEnabled bool `json:"gzip_enabled"`
|
GzipEnabled bool `json:"gzip_enabled"`
|
||||||
GzipMinLength int `json:"gzip_min_length"`
|
GzipMinLength int `json:"gzip_min_length"`
|
||||||
GzipCompLevel int `json:"gzip_comp_level"`
|
GzipCompLevel int `json:"gzip_comp_level"`
|
||||||
Resolvers string `json:"resolvers,omitempty"`
|
Resolvers string `json:"resolvers,omitempty"`
|
||||||
CacheEnabled bool `json:"cache_enabled"`
|
CacheEnabled bool `json:"cache_enabled"`
|
||||||
CachePath string `json:"cache_path,omitempty"`
|
CachePath string `json:"cache_path,omitempty"`
|
||||||
CacheLevels string `json:"cache_levels"`
|
CacheLevels string `json:"cache_levels"`
|
||||||
CacheInactive string `json:"cache_inactive"`
|
CacheInactive string `json:"cache_inactive"`
|
||||||
CacheMaxSize string `json:"cache_max_size"`
|
CacheMaxSize string `json:"cache_max_size"`
|
||||||
CacheKeyTemplate string `json:"cache_key_template"`
|
CacheKeyTemplate string `json:"cache_key_template"`
|
||||||
CacheLockEnabled bool `json:"cache_lock_enabled"`
|
CacheLockEnabled bool `json:"cache_lock_enabled"`
|
||||||
CacheLockTimeout string `json:"cache_lock_timeout"`
|
CacheLockTimeout string `json:"cache_lock_timeout"`
|
||||||
CacheUseStale string `json:"cache_use_stale"`
|
CacheUseStale string `json:"cache_use_stale"`
|
||||||
MainConfigTemplate string `json:"main_config_template,omitempty"`
|
MainConfigTemplate string `json:"main_config_template,omitempty"`
|
||||||
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
|
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
|
||||||
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 {
|
||||||
@@ -511,47 +514,50 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
snapshot := openRestyConfigSnapshot{
|
snapshot := openRestyConfigSnapshot{
|
||||||
DefaultServerReturnStatus: getIntConfig(model.ConfigKeyOpenRestyDefaultServerReturnStatus, defaultOpenRestyReturnStatus),
|
DefaultServerReturnStatus: getIntConfig(model.ConfigKeyOpenRestyDefaultServerReturnStatus, defaultOpenRestyReturnStatus),
|
||||||
WorkerProcesses: getStringConfig(model.ConfigKeyOpenRestyWorkerProcesses, "auto"),
|
WorkerProcesses: getStringConfig(model.ConfigKeyOpenRestyWorkerProcesses, "auto"),
|
||||||
WorkerConnections: getIntConfig(model.ConfigKeyOpenRestyWorkerConnections, defaultOpenRestyWorkerConns),
|
WorkerConnections: getIntConfig(model.ConfigKeyOpenRestyWorkerConnections, defaultOpenRestyWorkerConns),
|
||||||
WorkerRlimitNofile: getIntConfig(model.ConfigKeyOpenRestyWorkerRlimitNofile, defaultOpenRestyRlimitNofile),
|
WorkerRlimitNofile: getIntConfig(model.ConfigKeyOpenRestyWorkerRlimitNofile, defaultOpenRestyRlimitNofile),
|
||||||
EventsUse: getStringConfig(model.ConfigKeyOpenRestyEventsUse, "epoll"),
|
EventsUse: getStringConfig(model.ConfigKeyOpenRestyEventsUse, "epoll"),
|
||||||
EventsMultiAcceptEnabled: getBoolConfig(model.ConfigKeyOpenRestyEventsMultiAcceptEnabled, true),
|
EventsMultiAcceptEnabled: getBoolConfig(model.ConfigKeyOpenRestyEventsMultiAcceptEnabled, true),
|
||||||
KeepaliveTimeout: getIntConfig(model.ConfigKeyOpenRestyKeepaliveTimeout, defaultOpenRestyKeepaliveTimeout),
|
KeepaliveTimeout: getIntConfig(model.ConfigKeyOpenRestyKeepaliveTimeout, defaultOpenRestyKeepaliveTimeout),
|
||||||
KeepaliveRequests: getIntConfig(model.ConfigKeyOpenRestyKeepaliveRequests, defaultOpenRestyKeepaliveReqs),
|
KeepaliveRequests: getIntConfig(model.ConfigKeyOpenRestyKeepaliveRequests, defaultOpenRestyKeepaliveReqs),
|
||||||
ClientHeaderTimeout: getIntConfig(model.ConfigKeyOpenRestyClientHeaderTimeout, defaultOpenRestyHeaderTimeout),
|
ClientHeaderTimeout: getIntConfig(model.ConfigKeyOpenRestyClientHeaderTimeout, defaultOpenRestyHeaderTimeout),
|
||||||
ClientBodyTimeout: getIntConfig(model.ConfigKeyOpenRestyClientBodyTimeout, defaultOpenRestyBodyTimeout),
|
ClientBodyTimeout: getIntConfig(model.ConfigKeyOpenRestyClientBodyTimeout, defaultOpenRestyBodyTimeout),
|
||||||
ClientMaxBodySize: getStringConfig(model.ConfigKeyOpenRestyClientMaxBodySize, "64m"),
|
ClientMaxBodySize: getStringConfig(model.ConfigKeyOpenRestyClientMaxBodySize, "64m"),
|
||||||
LargeClientHeaderBuffers: getStringConfig(model.ConfigKeyOpenRestyLargeClientHeaderBuffers, "4 16k"),
|
LargeClientHeaderBuffers: getStringConfig(model.ConfigKeyOpenRestyLargeClientHeaderBuffers, "4 16k"),
|
||||||
SendTimeout: getIntConfig(model.ConfigKeyOpenRestySendTimeout, defaultOpenRestySendTimeout),
|
SendTimeout: getIntConfig(model.ConfigKeyOpenRestySendTimeout, defaultOpenRestySendTimeout),
|
||||||
ProxyConnectTimeout: getIntConfig(model.ConfigKeyOpenRestyProxyConnectTimeout, defaultOpenRestyConnectTimeout),
|
ProxyConnectTimeout: getIntConfig(model.ConfigKeyOpenRestyProxyConnectTimeout, defaultOpenRestyConnectTimeout),
|
||||||
ProxySendTimeout: getIntConfig(model.ConfigKeyOpenRestyProxySendTimeout, defaultOpenRestyProxyTimeout),
|
ProxySendTimeout: getIntConfig(model.ConfigKeyOpenRestyProxySendTimeout, defaultOpenRestyProxyTimeout),
|
||||||
ProxyReadTimeout: getIntConfig(model.ConfigKeyOpenRestyProxyReadTimeout, defaultOpenRestyProxyTimeout),
|
ProxyReadTimeout: getIntConfig(model.ConfigKeyOpenRestyProxyReadTimeout, defaultOpenRestyProxyTimeout),
|
||||||
WebsocketEnabled: getBoolConfig(model.ConfigKeyOpenRestyWebsocketEnabled, true),
|
WebsocketEnabled: getBoolConfig(model.ConfigKeyOpenRestyWebsocketEnabled, true),
|
||||||
HTTP3Enabled: getBoolConfig(model.ConfigKeyOpenRestyHTTP3Enabled, true),
|
HTTP3Enabled: getBoolConfig(model.ConfigKeyOpenRestyHTTP3Enabled, true),
|
||||||
ProxyRequestBuffering: getBoolConfig(model.ConfigKeyOpenRestyProxyRequestBufferingEnabled, false),
|
ProxyRequestBuffering: getBoolConfig(model.ConfigKeyOpenRestyProxyRequestBufferingEnabled, false),
|
||||||
ProxyBufferingEnabled: getBoolConfig(model.ConfigKeyOpenRestyProxyBufferingEnabled, true),
|
ProxyBufferingEnabled: getBoolConfig(model.ConfigKeyOpenRestyProxyBufferingEnabled, true),
|
||||||
ProxyBuffers: getStringConfig(model.ConfigKeyOpenRestyProxyBuffers, "16 16k"),
|
ProxyBuffers: getStringConfig(model.ConfigKeyOpenRestyProxyBuffers, "16 16k"),
|
||||||
ProxyBufferSize: getStringConfig(model.ConfigKeyOpenRestyProxyBufferSize, "8k"),
|
ProxyBufferSize: getStringConfig(model.ConfigKeyOpenRestyProxyBufferSize, "8k"),
|
||||||
ProxyBusyBuffersSize: getStringConfig(model.ConfigKeyOpenRestyProxyBusyBuffersSize, "64k"),
|
ProxyBusyBuffersSize: getStringConfig(model.ConfigKeyOpenRestyProxyBusyBuffersSize, "64k"),
|
||||||
GzipEnabled: getBoolConfig(model.ConfigKeyOpenRestyGzipEnabled, true),
|
GzipEnabled: getBoolConfig(model.ConfigKeyOpenRestyGzipEnabled, true),
|
||||||
GzipMinLength: getIntConfig(model.ConfigKeyOpenRestyGzipMinLength, defaultOpenRestyGzipMinLen),
|
GzipMinLength: getIntConfig(model.ConfigKeyOpenRestyGzipMinLength, defaultOpenRestyGzipMinLen),
|
||||||
GzipCompLevel: getIntConfig(model.ConfigKeyOpenRestyGzipCompLevel, defaultOpenRestyGzipLevel),
|
GzipCompLevel: getIntConfig(model.ConfigKeyOpenRestyGzipCompLevel, defaultOpenRestyGzipLevel),
|
||||||
Resolvers: getStringConfig(model.ConfigKeyOpenRestyResolvers, ""),
|
Resolvers: getStringConfig(model.ConfigKeyOpenRestyResolvers, ""),
|
||||||
CacheEnabled: getBoolConfig(model.ConfigKeyOpenRestyCacheEnabled, false),
|
CacheEnabled: getBoolConfig(model.ConfigKeyOpenRestyCacheEnabled, false),
|
||||||
CachePath: getStringConfig(model.ConfigKeyOpenRestyCachePath, ""),
|
CachePath: getStringConfig(model.ConfigKeyOpenRestyCachePath, ""),
|
||||||
CacheLevels: getStringConfig(model.ConfigKeyOpenRestyCacheLevels, "1:2"),
|
CacheLevels: getStringConfig(model.ConfigKeyOpenRestyCacheLevels, "1:2"),
|
||||||
CacheInactive: getStringConfig(model.ConfigKeyOpenRestyCacheInactive, "30m"),
|
CacheInactive: getStringConfig(model.ConfigKeyOpenRestyCacheInactive, "30m"),
|
||||||
CacheMaxSize: getStringConfig(model.ConfigKeyOpenRestyCacheMaxSize, "1g"),
|
CacheMaxSize: getStringConfig(model.ConfigKeyOpenRestyCacheMaxSize, "1g"),
|
||||||
CacheKeyTemplate: getStringConfig(model.ConfigKeyOpenRestyCacheKeyTemplate, "$scheme$host$request_uri"),
|
CacheKeyTemplate: getStringConfig(model.ConfigKeyOpenRestyCacheKeyTemplate, "$scheme$host$request_uri"),
|
||||||
CacheLockEnabled: getBoolConfig(model.ConfigKeyOpenRestyCacheLockEnabled, true),
|
CacheLockEnabled: getBoolConfig(model.ConfigKeyOpenRestyCacheLockEnabled, true),
|
||||||
CacheLockTimeout: getStringConfig(model.ConfigKeyOpenRestyCacheLockTimeout, "5s"),
|
CacheLockTimeout: getStringConfig(model.ConfigKeyOpenRestyCacheLockTimeout, "5s"),
|
||||||
CacheUseStale: getStringConfig(model.ConfigKeyOpenRestyCacheUseStale, "error timeout updating http_500 http_502 http_503 http_504"),
|
CacheUseStale: getStringConfig(model.ConfigKeyOpenRestyCacheUseStale, "error timeout updating http_500 http_502 http_503 http_504"),
|
||||||
MainConfigTemplate: getStringConfig(model.ConfigKeyOpenRestyMainConfigTemplate, model.DefaultOpenRestyMainConfigTemplate),
|
MainConfigTemplate: getStringConfig(model.ConfigKeyOpenRestyMainConfigTemplate, model.DefaultOpenRestyMainConfigTemplate),
|
||||||
DefaultLimitConnPerServer: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerServer, 0),
|
DefaultLimitConnPerServer: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerServer, 0),
|
||||||
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")
|
||||||
|
}
|
||||||
+14
@@ -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'
|
||||||
|
);
|
||||||
+14
@@ -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'
|
||||||
|
);
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user