// Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 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 maxSWOfflineDomains = 1000 ) var openRestyOptionValidators = map[string]func(key, value string) error{ model.ConfigKeyOpenRestyDefaultServerReturnStatus: validateOpenRestyDefaultServerReturnStatus, model.ConfigKeyOpenRestyWorkerProcesses: validateOpenRestyWorkerProcesses, model.ConfigKeyOpenRestyWorkerConnections: validatePositiveIntegerOption, model.ConfigKeyOpenRestyWorkerRlimitNofile: validatePositiveIntegerOption, model.ConfigKeyOpenRestyKeepaliveTimeout: validatePositiveIntegerOption, model.ConfigKeyOpenRestyKeepaliveRequests: validatePositiveIntegerOption, model.ConfigKeyOpenRestyClientHeaderTimeout: validatePositiveIntegerOption, model.ConfigKeyOpenRestyClientBodyTimeout: validatePositiveIntegerOption, model.ConfigKeyOpenRestySendTimeout: validatePositiveIntegerOption, model.ConfigKeyOpenRestyProxyConnectTimeout: validatePositiveIntegerOption, model.ConfigKeyOpenRestyProxySendTimeout: validatePositiveIntegerOption, model.ConfigKeyOpenRestyProxyReadTimeout: validatePositiveIntegerOption, model.ConfigKeyOpenRestyGzipMinLength: validatePositiveIntegerOption, model.ConfigKeyOpenRestyGzipCompLevel: validateOpenRestyGzipCompLevel, model.ConfigKeyOpenRestyEventsUse: validateOpenRestyEventsUse, model.ConfigKeyOpenRestyResolvers: validateOpenRestyResolvers, model.ConfigKeyOpenRestyEventsMultiAcceptEnabled: validateBooleanOption, model.ConfigKeyOpenRestyWebsocketEnabled: validateBooleanOption, model.ConfigKeyOpenRestyHTTP3Enabled: validateBooleanOption, model.ConfigKeyOpenRestyProxyRequestBufferingEnabled: validateBooleanOption, model.ConfigKeyOpenRestyProxyBufferingEnabled: validateBooleanOption, model.ConfigKeyOpenRestyGzipEnabled: validateBooleanOption, model.ConfigKeyOpenRestyCacheEnabled: validateBooleanOption, model.ConfigKeyOpenRestyCacheLockEnabled: validateBooleanOption, model.ConfigKeyOpenRestyProxyBuffers: validateOpenRestyProxyBuffers, model.ConfigKeyOpenRestyLargeClientHeaderBuffers: validateOpenRestyProxyBuffers, model.ConfigKeyOpenRestyProxyBufferSize: validateOpenRestySizeValue, model.ConfigKeyOpenRestyProxyBusyBuffersSize: validateOpenRestySizeValue, model.ConfigKeyOpenRestyCacheMaxSize: validateOpenRestySizeValue, model.ConfigKeyOpenRestyClientMaxBodySize: validateOpenRestySizeValue, model.ConfigKeyOpenRestyCachePath: validateOpenRestyCachePath, model.ConfigKeyOpenRestyCacheLevels: validateOpenRestyCacheLevels, model.ConfigKeyOpenRestyCacheInactive: validateOpenRestyDurationToken, model.ConfigKeyOpenRestyCacheLockTimeout: validateOpenRestyDurationToken, model.ConfigKeyOpenRestyCacheKeyTemplate: validateOpenRestyCacheKeyTemplate, model.ConfigKeyOpenRestyCacheUseStale: validateOpenRestyCacheUseStale, model.ConfigKeyOpenRestyMainConfigTemplate: validateOpenRestyMainConfigTemplate, model.ConfigKeyOpenRestyDefaultLimitConnPerServer: validateNonNegativeIntegerOption, model.ConfigKeyOpenRestyDefaultLimitConnPerIP: validateNonNegativeIntegerOption, model.ConfigKeyOpenRestyDefaultLimitRate: validateOpenRestyDefaultLimitRate, model.ConfigKeyOpenRestyDefaultLimitReqPerIP: validateOpenRestyDefaultLimitReqPerIP, model.ConfigKeyOriginErrorPageEnabled: validateBooleanOption, model.ConfigKeyOriginErrorPageStatusCodes: validateOriginErrorPageStatusCodes, model.ConfigKeyOriginErrorPageHTML: validateOriginErrorPageHTML, model.ConfigKeyOriginErrorPageGetOnly: validateBooleanOption, model.ConfigKeySWOfflineEnabled: validateBooleanOption, model.ConfigKeySWOfflineHTML: validateSWOfflineHTML, model.ConfigKeySWOfflineDomains: validateSWOfflineDomains, } var openRestyDefaultLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`) func validateOpenRestyOption(key, value string) error { // HTML 按原始字节长度校验,避免 TrimSpace 影响上限判断 if key == model.ConfigKeyOriginErrorPageHTML || key == model.ConfigKeySWOfflineHTML { return validateOriginErrorPageHTML(key, value) } trimmed := strings.TrimSpace(value) if validator, ok := openRestyOptionValidators[key]; ok { return validator(key, trimmed) } return nil } func validateOpenRestyDefaultServerReturnStatus(key, trimmed string) error { if err := validatePositiveIntegerOption(key, trimmed); err != nil { return err } statusCode, _ := strconv.Atoi(trimmed) if statusCode < 100 || statusCode > 999 { return fmt.Errorf("%s 必须在 100 到 999 之间", key) } return nil } func validateOpenRestyWorkerProcesses(key, trimmed string) error { if trimmed == "auto" { return nil } return validatePositiveIntegerOption(key, trimmed) } func validateOpenRestyGzipCompLevel(key, trimmed string) error { if err := validatePositiveIntegerOption(key, trimmed); err != nil { return err } level, _ := strconv.Atoi(trimmed) if level > maxOpenRestyGzipCompLevel { return fmt.Errorf("%s 不能大于 %d", key, maxOpenRestyGzipCompLevel) } return nil } func validateOpenRestyEventsUse(key, trimmed string) error { if trimmed == "" { return nil } switch trimmed { case "epoll", "kqueue", "poll", "select", "rtsig", "/dev/poll", "eventport": return nil default: return fmt.Errorf("%s 仅支持 epoll、kqueue、poll、select、rtsig、/dev/poll、eventport 或留空", key) } } func validateOpenRestyResolvers(key, trimmed string) error { if trimmed == "" { return nil } if !regexp.MustCompile(`^[a-zA-Z0-9.:\-\s]+$`).MatchString(trimmed) { return fmt.Errorf("%s 包含非法字符,请填入有效的 IP 地址或域名,以空格分隔", key) } return nil } func validateOpenRestyProxyBuffers(key, trimmed string) error { if openRestyProxyBuffersPattern.MatchString(trimmed) { return nil } return fmt.Errorf("%s 格式必须类似 \"16 16k\"", key) } func validateOpenRestySizeValue(key, trimmed string) error { if openRestySizePattern.MatchString(trimmed) { return nil } return fmt.Errorf("%s 格式必须为整数或带 k/m/g 单位的大小值", key) } func validateOpenRestyCachePath(key, trimmed string) error { if strings.ContainsAny(trimmed, "\r\n\t") { return fmt.Errorf("%s 不能包含换行或制表符", key) } return nil } func validateOpenRestyCacheLevels(key, trimmed string) error { if openRestyCacheLevelsPattern.MatchString(trimmed) { return nil } return fmt.Errorf("%s 格式必须类似 \"1:2\" 或 \"1:2:2\"", key) } func validateOpenRestyDurationToken(key, trimmed string) error { if openRestyDurationTokenPattern.MatchString(trimmed) { return nil } return fmt.Errorf("%s 格式必须为带单位的时长,例如 30m 或 5s", key) } func validateOpenRestyCacheKeyTemplate(key, trimmed string) error { if trimmed == "" { return fmt.Errorf("%s 不能为空", key) } if strings.ContainsAny(trimmed, "\r\n") { return fmt.Errorf("%s 不能包含换行", key) } return nil } func validateOpenRestyCacheUseStale(key, trimmed string) error { if trimmed == "" { return fmt.Errorf("%s 不能为空", key) } allowedTokens := map[string]struct{}{ "error": {}, "timeout": {}, "invalid_header": {}, "updating": {}, "http_500": {}, "http_502": {}, "http_503": {}, "http_504": {}, "http_403": {}, "http_404": {}, "http_429": {}, "off": {}, } for token := range strings.FieldsSeq(trimmed) { if _, ok := allowedTokens[token]; !ok { return fmt.Errorf("%s 包含不支持的值 %q", key, token) } } return nil } func validateOpenRestyMainConfigTemplate(key, value string) error { if strings.TrimSpace(value) == "" { return fmt.Errorf("%s 不能为空", key) } return nil } func validateOpenRestyDefaultLimitRate(key, trimmed string) error { if trimmed == "" || trimmed == "0" { return nil } if !openRestyDefaultLimitRatePattern.MatchString(strings.ToLower(trimmed)) { return fmt.Errorf("%s 格式不合法,请使用 512k、1m 或纯数字,空表示关闭", key) } return nil } var openRestyDefaultLimitReqPerIPPattern = regexp.MustCompile(`^\d+r/[sm]$`) func validateOpenRestyDefaultLimitReqPerIP(key, trimmed string) error { if trimmed == "" || trimmed == "0" { return nil } if !openRestyDefaultLimitReqPerIPPattern.MatchString(strings.ToLower(trimmed)) { return fmt.Errorf("%s 格式不合法,请输入类似 10r/s、100r/m,或留空关闭", key) } 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: %w", 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 } func validateSWOfflineHTML(key, value string) error { return validateOriginErrorPageHTML(key, value) } func validateSWOfflineDomains(key, value string) error { var domains []string if err := json.Unmarshal([]byte(value), &domains); err != nil || domains == nil { return fmt.Errorf("%s 必须为 JSON 字符串数组", key) } if len(domains) > maxSWOfflineDomains { return fmt.Errorf("%s 最多支持 %d 个域名", key, maxSWOfflineDomains) } seen := make(map[string]struct{}, len(domains)) for _, raw := range domains { domain := strings.ToLower(strings.TrimSpace(raw)) if domain == "" { return fmt.Errorf("%s 包含空域名", key) } if raw != domain { return fmt.Errorf("%s 域名必须为小写且不含首尾空格:%s", key, raw) } if _, ok := seen[domain]; ok { return fmt.Errorf("%s 包含重复域名 %s", key, domain) } seen[domain] = struct{}{} } return nil }