[功能] 添加OpenRestyResolvers支持,优化DNS解析器配置和验证逻辑

This commit is contained in:
ryan
2026-03-18 13:52:58 +08:00
parent e2202d1456
commit 7a22167997
14 changed files with 332 additions and 31 deletions
+1
View File
@@ -81,6 +81,7 @@ var OpenRestyProxyBusyBuffersSize = "64k"
var OpenRestyGzipEnabled = true
var OpenRestyGzipMinLength = 1024
var OpenRestyGzipCompLevel = 5
var OpenRestyResolvers = ""
var OpenRestyCacheEnabled = false
var OpenRestyCachePath = ""
var OpenRestyCacheLevels = "1:2"
+17
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"net"
"net/http"
"openflare/common"
"openflare/model"
@@ -113,6 +114,16 @@ func validateOpenRestyOption(key string, value string) error {
default:
return fmt.Errorf("%s 仅支持 epoll、kqueue、poll、select、rtsig、/dev/poll、eventport 或留空", key)
}
case "OpenRestyResolvers":
if trimmed == "" {
return nil
}
for _, token := range splitOpenRestyResolvers(trimmed) {
if net.ParseIP(token) == nil {
return fmt.Errorf("%s only supports IP resolver entries, invalid value %q", key, token)
}
}
return nil
case "OpenRestyEventsMultiAcceptEnabled",
"OpenRestyWebsocketEnabled",
"OpenRestyProxyRequestBufferingEnabled",
@@ -176,6 +187,12 @@ func validateOpenRestyOption(key string, value string) error {
}
}
func splitOpenRestyResolvers(value string) []string {
return strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == '\n' || r == '\r' || r == '\t' || r == ' '
})
}
// GetOptions godoc
// @Summary List editable options
// @Tags Options
@@ -32,6 +32,9 @@ func TestValidateOpenRestyOption(t *testing.T) {
{name: "cache use stale invalid", key: "OpenRestyCacheUseStale", value: "error whatever", wantErr: true},
{name: "gzip level valid", key: "OpenRestyGzipCompLevel", value: "9"},
{name: "gzip level invalid", key: "OpenRestyGzipCompLevel", value: "10", wantErr: true},
{name: "resolvers empty", key: "OpenRestyResolvers", value: ""},
{name: "resolvers valid", key: "OpenRestyResolvers", value: "1.1.1.1, 8.8.8.8"},
{name: "resolvers invalid", key: "OpenRestyResolvers", value: "dns.internal", wantErr: true},
}
for _, testCase := range testCases {
+3
View File
@@ -80,6 +80,7 @@ func InitOptionMap() {
common.OptionMap["OpenRestyGzipEnabled"] = strconv.FormatBool(common.OpenRestyGzipEnabled)
common.OptionMap["OpenRestyGzipMinLength"] = strconv.Itoa(common.OpenRestyGzipMinLength)
common.OptionMap["OpenRestyGzipCompLevel"] = strconv.Itoa(common.OpenRestyGzipCompLevel)
common.OptionMap["OpenRestyResolvers"] = common.OpenRestyResolvers
common.OptionMap["OpenRestyCacheEnabled"] = strconv.FormatBool(common.OpenRestyCacheEnabled)
common.OptionMap["OpenRestyCachePath"] = common.OpenRestyCachePath
common.OptionMap["OpenRestyCacheLevels"] = common.OpenRestyCacheLevels
@@ -298,6 +299,8 @@ func updateOptionMap(key string, value string) {
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.OpenRestyGzipCompLevel = v
}
case "OpenRestyResolvers":
common.OpenRestyResolvers = strings.TrimSpace(value)
case "OpenRestyCacheEnabled":
common.OpenRestyCacheEnabled = value == "true"
case "OpenRestyCachePath":
+48 -12
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"openflare/common"
"openflare/model"
@@ -89,6 +90,7 @@ type openRestyConfigSnapshot struct {
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"`
@@ -124,7 +126,6 @@ const (
nginxLuaDirPlaceholder = "__OPENFLARE_LUA_DIR__"
nginxObservabilityListenPlaceholder = "__OPENFLARE_OBSERVABILITY_LISTEN__"
nginxObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__"
nginxResolverDirectivePlaceholder = "__OPENFLARE_RESOLVER_DIRECTIVE__"
)
var requiredMainConfigTemplatePlaceholders = []string{
@@ -355,7 +356,7 @@ func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) {
if err != nil {
return nil, err
}
routeConfig, supportFiles, err := renderRouteConfig(routes)
routeConfig, supportFiles, err := renderRouteConfig(routes, openRestyConfig)
if err != nil {
return nil, err
}
@@ -469,6 +470,7 @@ func buildOpenRestyConfigSnapshot() openRestyConfigSnapshot {
GzipEnabled: common.OpenRestyGzipEnabled,
GzipMinLength: common.OpenRestyGzipMinLength,
GzipCompLevel: common.OpenRestyGzipCompLevel,
Resolvers: common.OpenRestyResolvers,
CacheEnabled: common.OpenRestyCacheEnabled,
CachePath: common.OpenRestyCachePath,
CacheLevels: common.OpenRestyCacheLevels,
@@ -530,6 +532,7 @@ func diffOpenRestyOptionDetails(left openRestyConfigSnapshot, right openRestyCon
appendIfChanged("OpenRestyGzipEnabled", fmt.Sprintf("%t", left.GzipEnabled), fmt.Sprintf("%t", right.GzipEnabled))
appendIfChanged("OpenRestyGzipMinLength", fmt.Sprintf("%d", left.GzipMinLength), fmt.Sprintf("%d", right.GzipMinLength))
appendIfChanged("OpenRestyGzipCompLevel", fmt.Sprintf("%d", left.GzipCompLevel), fmt.Sprintf("%d", right.GzipCompLevel))
appendIfChanged("OpenRestyResolvers", left.Resolvers, right.Resolvers)
appendIfChanged("OpenRestyCacheEnabled", fmt.Sprintf("%t", left.CacheEnabled), fmt.Sprintf("%t", right.CacheEnabled))
appendIfChanged("OpenRestyCachePath", left.CachePath, right.CachePath)
appendIfChanged("OpenRestyCacheLevels", left.CacheLevels, right.CacheLevels)
@@ -576,6 +579,7 @@ func openRestyOptionKeys() []string {
"OpenRestyGzipEnabled",
"OpenRestyGzipMinLength",
"OpenRestyGzipCompLevel",
"OpenRestyResolvers",
"OpenRestyCacheEnabled",
"OpenRestyCachePath",
"OpenRestyCacheLevels",
@@ -588,7 +592,7 @@ func openRestyOptionKeys() []string {
}
}
func renderRouteConfig(routes []*model.ProxyRoute) (string, []SupportFile, error) {
func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot) (string, []SupportFile, error) {
var builder strings.Builder
builder.WriteString("# This file is generated by OpenFlare. Do not edit manually.\n")
supportFiles := make([]SupportFile, 0)
@@ -598,7 +602,7 @@ func renderRouteConfig(routes []*model.ProxyRoute) (string, []SupportFile, error
return "", nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain)
}
if !route.EnableHTTPS {
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders))
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cfg))
continue
}
if route.CertID == nil || *route.CertID == 0 {
@@ -615,9 +619,9 @@ func renderRouteConfig(routes []*model.ProxyRoute) (string, []SupportFile, error
if route.RedirectHTTP {
builder.WriteString(renderHTTPRedirectServer(route.Domain))
} else {
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders))
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cfg))
}
builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, route.OriginHost, certificate.ID, customHeaders))
builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, route.OriginHost, certificate.ID, customHeaders, cfg))
}
return builder.String(), dedupeSupportFiles(supportFiles), nil
}
@@ -673,7 +677,7 @@ func renderMainConfigTemplate(templateText string, cfg openRestyConfigSnapshot)
"{{OpenRestyGzip}}", onOff(cfg.GzipEnabled),
"{{OpenRestyGzipMinLength}}", fmt.Sprintf("%d", cfg.GzipMinLength),
"{{OpenRestyGzipCompLevel}}", fmt.Sprintf("%d", cfg.GzipCompLevel),
"{{OpenRestyResolverDirective}}", nginxResolverDirectivePlaceholder,
"{{OpenRestyResolverDirective}}", renderResolverDirective(cfg.Resolvers),
"{{OpenRestyCacheBlock}}", renderOpenRestyCacheTemplateBlock(cfg),
"{{OpenRestyRouteConfigInclude}}", nginxRouteConfigPlaceholder,
)
@@ -752,18 +756,18 @@ func nextVersionNumber(now time.Time) (string, error) {
return fmt.Sprintf("%s-%03d", prefix, count+1), nil
}
func renderHTTPProxyServer(domain string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput) string {
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s\n location / {\n%s%s }\n}\n\n", domain, renderExactHostGuard(domain), renderProxyHeaderBlock(originURL, originHost, customHeaders), renderProxyPassBlock(originURL))
func renderHTTPProxyServer(domain string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, cfg openRestyConfigSnapshot) string {
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s\n location / {\n%s%s }\n}\n\n", domain, renderExactHostGuard(domain), renderProxyHeaderBlock(originURL, originHost, customHeaders), renderProxyPassBlock(originURL, cfg))
}
func renderHTTPRedirectServer(domain string) string {
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s\n return 301 https://$host$request_uri;\n}\n\n", domain, renderExactHostGuard(domain))
}
func renderHTTPSServer(domain string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput) string {
func renderHTTPSServer(domain string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput, cfg openRestyConfigSnapshot) string {
certPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateCertFileName(certificateID))
keyPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateKeyFileName(certificateID))
return fmt.Sprintf("server {\n listen 443 ssl;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s\n location / {\n%s%s }\n}\n\n", domain, certPath, keyPath, renderExactHostGuard(domain), renderProxyHeaderBlock(originURL, originHost, customHeaders), renderProxyPassBlock(originURL))
return fmt.Sprintf("server {\n listen 443 ssl;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s\n location / {\n%s%s }\n}\n\n", domain, certPath, keyPath, renderExactHostGuard(domain), renderProxyHeaderBlock(originURL, originHost, customHeaders), renderProxyPassBlock(originURL, cfg))
}
func renderExactHostGuard(domain string) string {
@@ -798,11 +802,14 @@ func renderProxyHeaderBlock(originURL string, originHost string, customHeaders [
return builder.String()
}
func renderProxyPassBlock(originURL string) string {
func renderProxyPassBlock(originURL string, cfg openRestyConfigSnapshot) string {
parsed, err := url.Parse(originURL)
if err != nil || parsed.Host == "" || parsed.Scheme == "" {
return fmt.Sprintf(" proxy_pass %s;\n", originURL)
}
if !shouldUseRuntimeResolver(originURL, cfg.Resolvers) {
return fmt.Sprintf(" proxy_pass %s;\n", originURL)
}
upstreamURL := fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
basePath := strings.TrimRight(parsed.EscapedPath(), "/")
if basePath == "" || basePath == "." {
@@ -825,6 +832,35 @@ func renderProxyPassBlock(originURL string) string {
return builder.String()
}
func shouldUseRuntimeResolver(originURL string, resolvers string) bool {
if strings.TrimSpace(resolvers) == "" {
return false
}
return requiresRuntimeResolver(originURL)
}
func requiresRuntimeResolver(originURL string) bool {
parsed, err := url.Parse(strings.TrimSpace(originURL))
if err != nil || parsed.Hostname() == "" {
return false
}
return net.ParseIP(parsed.Hostname()) == nil
}
func renderResolverDirective(value string) string {
resolvers := splitResolverList(value)
if len(resolvers) == 0 {
return ""
}
return fmt.Sprintf(" resolver %s valid=30s ipv6=off;\n resolver_timeout 5s;\n", strings.Join(resolvers, " "))
}
func splitResolverList(value string) []string {
return strings.FieldsFunc(strings.TrimSpace(value), func(r rune) bool {
return r == ',' || r == '\n' || r == '\r' || r == '\t' || r == ' '
})
}
func resolveUpstreamServerName(originURL string, originHost string) string {
parsed, err := url.Parse(originURL)
if err != nil || !strings.EqualFold(parsed.Scheme, "https") {
+62 -8
View File
@@ -63,8 +63,8 @@ func TestCreateTLSCertificateAndRenderHTTPSConfig(t *testing.T) {
if !strings.Contains(result.Version.MainConfig, "listen __OPENFLARE_OBSERVABILITY_LISTEN__;") {
t.Fatal("expected main config to include managed openresty observability listen placeholder")
}
if !strings.Contains(result.Version.MainConfig, "__OPENFLARE_RESOLVER_DIRECTIVE__") {
t.Fatal("expected main config to include managed resolver directive placeholder")
if strings.Contains(result.Version.MainConfig, "resolver ") {
t.Fatal("expected main config to omit resolver directive when no resolvers are configured")
}
if strings.Contains(result.Version.MainConfig, "allow 127.0.0.1;") {
t.Fatal("expected main config to avoid hard-coded allow rules on observability server")
@@ -141,11 +141,11 @@ func TestPublishConfigVersionRendersCustomHeaders(t *testing.T) {
if !strings.Contains(result.Version.RenderedConfig, "proxy_set_header Connection $http_connection;") {
t.Fatal("expected rendered config to forward websocket connection header")
}
if !strings.Contains(result.Version.RenderedConfig, `set $openflare_upstream "https://origin.internal";`) {
t.Fatal("expected rendered config to defer upstream resolution via variable proxy_pass")
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://origin.internal;") {
t.Fatal("expected rendered config to keep direct proxy_pass when no resolvers are configured")
}
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass $openflare_upstream$request_uri;") {
t.Fatal("expected rendered config to proxy via runtime-resolved upstream variable")
if strings.Contains(result.Version.RenderedConfig, "proxy_pass $openflare_upstream$request_uri;") {
t.Fatal("expected rendered config to avoid runtime-resolved proxy_pass when no resolvers are configured")
}
}
@@ -175,14 +175,68 @@ func TestPublishConfigVersionOverridesOriginHostHeader(t *testing.T) {
if !strings.Contains(result.Version.RenderedConfig, `proxy_ssl_name "git.arctel.net";`) {
t.Fatal("expected rendered config to set proxy ssl name from origin host override")
}
if !strings.Contains(result.Version.RenderedConfig, `set $openflare_upstream "https://git.arctel.net";`) {
t.Fatal("expected rendered config to avoid resolving https upstream during config load")
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://git.arctel.net;") {
t.Fatal("expected rendered config to keep direct proxy_pass for hostname origin when resolvers are blank")
}
if !strings.Contains(result.Version.SnapshotJSON, `"origin_host":"git.arctel.net"`) {
t.Fatal("expected snapshot to include origin_host override")
}
}
func TestPublishConfigVersionUsesRuntimeResolverWhenConfigured(t *testing.T) {
setupServiceTestDB(t)
if err := model.UpdateOption("OpenRestyResolvers", "1.1.1.1, 8.8.8.8"); err != nil {
t.Fatalf("UpdateOption OpenRestyResolvers failed: %v", err)
}
_, err := CreateProxyRoute(ProxyRouteInput{
Domain: "resolver.example.com",
OriginURL: "https://origin.internal",
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
result, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if !strings.Contains(result.Version.MainConfig, "resolver 1.1.1.1 8.8.8.8 valid=30s ipv6=off;") {
t.Fatal("expected main config to render configured resolver directive")
}
if !strings.Contains(result.Version.RenderedConfig, `set $openflare_upstream "https://origin.internal";`) {
t.Fatal("expected rendered config to use runtime upstream variable when resolvers are configured")
}
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass $openflare_upstream$request_uri;") {
t.Fatal("expected rendered config to proxy via runtime-resolved upstream variable when resolvers are configured")
}
}
func TestPublishConfigVersionKeepsDirectProxyPassForIPOrigins(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
Domain: "ip-origin.example.com",
OriginURL: "http://10.0.0.8:8080",
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
result, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if !strings.Contains(result.Version.RenderedConfig, "proxy_pass http://10.0.0.8:8080;") {
t.Fatal("expected rendered config to keep direct proxy_pass for IP origin")
}
if strings.Contains(result.Version.RenderedConfig, `set $openflare_upstream "http://10.0.0.8:8080"`) {
t.Fatal("expected rendered config to avoid runtime resolver variables for IP origin")
}
}
func TestPreviewConfigVersionCanDisableWebsocketHeaders(t *testing.T) {
setupServiceTestDB(t)
@@ -55,6 +55,7 @@ const defaultPerformanceFields = {
OpenRestyGzipEnabled: true,
OpenRestyGzipMinLength: '1024',
OpenRestyGzipCompLevel: '5',
OpenRestyResolvers: '',
OpenRestyCacheEnabled: false,
OpenRestyCachePath: '',
OpenRestyCacheLevels: '1:2',
@@ -103,6 +104,8 @@ const performanceFieldTooltips: Record<string, string> = {
gzip_min_length:
'只有响应体超过该字节数时才会启用 gzip,避免对极小响应做无意义压缩。',
gzip_comp_level: 'gzip 压缩等级,1 更省 CPU,9 压缩更高但更耗 CPU。',
resolvers:
'可选填写运行时 DNS 解析器 IP,支持逗号、空格或换行分隔;留空时不额外生成 resolver 指令。',
proxy_cache_path: '缓存目录路径,对应 proxy_cache_path 指令中的磁盘位置。',
levels: '缓存目录层级,例如 1:2,可控制缓存文件的目录分布。',
inactive: '缓存对象在未命中访问时的失活时间,例如 30m。',
@@ -241,6 +244,7 @@ export function PerformancePage() {
OpenRestyGzipEnabled: toBoolean(optionMap.OpenRestyGzipEnabled, true),
OpenRestyGzipMinLength: optionMap.OpenRestyGzipMinLength ?? '1024',
OpenRestyGzipCompLevel: optionMap.OpenRestyGzipCompLevel ?? '5',
OpenRestyResolvers: optionMap.OpenRestyResolvers ?? '',
OpenRestyCacheEnabled: toBoolean(optionMap.OpenRestyCacheEnabled, false),
OpenRestyCachePath: optionMap.OpenRestyCachePath ?? '',
OpenRestyCacheLevels: optionMap.OpenRestyCacheLevels ?? '1:2',
@@ -420,6 +424,7 @@ export function PerformancePage() {
'OpenRestyProxyReadTimeout',
performanceFields.OpenRestyProxyReadTimeout.trim(),
],
['OpenRestyResolvers', performanceFields.OpenRestyResolvers.trim()],
[
'OpenRestyWebsocketEnabled',
String(performanceFields.OpenRestyWebsocketEnabled),
@@ -938,6 +943,23 @@ export function PerformancePage() {
}
/>
</ResourceField>
<ResourceField
label="resolver"
tooltip={performanceFieldTooltips.resolvers}
hint="留空时走 OpenResty 默认行为;填写时请使用 DNS 服务器 IP。"
>
<ResourceTextarea
value={performanceFields.OpenRestyResolvers}
onChange={(event) =>
setPerformanceFields((previous) => ({
...previous,
OpenRestyResolvers: event.target.value,
}))
}
placeholder="例如:10.0.0.2, 1.1.1.1"
minRows={3}
/>
</ResourceField>
<ToggleField
label="websocket"
tooltip={performanceFieldTooltips.websocket}