From 1fbe156a7c2098789dab8f16828a47cb9a80fc2e Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 18 Mar 2026 22:24:05 +0800 Subject: [PATCH] =?UTF-8?q?[=E5=8A=9F=E8=83=BD]=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=9A=E4=B8=AA=E4=B8=8A=E6=B8=B8=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=EF=BC=8C=E4=BC=98=E5=8C=96=E4=BB=A3=E7=90=86=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E9=85=8D=E7=BD=AE=E5=92=8C=E8=B4=9F=E8=BD=BD=E5=9D=87?= =?UTF-8?q?=E8=A1=A1=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/app-config.md | 2 + docs/design.md | 5 +- docs/development-guidelines.md | 2 +- openflare_server/common/constants.go | 6 +- openflare_server/model/proxy_route.go | 2 + openflare_server/router/api_phase1_test.go | 8 ++ openflare_server/service/config_version.go | 90 +++++++++++++++---- openflare_server/service/https_phase1_test.go | 53 +++++++++++ openflare_server/service/proxy_route.go | 80 ++++++++++++++++- .../components/performance-page.tsx | 12 +-- .../components/proxy-routes-page.tsx | 48 ++++++++++ .../web/features/proxy-routes/types.ts | 6 +- .../settings/components/settings-page.tsx | 12 +-- 13 files changed, 287 insertions(+), 39 deletions(-) diff --git a/docs/app-config.md b/docs/app-config.md index f51d2b31..c6a6bed2 100644 --- a/docs/app-config.md +++ b/docs/app-config.md @@ -85,6 +85,8 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前 * `OpenRestyResolvers` 由管理端性能页面维护,支持填写多个 DNS 服务器 IP;留空时不额外生成 `resolver` 指令。 * `OpenRestyCacheEnabled` 用于启用缓存基础设施与全局默认参数;实际是否缓存、按 URL / 后缀 / 路径等命中策略由各条 `proxy_routes` 单独决定,不再默认对所有规则开启缓存。 +* 默认缓存 Key 为 `$scheme$host$request_uri`,更贴近代理域名维度;如需按其他维度命中,可在性能页显式覆盖。 +* 默认 `keepalive_timeout` 为 `20` 秒,默认 `proxy_connect_timeout` 为 `3` 秒,优先兼顾资源占用与回源失败切换速度。 * 默认事件模型为 `epoll`,并默认开启 `multi_accept`;HTTPS 监听默认附带 `reuseport`,以改善多 worker 下的连接分发。 ### 1.5 前端构建环境变量 diff --git a/docs/design.md b/docs/design.md index 7a1145dd..44a4ae39 100644 --- a/docs/design.md +++ b/docs/design.md @@ -119,10 +119,11 @@ Origin 稳定约束: -* 一个域名只对应一个 `origin_url` +* 一个域名只对应一条 `proxy_routes` 规则 +* `proxy_routes` 至少包含一个上游地址;为兼容历史数据保留 `origin_url` 主上游字段,也允许在同一规则内补充多个上游做负载均衡 * `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头;未设置时默认透传访问域名 * `proxy_routes.domain` 必须唯一 -* `origin_url` 必须为合法 `http://` 或 `https://` +* 所有上游地址都必须为合法 `http://` 或 `https://` * `config_versions` 必须保存完整快照、渲染结果与 `checksum` * 全局同时只能有一个激活版本 * 回滚通过重新激活旧版本实现 diff --git a/docs/development-guidelines.md b/docs/development-guidelines.md index 16c1604d..85d7d908 100644 --- a/docs/development-guidelines.md +++ b/docs/development-guidelines.md @@ -115,7 +115,7 @@ 通用约束: * 不新增平台化对象,除非设计文档明确要求 -* `proxy_routes` 维持一条域名对应一个 `origin_url` +* `proxy_routes` 维持一条域名对应一条规则;规则内允许保存一个或多个上游地址用于负载均衡,但不引入独立 `origin_pool` * `proxy_routes.origin_host` 为可选字段,仅用于覆盖回源 `Host` 请求头,不引入新的平台化对象 * `config_versions` 必须保存完整快照与渲染结果 * 全局同时只能有一个激活版本 diff --git a/openflare_server/common/constants.go b/openflare_server/common/constants.go index 34045ea2..1a9fe6f5 100644 --- a/openflare_server/common/constants.go +++ b/openflare_server/common/constants.go @@ -62,14 +62,14 @@ var OpenRestyWorkerConnections = 4096 var OpenRestyWorkerRlimitNofile = 65535 var OpenRestyEventsUse = "epoll" var OpenRestyEventsMultiAcceptEnabled = true -var OpenRestyKeepaliveTimeout = 65 +var OpenRestyKeepaliveTimeout = 20 var OpenRestyKeepaliveRequests = 1000 var OpenRestyClientHeaderTimeout = 15 var OpenRestyClientBodyTimeout = 15 var OpenRestyClientMaxBodySize = "64m" var OpenRestyLargeClientHeaderBuffers = "4 16k" var OpenRestySendTimeout = 30 -var OpenRestyProxyConnectTimeout = 5 +var OpenRestyProxyConnectTimeout = 3 var OpenRestyProxySendTimeout = 60 var OpenRestyProxyReadTimeout = 60 var OpenRestyWebsocketEnabled = true @@ -87,7 +87,7 @@ var OpenRestyCachePath = "" var OpenRestyCacheLevels = "1:2" var OpenRestyCacheInactive = "30m" var OpenRestyCacheMaxSize = "1g" -var OpenRestyCacheKeyTemplate = "$scheme$proxy_host$request_uri" +var OpenRestyCacheKeyTemplate = "$scheme$host$request_uri" var OpenRestyCacheLockEnabled = true var OpenRestyCacheLockTimeout = "5s" var OpenRestyCacheUseStale = "error timeout updating http_500 http_502 http_503 http_504" diff --git a/openflare_server/model/proxy_route.go b/openflare_server/model/proxy_route.go index 7fbbf16a..dd75f983 100644 --- a/openflare_server/model/proxy_route.go +++ b/openflare_server/model/proxy_route.go @@ -7,6 +7,7 @@ type ProxyRoute struct { Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"` OriginURL string `json:"origin_url" gorm:"size:2048;not null"` OriginHost string `json:"origin_host" gorm:"size:255"` + Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"` Enabled bool `json:"enabled" gorm:"not null;default:true"` EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"` CertID *uint `json:"cert_id"` @@ -45,6 +46,7 @@ func (route *ProxyRoute) Update() error { "domain": route.Domain, "origin_url": route.OriginURL, "origin_host": route.OriginHost, + "upstreams": route.Upstreams, "enabled": route.Enabled, "enable_https": route.EnableHTTPS, "cert_id": route.CertID, diff --git a/openflare_server/router/api_phase1_test.go b/openflare_server/router/api_phase1_test.go index 9de7a5ac..7aa64daf 100644 --- a/openflare_server/router/api_phase1_test.go +++ b/openflare_server/router/api_phase1_test.go @@ -46,6 +46,7 @@ func TestPhase1PublishLifecycle(t *testing.T) { createBody := map[string]any{ "domain": "app.example.com", "origin_url": "https://origin-a.internal", + "upstreams": []string{"https://origin-a-backup.internal"}, "origin_host": "origin-a.internal", "enabled": true, "cache_enabled": true, @@ -65,6 +66,9 @@ func TestPhase1PublishLifecycle(t *testing.T) { if !createdRoute.CacheEnabled || createdRoute.CachePolicy != "path_prefix" { t.Fatalf("expected route cache settings to persist, got %+v", createdRoute) } + if !strings.Contains(createdRoute.Upstreams, "origin-a-backup.internal") { + t.Fatalf("expected route upstream list to persist, got %s", createdRoute.Upstreams) + } if !strings.Contains(createdRoute.CacheRules, "/assets") { t.Fatalf("expected route cache rules to persist, got %s", createdRoute.CacheRules) } @@ -114,6 +118,7 @@ func TestPhase1PublishLifecycle(t *testing.T) { updateBody := map[string]any{ "domain": "app.example.com", "origin_url": "https://origin-b.internal", + "upstreams": []string{"https://origin-b-backup.internal"}, "origin_host": "origin-b.internal", "enabled": true, "cache_enabled": true, @@ -133,6 +138,9 @@ func TestPhase1PublishLifecycle(t *testing.T) { if createdRoute.CachePolicy != "path_exact" || !strings.Contains(createdRoute.CacheRules, "/robots.txt") { t.Fatalf("expected updated route cache rules to persist, got %+v", createdRoute) } + if !strings.Contains(createdRoute.Upstreams, "origin-b-backup.internal") { + t.Fatalf("expected updated route upstream list to persist, got %s", createdRoute.Upstreams) + } resp = performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil) var version2 model.ConfigVersion diff --git a/openflare_server/service/config_version.go b/openflare_server/service/config_version.go index 125245dc..09294f9e 100644 --- a/openflare_server/service/config_version.go +++ b/openflare_server/service/config_version.go @@ -62,6 +62,7 @@ type snapshotRoute struct { Domain string `json:"domain"` OriginURL string `json:"origin_url"` OriginHost string `json:"origin_host,omitempty"` + Upstreams []string `json:"upstreams,omitempty"` Enabled bool `json:"enabled"` EnableHTTPS bool `json:"enable_https"` CertID *uint `json:"cert_id,omitempty"` @@ -82,7 +83,7 @@ type routeCacheConfig struct { type routeUpstreamConfig struct { Name string Scheme string - Address string + Addresses []string UsesNamedUpstream bool } @@ -408,6 +409,10 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) { if err != nil { return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain) } + upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL) + if err != nil { + return nil, fmt.Errorf("路由 %s 上游配置无效", route.Domain) + } cacheRules, err := decodeStoredCacheRules(route.CacheRules) if err != nil { return nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain) @@ -416,6 +421,7 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) { Domain: route.Domain, OriginURL: route.OriginURL, OriginHost: route.OriginHost, + Upstreams: upstreams, Enabled: route.Enabled, EnableHTTPS: route.EnableHTTPS, CertID: route.CertID, @@ -459,6 +465,11 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute { if err == nil { routes[index].CustomHeaders = normalizedHeaders } + normalizedUpstreams, err := normalizeUpstreams(routes[index].OriginURL, routes[index].Upstreams) + if err == nil { + routes[index].OriginURL = normalizedUpstreams[0] + routes[index].Upstreams = normalizedUpstreams + } normalizedCacheRules, err := normalizeCacheRules(routes[index].CacheEnabled, routes[index].CachePolicy, routes[index].CacheRules) if err == nil { routes[index].CachePolicy = normalizeCachePolicy(routes[index].CacheEnabled, routes[index].CachePolicy) @@ -472,6 +483,14 @@ func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool { if left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || !uintPointerEqual(left.CertID, right.CertID) { return false } + if len(left.Upstreams) != len(right.Upstreams) { + return false + } + for index := range left.Upstreams { + if left.Upstreams[index] != right.Upstreams[index] { + return false + } + } if len(left.CacheRules) != len(right.CacheRules) { return false } @@ -648,6 +667,10 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot) if err != nil { return "", nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain) } + upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL) + if err != nil { + return "", nil, fmt.Errorf("路由 %s 上游配置无效", route.Domain) + } cacheRules, err := decodeStoredCacheRules(route.CacheRules) if err != nil { return "", nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain) @@ -657,7 +680,7 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot) Policy: route.CachePolicy, Rules: cacheRules, } - upstreamConfig := buildRouteUpstreamConfig(route, cfg) + upstreamConfig := buildRouteUpstreamConfig(route, upstreams, cfg) if upstreamConfig.UsesNamedUpstream { builder.WriteString(renderNamedUpstreamBlock(upstreamConfig)) } @@ -965,24 +988,55 @@ func renderProxyPassBlock(originURL string, upstreamConfig routeUpstreamConfig, return builder.String() } -func buildRouteUpstreamConfig(route *model.ProxyRoute, cfg openRestyConfigSnapshot) routeUpstreamConfig { - parsed, err := url.Parse(strings.TrimSpace(route.OriginURL)) - if err != nil || parsed.Host == "" || parsed.Scheme == "" { +func buildRouteUpstreamConfig(route *model.ProxyRoute, upstreams []string, cfg openRestyConfigSnapshot) routeUpstreamConfig { + if len(upstreams) == 0 { return routeUpstreamConfig{} } - if shouldUseRuntimeResolver(route.OriginURL, cfg.Resolvers) { - return routeUpstreamConfig{} + if len(upstreams) == 1 { + parsed, err := url.Parse(strings.TrimSpace(upstreams[0])) + if err != nil || parsed.Host == "" || parsed.Scheme == "" { + return routeUpstreamConfig{} + } + if shouldUseRuntimeResolver(upstreams[0], cfg.Resolvers) { + return routeUpstreamConfig{} + } + if strings.TrimSpace(parsed.EscapedPath()) != "" && strings.TrimSpace(parsed.EscapedPath()) != "/" { + return routeUpstreamConfig{} + } + if parsed.RawQuery != "" { + return routeUpstreamConfig{} + } + return routeUpstreamConfig{ + Name: buildRouteUpstreamName(route), + Scheme: parsed.Scheme, + Addresses: []string{parsed.Host}, + UsesNamedUpstream: true, + } } - if strings.TrimSpace(parsed.EscapedPath()) != "" && strings.TrimSpace(parsed.EscapedPath()) != "/" { - return routeUpstreamConfig{} - } - if parsed.RawQuery != "" { - return routeUpstreamConfig{} + addresses := make([]string, 0, len(upstreams)) + var scheme string + for _, upstream := range upstreams { + parsed, err := url.Parse(strings.TrimSpace(upstream)) + if err != nil || parsed.Host == "" || parsed.Scheme == "" { + return routeUpstreamConfig{} + } + if strings.TrimSpace(parsed.EscapedPath()) != "" && strings.TrimSpace(parsed.EscapedPath()) != "/" { + return routeUpstreamConfig{} + } + if parsed.RawQuery != "" { + return routeUpstreamConfig{} + } + if scheme == "" { + scheme = parsed.Scheme + } else if scheme != parsed.Scheme { + return routeUpstreamConfig{} + } + addresses = append(addresses, parsed.Host) } return routeUpstreamConfig{ Name: buildRouteUpstreamName(route), - Scheme: parsed.Scheme, - Address: parsed.Host, + Scheme: scheme, + Addresses: addresses, UsesNamedUpstream: true, } } @@ -1008,7 +1062,13 @@ func buildRouteUpstreamName(route *model.ProxyRoute) string { } func renderNamedUpstreamBlock(upstreamConfig routeUpstreamConfig) string { - return fmt.Sprintf("upstream %s {\n server %s max_fails=3 fail_timeout=10s;\n keepalive 128;\n}\n\n", upstreamConfig.Name, upstreamConfig.Address) + var builder strings.Builder + builder.WriteString(fmt.Sprintf("upstream %s {\n", upstreamConfig.Name)) + for _, address := range upstreamConfig.Addresses { + builder.WriteString(fmt.Sprintf(" server %s max_fails=3 fail_timeout=10s;\n", address)) + } + builder.WriteString(" keepalive 128;\n}\n\n") + return builder.String() } func shouldUseRuntimeResolver(originURL string, resolvers string) bool { diff --git a/openflare_server/service/https_phase1_test.go b/openflare_server/service/https_phase1_test.go index 241a9446..bf9af93b 100644 --- a/openflare_server/service/https_phase1_test.go +++ b/openflare_server/service/https_phase1_test.go @@ -72,6 +72,12 @@ func TestCreateTLSCertificateAndRenderHTTPSConfig(t *testing.T) { if !strings.Contains(result.Version.MainConfig, "multi_accept on;") { t.Fatal("expected main config to default multi_accept to on") } + if !strings.Contains(result.Version.MainConfig, "keepalive_timeout 20;") { + t.Fatal("expected main config to default keepalive_timeout to 20") + } + if !strings.Contains(result.Version.MainConfig, "proxy_connect_timeout 3;") { + t.Fatal("expected main config to default proxy_connect_timeout to 3") + } 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") } @@ -212,6 +218,9 @@ func TestPublishConfigVersionRendersRouteLevelCachePolicy(t *testing.T) { if !strings.Contains(result.Version.MainConfig, "proxy_cache_path /var/cache/openresty/openflare") { t.Fatal("expected main config to include cache zone when cache infra is enabled") } + if !strings.Contains(result.Version.MainConfig, `proxy_cache_key "$scheme$host$request_uri";`) { + t.Fatal("expected main config to default cache key to host dimension") + } if !strings.Contains(result.Version.RenderedConfig, "proxy_cache_methods GET;") { t.Fatal("expected rendered config to only cache GET requests") } @@ -244,6 +253,50 @@ func TestPublishConfigVersionRendersRouteLevelCachePolicy(t *testing.T) { } } +func TestPublishConfigVersionRendersMultipleUpstreams(t *testing.T) { + setupServiceTestDB(t) + + route, err := CreateProxyRoute(ProxyRouteInput{ + Domain: "lb.example.com", + OriginURL: "http://c1:39010", + Upstreams: []string{"http://c2:39010", "http://c3:39010"}, + Enabled: true, + OriginHost: "lb.example.com", + }) + if err != nil { + t.Fatalf("CreateProxyRoute failed: %v", err) + } + if !strings.Contains(route.Upstreams, "c2:39010") { + t.Fatalf("expected route upstreams to persist, got %s", route.Upstreams) + } + + result, err := PublishConfigVersion("root") + if err != nil { + t.Fatalf("PublishConfigVersion failed: %v", err) + } + if !strings.Contains(result.Version.RenderedConfig, "upstream backend_lb_example_com_1 {") { + t.Fatal("expected rendered config to define upstream block for load balancing route") + } + if strings.Count(result.Version.RenderedConfig, "server c") < 3 { + t.Fatal("expected rendered config to include every upstream server") + } + if !strings.Contains(result.Version.RenderedConfig, "server c1:39010 max_fails=3 fail_timeout=10s;") { + t.Fatal("expected rendered config to include primary upstream server") + } + if !strings.Contains(result.Version.RenderedConfig, "server c2:39010 max_fails=3 fail_timeout=10s;") { + t.Fatal("expected rendered config to include secondary upstream server") + } + if !strings.Contains(result.Version.RenderedConfig, "server c3:39010 max_fails=3 fail_timeout=10s;") { + t.Fatal("expected rendered config to include tertiary upstream server") + } + if !strings.Contains(result.Version.RenderedConfig, "proxy_pass http://backend_lb_example_com_1;") { + t.Fatal("expected rendered config to proxy through load balancing upstream") + } + if !strings.Contains(result.Version.SnapshotJSON, `"upstreams":["http://c1:39010","http://c2:39010","http://c3:39010"]`) { + t.Fatal("expected snapshot to include upstream list") + } +} + func TestPublishConfigVersionOverridesOriginHostHeader(t *testing.T) { setupServiceTestDB(t) diff --git a/openflare_server/service/proxy_route.go b/openflare_server/service/proxy_route.go index 3fabb2da..11cf0f32 100644 --- a/openflare_server/service/proxy_route.go +++ b/openflare_server/service/proxy_route.go @@ -27,6 +27,7 @@ type ProxyRouteInput struct { Domain string `json:"domain"` OriginURL string `json:"origin_url"` OriginHost string `json:"origin_host"` + Upstreams []string `json:"upstreams"` Enabled bool `json:"enabled"` EnableHTTPS bool `json:"enable_https"` CertID *uint `json:"cert_id"` @@ -87,6 +88,10 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro originURL := strings.TrimSpace(input.OriginURL) originHost := strings.TrimSpace(input.OriginHost) remark := strings.TrimSpace(input.Remark) + upstreams, err := normalizeUpstreams(originURL, input.Upstreams) + if err != nil { + return nil, err + } cachePolicy := strings.TrimSpace(input.CachePolicy) cacheRules, err := normalizeCacheRules(input.CacheEnabled, cachePolicy, input.CacheRules) if err != nil { @@ -100,6 +105,10 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro if err != nil { return nil, err } + upstreamsJSON, err := json.Marshal(upstreams) + if err != nil { + return nil, err + } customHeadersJSON, err := json.Marshal(customHeaders) if err != nil { return nil, err @@ -110,9 +119,6 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro if strings.Contains(domain, "://") || strings.Contains(domain, "/") { return nil, errors.New("域名格式不合法") } - if err := validateOriginURL(originURL); err != nil { - return nil, err - } if err := validateOriginHost(originHost); err != nil { return nil, err } @@ -135,8 +141,9 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro route = &model.ProxyRoute{} } route.Domain = domain - route.OriginURL = originURL + route.OriginURL = upstreams[0] route.OriginHost = originHost + route.Upstreams = string(upstreamsJSON) route.Enabled = input.Enabled route.EnableHTTPS = input.EnableHTTPS route.CertID = input.CertID @@ -177,6 +184,59 @@ func normalizeCustomHeaders(headers []ProxyRouteCustomHeaderInput) ([]ProxyRoute return normalized, nil } +func normalizeUpstreams(originURL string, upstreams []string) ([]string, error) { + candidates := make([]string, 0, len(upstreams)+1) + if strings.TrimSpace(originURL) != "" { + candidates = append(candidates, originURL) + } + candidates = append(candidates, upstreams...) + trimmed := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + item := strings.TrimSpace(candidate) + if item == "" { + continue + } + trimmed = append(trimmed, item) + } + unique := make([]string, 0, len(trimmed)) + seen := make(map[string]struct{}, len(trimmed)) + for _, item := range trimmed { + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + unique = append(unique, item) + } + normalized := make([]string, 0, len(unique)) + var scheme string + multiUpstream := len(unique) > 1 + for _, item := range unique { + if err := validateOriginURL(item); err != nil { + return nil, err + } + parsed, err := url.ParseRequestURI(item) + if err != nil { + return nil, errors.New("源站地址格式不合法") + } + if multiUpstream && parsed.Path != "" && parsed.Path != "/" { + return nil, errors.New("多上游模式暂不支持带路径的源站地址") + } + if multiUpstream && parsed.RawQuery != "" { + return nil, errors.New("多上游模式暂不支持带查询参数的源站地址") + } + if scheme == "" { + scheme = parsed.Scheme + } else if scheme != parsed.Scheme { + return nil, errors.New("同一规则的多个上游必须使用相同协议") + } + normalized = append(normalized, item) + } + if len(normalized) == 0 { + return nil, errors.New("至少填写一个上游地址") + } + return normalized, nil +} + func decodeStoredCustomHeaders(raw string) ([]ProxyRouteCustomHeaderInput, error) { text := strings.TrimSpace(raw) if text == "" { @@ -291,6 +351,18 @@ func decodeStoredCacheRules(raw string) ([]string, error) { return normalized, nil } +func decodeStoredUpstreams(raw string, fallbackOriginURL string) ([]string, error) { + text := strings.TrimSpace(raw) + if text == "" { + return normalizeUpstreams(fallbackOriginURL, nil) + } + var upstreams []string + if err := json.Unmarshal([]byte(text), &upstreams); err != nil { + return nil, errors.New("上游配置格式不合法") + } + return normalizeUpstreams(fallbackOriginURL, upstreams) +} + func validateOriginURL(raw string) error { if raw == "" { return errors.New("源站地址不能为空") diff --git a/openflare_server/web/features/performance/components/performance-page.tsx b/openflare_server/web/features/performance/components/performance-page.tsx index 5cc59e69..fbdb6f7f 100644 --- a/openflare_server/web/features/performance/components/performance-page.tsx +++ b/openflare_server/web/features/performance/components/performance-page.tsx @@ -36,14 +36,14 @@ const defaultPerformanceFields = { OpenRestyWorkerRlimitNofile: '65535', OpenRestyEventsUse: 'epoll', OpenRestyEventsMultiAcceptEnabled: true, - OpenRestyKeepaliveTimeout: '65', + OpenRestyKeepaliveTimeout: '20', OpenRestyKeepaliveRequests: '1000', OpenRestyClientHeaderTimeout: '15', OpenRestyClientBodyTimeout: '15', OpenRestyClientMaxBodySize: '64m', OpenRestyLargeClientHeaderBuffers: '4 16k', OpenRestySendTimeout: '30', - OpenRestyProxyConnectTimeout: '5', + OpenRestyProxyConnectTimeout: '3', OpenRestyProxySendTimeout: '60', OpenRestyProxyReadTimeout: '60', OpenRestyWebsocketEnabled: true, @@ -61,7 +61,7 @@ const defaultPerformanceFields = { OpenRestyCacheLevels: '1:2', OpenRestyCacheInactive: '30m', OpenRestyCacheMaxSize: '1g', - OpenRestyCacheKeyTemplate: '$scheme$proxy_host$request_uri', + OpenRestyCacheKeyTemplate: '$scheme$host$request_uri', OpenRestyCacheLockEnabled: true, OpenRestyCacheLockTimeout: '5s', OpenRestyCacheUseStale: @@ -211,7 +211,7 @@ export function PerformancePage() { optionMap.OpenRestyEventsMultiAcceptEnabled, true, ), - OpenRestyKeepaliveTimeout: optionMap.OpenRestyKeepaliveTimeout ?? '65', + OpenRestyKeepaliveTimeout: optionMap.OpenRestyKeepaliveTimeout ?? '20', OpenRestyKeepaliveRequests: optionMap.OpenRestyKeepaliveRequests ?? '1000', OpenRestyClientHeaderTimeout: @@ -222,7 +222,7 @@ export function PerformancePage() { optionMap.OpenRestyLargeClientHeaderBuffers ?? '4 16k', OpenRestySendTimeout: optionMap.OpenRestySendTimeout ?? '30', OpenRestyProxyConnectTimeout: - optionMap.OpenRestyProxyConnectTimeout ?? '5', + optionMap.OpenRestyProxyConnectTimeout ?? '3', OpenRestyProxySendTimeout: optionMap.OpenRestyProxySendTimeout ?? '60', OpenRestyProxyReadTimeout: optionMap.OpenRestyProxyReadTimeout ?? '60', OpenRestyWebsocketEnabled: toBoolean( @@ -251,7 +251,7 @@ export function PerformancePage() { OpenRestyCacheInactive: optionMap.OpenRestyCacheInactive ?? '30m', OpenRestyCacheMaxSize: optionMap.OpenRestyCacheMaxSize ?? '1g', OpenRestyCacheKeyTemplate: - optionMap.OpenRestyCacheKeyTemplate ?? '$scheme$proxy_host$request_uri', + optionMap.OpenRestyCacheKeyTemplate ?? '$scheme$host$request_uri', OpenRestyCacheLockEnabled: toBoolean( optionMap.OpenRestyCacheLockEnabled, true, diff --git a/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx b/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx index 0da899bc..a70212b5 100644 --- a/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx +++ b/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx @@ -89,6 +89,7 @@ const proxyRouteSchema = z })()), '请输入合法的回源主机名', ), + upstreams_text: z.string(), enabled: z.boolean(), enable_https: z.boolean(), cert_id: z.string(), @@ -108,6 +109,15 @@ const proxyRouteSchema = z }); } + const upstreams = parseUpstreamsText(value.origin_url, value.upstreams_text); + if (upstreams.length === 0) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['origin_url'], + message: '至少需要一个上游地址', + }); + } + if (value.cache_enabled) { const cacheRules = parseCacheRulesText(value.cache_rules_text); if (value.cache_policy !== 'url' && cacheRules.length === 0) { @@ -164,6 +174,7 @@ const defaultValues: ProxyRouteFormValues = { domain: '', origin_url: '', origin_host: '', + upstreams_text: '', enabled: true, enable_https: false, cert_id: '', @@ -234,6 +245,25 @@ function parseCacheRulesText(value: string) { .filter(Boolean); } +function parseUpstreams(rawValue: string) { + if (!rawValue) { + return [] as string[]; + } + + try { + const parsed = JSON.parse(rawValue) as string[]; + return Array.isArray(parsed) ? parsed.filter(Boolean) : []; + } catch { + return []; + } +} + +function parseUpstreamsText(primary: string, value: string) { + return [primary.trim(), ...value.split(/\r?\n/)] + .map((item) => item.trim()) + .filter(Boolean); +} + function buildCachePolicyLabel(policy: string) { switch (policy) { case 'suffix': @@ -271,6 +301,7 @@ function toPayload(values: ProxyRouteFormValues): ProxyRouteMutationPayload { domain: values.domain.trim(), origin_url: values.origin_url.trim(), origin_host: values.origin_host.trim(), + upstreams: parseUpstreamsText(values.origin_url, values.upstreams_text).slice(1), enabled: values.enabled, enable_https: values.enable_https, cert_id: @@ -291,11 +322,13 @@ function toPayload(values: ProxyRouteFormValues): ProxyRouteMutationPayload { function toFormValues(route: ProxyRouteItem): ProxyRouteFormValues { const headers = parseCustomHeaders(route.custom_headers); const cacheRules = parseCacheRules(route.cache_rules); + const upstreams = parseUpstreams(route.upstreams); return { domain: route.domain, origin_url: route.origin_url, origin_host: route.origin_host || '', + upstreams_text: upstreams.slice(1).join('\n'), enabled: route.enabled, enable_https: route.enable_https, cert_id: route.cert_id ? String(route.cert_id) : '', @@ -608,6 +641,7 @@ export function ProxyRoutesPage() { {routes.map((route) => { const headers = parseCustomHeaders(route.custom_headers); const cacheRules = parseCacheRules(route.cache_rules); + const upstreams = parseUpstreams(route.upstreams); return ( @@ -620,6 +654,9 @@ export function ProxyRoutesPage() {

回源主机名: {route.origin_host || '$host'}

+

+ 上游数量: {Math.max(upstreams.length, 1)} +

@@ -770,6 +807,17 @@ export function ProxyRoutesPage() { /> + + + +