mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +08:00
[功能] 添加支持多个上游地址,优化代理路由配置和负载均衡逻辑
This commit is contained in:
@@ -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
@@ -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`
|
||||
* 全局同时只能有一个激活版本
|
||||
* 回滚通过重新激活旧版本实现
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
通用约束:
|
||||
|
||||
* 不新增平台化对象,除非设计文档明确要求
|
||||
* `proxy_routes` 维持一条域名对应一个 `origin_url`
|
||||
* `proxy_routes` 维持一条域名对应一条规则;规则内允许保存一个或多个上游地址用于负载均衡,但不引入独立 `origin_pool`
|
||||
* `proxy_routes.origin_host` 为可选字段,仅用于覆盖回源 `Host` 请求头,不引入新的平台化对象
|
||||
* `config_versions` 必须保存完整快照与渲染结果
|
||||
* 全局同时只能有一个激活版本
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user