From 7a221679976575096bb2bc72a6601a613193c145 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 18 Mar 2026 13:52:58 +0800 Subject: [PATCH] =?UTF-8?q?[=E5=8A=9F=E8=83=BD]=20=E6=B7=BB=E5=8A=A0OpenRe?= =?UTF-8?q?styResolvers=E6=94=AF=E6=8C=81=EF=BC=8C=E4=BC=98=E5=8C=96DNS?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E5=99=A8=E9=85=8D=E7=BD=AE=E5=92=8C=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + docs/app-config.md | 2 + openflare_agent/cmd/agent/main.go | 2 +- openflare_agent/internal/config/config.go | 27 +++++++ .../internal/config/config_test.go | 39 +++++++++++ openflare_agent/internal/nginx/manager.go | 63 +++++++++++++++-- .../internal/nginx/manager_test.go | 53 ++++++++++++-- openflare_server/common/constants.go | 1 + openflare_server/controller/option.go | 17 +++++ openflare_server/controller/option_test.go | 3 + openflare_server/model/option.go | 3 + openflare_server/service/config_version.go | 60 ++++++++++++---- openflare_server/service/https_phase1_test.go | 70 ++++++++++++++++--- .../components/performance-page.tsx | 22 ++++++ 14 files changed, 332 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index 505622db..715ad0e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .cache +.gocache* .idea .vscode upload diff --git a/docs/app-config.md b/docs/app-config.md index 98e50e6a..d4a9635b 100644 --- a/docs/app-config.md +++ b/docs/app-config.md @@ -76,12 +76,14 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前 * `OpenRestyProxyReadTimeout` * `OpenRestyProxyBufferingEnabled` * `OpenRestyGzipEnabled` +* `OpenRestyResolvers` * `OpenRestyCacheEnabled` * `OpenRestyCachePath` * `OpenRestyCacheMaxSize` 这类参数必须以结构化方式校验、保存并参与版本渲染。 +* `OpenRestyResolvers` 由管理端性能页面维护,支持填写多个 DNS 服务器 IP;留空时不额外生成 `resolver` 指令。 ### 1.5 前端构建环境变量 | 环境变量 | 作用 | 默认值 | diff --git a/openflare_agent/cmd/agent/main.go b/openflare_agent/cmd/agent/main.go index 7df23c0f..ef548579 100644 --- a/openflare_agent/cmd/agent/main.go +++ b/openflare_agent/cmd/agent/main.go @@ -73,7 +73,7 @@ func main() { NginxLuaDir: cfg.OpenrestyLuaDir, OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyPath, cfg.OpenrestyObservabilityPort), OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort, - OpenrestyResolverDirective: nginx.ResolverDirective(cfg.OpenrestyPath), + OpenrestyResolverDirective: "", Executor: nginx.NewExecutor(nginx.ExecutorOptions{ NginxPath: cfg.OpenrestyPath, DockerBinary: cfg.DockerBinary, diff --git a/openflare_agent/internal/config/config.go b/openflare_agent/internal/config/config.go index d522ad86..37e939ca 100644 --- a/openflare_agent/internal/config/config.go +++ b/openflare_agent/internal/config/config.go @@ -33,6 +33,7 @@ type Config struct { AgentVersion string `json:"-"` NginxVersion string `json:"-"` OpenrestyPath string `json:"openresty_path"` + OpenrestyResolvers []string `json:"openresty_resolvers,omitempty"` OpenrestyContainerName string `json:"openresty_container_name"` OpenrestyDockerImage string `json:"openresty_docker_image"` DockerBinary string `json:"docker_binary"` @@ -59,6 +60,7 @@ type configFile struct { NodeName string `json:"node_name"` NodeIP string `json:"node_ip"` OpenrestyPath string `json:"openresty_path"` + OpenrestyResolvers []string `json:"openresty_resolvers"` OpenrestyContainerName string `json:"openresty_container_name"` OpenrestyDockerImage string `json:"openresty_docker_image"` DockerBinary string `json:"docker_binary"` @@ -93,6 +95,7 @@ func Load(path string) (*Config, error) { NodeName: file.NodeName, NodeIP: file.NodeIP, OpenrestyPath: file.OpenrestyPath, + OpenrestyResolvers: append([]string{}, file.OpenrestyResolvers...), OpenrestyContainerName: file.OpenrestyContainerName, OpenrestyDockerImage: file.OpenrestyDockerImage, DockerBinary: file.DockerBinary, @@ -121,6 +124,7 @@ func Load(path string) (*Config, error) { func applyDefaults(cfg *Config, baseDir string) { baseDir = filepath.Clean(baseDir) cfg.AgentVersion = AgentVersion + cfg.OpenrestyResolvers = normalizeResolverList(cfg.OpenrestyResolvers) if cfg.OpenrestyContainerName == "" { cfg.OpenrestyContainerName = "openflare-openresty" } @@ -290,6 +294,29 @@ func detectHostname() string { return strings.TrimSpace(host) } +func normalizeResolverList(values []string) []string { + if len(values) == 0 { + return nil + } + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + if len(result) == 0 { + return nil + } + return result +} + func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { diff --git a/openflare_agent/internal/config/config_test.go b/openflare_agent/internal/config/config_test.go index 2b692afb..d67ff231 100644 --- a/openflare_agent/internal/config/config_test.go +++ b/openflare_agent/internal/config/config_test.go @@ -120,6 +120,40 @@ func TestLoadPathModeKeepsExplicitPaths(t *testing.T) { } } +func TestLoadNormalizesExplicitResolvers(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "agent.json") + payload := map[string]any{ + "server_url": "http://127.0.0.1:3000", + "agent_token": "token", + "node_name": "edge-01", + "node_ip": "10.0.0.8", + "openresty_resolvers": []string{" 10.0.0.2 ", "10.0.0.2", "", "1.1.1.1"}, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err = os.WriteFile(configPath, data, 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + expected := []string{"10.0.0.2", "1.1.1.1"} + if len(cfg.OpenrestyResolvers) != len(expected) { + t.Fatalf("unexpected resolver count: %#v", cfg.OpenrestyResolvers) + } + for index, value := range expected { + if cfg.OpenrestyResolvers[index] != value { + t.Fatalf("unexpected resolver at %d: got %q want %q", index, cfg.OpenrestyResolvers[index], value) + } + } +} + func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "agent.json") @@ -209,6 +243,7 @@ func TestSavePersistsMillisecondsAndOmitsRuntimeVersions(t *testing.T) { cfg.NginxVersion = "1.27.1.2" cfg.HeartbeatInterval = MillisecondDuration(5 * time.Second) cfg.RequestTimeout = MillisecondDuration(7 * time.Second) + cfg.OpenrestyResolvers = []string{"10.0.0.2", "1.1.1.1"} if err = cfg.Save(); err != nil { t.Fatalf("Save failed: %v", err) @@ -234,6 +269,10 @@ func TestSavePersistsMillisecondsAndOmitsRuntimeVersions(t *testing.T) { if decoded["request_timeout"] != float64(7000) { t.Fatalf("unexpected request timeout: %#v", decoded["request_timeout"]) } + resolvers, ok := decoded["openresty_resolvers"].([]any) + if !ok || len(resolvers) != 2 || resolvers[0] != "10.0.0.2" || resolvers[1] != "1.1.1.1" { + t.Fatalf("unexpected resolvers: %#v", decoded["openresty_resolvers"]) + } if decoded["openresty_observability_port"] != float64(defaultOpenRestyObservabilityPort) { t.Fatalf("unexpected observability port: %#v", decoded["openresty_observability_port"]) } diff --git a/openflare_agent/internal/nginx/manager.go b/openflare_agent/internal/nginx/manager.go index f5bce03d..8c81da21 100644 --- a/openflare_agent/internal/nginx/manager.go +++ b/openflare_agent/internal/nginx/manager.go @@ -8,6 +8,8 @@ import ( "fmt" "io/fs" "log/slog" + "net" + "net/url" "os" "os/exec" "path/filepath" @@ -394,6 +396,9 @@ func (m *Manager) writeTargetFiles(mainConfig string, routeConfig string, suppor if err := m.writeCertFiles(supportFiles); err != nil { return err } + if strings.TrimSpace(m.OpenrestyResolverDirective) == "" && strings.Contains(routeConfig, "set $openflare_upstream ") { + slog.Warn("runtime-resolved hostname upstreams detected without available resolvers; hostname origin requests may fail until resolvers are configured") + } renderedMainConfig := m.renderMainConfig(mainConfig) if err := os.WriteFile(m.MainConfigPath, []byte(renderedMainConfig), 0o644); err != nil { return err @@ -1022,23 +1027,27 @@ func ObservabilityListenAddress(openrestyPath string, port int) string { return fmt.Sprintf("%d", port) } -func ResolverDirective(openrestyPath string) string { - resolvers := resolverAddresses(openrestyPath) +func ResolverDirective(openrestyPath string, explicitResolvers []string) string { + resolvers := resolverAddresses(openrestyPath, explicitResolvers) if len(resolvers) == 0 { return "" } return fmt.Sprintf(" resolver %s valid=30s ipv6=off;\n resolver_timeout 5s;\n", strings.Join(resolvers, " ")) } -func resolverAddresses(openrestyPath string) []string { - if strings.TrimSpace(openrestyPath) == "" { - return []string{"127.0.0.11"} +func resolverAddresses(openrestyPath string, explicitResolvers []string) []string { + if resolvers := normalizeResolverAddresses(explicitResolvers); len(resolvers) > 0 { + return resolvers } data, err := os.ReadFile("/etc/resolv.conf") if err != nil { return nil } - lines := strings.Split(string(data), "\n") + return parseResolverAddresses(string(data), strings.TrimSpace(openrestyPath) == "") +} + +func parseResolverAddresses(content string, dockerMode bool) []string { + lines := strings.Split(content, "\n") resolvers := make([]string, 0, 2) seen := make(map[string]struct{}) for _, line := range lines { @@ -1050,6 +1059,9 @@ func resolverAddresses(openrestyPath string) []string { if addr == "" { continue } + if dockerMode && !isUsableDockerResolver(addr) { + continue + } if _, ok := seen[addr]; ok { continue } @@ -1059,6 +1071,45 @@ func resolverAddresses(openrestyPath string) []string { return resolvers } +func isUsableDockerResolver(addr string) bool { + ip := net.ParseIP(addr) + if ip == nil { + return false + } + return !ip.IsLoopback() && !ip.IsUnspecified() +} + +func normalizeResolverAddresses(values []string) []string { + if len(values) == 0 { + return nil + } + resolvers := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + addr := strings.TrimSpace(value) + if addr == "" { + continue + } + if _, ok := seen[addr]; ok { + continue + } + seen[addr] = struct{}{} + resolvers = append(resolvers, addr) + } + if len(resolvers) == 0 { + return nil + } + return resolvers +} + +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 (m *Manager) routeConfigIncludePath() string { if strings.TrimSpace(m.RuntimeRouteConfigPath) != "" { return strings.TrimSpace(m.RuntimeRouteConfigPath) diff --git a/openflare_agent/internal/nginx/manager_test.go b/openflare_agent/internal/nginx/manager_test.go index d9e13f31..baa06226 100644 --- a/openflare_agent/internal/nginx/manager_test.go +++ b/openflare_agent/internal/nginx/manager_test.go @@ -762,10 +762,55 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) { } } -func TestResolverDirectiveForDockerMode(t *testing.T) { - got := ResolverDirective("") - if !strings.Contains(got, "resolver 127.0.0.11") { - t.Fatalf("expected docker resolver directive, got %q", got) +func TestResolverDirectiveUsesExplicitResolvers(t *testing.T) { + got := ResolverDirective("", []string{"10.0.0.2", "1.1.1.1"}) + if !strings.Contains(got, "resolver 10.0.0.2 1.1.1.1") { + t.Fatalf("expected explicit resolver directive, got %q", got) + } +} + +func TestParseResolverAddressesFiltersLoopbackForDocker(t *testing.T) { + content := strings.Join([]string{ + "nameserver 127.0.0.53", + "nameserver 10.0.0.2", + "nameserver ::1", + "nameserver 1.1.1.1", + }, "\n") + got := parseResolverAddresses(content, true) + expected := []string{"10.0.0.2", "1.1.1.1"} + if !reflect.DeepEqual(got, expected) { + t.Fatalf("unexpected docker resolvers: got %#v want %#v", got, expected) + } +} + +func TestParseResolverAddressesKeepsLoopbackForLocalBinary(t *testing.T) { + content := strings.Join([]string{ + "nameserver 127.0.0.53", + "nameserver 10.0.0.2", + }, "\n") + got := parseResolverAddresses(content, false) + expected := []string{"127.0.0.53", "10.0.0.2"} + if !reflect.DeepEqual(got, expected) { + t.Fatalf("unexpected local resolvers: got %#v want %#v", got, expected) + } +} + +func TestRequiresRuntimeResolver(t *testing.T) { + testCases := []struct { + name string + originURL string + want bool + }{ + {name: "hostname", originURL: "https://origin.internal", want: true}, + {name: "ipv4", originURL: "https://10.0.0.8", want: false}, + {name: "ipv6", originURL: "https://[2001:db8::1]", want: false}, + {name: "invalid", originURL: "://bad", want: false}, + } + + for _, testCase := range testCases { + if got := RequiresRuntimeResolver(testCase.originURL); got != testCase.want { + t.Fatalf("%s: got %v want %v", testCase.name, got, testCase.want) + } } } diff --git a/openflare_server/common/constants.go b/openflare_server/common/constants.go index e6ab7782..4a74bf75 100644 --- a/openflare_server/common/constants.go +++ b/openflare_server/common/constants.go @@ -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" diff --git a/openflare_server/controller/option.go b/openflare_server/controller/option.go index 18e2dcf4..9f1afaca 100644 --- a/openflare_server/controller/option.go +++ b/openflare_server/controller/option.go @@ -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 diff --git a/openflare_server/controller/option_test.go b/openflare_server/controller/option_test.go index 6364beb3..d69cfdd4 100644 --- a/openflare_server/controller/option_test.go +++ b/openflare_server/controller/option_test.go @@ -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 { diff --git a/openflare_server/model/option.go b/openflare_server/model/option.go index 359d341c..3aa11ae7 100644 --- a/openflare_server/model/option.go +++ b/openflare_server/model/option.go @@ -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": diff --git a/openflare_server/service/config_version.go b/openflare_server/service/config_version.go index 50823fcc..0f30a669 100644 --- a/openflare_server/service/config_version.go +++ b/openflare_server/service/config_version.go @@ -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") { diff --git a/openflare_server/service/https_phase1_test.go b/openflare_server/service/https_phase1_test.go index c945df17..8d92e0b3 100644 --- a/openflare_server/service/https_phase1_test.go +++ b/openflare_server/service/https_phase1_test.go @@ -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) diff --git a/openflare_server/web/features/performance/components/performance-page.tsx b/openflare_server/web/features/performance/components/performance-page.tsx index cf1df7df..ef72866d 100644 --- a/openflare_server/web/features/performance/components/performance-page.tsx +++ b/openflare_server/web/features/performance/components/performance-page.tsx @@ -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 = { 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() { } /> + + + setPerformanceFields((previous) => ({ + ...previous, + OpenRestyResolvers: event.target.value, + })) + } + placeholder="例如:10.0.0.2, 1.1.1.1" + minRows={3} + /> +