[功能] 添加支持多个上游地址,优化代理路由配置和负载均衡逻辑

This commit is contained in:
ryan
2026-03-18 22:24:05 +08:00
parent c844f4c784
commit 1fbe156a7c
13 changed files with 287 additions and 39 deletions
+2
View File
@@ -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 前端构建环境变量
+3 -2
View File
@@ -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`
* 全局同时只能有一个激活版本
* 回滚通过重新激活旧版本实现
+1 -1
View File
@@ -115,7 +115,7 @@
通用约束:
* 不新增平台化对象,除非设计文档明确要求
* `proxy_routes` 维持一条域名对应一个 `origin_url`
* `proxy_routes` 维持一条域名对应一条规则;规则内允许保存一个或多个上游地址用于负载均衡,但不引入独立 `origin_pool`
* `proxy_routes.origin_host` 为可选字段,仅用于覆盖回源 `Host` 请求头,不引入新的平台化对象
* `config_versions` 必须保存完整快照与渲染结果
* 全局同时只能有一个激活版本
+3 -3
View File
@@ -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"
+2
View File
@@ -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,
@@ -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
+75 -15
View File
@@ -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 {
@@ -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)
+76 -4
View File
@@ -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("源站地址不能为空")
@@ -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,
@@ -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 (
<tr key={route.id} className="align-top">
@@ -620,6 +654,9 @@ export function ProxyRoutesPage() {
<p className="text-xs text-[var(--foreground-muted)]">
回源主机名: {route.origin_host || '$host'}
</p>
<p className="text-xs text-[var(--foreground-muted)]">
上游数量: {Math.max(upstreams.length, 1)}
</p>
</div>
</td>
<td className="px-3 py-4">
@@ -770,6 +807,17 @@ export function ProxyRoutesPage() {
/>
</ResourceField>
<ResourceField
label="附加上游"
hint="可选。每行一个上游地址,用于同一规则下的负载均衡;需与主上游保持相同协议,且不能带路径或查询参数。"
error={form.formState.errors.upstreams_text?.message}
>
<ResourceTextarea
placeholder={'https://origin-b.internal\nhttps://origin-c.internal'}
{...form.register('upstreams_text')}
/>
</ResourceField>
<div className="grid gap-4 lg:grid-cols-2">
<ToggleField
label="启用规则"
@@ -8,6 +8,7 @@ export interface ProxyRouteItem {
domain: string;
origin_url: string;
origin_host: string;
upstreams: string;
enabled: boolean;
enable_https: boolean;
cert_id: number | null;
@@ -23,8 +24,9 @@ export interface ProxyRouteItem {
export interface ProxyRouteMutationPayload {
domain: string;
origin_url: string;
origin_host: string;
origin_url: string;
origin_host: string;
upstreams: string[];
enabled: boolean;
enable_https: boolean;
cert_id: number | null;
@@ -80,12 +80,12 @@ const defaultOperationFields = {
OpenRestyWorkerRlimitNofile: '65535',
OpenRestyEventsUse: 'epoll',
OpenRestyEventsMultiAcceptEnabled: true,
OpenRestyKeepaliveTimeout: '65',
OpenRestyKeepaliveTimeout: '20',
OpenRestyKeepaliveRequests: '1000',
OpenRestyClientHeaderTimeout: '15',
OpenRestyClientBodyTimeout: '15',
OpenRestySendTimeout: '30',
OpenRestyProxyConnectTimeout: '5',
OpenRestyProxyConnectTimeout: '3',
OpenRestyProxySendTimeout: '60',
OpenRestyProxyReadTimeout: '60',
OpenRestyProxyBufferingEnabled: true,
@@ -100,7 +100,7 @@ const defaultOperationFields = {
OpenRestyCacheLevels: '1:2',
OpenRestyCacheInactive: '30m',
OpenRestyCacheMaxSize: '1g',
OpenRestyCacheKeyTemplate: '$scheme$proxy_host$request_uri',
OpenRestyCacheKeyTemplate: '$scheme$host$request_uri',
OpenRestyCacheLockEnabled: true,
OpenRestyCacheLockTimeout: '5s',
OpenRestyCacheUseStale:
@@ -353,7 +353,7 @@ export function SettingsPage() {
optionMap.OpenRestyEventsMultiAcceptEnabled,
true,
),
OpenRestyKeepaliveTimeout: optionMap.OpenRestyKeepaliveTimeout ?? '65',
OpenRestyKeepaliveTimeout: optionMap.OpenRestyKeepaliveTimeout ?? '20',
OpenRestyKeepaliveRequests:
optionMap.OpenRestyKeepaliveRequests ?? '1000',
OpenRestyClientHeaderTimeout:
@@ -361,7 +361,7 @@ export function SettingsPage() {
OpenRestyClientBodyTimeout: optionMap.OpenRestyClientBodyTimeout ?? '15',
OpenRestySendTimeout: optionMap.OpenRestySendTimeout ?? '30',
OpenRestyProxyConnectTimeout:
optionMap.OpenRestyProxyConnectTimeout ?? '5',
optionMap.OpenRestyProxyConnectTimeout ?? '3',
OpenRestyProxySendTimeout: optionMap.OpenRestyProxySendTimeout ?? '60',
OpenRestyProxyReadTimeout: optionMap.OpenRestyProxyReadTimeout ?? '60',
OpenRestyProxyBufferingEnabled: toBoolean(
@@ -381,7 +381,7 @@ export function SettingsPage() {
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,