From 4945b8b44f4f381350e3e7607ef97663c66d94ac Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 18 Mar 2026 22:57:14 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BF=AE=E5=A4=8D]=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E5=AD=97=E6=AE=B5=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E4=B8=BAtext=EF=BC=8C=E6=B7=BB=E5=8A=A0=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=88=AA=E6=96=AD=E9=80=BB=E8=BE=91=E4=BB=A5=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=9B=B4=E9=95=BF=E7=9A=84=E6=B6=88=E6=81=AF=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/app-config.md | 3 +- docs/design.md | 1 + docs/development-guidelines.md | 1 + openflare_server/model/apply_log.go | 2 +- openflare_server/model/main.go | 30 +++++ openflare_server/model/node.go | 4 +- openflare_server/model/node_health_event.go | 2 +- openflare_server/router/api_phase1_test.go | 21 ++-- openflare_server/service/agent.go | 12 ++ openflare_server/service/config_version.go | 65 +++++++++-- openflare_server/service/https_phase1_test.go | 107 +++++++++++++----- openflare_server/service/node.go | 8 +- openflare_server/service/observability.go | 8 +- 13 files changed, 204 insertions(+), 60 deletions(-) diff --git a/docs/app-config.md b/docs/app-config.md index c6a6bed2..db2ebcdc 100644 --- a/docs/app-config.md +++ b/docs/app-config.md @@ -84,10 +84,11 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前 这类参数必须以结构化方式校验、保存并参与版本渲染。 * `OpenRestyResolvers` 由管理端性能页面维护,支持填写多个 DNS 服务器 IP;留空时不额外生成 `resolver` 指令。 +* 规则内如使用多主机名上游做负载均衡,或希望主机名上游渲染为带 keepalive 的 named `upstream`,必须先配置 `OpenRestyResolvers`;否则会回退为 direct `proxy_pass`,或在不安全的多上游场景直接阻止发布。 * `OpenRestyCacheEnabled` 用于启用缓存基础设施与全局默认参数;实际是否缓存、按 URL / 后缀 / 路径等命中策略由各条 `proxy_routes` 单独决定,不再默认对所有规则开启缓存。 * 默认缓存 Key 为 `$scheme$host$request_uri`,更贴近代理域名维度;如需按其他维度命中,可在性能页显式覆盖。 * 默认 `keepalive_timeout` 为 `20` 秒,默认 `proxy_connect_timeout` 为 `3` 秒,优先兼顾资源占用与回源失败切换速度。 -* 默认事件模型为 `epoll`,并默认开启 `multi_accept`;HTTPS 监听默认附带 `reuseport`,以改善多 worker 下的连接分发。 +* 默认事件模型为 `epoll`,并默认开启 `multi_accept`;HTTPS 监听默认使用独立 `http2 on;` 指令,避免新版 Nginx/OpenResty 对 `listen ... http2` 的弃用告警。 ### 1.5 前端构建环境变量 | 环境变量 | 作用 | 默认值 | diff --git a/docs/design.md b/docs/design.md index 44a4ae39..56c201a0 100644 --- a/docs/design.md +++ b/docs/design.md @@ -121,6 +121,7 @@ Origin * 一个域名只对应一条 `proxy_routes` 规则 * `proxy_routes` 至少包含一个上游地址;为兼容历史数据保留 `origin_url` 主上游字段,也允许在同一规则内补充多个上游做负载均衡 +* 单上游或多上游只有在目标可静态解析,或已配置 `OpenRestyResolvers` 时,才会渲染为带 keepalive 的 named `upstream` * `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头;未设置时默认透传访问域名 * `proxy_routes.domain` 必须唯一 * 所有上游地址都必须为合法 `http://` 或 `https://` diff --git a/docs/development-guidelines.md b/docs/development-guidelines.md index 85d7d908..dee55f60 100644 --- a/docs/development-guidelines.md +++ b/docs/development-guidelines.md @@ -116,6 +116,7 @@ * 不新增平台化对象,除非设计文档明确要求 * `proxy_routes` 维持一条域名对应一条规则;规则内允许保存一个或多个上游地址用于负载均衡,但不引入独立 `origin_pool` +* 生成 named `upstream` 时必须区分可静态解析与依赖运行时 resolver 的主机名;禁止在无 `OpenRestyResolvers` 的情况下为多主机名上游生成会导致 OpenResty 启动失败的配置 * `proxy_routes.origin_host` 为可选字段,仅用于覆盖回源 `Host` 请求头,不引入新的平台化对象 * `config_versions` 必须保存完整快照与渲染结果 * 全局同时只能有一个激活版本 diff --git a/openflare_server/model/apply_log.go b/openflare_server/model/apply_log.go index 545bbcbc..abbf03f8 100644 --- a/openflare_server/model/apply_log.go +++ b/openflare_server/model/apply_log.go @@ -17,7 +17,7 @@ type ApplyLog struct { NodeID string `json:"node_id" gorm:"index;size:64;not null"` Version string `json:"version" gorm:"size:32;not null"` Result string `json:"result" gorm:"size:32;not null"` - Message string `json:"message" gorm:"size:1024"` + Message string `json:"message" gorm:"type:text"` Checksum string `json:"checksum" gorm:"size:64;not null;default:''"` MainConfigChecksum string `json:"main_config_checksum" gorm:"size:64;not null;default:''"` RouteConfigChecksum string `json:"route_config_checksum" gorm:"size:64;not null;default:''"` diff --git a/openflare_server/model/main.go b/openflare_server/model/main.go index 7aad5cd4..a7767adc 100644 --- a/openflare_server/model/main.go +++ b/openflare_server/model/main.go @@ -126,6 +126,33 @@ func autoMigrateAll(db *gorm.DB) error { return nil } +func migrateTextColumns(db *gorm.DB, backend string) error { + if backend != "postgres" { + return nil + } + type textColumn struct { + model any + table string + column string + } + columns := []textColumn{ + {model: &Node{}, table: "nodes", column: "openresty_message"}, + {model: &Node{}, table: "nodes", column: "last_error"}, + {model: &ApplyLog{}, table: "apply_logs", column: "message"}, + {model: &NodeHealthEvent{}, table: "node_health_events", column: "message"}, + } + for _, item := range columns { + if !db.Migrator().HasTable(item.model) || !db.Migrator().HasColumn(item.model, item.column) { + continue + } + sql := fmt.Sprintf(`ALTER TABLE "%s" ALTER COLUMN "%s" TYPE text`, item.table, item.column) + if err := db.Exec(sql).Error; err != nil { + return fmt.Errorf("migrate column %s.%s to text failed: %w", item.table, item.column, err) + } + } + return nil +} + func isDatabaseEmpty(db *gorm.DB) (bool, error) { for _, item := range registeredModels() { var count int64 @@ -272,6 +299,9 @@ func InitDB() (err error) { if err = autoMigrateAll(db); err != nil { return err } + if err = migrateTextColumns(db, backend); err != nil { + return err + } if err = migrateSQLiteDataIfNeeded(db, backend); err != nil { return err } diff --git a/openflare_server/model/node.go b/openflare_server/model/node.go index 42afd2b2..d06892bb 100644 --- a/openflare_server/model/node.go +++ b/openflare_server/model/node.go @@ -20,11 +20,11 @@ type Node struct { AgentVersion string `json:"agent_version" gorm:"size:64;not null"` NginxVersion string `json:"nginx_version" gorm:"size:64"` OpenrestyStatus string `json:"openresty_status" gorm:"size:16;not null;default:'unknown'"` - OpenrestyMessage string `json:"openresty_message" gorm:"size:2048"` + OpenrestyMessage string `json:"openresty_message" gorm:"type:text"` Status string `json:"status" gorm:"size:16;not null;default:'offline'"` CurrentVersion string `json:"current_version" gorm:"size:32"` LastSeenAt time.Time `json:"last_seen_at"` - LastError string `json:"last_error" gorm:"size:1024"` + LastError string `json:"last_error" gorm:"type:text"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/openflare_server/model/node_health_event.go b/openflare_server/model/node_health_event.go index 082006a3..0bec5b20 100644 --- a/openflare_server/model/node_health_event.go +++ b/openflare_server/model/node_health_event.go @@ -8,7 +8,7 @@ type NodeHealthEvent struct { EventType string `json:"event_type" gorm:"index;size:64;not null"` Severity string `json:"severity" gorm:"size:16;not null"` Status string `json:"status" gorm:"index;size:16;not null"` - Message string `json:"message" gorm:"size:2048"` + Message string `json:"message" gorm:"type:text"` FirstTriggeredAt time.Time `json:"first_triggered_at" gorm:"index"` LastTriggeredAt time.Time `json:"last_triggered_at" gorm:"index"` ReportedAt time.Time `json:"reported_at" gorm:"index"` diff --git a/openflare_server/router/api_phase1_test.go b/openflare_server/router/api_phase1_test.go index 7aa64daf..b718baf6 100644 --- a/openflare_server/router/api_phase1_test.go +++ b/openflare_server/router/api_phase1_test.go @@ -45,8 +45,8 @@ 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_url": "https://10.0.0.11:8443", + "upstreams": []string{"https://10.0.0.12:8443"}, "origin_host": "origin-a.internal", "enabled": true, "cache_enabled": true, @@ -66,7 +66,7 @@ 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") { + if !strings.Contains(createdRoute.Upstreams, "10.0.0.12:8443") { t.Fatalf("expected route upstream list to persist, got %s", createdRoute.Upstreams) } if !strings.Contains(createdRoute.CacheRules, "/assets") { @@ -117,8 +117,8 @@ 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_url": "https://10.0.0.21:8443", + "upstreams": []string{"https://10.0.0.22:8443"}, "origin_host": "origin-b.internal", "enabled": true, "cache_enabled": true, @@ -129,7 +129,7 @@ func TestPhase1PublishLifecycle(t *testing.T) { routePath := "/api/proxy-routes/" + toString(createdRoute.ID) resp = performJSONRequest(t, engine, token, http.MethodPost, routePath+"/update", updateBody) decodeResponseData(t, resp, &createdRoute) - if createdRoute.OriginURL != "https://origin-b.internal" { + if createdRoute.OriginURL != "https://10.0.0.21:8443" { t.Fatalf("unexpected updated route origin: %s", createdRoute.OriginURL) } if createdRoute.OriginHost != "origin-b.internal" { @@ -138,7 +138,7 @@ 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") { + if !strings.Contains(createdRoute.Upstreams, "10.0.0.22:8443") { t.Fatalf("expected updated route upstream list to persist, got %s", createdRoute.Upstreams) } @@ -349,8 +349,11 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) { if !strings.Contains(version.MainConfig, "include __OPENFLARE_ROUTE_CONFIG__;") { t.Fatal("expected active config to render managed main config") } - if !strings.Contains(version.RenderedConfig, "listen 443 ssl http2 reuseport;") { - t.Fatal("expected active config to render https listener with http2, reuseport enabled") + if !strings.Contains(version.RenderedConfig, "listen 443 ssl;") { + t.Fatal("expected active config to render https ssl listener") + } + if !strings.Contains(version.RenderedConfig, "http2 on;") { + t.Fatal("expected active config to render dedicated http2 directive") } if !strings.Contains(version.RenderedConfig, "return 301 https://$host$request_uri;") { t.Fatal("expected active config to render redirect server") diff --git a/openflare_server/service/agent.go b/openflare_server/service/agent.go index 3776c02a..72e30957 100644 --- a/openflare_server/service/agent.go +++ b/openflare_server/service/agent.go @@ -254,6 +254,7 @@ func ReportApplyLog(payload ApplyLogPayload) (*model.ApplyLog, error) { payload.Checksum = strings.TrimSpace(payload.Checksum) payload.MainConfigChecksum = strings.TrimSpace(payload.MainConfigChecksum) payload.RouteConfigChecksum = strings.TrimSpace(payload.RouteConfigChecksum) + payload.Message = truncateForDatabase(payload.Message, 16000) if payload.NodeID == "" { return nil, errors.New("node_id 不能为空") } @@ -339,6 +340,17 @@ func ListNodeViews() ([]*NodeView, error) { return views, nil } +func truncateForDatabase(value string, max int) string { + if max <= 0 { + return "" + } + runes := []rune(strings.TrimSpace(value)) + if len(runes) <= max { + return string(runes) + } + return string(runes[:max]) +} + const ( defaultApplyLogPageSize = 20 maxApplyLogPageSize = 200 diff --git a/openflare_server/service/config_version.go b/openflare_server/service/config_version.go index 09294f9e..ba246f39 100644 --- a/openflare_server/service/config_version.go +++ b/openflare_server/service/config_version.go @@ -83,10 +83,15 @@ type routeCacheConfig struct { type routeUpstreamConfig struct { Name string Scheme string - Addresses []string + Servers []routeUpstreamServer UsesNamedUpstream bool } +type routeUpstreamServer struct { + Address string + Resolve bool +} + type openRestyConfigSnapshot struct { WorkerProcesses string `json:"worker_processes"` WorkerConnections int `json:"worker_connections"` @@ -671,6 +676,9 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot) if err != nil { return "", nil, fmt.Errorf("路由 %s 上游配置无效", route.Domain) } + if err := validateRenderableUpstreams(route.Domain, upstreams, cfg); err != nil { + return "", nil, err + } cacheRules, err := decodeStoredCacheRules(route.CacheRules) if err != nil { return "", nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain) @@ -852,7 +860,7 @@ func renderHTTPRedirectServer(domain string) string { func renderHTTPSServer(domain string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, upstreamConfig routeUpstreamConfig, 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 http2 reuseport;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n\n location / {\n%s%s%s }\n}\n\n", domain, certPath, keyPath, renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig, cfg)) + return fmt.Sprintf("server {\n listen 443 ssl;\n http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n\n location / {\n%s%s%s }\n}\n\n", domain, certPath, keyPath, renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig, cfg)) } func renderConnectionUpgradeMap() string { @@ -997,23 +1005,27 @@ func buildRouteUpstreamConfig(route *model.ProxyRoute, upstreams []string, cfg o 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{} } + server := routeUpstreamServer{Address: parsed.Host} + if !isIPAddressHostname(parsed.Hostname()) { + if strings.TrimSpace(cfg.Resolvers) == "" { + return routeUpstreamConfig{} + } + server.Resolve = true + } return routeUpstreamConfig{ Name: buildRouteUpstreamName(route), Scheme: parsed.Scheme, - Addresses: []string{parsed.Host}, + Servers: []routeUpstreamServer{server}, UsesNamedUpstream: true, } } - addresses := make([]string, 0, len(upstreams)) + servers := make([]routeUpstreamServer, 0, len(upstreams)) var scheme string for _, upstream := range upstreams { parsed, err := url.Parse(strings.TrimSpace(upstream)) @@ -1031,12 +1043,16 @@ func buildRouteUpstreamConfig(route *model.ProxyRoute, upstreams []string, cfg o } else if scheme != parsed.Scheme { return routeUpstreamConfig{} } - addresses = append(addresses, parsed.Host) + server := routeUpstreamServer{Address: parsed.Host} + if !isIPAddressHostname(parsed.Hostname()) { + server.Resolve = true + } + servers = append(servers, server) } return routeUpstreamConfig{ Name: buildRouteUpstreamName(route), Scheme: scheme, - Addresses: addresses, + Servers: servers, UsesNamedUpstream: true, } } @@ -1064,13 +1080,40 @@ func buildRouteUpstreamName(route *model.ProxyRoute) string { func renderNamedUpstreamBlock(upstreamConfig routeUpstreamConfig) string { 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)) + for _, server := range upstreamConfig.Servers { + if server.Resolve { + builder.WriteString(fmt.Sprintf(" server %s resolve max_fails=3 fail_timeout=10s;\n", server.Address)) + continue + } + builder.WriteString(fmt.Sprintf(" server %s max_fails=3 fail_timeout=10s;\n", server.Address)) } builder.WriteString(" keepalive 128;\n}\n\n") return builder.String() } +func validateRenderableUpstreams(domain string, upstreams []string, cfg openRestyConfigSnapshot) error { + if len(upstreams) <= 1 { + return nil + } + if strings.TrimSpace(cfg.Resolvers) != "" { + return nil + } + for _, upstream := range upstreams { + parsed, err := url.Parse(strings.TrimSpace(upstream)) + if err != nil || parsed.Hostname() == "" { + return fmt.Errorf("路由 %s 上游配置无效", domain) + } + if !isIPAddressHostname(parsed.Hostname()) { + return fmt.Errorf("路由 %s 的多上游主机名需要先配置 OpenRestyResolvers", domain) + } + } + return nil +} + +func isIPAddressHostname(host string) bool { + return net.ParseIP(strings.TrimSpace(host)) != nil +} + func shouldUseRuntimeResolver(originURL string, resolvers string) bool { if strings.TrimSpace(resolvers) == "" { return false diff --git a/openflare_server/service/https_phase1_test.go b/openflare_server/service/https_phase1_test.go index bf9af93b..b2184405 100644 --- a/openflare_server/service/https_phase1_test.go +++ b/openflare_server/service/https_phase1_test.go @@ -81,8 +81,11 @@ func TestCreateTLSCertificateAndRenderHTTPSConfig(t *testing.T) { 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") } - if !strings.Contains(result.Version.RenderedConfig, "listen 443 ssl http2 reuseport;") { - t.Fatal("expected rendered config to include https server block with http2 and reuseport enabled") + if !strings.Contains(result.Version.RenderedConfig, "listen 443 ssl;") { + t.Fatal("expected rendered config to include https ssl listener") + } + if !strings.Contains(result.Version.RenderedConfig, "http2 on;") { + t.Fatal("expected rendered config to enable http2 with dedicated directive") } if strings.Contains(result.Version.RenderedConfig, `if ($host != "app.example.com") {`) { t.Fatal("expected rendered config to avoid per-route host guard") @@ -153,14 +156,8 @@ func TestPublishConfigVersionRendersCustomHeaders(t *testing.T) { if !strings.Contains(result.Version.RenderedConfig, "proxy_set_header Connection $connection_upgrade;") { t.Fatal("expected rendered config to use normalized websocket connection header") } - if !strings.Contains(result.Version.RenderedConfig, "upstream backend_custom_example_com_1 {") { - t.Fatal("expected rendered config to define named upstream for simple origins") - } - if !strings.Contains(result.Version.RenderedConfig, "keepalive 128;") { - t.Fatal("expected rendered config to enable upstream keepalive") - } - if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://backend_custom_example_com_1;") { - t.Fatal("expected rendered config to proxy through named upstream when no resolver is required") + if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://origin.internal;") { + t.Fatal("expected hostname origin without resolver to keep direct proxy_pass") } 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") @@ -242,8 +239,8 @@ func TestPublishConfigVersionRendersRouteLevelCachePolicy(t *testing.T) { if strings.Count(result.Version.RenderedConfig, "proxy_cache openflare_cache;") != 1 { t.Fatal("expected only cache-enabled route to include proxy_cache directive") } - if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://backend_static_example_com_1;") { - t.Fatal("expected cache-enabled route to proxy through named upstream") + if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://origin.internal;") { + t.Fatal("expected cache-enabled hostname route without resolver to keep direct proxy_pass") } if !strings.Contains(result.Version.SnapshotJSON, `"cache_enabled":true`) { t.Fatal("expected snapshot to include route cache toggle") @@ -258,15 +255,15 @@ func TestPublishConfigVersionRendersMultipleUpstreams(t *testing.T) { route, err := CreateProxyRoute(ProxyRouteInput{ Domain: "lb.example.com", - OriginURL: "http://c1:39010", - Upstreams: []string{"http://c2:39010", "http://c3:39010"}, + OriginURL: "http://10.0.0.11:39010", + Upstreams: []string{"http://10.0.0.12:39010", "http://10.0.0.13:39010"}, Enabled: true, OriginHost: "lb.example.com", }) if err != nil { t.Fatalf("CreateProxyRoute failed: %v", err) } - if !strings.Contains(route.Upstreams, "c2:39010") { + if !strings.Contains(route.Upstreams, "10.0.0.12:39010") { t.Fatalf("expected route upstreams to persist, got %s", route.Upstreams) } @@ -277,26 +274,45 @@ func TestPublishConfigVersionRendersMultipleUpstreams(t *testing.T) { 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 { + if strings.Count(result.Version.RenderedConfig, "max_fails=3 fail_timeout=10s;") < 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;") { + if !strings.Contains(result.Version.RenderedConfig, "server 10.0.0.11: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;") { + if !strings.Contains(result.Version.RenderedConfig, "server 10.0.0.12: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;") { + if !strings.Contains(result.Version.RenderedConfig, "server 10.0.0.13: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"]`) { + if !strings.Contains(result.Version.SnapshotJSON, `"upstreams":["http://10.0.0.11:39010","http://10.0.0.12:39010","http://10.0.0.13:39010"]`) { t.Fatal("expected snapshot to include upstream list") } } +func TestPublishConfigVersionRejectsHostnameLoadBalancingWithoutResolvers(t *testing.T) { + setupServiceTestDB(t) + + _, err := CreateProxyRoute(ProxyRouteInput{ + Domain: "hostname-lb.example.com", + OriginURL: "http://c1:39010", + Upstreams: []string{"http://c2:39010"}, + Enabled: true, + }) + if err != nil { + t.Fatalf("CreateProxyRoute failed: %v", err) + } + + _, err = PublishConfigVersion("root") + if err == nil || !strings.Contains(err.Error(), "多上游主机名需要先配置 OpenRestyResolvers") { + t.Fatalf("expected hostname load balancing publish validation error, got %v", err) + } +} + func TestPublishConfigVersionOverridesOriginHostHeader(t *testing.T) { setupServiceTestDB(t) @@ -323,11 +339,8 @@ 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, "upstream backend_git_arctel_de_1 {") { - t.Fatal("expected rendered config to define named upstream for static hostname origins") - } - if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://backend_git_arctel_de_1;") { - t.Fatal("expected rendered config to proxy through named upstream for hostname origin when resolvers are blank") + 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") @@ -342,7 +355,7 @@ func TestPublishConfigVersionUsesRuntimeResolverWhenConfigured(t *testing.T) { _, err := CreateProxyRoute(ProxyRouteInput{ Domain: "resolver.example.com", - OriginURL: "https://origin.internal", + OriginURL: "https://origin.internal/api", Enabled: true, }) if err != nil { @@ -357,13 +370,46 @@ func TestPublishConfigVersionUsesRuntimeResolverWhenConfigured(t *testing.T) { t.Fatal("expected main config to render configured resolver directive") } if strings.Contains(result.Version.RenderedConfig, "upstream backend_resolver_example_com_1 {") { - t.Fatal("expected runtime-resolved origin to avoid named upstream block") + t.Fatal("expected hostname origin with base path to avoid named upstream block") } 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") + if !strings.Contains(result.Version.RenderedConfig, `set $openflare_upstream_base_path "/api";`) { + t.Fatal("expected rendered config to preserve origin base path for runtime-resolved origin") + } + if !strings.Contains(result.Version.RenderedConfig, "proxy_pass $openflare_upstream$openflare_upstream_base_path$request_uri;") { + t.Fatal("expected rendered config to proxy via runtime-resolved upstream variable and base path when resolvers are configured") + } +} + +func TestPublishConfigVersionUsesNamedUpstreamForHostnameOriginsWhenResolversConfigured(t *testing.T) { + setupServiceTestDB(t) + if err := model.UpdateOption("OpenRestyResolvers", "1.1.1.1"); err != nil { + t.Fatalf("UpdateOption OpenRestyResolvers failed: %v", err) + } + + _, err := CreateProxyRoute(ProxyRouteInput{ + Domain: "resolver-upstream.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.RenderedConfig, "upstream backend_resolver_upstream_example_com_1 {") { + t.Fatal("expected rendered config to define named upstream for hostname origin when resolvers are configured") + } + if !strings.Contains(result.Version.RenderedConfig, "server origin.internal resolve max_fails=3 fail_timeout=10s;") { + t.Fatal("expected rendered config to mark hostname upstream server as resolve") + } + if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://backend_resolver_upstream_example_com_1;") { + t.Fatal("expected rendered config to proxy through named upstream when resolver-backed hostname upstream is safe") } } @@ -396,6 +442,9 @@ func TestPublishConfigVersionKeepsDirectProxyPassForIPOrigins(t *testing.T) { func TestPreviewConfigVersionCanDisableWebsocketHeaders(t *testing.T) { setupServiceTestDB(t) + if err := model.UpdateOption("OpenRestyResolvers", ""); err != nil { + t.Fatalf("UpdateOption OpenRestyResolvers failed: %v", err) + } _, err := CreateProxyRoute(ProxyRouteInput{ Domain: "ws-off.example.com", diff --git a/openflare_server/service/node.go b/openflare_server/service/node.go index 4084343e..67ee647b 100644 --- a/openflare_server/service/node.go +++ b/openflare_server/service/node.go @@ -418,9 +418,9 @@ func normalizeAgentNodePayload(payload AgentNodePayload) AgentNodePayload { payload.AgentVersion = strings.TrimSpace(payload.AgentVersion) payload.NginxVersion = strings.TrimSpace(payload.NginxVersion) payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion) - payload.LastError = strings.TrimSpace(payload.LastError) + payload.LastError = truncateForDatabase(payload.LastError, 16000) payload.OpenrestyStatus = normalizeOpenrestyStatus(payload.OpenrestyStatus) - payload.OpenrestyMessage = strings.TrimSpace(payload.OpenrestyMessage) + payload.OpenrestyMessage = truncateForDatabase(payload.OpenrestyMessage, 16000) return payload } @@ -444,11 +444,11 @@ func applyNodeRuntime(node *model.Node, payload AgentNodePayload, preserveName b node.AgentVersion = strings.TrimSpace(payload.AgentVersion) node.NginxVersion = strings.TrimSpace(payload.NginxVersion) node.OpenrestyStatus = normalizeOpenrestyStatus(payload.OpenrestyStatus) - node.OpenrestyMessage = strings.TrimSpace(payload.OpenrestyMessage) + node.OpenrestyMessage = truncateForDatabase(payload.OpenrestyMessage, 16000) node.Status = NodeStatusOnline node.CurrentVersion = strings.TrimSpace(payload.CurrentVersion) node.LastSeenAt = time.Now() - node.LastError = strings.TrimSpace(payload.LastError) + node.LastError = truncateForDatabase(payload.LastError, 16000) if !node.GeoManualOverride { applyGeoInfoFromIP(node, node.IP) } diff --git a/openflare_server/service/observability.go b/openflare_server/service/observability.go index 211f268d..73b8f622 100644 --- a/openflare_server/service/observability.go +++ b/openflare_server/service/observability.go @@ -271,7 +271,7 @@ func reconcileNodeHealthEvents(tx *gorm.DB, nodeID string, events []AgentNodeHea triggeredAt := timeFromUnix(event.TriggeredAtUnix, reportedAt) if existing, ok := activeByType[eventType]; ok { existing.Severity = event.Severity - existing.Message = strings.TrimSpace(event.Message) + existing.Message = normalizeHealthEventMessage(event.Message) existing.LastTriggeredAt = triggeredAt existing.ReportedAt = reportedAt existing.RawJSON = marshalJSON(event) @@ -286,7 +286,7 @@ func reconcileNodeHealthEvents(tx *gorm.DB, nodeID string, events []AgentNodeHea EventType: eventType, Severity: event.Severity, Status: NodeHealthEventStatusActive, - Message: strings.TrimSpace(event.Message), + Message: normalizeHealthEventMessage(event.Message), FirstTriggeredAt: triggeredAt, LastTriggeredAt: triggeredAt, ReportedAt: reportedAt, @@ -330,6 +330,10 @@ func normalizeHealthSeverity(severity string) string { } } +func normalizeHealthEventMessage(message string) string { + return truncateForDatabase(message, 4096) +} + func timeFromUnix(unixSeconds int64, fallback time.Time) time.Time { if unixSeconds <= 0 { return fallback