diff --git a/docs/changelog/index.md b/docs/changelog/index.md index ed651016..b8d73194 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -24,6 +24,7 @@ sidebar: false ### 新增 +- 新增源站错误页全局配置(开关、状态码标签、自定义 HTML),写入配置版本快照后随发布下发到边缘;默认拦截 5xx 并使用内置错误页模板。 - 新增 Cloudflare DNS 指向管理:可复用现有 Cloudflare DNS 账号或配置独立 Token,按分组将 ZoneDomain 的单条 A 记录异步同步到边缘节点 IPv4,并支持成员橙云、同步状态与节点 IP 变更联动。 ### 改进 diff --git a/internal/apps/openflare/config_version/logics.go b/internal/apps/openflare/config_version/logics.go index 88437be9..76645c40 100644 --- a/internal/apps/openflare/config_version/logics.go +++ b/internal/apps/openflare/config_version/logics.go @@ -534,9 +534,23 @@ func diffOpenRestyOptionDetails(left openRestyConfigSnapshot, right openRestyCon appendIfChanged("OpenRestyDefaultLimitConnPerIP", fmt.Sprintf("%d", left.DefaultLimitConnPerIP), fmt.Sprintf("%d", right.DefaultLimitConnPerIP)) appendIfChanged("OpenRestyDefaultLimitRate", left.DefaultLimitRate, right.DefaultLimitRate) 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 } +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 { keys := make([]string, 0, len(details)) for _, item := range details { @@ -586,5 +600,8 @@ func openRestyOptionKeys() []string { "OpenRestyDefaultLimitConnPerIP", "OpenRestyDefaultLimitRate", "OpenRestyDefaultLimitReqPerIP", + "OriginErrorPageEnabled", + "OriginErrorPageStatusCodes", + "OriginErrorPageHTML", } } diff --git a/internal/apps/openflare/config_version/origin_error_page_snapshot_test.go b/internal/apps/openflare/config_version/origin_error_page_snapshot_test.go new file mode 100644 index 00000000..8885e680 --- /dev/null +++ b/internal/apps/openflare/config_version/origin_error_page_snapshot_test.go @@ -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: "
x
", + } + 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, "x
", keys["OriginErrorPageHTML"].CurrentValue) +} diff --git a/internal/apps/openflare/config_version/snapshot.go b/internal/apps/openflare/config_version/snapshot.go index e6aa6d86..0d601dba 100644 --- a/internal/apps/openflare/config_version/snapshot.go +++ b/internal/apps/openflare/config_version/snapshot.go @@ -99,47 +99,50 @@ type snapshotWAFDocument struct { } type openRestyConfigSnapshot struct { - DefaultServerReturnStatus int `json:"default_server_return_status"` - WorkerProcesses string `json:"worker_processes"` - WorkerConnections int `json:"worker_connections"` - WorkerRlimitNofile int `json:"worker_rlimit_nofile"` - EventsUse string `json:"events_use,omitempty"` - EventsMultiAcceptEnabled bool `json:"events_multi_accept_enabled"` - KeepaliveTimeout int `json:"keepalive_timeout"` - KeepaliveRequests int `json:"keepalive_requests"` - ClientHeaderTimeout int `json:"client_header_timeout"` - ClientBodyTimeout int `json:"client_body_timeout"` - ClientMaxBodySize string `json:"client_max_body_size"` - LargeClientHeaderBuffers string `json:"large_client_header_buffers"` - SendTimeout int `json:"send_timeout"` - ProxyConnectTimeout int `json:"proxy_connect_timeout"` - ProxySendTimeout int `json:"proxy_send_timeout"` - ProxyReadTimeout int `json:"proxy_read_timeout"` - WebsocketEnabled bool `json:"websocket_enabled"` - HTTP3Enabled bool `json:"http3_enabled"` - ProxyRequestBuffering bool `json:"proxy_request_buffering"` - ProxyBufferingEnabled bool `json:"proxy_buffering_enabled"` - ProxyBuffers string `json:"proxy_buffers"` - ProxyBufferSize string `json:"proxy_buffer_size"` - ProxyBusyBuffersSize string `json:"proxy_busy_buffers_size"` - GzipEnabled bool `json:"gzip_enabled"` - GzipMinLength int `json:"gzip_min_length"` - GzipCompLevel int `json:"gzip_comp_level"` - Resolvers string `json:"resolvers,omitempty"` - CacheEnabled bool `json:"cache_enabled"` - CachePath string `json:"cache_path,omitempty"` - CacheLevels string `json:"cache_levels"` - CacheInactive string `json:"cache_inactive"` - CacheMaxSize string `json:"cache_max_size"` - CacheKeyTemplate string `json:"cache_key_template"` - CacheLockEnabled bool `json:"cache_lock_enabled"` - CacheLockTimeout string `json:"cache_lock_timeout"` - CacheUseStale string `json:"cache_use_stale"` - MainConfigTemplate string `json:"main_config_template,omitempty"` - DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"` - DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"` - DefaultLimitRate string `json:"default_limit_rate,omitempty"` - DefaultLimitReqPerIP string `json:"default_limit_req_per_ip,omitempty"` + DefaultServerReturnStatus int `json:"default_server_return_status"` + WorkerProcesses string `json:"worker_processes"` + WorkerConnections int `json:"worker_connections"` + WorkerRlimitNofile int `json:"worker_rlimit_nofile"` + EventsUse string `json:"events_use,omitempty"` + EventsMultiAcceptEnabled bool `json:"events_multi_accept_enabled"` + KeepaliveTimeout int `json:"keepalive_timeout"` + KeepaliveRequests int `json:"keepalive_requests"` + ClientHeaderTimeout int `json:"client_header_timeout"` + ClientBodyTimeout int `json:"client_body_timeout"` + ClientMaxBodySize string `json:"client_max_body_size"` + LargeClientHeaderBuffers string `json:"large_client_header_buffers"` + SendTimeout int `json:"send_timeout"` + ProxyConnectTimeout int `json:"proxy_connect_timeout"` + ProxySendTimeout int `json:"proxy_send_timeout"` + ProxyReadTimeout int `json:"proxy_read_timeout"` + WebsocketEnabled bool `json:"websocket_enabled"` + HTTP3Enabled bool `json:"http3_enabled"` + ProxyRequestBuffering bool `json:"proxy_request_buffering"` + ProxyBufferingEnabled bool `json:"proxy_buffering_enabled"` + ProxyBuffers string `json:"proxy_buffers"` + ProxyBufferSize string `json:"proxy_buffer_size"` + ProxyBusyBuffersSize string `json:"proxy_busy_buffers_size"` + GzipEnabled bool `json:"gzip_enabled"` + GzipMinLength int `json:"gzip_min_length"` + GzipCompLevel int `json:"gzip_comp_level"` + Resolvers string `json:"resolvers,omitempty"` + CacheEnabled bool `json:"cache_enabled"` + CachePath string `json:"cache_path,omitempty"` + CacheLevels string `json:"cache_levels"` + CacheInactive string `json:"cache_inactive"` + CacheMaxSize string `json:"cache_max_size"` + CacheKeyTemplate string `json:"cache_key_template"` + CacheLockEnabled bool `json:"cache_lock_enabled"` + CacheLockTimeout string `json:"cache_lock_timeout"` + CacheUseStale string `json:"cache_use_stale"` + MainConfigTemplate string `json:"main_config_template,omitempty"` + DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"` + DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"` + DefaultLimitRate string `json:"default_limit_rate,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 { @@ -511,47 +514,50 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot { } snapshot := openRestyConfigSnapshot{ - DefaultServerReturnStatus: getIntConfig(model.ConfigKeyOpenRestyDefaultServerReturnStatus, defaultOpenRestyReturnStatus), - WorkerProcesses: getStringConfig(model.ConfigKeyOpenRestyWorkerProcesses, "auto"), - WorkerConnections: getIntConfig(model.ConfigKeyOpenRestyWorkerConnections, defaultOpenRestyWorkerConns), - WorkerRlimitNofile: getIntConfig(model.ConfigKeyOpenRestyWorkerRlimitNofile, defaultOpenRestyRlimitNofile), - EventsUse: getStringConfig(model.ConfigKeyOpenRestyEventsUse, "epoll"), - EventsMultiAcceptEnabled: getBoolConfig(model.ConfigKeyOpenRestyEventsMultiAcceptEnabled, true), - KeepaliveTimeout: getIntConfig(model.ConfigKeyOpenRestyKeepaliveTimeout, defaultOpenRestyKeepaliveTimeout), - KeepaliveRequests: getIntConfig(model.ConfigKeyOpenRestyKeepaliveRequests, defaultOpenRestyKeepaliveReqs), - ClientHeaderTimeout: getIntConfig(model.ConfigKeyOpenRestyClientHeaderTimeout, defaultOpenRestyHeaderTimeout), - ClientBodyTimeout: getIntConfig(model.ConfigKeyOpenRestyClientBodyTimeout, defaultOpenRestyBodyTimeout), - ClientMaxBodySize: getStringConfig(model.ConfigKeyOpenRestyClientMaxBodySize, "64m"), - LargeClientHeaderBuffers: getStringConfig(model.ConfigKeyOpenRestyLargeClientHeaderBuffers, "4 16k"), - SendTimeout: getIntConfig(model.ConfigKeyOpenRestySendTimeout, defaultOpenRestySendTimeout), - ProxyConnectTimeout: getIntConfig(model.ConfigKeyOpenRestyProxyConnectTimeout, defaultOpenRestyConnectTimeout), - ProxySendTimeout: getIntConfig(model.ConfigKeyOpenRestyProxySendTimeout, defaultOpenRestyProxyTimeout), - ProxyReadTimeout: getIntConfig(model.ConfigKeyOpenRestyProxyReadTimeout, defaultOpenRestyProxyTimeout), - WebsocketEnabled: getBoolConfig(model.ConfigKeyOpenRestyWebsocketEnabled, true), - HTTP3Enabled: getBoolConfig(model.ConfigKeyOpenRestyHTTP3Enabled, true), - ProxyRequestBuffering: getBoolConfig(model.ConfigKeyOpenRestyProxyRequestBufferingEnabled, false), - ProxyBufferingEnabled: getBoolConfig(model.ConfigKeyOpenRestyProxyBufferingEnabled, true), - ProxyBuffers: getStringConfig(model.ConfigKeyOpenRestyProxyBuffers, "16 16k"), - ProxyBufferSize: getStringConfig(model.ConfigKeyOpenRestyProxyBufferSize, "8k"), - ProxyBusyBuffersSize: getStringConfig(model.ConfigKeyOpenRestyProxyBusyBuffersSize, "64k"), - GzipEnabled: getBoolConfig(model.ConfigKeyOpenRestyGzipEnabled, true), - GzipMinLength: getIntConfig(model.ConfigKeyOpenRestyGzipMinLength, defaultOpenRestyGzipMinLen), - GzipCompLevel: getIntConfig(model.ConfigKeyOpenRestyGzipCompLevel, defaultOpenRestyGzipLevel), - Resolvers: getStringConfig(model.ConfigKeyOpenRestyResolvers, ""), - CacheEnabled: getBoolConfig(model.ConfigKeyOpenRestyCacheEnabled, false), - CachePath: getStringConfig(model.ConfigKeyOpenRestyCachePath, ""), - CacheLevels: getStringConfig(model.ConfigKeyOpenRestyCacheLevels, "1:2"), - CacheInactive: getStringConfig(model.ConfigKeyOpenRestyCacheInactive, "30m"), - CacheMaxSize: getStringConfig(model.ConfigKeyOpenRestyCacheMaxSize, "1g"), - CacheKeyTemplate: getStringConfig(model.ConfigKeyOpenRestyCacheKeyTemplate, "$scheme$host$request_uri"), - CacheLockEnabled: getBoolConfig(model.ConfigKeyOpenRestyCacheLockEnabled, true), - CacheLockTimeout: getStringConfig(model.ConfigKeyOpenRestyCacheLockTimeout, "5s"), - CacheUseStale: getStringConfig(model.ConfigKeyOpenRestyCacheUseStale, "error timeout updating http_500 http_502 http_503 http_504"), - MainConfigTemplate: getStringConfig(model.ConfigKeyOpenRestyMainConfigTemplate, model.DefaultOpenRestyMainConfigTemplate), - DefaultLimitConnPerServer: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerServer, 0), - DefaultLimitConnPerIP: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerIP, 0), - DefaultLimitRate: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitRate, ""))), - DefaultLimitReqPerIP: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitReqPerIP, ""))), + DefaultServerReturnStatus: getIntConfig(model.ConfigKeyOpenRestyDefaultServerReturnStatus, defaultOpenRestyReturnStatus), + WorkerProcesses: getStringConfig(model.ConfigKeyOpenRestyWorkerProcesses, "auto"), + WorkerConnections: getIntConfig(model.ConfigKeyOpenRestyWorkerConnections, defaultOpenRestyWorkerConns), + WorkerRlimitNofile: getIntConfig(model.ConfigKeyOpenRestyWorkerRlimitNofile, defaultOpenRestyRlimitNofile), + EventsUse: getStringConfig(model.ConfigKeyOpenRestyEventsUse, "epoll"), + EventsMultiAcceptEnabled: getBoolConfig(model.ConfigKeyOpenRestyEventsMultiAcceptEnabled, true), + KeepaliveTimeout: getIntConfig(model.ConfigKeyOpenRestyKeepaliveTimeout, defaultOpenRestyKeepaliveTimeout), + KeepaliveRequests: getIntConfig(model.ConfigKeyOpenRestyKeepaliveRequests, defaultOpenRestyKeepaliveReqs), + ClientHeaderTimeout: getIntConfig(model.ConfigKeyOpenRestyClientHeaderTimeout, defaultOpenRestyHeaderTimeout), + ClientBodyTimeout: getIntConfig(model.ConfigKeyOpenRestyClientBodyTimeout, defaultOpenRestyBodyTimeout), + ClientMaxBodySize: getStringConfig(model.ConfigKeyOpenRestyClientMaxBodySize, "64m"), + LargeClientHeaderBuffers: getStringConfig(model.ConfigKeyOpenRestyLargeClientHeaderBuffers, "4 16k"), + SendTimeout: getIntConfig(model.ConfigKeyOpenRestySendTimeout, defaultOpenRestySendTimeout), + ProxyConnectTimeout: getIntConfig(model.ConfigKeyOpenRestyProxyConnectTimeout, defaultOpenRestyConnectTimeout), + ProxySendTimeout: getIntConfig(model.ConfigKeyOpenRestyProxySendTimeout, defaultOpenRestyProxyTimeout), + ProxyReadTimeout: getIntConfig(model.ConfigKeyOpenRestyProxyReadTimeout, defaultOpenRestyProxyTimeout), + WebsocketEnabled: getBoolConfig(model.ConfigKeyOpenRestyWebsocketEnabled, true), + HTTP3Enabled: getBoolConfig(model.ConfigKeyOpenRestyHTTP3Enabled, true), + ProxyRequestBuffering: getBoolConfig(model.ConfigKeyOpenRestyProxyRequestBufferingEnabled, false), + ProxyBufferingEnabled: getBoolConfig(model.ConfigKeyOpenRestyProxyBufferingEnabled, true), + ProxyBuffers: getStringConfig(model.ConfigKeyOpenRestyProxyBuffers, "16 16k"), + ProxyBufferSize: getStringConfig(model.ConfigKeyOpenRestyProxyBufferSize, "8k"), + ProxyBusyBuffersSize: getStringConfig(model.ConfigKeyOpenRestyProxyBusyBuffersSize, "64k"), + GzipEnabled: getBoolConfig(model.ConfigKeyOpenRestyGzipEnabled, true), + GzipMinLength: getIntConfig(model.ConfigKeyOpenRestyGzipMinLength, defaultOpenRestyGzipMinLen), + GzipCompLevel: getIntConfig(model.ConfigKeyOpenRestyGzipCompLevel, defaultOpenRestyGzipLevel), + Resolvers: getStringConfig(model.ConfigKeyOpenRestyResolvers, ""), + CacheEnabled: getBoolConfig(model.ConfigKeyOpenRestyCacheEnabled, false), + CachePath: getStringConfig(model.ConfigKeyOpenRestyCachePath, ""), + CacheLevels: getStringConfig(model.ConfigKeyOpenRestyCacheLevels, "1:2"), + CacheInactive: getStringConfig(model.ConfigKeyOpenRestyCacheInactive, "30m"), + CacheMaxSize: getStringConfig(model.ConfigKeyOpenRestyCacheMaxSize, "1g"), + CacheKeyTemplate: getStringConfig(model.ConfigKeyOpenRestyCacheKeyTemplate, "$scheme$host$request_uri"), + CacheLockEnabled: getBoolConfig(model.ConfigKeyOpenRestyCacheLockEnabled, true), + CacheLockTimeout: getStringConfig(model.ConfigKeyOpenRestyCacheLockTimeout, "5s"), + CacheUseStale: getStringConfig(model.ConfigKeyOpenRestyCacheUseStale, "error timeout updating http_500 http_502 http_503 http_504"), + MainConfigTemplate: getStringConfig(model.ConfigKeyOpenRestyMainConfigTemplate, model.DefaultOpenRestyMainConfigTemplate), + DefaultLimitConnPerServer: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerServer, 0), + DefaultLimitConnPerIP: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerIP, 0), + DefaultLimitRate: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitRate, ""))), + 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" { snapshot.DefaultLimitRate = "" @@ -563,6 +569,19 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot { 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 { if !cacheEnabled { return strings.TrimSpace(cachePath) diff --git a/internal/apps/openflare/option/openresty_validators.go b/internal/apps/openflare/option/openresty_validators.go index 7185221c..ff37a783 100644 --- a/internal/apps/openflare/option/openresty_validators.go +++ b/internal/apps/openflare/option/openresty_validators.go @@ -4,14 +4,18 @@ package option import ( + "encoding/json" "fmt" "regexp" "strconv" "strings" "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{ model.ConfigKeyOpenRestyDefaultServerReturnStatus: validateOpenRestyDefaultServerReturnStatus, model.ConfigKeyOpenRestyWorkerProcesses: validateOpenRestyWorkerProcesses, @@ -54,11 +58,18 @@ var openRestyOptionValidators = map[string]func(key, value string) error{ model.ConfigKeyOpenRestyDefaultLimitConnPerIP: validateNonNegativeIntegerOption, model.ConfigKeyOpenRestyDefaultLimitRate: validateOpenRestyDefaultLimitRate, model.ConfigKeyOpenRestyDefaultLimitReqPerIP: validateOpenRestyDefaultLimitReqPerIP, + model.ConfigKeyOriginErrorPageEnabled: validateBooleanOption, + model.ConfigKeyOriginErrorPageStatusCodes: validateOriginErrorPageStatusCodes, + model.ConfigKeyOriginErrorPageHTML: validateOriginErrorPageHTML, } var openRestyDefaultLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`) func validateOpenRestyOption(key, value string) error { + // HTML 按原始字节长度校验,避免 TrimSpace 影响上限判断 + if key == model.ConfigKeyOriginErrorPageHTML { + return validateOriginErrorPageHTML(key, value) + } trimmed := strings.TrimSpace(value) if validator, ok := openRestyOptionValidators[key]; ok { return validator(key, trimmed) @@ -207,3 +218,31 @@ func validateOpenRestyDefaultLimitReqPerIP(key, trimmed string) error { } 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 +} diff --git a/internal/apps/openflare/option/openresty_validators_test.go b/internal/apps/openflare/option/openresty_validators_test.go new file mode 100644 index 00000000..cd129612 --- /dev/null +++ b/internal/apps/openflare/option/openresty_validators_test.go @@ -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, "ok")) + + 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") +} diff --git a/internal/infra/persistence/migrator/goose/postgres/202608060001_add_origin_error_page_options.sql b/internal/infra/persistence/migrator/goose/postgres/202608060001_add_origin_error_page_options.sql new file mode 100644 index 00000000..6646d974 --- /dev/null +++ b/internal/infra/persistence/migrator/goose/postgres/202608060001_add_origin_error_page_options.sql @@ -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' +); diff --git a/internal/infra/persistence/migrator/goose/sqlite/202608060001_add_origin_error_page_options.sql b/internal/infra/persistence/migrator/goose/sqlite/202608060001_add_origin_error_page_options.sql new file mode 100644 index 00000000..6646d974 --- /dev/null +++ b/internal/infra/persistence/migrator/goose/sqlite/202608060001_add_origin_error_page_options.sql @@ -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' +); diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go index 746f87d6..95fd60e0 100644 --- a/internal/model/system_configs.go +++ b/internal/model/system_configs.go @@ -109,6 +109,11 @@ const ( ConfigKeyOpenRestyDefaultLimitConnPerIP = "openresty_default_limit_conn_per_ip" // 默认单 IP 并发连接 ConfigKeyOpenRestyDefaultLimitRate = "openresty_default_limit_rate" // 默认单请求带宽 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 ( diff --git a/pkg/render/openresty/status_codes.go b/pkg/render/openresty/status_codes.go index 65c4b006..e7758b0b 100644 --- a/pkg/render/openresty/status_codes.go +++ b/pkg/render/openresty/status_codes.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + package openresty import ( @@ -8,10 +11,14 @@ import ( ) const ( + // StatusCodeMin is the lowest HTTP status code accepted for origin error pages. StatusCodeMin = 400 + // StatusCodeMax is the highest HTTP status code accepted for origin error pages. 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) { tag = strings.TrimSpace(tag) if tag == "" { @@ -42,6 +49,7 @@ func ParseStatusCodeTag(tag string) (lo, hi int, err error) { return lo, hi, nil } +// ExpandStatusCodeTags expands status code tags into a sorted unique list of integers. func ExpandStatusCodeTags(tags []string) ([]int, error) { set := map[int]struct{}{} for _, tag := range tags {