From d4d9bad74d2728df91f167e8b266fbb3635b4d10 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 12 Jul 2026 15:03:24 +0800 Subject: [PATCH] refactor(config): render routes from zone domains --- docs/changelog/index.md | 4 + .../certificate_snapshot_test.go | 42 ++++++- .../apps/openflare/config_version/helpers.go | 106 ++++-------------- .../apps/openflare/config_version/logics.go | 47 +------- .../openflare/config_version/logics_test.go | 23 +++- .../config_version/pages_snapshot.go | 16 +-- .../config_version/pages_snapshot_test.go | 1 + .../apps/openflare/config_version/snapshot.go | 51 ++++----- internal/apps/openflare/flared/logics.go | 17 ++- .../apps/openflare/routeidentity/identity.go | 29 +---- .../openflare/routeidentity/identity_test.go | 24 +--- internal/apps/openflare/uptimekuma/sync.go | 15 ++- .../apps/openflare/uptimekuma/sync_helpers.go | 5 +- .../apps/openflare/uptimekuma/sync_test.go | 18 ++- pkg/render/openresty/render.go | 46 ++------ pkg/render/openresty/render_route.go | 16 +-- pkg/render/openresty/render_test.go | 10 +- pkg/render/openresty/types.go | 3 - 18 files changed, 190 insertions(+), 283 deletions(-) diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 6e15e726..8dff2add 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -26,6 +26,10 @@ sidebar: false - 新增第一阶段 Zone 与正规化 Zone 域名数据库表及路由绑定模型,为后续以稳定 ID 管理网站与域名关联提供基础。 - 新增 Zone 管理 API 与显式历史域名导入命令,使用公共后缀列表验证注册根域和域名归属。 +### 修改 + +- 配置快照、OpenResty 渲染、Tunnel 与 Uptime Kuma 监控改为从 Zone 域名绑定读取域名和证书,移除对反代路由旧域名/证书字段的运行时回退。 + ### 修复 - Docker ClickHouse 性能配置改为单文件挂载,避免覆盖镜像内置的 Docker 网络监听配置,导致宿主机无法通过 8123/9000 访问服务。 diff --git a/internal/apps/openflare/config_version/certificate_snapshot_test.go b/internal/apps/openflare/config_version/certificate_snapshot_test.go index ffb4aeaa..f4a4e048 100644 --- a/internal/apps/openflare/config_version/certificate_snapshot_test.go +++ b/internal/apps/openflare/config_version/certificate_snapshot_test.go @@ -42,7 +42,7 @@ func TestBuildCertificateSupportFilesDecryptsSealedPrivateKey(t *testing.T) { require.NoError(t, err) files, err := buildCertificateSupportFiles(ctx, []snapshotRoute{ - {CertIDs: []uint{certificate.ID}}, + {DomainCertIDs: []uint{certificate.ID}}, }) require.NoError(t, err) require.Len(t, files, 2) @@ -58,13 +58,51 @@ func TestBuildCertificateSupportFilesDecryptsSealedPrivateKey(t *testing.T) { assert.Equal(t, normalizePEM(strings.TrimSpace(keyPEM)), keyContent) } +func TestBuildSnapshotReadsZoneDomainCertificates(t *testing.T) { + cleanup := setupConfigVersionTestDB(t) + defer cleanup() + ctx := context.Background() + require.NoError(t, db.DB(ctx).AutoMigrate(&model.TLSCertificate{})) + + oldSecret := config.Config.App.SessionSecret + config.Config.App.SessionSecret = "test-session-secret-for-zone-domain-snapshots" + t.Cleanup(func() { config.Config.App.SessionSecret = oldSecret }) + + firstCertPEM, firstKeyPEM := generateTestCertKeyPairForSnapshotForDomain(t, "one.example.com") + first, err := oftls.CreateCertificate(ctx, oftls.CertificateInput{Name: "first", CertPEM: firstCertPEM, KeyPEM: firstKeyPEM}) + require.NoError(t, err) + secondCertPEM, secondKeyPEM := generateTestCertKeyPairForSnapshotForDomain(t, "two.example.com") + second, err := oftls.CreateCertificate(ctx, oftls.CertificateInput{Name: "second", CertPEM: secondCertPEM, KeyPEM: secondKeyPEM}) + require.NoError(t, err) + + route := &model.ProxyRoute{SiteName: "tls-site", Domain: "legacy.invalid", OriginURL: "http://origin:8080", Upstreams: `["http://origin:8080"]`, Enabled: true, EnableHTTPS: true} + require.NoError(t, model.CreateProxyRouteRecord(ctx, route)) + zone := &model.Zone{Domain: "example.com"} + require.NoError(t, db.DB(ctx).Create(zone).Error) + require.NoError(t, db.DB(ctx).Create(&model.ZoneDomain{ZoneID: zone.ID, ProxyRouteID: &route.ID, Domain: "one.example.com", CertID: &first.ID}).Error) + require.NoError(t, db.DB(ctx).Create(&model.ZoneDomain{ZoneID: zone.ID, ProxyRouteID: &route.ID, Domain: "two.example.com", CertID: &second.ID}).Error) + + bundle, err := buildCurrentConfigBundle(ctx, true) + require.NoError(t, err) + require.Len(t, bundle.SnapshotRoutes, 1) + assert.Equal(t, []string{"one.example.com", "two.example.com"}, bundle.SnapshotRoutes[0].Domains) + assert.Equal(t, []uint{first.ID, second.ID}, bundle.SnapshotRoutes[0].DomainCertIDs) + assert.Contains(t, bundle.RouteConfig, "server_name one.example.com;") + assert.Contains(t, bundle.RouteConfig, "server_name two.example.com;") +} + func generateTestCertKeyPairForSnapshot(t *testing.T) (certPEM string, keyPEM string) { + return generateTestCertKeyPairForSnapshotForDomain(t, "test.example.com") +} + +func generateTestCertKeyPairForSnapshotForDomain(t *testing.T, domain string) (certPEM string, keyPEM string) { t.Helper() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) template := x509.Certificate{ SerialNumber: big.NewInt(1), - Subject: pkix.Name{CommonName: "test.example.com"}, + Subject: pkix.Name{CommonName: domain}, + DNSNames: []string{domain}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, diff --git a/internal/apps/openflare/config_version/helpers.go b/internal/apps/openflare/config_version/helpers.go index 1908b4c1..9e39dcdd 100644 --- a/internal/apps/openflare/config_version/helpers.go +++ b/internal/apps/openflare/config_version/helpers.go @@ -19,6 +19,26 @@ type customHeaderInput struct { Value string `json:"value"` } +func normalizeSnapshotDomains(domains []string) ([]string, error) { + normalized := make([]string, 0, len(domains)) + seen := make(map[string]struct{}, len(domains)) + for _, raw := range domains { + domain := strings.ToLower(strings.TrimSpace(raw)) + if domain == "" || strings.Contains(domain, "://") || strings.Contains(domain, "/") { + return nil, fmt.Errorf("domains payload is invalid") + } + if _, ok := seen[domain]; ok { + continue + } + seen[domain] = struct{}{} + normalized = append(normalized, domain) + } + if len(normalized) == 0 { + return nil, fmt.Errorf("domain is required") + } + return normalized, nil +} + func isUniqueConstraintError(err error) bool { if err == nil { return false @@ -94,92 +114,6 @@ func decodeStoredCacheRules(raw string) ([]string, error) { return normalized, nil } -func decodeStoredCertIDs(raw string, fallbackCertID *uint) ([]uint, error) { - text := strings.TrimSpace(raw) - if text == "" { - if fallbackCertID == nil || *fallbackCertID == 0 { - return []uint{}, nil - } - return []uint{*fallbackCertID}, nil - } - var certIDs []uint - if err := json.Unmarshal([]byte(text), &certIDs); err != nil { - return nil, fmt.Errorf("cert_ids payload is invalid") - } - normalized := make([]uint, 0, len(certIDs)) - seen := make(map[uint]struct{}, len(certIDs)) - for _, certID := range certIDs { - if certID == 0 { - continue - } - if _, ok := seen[certID]; ok { - continue - } - seen[certID] = struct{}{} - normalized = append(normalized, certID) - } - if len(normalized) == 0 && fallbackCertID != nil && *fallbackCertID != 0 { - return []uint{*fallbackCertID}, nil - } - return normalized, nil -} - -func resolveDomainCertIDs(domains []string, certIDs []uint, rawDomainCertIDs string) ([]uint, error) { - text := strings.TrimSpace(rawDomainCertIDs) - if text != "" { - var domainCertIDs []uint - if err := json.Unmarshal([]byte(text), &domainCertIDs); err != nil { - return nil, fmt.Errorf("domain_cert_ids payload is invalid") - } - if len(domains) > 0 && len(domainCertIDs) != len(domains) { - return nil, fmt.Errorf("domain_cert_ids length is invalid") - } - return domainCertIDs, nil - } - if len(certIDs) == 0 { - return []uint{}, nil - } - if len(certIDs) == 1 { - result := make([]uint, len(domains)) - for index := range result { - result[index] = certIDs[0] - } - return result, nil - } - if len(certIDs) == len(domains) { - result := make([]uint, len(certIDs)) - copy(result, certIDs) - return result, nil - } - return []uint{}, nil -} - -func mustDecodeCertIDs(route *model.ProxyRoute) []uint { - if route == nil { - return []uint{} - } - certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID) - if err != nil { - return []uint{} - } - return certIDs -} - -func mustDecodeDomainCertIDs(route *model.ProxyRoute, domains []string) []uint { - if route == nil { - return []uint{} - } - certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID) - if err != nil { - return []uint{} - } - domainCertIDs, err := resolveDomainCertIDs(domains, certIDs, route.DomainCertIDs) - if err != nil { - return []uint{} - } - return domainCertIDs -} - func normalizeUpstreamType(raw string) string { value := strings.ToLower(strings.TrimSpace(raw)) switch value { diff --git a/internal/apps/openflare/config_version/logics.go b/internal/apps/openflare/config_version/logics.go index 037b8616..0e2e00f5 100644 --- a/internal/apps/openflare/config_version/logics.go +++ b/internal/apps/openflare/config_version/logics.go @@ -14,7 +14,6 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/routeidentity" "github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket" "github.com/Rain-kl/Wavelet/internal/model" pkgprotocol "github.com/Rain-kl/Wavelet/pkg/protocol" @@ -333,23 +332,10 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute { return []snapshotRoute{} } for index := range routes { - normalizedDomains, err := routeidentity.DecodeDomains("", routes[index].Domain) - if len(routes[index].Domains) > 0 { - normalizedDomains, err = routeidentity.NormalizeDomains(routes[index].Domains) - } + normalizedDomains, err := normalizeSnapshotDomains(routes[index].Domains) if err == nil && len(normalizedDomains) > 0 { routes[index].Domains = normalizedDomains - routes[index].Domain = normalizedDomains[0] - routes[index].SiteName = routeidentity.ResolveSiteName(nil, routes[index].SiteName, normalizedDomains[0]) - } - normalizedCertIDs, primaryCertID, certErr := normalizeSnapshotCertificateIDs(routes[index].CertID, routes[index].CertIDs) - if certErr == nil { - routes[index].CertID = primaryCertID - routes[index].CertIDs = normalizedCertIDs - } - normalizedDomainCertIDs, domainCertErr := resolveDomainCertIDs(routes[index].Domains, routes[index].CertIDs, "") - if domainCertErr == nil && len(routes[index].DomainCertIDs) == 0 { - routes[index].DomainCertIDs = normalizedDomainCertIDs + routes[index].SiteName = strings.TrimSpace(routes[index].SiteName) } normalizedUpstreams, upstreamErr := normalizeUpstreams(routes[index].OriginURL, routes[index].Upstreams) if upstreamErr == nil { @@ -378,7 +364,6 @@ func flattenSnapshotRoutesByDomain(routes []snapshotRoute) map[string]snapshotRo for _, route := range normalizeSnapshotRoutes(routes) { for _, domain := range route.Domains { item := route - item.Domain = domain domainMap[domain] = item } } @@ -398,12 +383,11 @@ func snapshotRouteScalarsEqual(left, right snapshotRoute) bool { snapshotRouteOriginEqual(left, right) && snapshotRoutePolicyEqual(left, right) && snapshotRouteTunnelEqual(left, right) && - uintSliceEqual(left.CertIDs, right.CertIDs) && uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) } func snapshotRouteIdentityEqual(left, right snapshotRoute) bool { - return left.SiteName == right.SiteName && left.Domain == right.Domain + return left.SiteName == right.SiteName } func snapshotRouteOriginEqual(left, right snapshotRoute) bool { @@ -463,31 +447,6 @@ func snapshotWAFConfigEqual(left snapshotWAFDocument, right snapshotWAFDocument) return string(leftJSON) == string(rightJSON) } -func normalizeSnapshotCertificateIDs(primaryCertID *uint, certIDs []uint) ([]uint, *uint, error) { - candidates := make([]uint, 0, len(certIDs)+1) - if primaryCertID != nil && *primaryCertID != 0 { - candidates = append(candidates, *primaryCertID) - } - candidates = append(candidates, certIDs...) - normalized := make([]uint, 0, len(candidates)) - seen := make(map[uint]struct{}, len(candidates)) - for _, certID := range candidates { - if certID == 0 { - continue - } - if _, ok := seen[certID]; ok { - continue - } - seen[certID] = struct{}{} - normalized = append(normalized, certID) - } - var normalizedPrimary *uint - if len(normalized) > 0 { - normalizedPrimary = &normalized[0] - } - return normalized, normalizedPrimary, nil -} - func buildInitialOpenRestyOptionDiffs(current openRestyConfigSnapshot) []ConfigOptionDiffItem { details := diffOpenRestyOptionDetails(openRestyConfigSnapshot{}, current) for index := range details { diff --git a/internal/apps/openflare/config_version/logics_test.go b/internal/apps/openflare/config_version/logics_test.go index a4d54006..0981634f 100644 --- a/internal/apps/openflare/config_version/logics_test.go +++ b/internal/apps/openflare/config_version/logics_test.go @@ -6,6 +6,7 @@ package config_version import ( "context" "encoding/json" + "fmt" "testing" "time" @@ -27,6 +28,8 @@ func setupConfigVersionTestDB(t *testing.T) func() { require.NoError(t, err) require.NoError(t, sqliteDB.AutoMigrate( &model.ProxyRoute{}, + &model.Zone{}, + &model.ZoneDomain{}, &model.ConfigVersion{}, &model.OpenFlareWAFRuleGroup{}, &model.OpenFlareWAFRuleGroupBinding{}, @@ -40,6 +43,19 @@ func setupConfigVersionTestDB(t *testing.T) func() { } } +func createSnapshotZoneDomains(t *testing.T, ctx context.Context, route *model.ProxyRoute, domains ...string) { + t.Helper() + zone := &model.Zone{Domain: fmt.Sprintf("zone-%d.example", route.ID)} + require.NoError(t, db.DB(ctx).Create(zone).Error) + for _, domain := range domains { + require.NoError(t, db.DB(ctx).Create(&model.ZoneDomain{ + ZoneID: zone.ID, + ProxyRouteID: &route.ID, + Domain: domain, + }).Error) + } +} + func TestListConfigVersionsOrdersByCreatedAtDesc(t *testing.T) { cleanup := setupConfigVersionTestDB(t) defer cleanup() @@ -87,6 +103,7 @@ func TestPublishConfigVersionCreatesVersion(t *testing.T) { Enabled: true, } require.NoError(t, model.CreateProxyRouteRecord(ctx, route)) + createSnapshotZoneDomains(t, ctx, route, "publish.example.com") version, err := PublishConfigVersion(ctx, "tester", false) require.NoError(t, err) @@ -103,7 +120,7 @@ func TestPublishConfigVersionCreatesVersion(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(version.SnapshotJSON), &snapshot)) require.Len(t, snapshot.Routes, 1) assert.Equal(t, "publish-site", snapshot.Routes[0].SiteName) - assert.Equal(t, "publish.example.com", snapshot.Routes[0].Domain) + assert.Equal(t, []string{"publish.example.com"}, snapshot.Routes[0].Domains) active, err := GetActiveConfigVersion(ctx) require.NoError(t, err) @@ -124,6 +141,7 @@ func TestBuildSnapshotWAFDocumentUsesNormalizedSiteNames(t *testing.T) { ctx := context.Background() route := &model.ProxyRoute{ + SiteName: "example.com", Domain: "Example.COM", Domains: `["example.com","www.example.com"]`, OriginURL: "http://origin.example.com:8080", @@ -131,6 +149,7 @@ func TestBuildSnapshotWAFDocumentUsesNormalizedSiteNames(t *testing.T) { Enabled: true, } require.NoError(t, model.CreateProxyRouteRecord(ctx, route)) + createSnapshotZoneDomains(t, ctx, route, "example.com", "www.example.com") require.NoError(t, waf.EnsureDefaultRuleGroup(ctx)) globalGroup, err := model.GetGlobalOpenFlareWAFRuleGroup(ctx) @@ -184,6 +203,7 @@ func TestBuildCurrentConfigBundleEnablesGlobalPoWWithoutExplicitBinding(t *testi ctx := context.Background() route := &model.ProxyRoute{ + SiteName: "pow-global.example.com", Domain: "pow-global.example.com", Domains: `["pow-global.example.com"]`, OriginURL: "http://origin.example.com:8080", @@ -191,6 +211,7 @@ func TestBuildCurrentConfigBundleEnablesGlobalPoWWithoutExplicitBinding(t *testi Enabled: true, } require.NoError(t, model.CreateProxyRouteRecord(ctx, route)) + createSnapshotZoneDomains(t, ctx, route, "pow-global.example.com") require.NoError(t, waf.EnsureDefaultRuleGroup(ctx)) globalGroup, err := model.GetGlobalOpenFlareWAFRuleGroup(ctx) diff --git a/internal/apps/openflare/config_version/pages_snapshot.go b/internal/apps/openflare/config_version/pages_snapshot.go index 9c51d747..456d99c3 100644 --- a/internal/apps/openflare/config_version/pages_snapshot.go +++ b/internal/apps/openflare/config_version/pages_snapshot.go @@ -25,36 +25,36 @@ func buildPagesRouteSnapshot( return "", nil, nil, nil, errors.New("pages 路由配置无效") } if !model.HasPagesProjectsTable(ctx) { - return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 模块不可用", route.Domain) + return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 模块不可用", route.SiteName) } if route.PagesProjectID == nil || *route.PagesProjectID == 0 { - return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: 未绑定 Pages 项目", route.Domain) + return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: 未绑定 Pages 项目", route.SiteName) } project, err := model.GetPagesProjectByID(ctx, *route.PagesProjectID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目不存在", route.Domain) + return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目不存在", route.SiteName) } return "", nil, nil, nil, err } if !project.Enabled { - return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目未启用", route.Domain) + return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目未启用", route.SiteName) } if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID == 0 { - return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目没有激活部署", route.Domain) + return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目没有激活部署", route.SiteName) } activeDeployment, err := model.GetPagesDeploymentByID(ctx, *project.ActiveDeploymentID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不存在", route.Domain) + return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不存在", route.SiteName) } return "", nil, nil, nil, err } if activeDeployment.ProjectID != project.ID { - return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不匹配", route.Domain) + return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不匹配", route.SiteName) } if strings.TrimSpace(activeDeployment.Checksum) == "" { - return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 部署校验和缺失", route.Domain) + return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 部署校验和缺失", route.SiteName) } pagesProjectID = route.PagesProjectID diff --git a/internal/apps/openflare/config_version/pages_snapshot_test.go b/internal/apps/openflare/config_version/pages_snapshot_test.go index 2d60e66f..dcf1fb45 100644 --- a/internal/apps/openflare/config_version/pages_snapshot_test.go +++ b/internal/apps/openflare/config_version/pages_snapshot_test.go @@ -54,6 +54,7 @@ func TestBuildSnapshotRoutesPages(t *testing.T) { PagesProjectID: &project.ID, } require.NoError(t, model.CreateProxyRouteRecord(ctx, route)) + createSnapshotZoneDomains(t, ctx, route, "speedtest.arctel.net") bundle, err := buildCurrentConfigBundle(ctx, true) require.NoError(t, err) diff --git a/internal/apps/openflare/config_version/snapshot.go b/internal/apps/openflare/config_version/snapshot.go index 9486f978..68225e09 100644 --- a/internal/apps/openflare/config_version/snapshot.go +++ b/internal/apps/openflare/config_version/snapshot.go @@ -11,7 +11,6 @@ import ( "sort" "strings" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/routeidentity" oftls "github.com/Rain-kl/Wavelet/internal/apps/openflare/tls" "github.com/Rain-kl/Wavelet/internal/apps/openflare/waf" "github.com/Rain-kl/Wavelet/internal/model" @@ -40,15 +39,12 @@ const ( type snapshotRoute struct { ID uint `json:"id,omitempty"` SiteName string `json:"site_name,omitempty"` - Domain string `json:"domain"` Domains []string `json:"domains,omitempty"` 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"` - CertIDs []uint `json:"cert_ids,omitempty"` DomainCertIDs []uint `json:"domain_cert_ids,omitempty"` RedirectHTTP bool `json:"redirect_http"` LimitConnPerServer int `json:"limit_conn_per_server,omitempty"` @@ -231,19 +227,32 @@ func buildCurrentConfigBundle(ctx context.Context, requireRoutes bool) (*configB func buildSnapshotRoutes(ctx context.Context, routes []*model.ProxyRoute) ([]snapshotRoute, error) { items := make([]snapshotRoute, 0, len(routes)) for _, route := range routes { - domains, err := routeidentity.DecodeDomains(route.Domains, route.Domain) + zoneDomains, err := model.ListZoneDomainsByRouteID(ctx, route.ID) if err != nil { - return nil, fmt.Errorf("route %s domains are invalid", route.Domain) + return nil, err + } + if len(zoneDomains) == 0 { + return nil, fmt.Errorf("route %s has no zone domains", route.SiteName) + } + domains := make([]string, 0, len(zoneDomains)) + domainCertIDs := make([]uint, 0, len(zoneDomains)) + for _, zoneDomain := range zoneDomains { + domains = append(domains, zoneDomain.Domain) + if zoneDomain.CertID == nil { + domainCertIDs = append(domainCertIDs, 0) + continue + } + domainCertIDs = append(domainCertIDs, *zoneDomain.CertID) } customHeaders, err := decodeStoredCustomHeaders(route.CustomHeaders) if err != nil { - return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain) + return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.SiteName) } upstreamType := normalizeUpstreamType(route.UpstreamType) originURL := route.OriginURL upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL) if err != nil { - return nil, fmt.Errorf("路由 %s 上游配置无效", route.Domain) + return nil, fmt.Errorf("路由 %s 上游配置无效", route.SiteName) } var tunnelNodeID *uint var tunnelTargetAddr string @@ -265,21 +274,18 @@ func buildSnapshotRoutes(ctx context.Context, routes []*model.ProxyRoute) ([]sna } cacheRules, err := decodeStoredCacheRules(route.CacheRules) if err != nil { - return nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain) + return nil, fmt.Errorf("路由 %s 缓存规则无效", route.SiteName) } items = append(items, snapshotRoute{ ID: route.ID, - SiteName: routeidentity.ResolveSiteName(route, route.SiteName, domains[0]), - Domain: domains[0], + SiteName: route.SiteName, Domains: domains, OriginURL: originURL, OriginHost: route.OriginHost, Upstreams: upstreams, Enabled: route.Enabled, EnableHTTPS: route.EnableHTTPS, - CertID: route.CertID, - CertIDs: mustDecodeCertIDs(route), - DomainCertIDs: mustDecodeDomainCertIDs(route, domains), + DomainCertIDs: domainCertIDs, RedirectHTTP: route.RedirectHTTP, LimitConnPerServer: route.LimitConnPerServer, LimitConnPerIP: route.LimitConnPerIP, @@ -344,11 +350,14 @@ func buildSnapshotWAFDocument(ctx context.Context, routes []*model.ProxyRoute) ( if route == nil { continue } - domains, domainErr := routeidentity.DecodeDomains(route.Domains, route.Domain) + domains, domainErr := model.ListZoneDomainsByRouteID(ctx, route.ID) if domainErr != nil { - return snapshotWAFDocument{}, fmt.Errorf("route %s domains are invalid", route.Domain) + return snapshotWAFDocument{}, domainErr } - enabledRouteSiteNames[route.ID] = routeidentity.ResolveSiteName(route, route.SiteName, domains[0]) + if len(domains) == 0 { + return snapshotWAFDocument{}, fmt.Errorf("route %s has no zone domains", route.SiteName) + } + enabledRouteSiteNames[route.ID] = route.SiteName } rawBindings, err := model.ListOpenFlareWAFRuleGroupBindings(ctx) if err != nil { @@ -552,14 +561,6 @@ func normalizeProxyCachePathForSnapshot(cacheEnabled bool, cachePath string) str func buildCertificateSupportFiles(ctx context.Context, routes []snapshotRoute) ([]SupportFile, error) { certIDSet := make(map[uint]struct{}) for _, route := range routes { - if route.CertID != nil && *route.CertID != 0 { - certIDSet[*route.CertID] = struct{}{} - } - for _, certID := range route.CertIDs { - if certID != 0 { - certIDSet[certID] = struct{}{} - } - } for _, certID := range route.DomainCertIDs { if certID != 0 { certIDSet[certID] = struct{}{} diff --git a/internal/apps/openflare/flared/logics.go b/internal/apps/openflare/flared/logics.go index 307099da..924f41a6 100644 --- a/internal/apps/openflare/flared/logics.go +++ b/internal/apps/openflare/flared/logics.go @@ -11,7 +11,6 @@ import ( "time" "github.com/Rain-kl/Wavelet/internal/apps/openflare/agent" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/routeidentity" "github.com/Rain-kl/Wavelet/internal/db" "github.com/Rain-kl/Wavelet/internal/model" "gorm.io/gorm" @@ -134,17 +133,17 @@ func GetTunnelConfig(ctx context.Context, node *model.OpenFlareNode) (*TunnelCon if !route.Enabled { continue } - domains, decodeErr := routeidentity.DecodeDomains(route.Domains, route.Domain) - if decodeErr != nil { + zoneDomains, domainErr := model.ListZoneDomainsByRouteID(ctx, route.ID) + if domainErr != nil || len(zoneDomains) == 0 { continue } localAddr, localPort := parseTunnelTargetAddr(route.TunnelTargetAddr) proxies = append(proxies, ProxyEntry{ - Name: fmt.Sprintf("%s-%s", node.NodeID, sanitizeProxyName(domains[0])), + Name: fmt.Sprintf("%s-%s", node.NodeID, sanitizeProxyName(zoneDomains[0].Domain)), Type: "http", LocalAddr: localAddr, LocalPort: localPort, - CustomDomains: domains, + CustomDomains: zoneDomainNames(zoneDomains), }) } @@ -156,6 +155,14 @@ func GetTunnelConfig(ctx context.Context, node *model.OpenFlareNode) (*TunnelCon }, nil } +func zoneDomainNames(domains []model.ZoneDomain) []string { + names := make([]string, 0, len(domains)) + for _, domain := range domains { + names = append(names, domain.Domain) + } + return names +} + // ReportApplyLog records an apply result from OpenFlared. func ReportApplyLog(ctx context.Context, payload ApplyLogPayload) (*model.OpenFlareApplyLog, error) { now := time.Now().UTC() diff --git a/internal/apps/openflare/routeidentity/identity.go b/internal/apps/openflare/routeidentity/identity.go index 3c7acce1..29bfdd09 100644 --- a/internal/apps/openflare/routeidentity/identity.go +++ b/internal/apps/openflare/routeidentity/identity.go @@ -10,8 +10,6 @@ import ( "errors" "fmt" "strings" - - "github.com/Rain-kl/Wavelet/internal/model" ) // NormalizeDomains lowercases, deduplicates, and validates proxy route domains. @@ -38,7 +36,8 @@ func NormalizeDomains(rawDomains []string) ([]string, error) { return normalized, nil } -// DecodeDomains parses stored domains JSON or falls back to a single domain value. +// DecodeDomains parses legacy route domain fields for the explicit migration +// command. Runtime consumers must read ZoneDomain bindings instead. func DecodeDomains(raw string, fallbackDomain string) ([]string, error) { text := strings.TrimSpace(raw) if text == "" { @@ -50,27 +49,3 @@ func DecodeDomains(raw string, fallbackDomain string) ([]string, error) { } return NormalizeDomains(domains) } - -// ResolveSiteName returns the runtime site identifier for a proxy route. -func ResolveSiteName(route *model.ProxyRoute, raw, primaryDomain string) string { - siteName := strings.TrimSpace(raw) - if siteName != "" { - return siteName - } - if route != nil && strings.TrimSpace(route.SiteName) != "" { - return strings.TrimSpace(route.SiteName) - } - return strings.TrimSpace(primaryDomain) -} - -// ResolveFromRoute decodes domains and resolves the site name for a stored route. -func ResolveFromRoute(route *model.ProxyRoute) (siteName string, domains []string, err error) { - if route == nil { - return "", nil, errors.New("proxy route is nil") - } - domains, err = DecodeDomains(route.Domains, route.Domain) - if err != nil { - return "", nil, err - } - return ResolveSiteName(route, route.SiteName, domains[0]), domains, nil -} diff --git a/internal/apps/openflare/routeidentity/identity_test.go b/internal/apps/openflare/routeidentity/identity_test.go index 590de1c3..b13a2591 100644 --- a/internal/apps/openflare/routeidentity/identity_test.go +++ b/internal/apps/openflare/routeidentity/identity_test.go @@ -4,11 +4,9 @@ package routeidentity import ( - "testing" - - "github.com/Rain-kl/Wavelet/internal/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "testing" ) func TestDecodeDomainsNormalizesCaseAndOrder(t *testing.T) { @@ -16,23 +14,3 @@ func TestDecodeDomainsNormalizesCaseAndOrder(t *testing.T) { require.NoError(t, err) assert.Equal(t, []string{"www.example.com", "example.com"}, domains) } - -func TestResolveSiteNamePrefersExplicitValue(t *testing.T) { - route := &model.ProxyRoute{SiteName: "stored-name", Domain: "example.com"} - assert.Equal(t, "custom", ResolveSiteName(route, "custom", "example.com")) - assert.Equal(t, "stored-name", ResolveSiteName(route, "", "example.com")) - - routeWithoutSiteName := &model.ProxyRoute{Domain: "example.com"} - assert.Equal(t, "example.com", ResolveSiteName(routeWithoutSiteName, "", "example.com")) -} - -func TestResolveFromRoute(t *testing.T) { - route := &model.ProxyRoute{ - Domain: "Example.COM", - Domains: `["example.com","www.example.com"]`, - } - siteName, domains, err := ResolveFromRoute(route) - require.NoError(t, err) - assert.Equal(t, "example.com", siteName) - assert.Equal(t, []string{"example.com", "www.example.com"}, domains) -} diff --git a/internal/apps/openflare/uptimekuma/sync.go b/internal/apps/openflare/uptimekuma/sync.go index 4df2fbbe..ca2a6a50 100644 --- a/internal/apps/openflare/uptimekuma/sync.go +++ b/internal/apps/openflare/uptimekuma/sync.go @@ -10,7 +10,6 @@ import ( "strings" "sync/atomic" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/routeidentity" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/repository" ) @@ -123,7 +122,7 @@ func SyncToUptimeKuma(ctx context.Context) error { } existingOpenFlareMonitors := filterOpenFlareMonitors(client.GetMonitorList(), openFlareTagID) - expectedSitesMap := syncRouteMonitors(client, expectedRoutes, existingOpenFlareMonitors, openFlareTagID, config) + expectedSitesMap := syncRouteMonitors(ctx, client, expectedRoutes, existingOpenFlareMonitors, openFlareTagID, config) removeStaleMonitors(client, existingOpenFlareMonitors, expectedSitesMap) return nil @@ -224,12 +223,18 @@ func filterOpenFlareMonitors(monitors map[string]Monitor, openFlareTagID int) ma return existingOpenFlareMonitors } -func routeMonitorURL(route *model.ProxyRoute) (string, error) { - domains, err := routeidentity.DecodeDomains(route.Domains, route.Domain) +func routeMonitorURL(ctx context.Context, route *model.ProxyRoute) (string, error) { + if route == nil { + return "", fmt.Errorf("proxy route is nil") + } + domains, err := model.ListZoneDomainsByRouteID(ctx, route.ID) if err != nil { return "", err } - domain := domains[0] + if len(domains) == 0 { + return "", fmt.Errorf("route %s has no zone domains", route.SiteName) + } + domain := domains[0].Domain if route.EnableHTTPS { return "https://" + domain, nil } diff --git a/internal/apps/openflare/uptimekuma/sync_helpers.go b/internal/apps/openflare/uptimekuma/sync_helpers.go index 869b72e3..14f7d63f 100644 --- a/internal/apps/openflare/uptimekuma/sync_helpers.go +++ b/internal/apps/openflare/uptimekuma/sync_helpers.go @@ -4,6 +4,7 @@ package uptimekuma import ( + "context" "fmt" "log/slog" "strings" @@ -69,11 +70,11 @@ func connectAndLoginUptimeKuma(kumaURL, kumaUsername, kumaPassword string) (*Soc return client, nil } -func syncRouteMonitors(client *SocketIOClient, expectedRoutes []*model.ProxyRoute, existingMonitors map[string]Monitor, openFlareTagID int, config *kumaConfig) map[string]bool { +func syncRouteMonitors(ctx context.Context, client *SocketIOClient, expectedRoutes []*model.ProxyRoute, existingMonitors map[string]Monitor, openFlareTagID int, config *kumaConfig) map[string]bool { expectedSitesMap := make(map[string]bool, len(expectedRoutes)) for _, route := range expectedRoutes { expectedSitesMap[route.SiteName] = true - targetURL, urlErr := routeMonitorURL(route) + targetURL, urlErr := routeMonitorURL(ctx, route) if urlErr != nil { slog.Error("Failed to resolve monitor URL", "name", route.SiteName, "error", urlErr) continue diff --git a/internal/apps/openflare/uptimekuma/sync_test.go b/internal/apps/openflare/uptimekuma/sync_test.go index 550b05ce..537b01ab 100644 --- a/internal/apps/openflare/uptimekuma/sync_test.go +++ b/internal/apps/openflare/uptimekuma/sync_test.go @@ -122,7 +122,7 @@ func setupSyncTestDB(t *testing.T) func() { DisableForeignKeyConstraintWhenMigrating: true, }) require.NoError(t, err) - require.NoError(t, sqliteDB.AutoMigrate(&model.ProxyRoute{}, &model.SystemConfig{})) + require.NoError(t, sqliteDB.AutoMigrate(&model.ProxyRoute{}, &model.Zone{}, &model.ZoneDomain{}, &model.SystemConfig{})) db.SetDB(sqliteDB) return func() { @@ -130,6 +130,17 @@ func setupSyncTestDB(t *testing.T) func() { } } +func createRouteZoneDomain(t *testing.T, ctx context.Context, route *model.ProxyRoute, domain string) { + t.Helper() + zone := &model.Zone{Domain: fmt.Sprintf("zone-%d.example", route.ID)} + require.NoError(t, db.DB(ctx).Create(zone).Error) + require.NoError(t, db.DB(ctx).Create(&model.ZoneDomain{ + ZoneID: zone.ID, + ProxyRouteID: &route.ID, + Domain: domain, + }).Error) +} + func backupUptimeKumaConfig(ctx context.Context) func() { // 备份所有 UptimeKuma 相关配置 configs := []string{ @@ -215,6 +226,9 @@ func TestSyncToUptimeKumaSuccess(t *testing.T) { require.NoError(t, model.CreateProxyRouteRecord(ctx, routeA)) require.NoError(t, model.CreateProxyRouteRecord(ctx, routeB)) require.NoError(t, model.CreateProxyRouteRecord(ctx, routeC)) + createRouteZoneDomain(t, ctx, routeA, "site-a.com") + createRouteZoneDomain(t, ctx, routeB, "site-b.com") + createRouteZoneDomain(t, ctx, routeC, "site-c.com") monitorListJSON := `{ "99": { @@ -318,6 +332,8 @@ func TestSyncToUptimeKumaSelectedScope(t *testing.T) { require.NoError(t, model.CreateProxyRouteRecord(ctx, routeA)) require.NoError(t, model.CreateProxyRouteRecord(ctx, routeB)) + createRouteZoneDomain(t, ctx, routeA, "site-a.com") + createRouteZoneDomain(t, ctx, routeB, "site-b.com") mockSrv := newMockKumaServer(`{}`) server := httptest.NewServer(mockSrv) diff --git a/pkg/render/openresty/render.go b/pkg/render/openresty/render.go index 5042645c..d85a922f 100644 --- a/pkg/render/openresty/render.go +++ b/pkg/render/openresty/render.go @@ -89,7 +89,7 @@ func RenderRouteConfig(doc Document, certificateFiles []SupportFile) (string, er for _, route := range doc.Routes { domains := normalizedRouteDomains(route) if len(domains) == 0 { - return "", fmt.Errorf("route %s domains are invalid", route.Domain) + return "", fmt.Errorf("route %s domains are invalid", route.SiteName) } serverNames := renderServerNames(domains) displayName := resolveRouteSiteName(route) @@ -642,7 +642,7 @@ func resolveRouteSiteName(route Route) string { if domains := normalizedRouteDomains(route); len(domains) > 0 { return domains[0] } - return strings.TrimSpace(route.Domain) + return "" } func buildRouteUpstreamName(route Route) string { @@ -708,24 +708,13 @@ func renderDefaultServerBlock(statusCode int, http3Enabled bool) string { } func normalizedRouteDomains(route Route) []string { - if len(route.Domains) > 0 { - return route.Domains - } - if strings.TrimSpace(route.Domain) == "" { - return nil - } - return []string{strings.TrimSpace(route.Domain)} + return route.Domains } -func normalizeCertIDs(primaryCertID *uint, certIDs []uint) []uint { - candidates := make([]uint, 0, len(certIDs)+1) - if primaryCertID != nil && *primaryCertID != 0 { - candidates = append(candidates, *primaryCertID) - } - candidates = append(candidates, certIDs...) - seen := make(map[uint]struct{}, len(candidates)) - normalized := make([]uint, 0, len(candidates)) - for _, id := range candidates { +func certificateIDsFromDomainCertIDs(domainCertIDs []uint) []uint { + seen := make(map[uint]struct{}, len(domainCertIDs)) + normalized := make([]uint, 0, len(domainCertIDs)) + for _, id := range domainCertIDs { if id == 0 { continue } @@ -738,27 +727,6 @@ func normalizeCertIDs(primaryCertID *uint, certIDs []uint) []uint { return normalized } -func normalizeDomainCertIDs(domains []string, certIDs []uint, domainCertIDs []uint) []uint { - if len(domainCertIDs) > 0 { - normalized := make([]uint, len(domainCertIDs)) - copy(normalized, domainCertIDs) - return normalized - } - if len(certIDs) == 1 { - normalized := make([]uint, len(domains)) - for index := range normalized { - normalized[index] = certIDs[0] - } - return normalized - } - if len(certIDs) == len(domains) { - normalized := make([]uint, len(certIDs)) - copy(normalized, certIDs) - return normalized - } - return []uint{} -} - func certificatesByID(files []SupportFile) map[uint]string { result := make(map[uint]string) for _, file := range files { diff --git a/pkg/render/openresty/render_route.go b/pkg/render/openresty/render_route.go index 40d97c42..b1c17b84 100644 --- a/pkg/render/openresty/render_route.go +++ b/pkg/render/openresty/render_route.go @@ -34,7 +34,7 @@ func validateRouteCertificates(route Route, displayName string, certIDs []uint, } certPEM, ok := certificates[certID] if !ok { - return fmt.Errorf("route %s certificate %d does not exist", route.Domain, certID) + return fmt.Errorf("route %s certificate %d does not exist", route.SiteName, certID) } if err := validateCertificateCoverage(certPEM, assignedDomains); err != nil { return fmt.Errorf("site %s certificate validation failed: %w", displayName, err) @@ -105,16 +105,16 @@ func renderProxyRouteHTTPS( func renderPagesRoute(builder *strings.Builder, route Route, displayName, serverNames string, certificates map[uint]string, limitConfig routeLimitConfig, powEnabled bool, cfg ConfigSnapshot) error { if route.PagesDeployment == nil { - return fmt.Errorf("route %s pages deployment is missing", route.Domain) + return fmt.Errorf("route %s pages deployment is missing", route.SiteName) } if !route.EnableHTTPS { builder.WriteString(renderHTTPPagesServer(serverNames, displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword)) return nil } - certIDs := normalizeCertIDs(route.CertID, route.CertIDs) - domainCertIDs := normalizeDomainCertIDs(normalizedRouteDomains(route), certIDs, route.DomainCertIDs) + certIDs := certificateIDsFromDomainCertIDs(route.DomainCertIDs) + domainCertIDs := route.DomainCertIDs if len(certIDs) == 0 { - return fmt.Errorf("路由 %s 未配置证书", route.Domain) + return fmt.Errorf("路由 %s 未配置证书", route.SiteName) } partition := partitionRouteDomainsByCert(normalizedRouteDomains(route), certIDs, domainCertIDs) if err := validateRouteCertificates(route, displayName, certIDs, partition, certificates); err != nil { @@ -137,10 +137,10 @@ func renderProxyRoute(builder *strings.Builder, route Route, displayName, server builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg)) return nil } - certIDs := normalizeCertIDs(route.CertID, route.CertIDs) - domainCertIDs := normalizeDomainCertIDs(normalizedRouteDomains(route), certIDs, route.DomainCertIDs) + certIDs := certificateIDsFromDomainCertIDs(route.DomainCertIDs) + domainCertIDs := route.DomainCertIDs if len(certIDs) == 0 { - return fmt.Errorf("路由 %s 未配置证书", route.Domain) + return fmt.Errorf("路由 %s 未配置证书", route.SiteName) } partition := partitionRouteDomainsByCert(normalizedRouteDomains(route), certIDs, domainCertIDs) if err := validateRouteCertificates(route, displayName, certIDs, partition, certificates); err != nil { diff --git a/pkg/render/openresty/render_test.go b/pkg/render/openresty/render_test.go index 57e19fc5..4d58ebb9 100644 --- a/pkg/render/openresty/render_test.go +++ b/pkg/render/openresty/render_test.go @@ -9,8 +9,8 @@ import ( func TestRenderWAFConfigIncludesAllRouteSiteNames(t *testing.T) { doc := Document{ Routes: []Route{ - {ID: 1, SiteName: "", Domain: "Example.COM", Domains: []string{"example.com", "www.example.com"}}, - {ID: 2, SiteName: "named-site", Domain: "other.example.com"}, + {ID: 1, SiteName: "example.com", Domains: []string{"example.com", "www.example.com"}}, + {ID: 2, SiteName: "named-site", Domains: []string{"other.example.com"}}, }, WAF: WAFDocument{ RuleGroups: []WAFRuleGroup{ @@ -293,7 +293,8 @@ func TestRenderRouteConfigPagesWithoutSPAFallbackServesRoot(t *testing.T) { Routes: []Route{ { ID: 1, - Domain: "speedtest.example.com", + SiteName: "speedtest.example.com", + Domains: []string{"speedtest.example.com"}, UpstreamType: "pages", EnableHTTPS: false, PagesDeployment: &PagesDeployment{ @@ -325,7 +326,8 @@ func TestRenderRouteConfigPagesWithSPAFallbackServesRoot(t *testing.T) { Routes: []Route{ { ID: 1, - Domain: "speedtest.example.com", + SiteName: "speedtest.example.com", + Domains: []string{"speedtest.example.com"}, UpstreamType: "pages", EnableHTTPS: false, PagesDeployment: &PagesDeployment{ diff --git a/pkg/render/openresty/types.go b/pkg/render/openresty/types.go index 8ad77538..94157c7f 100644 --- a/pkg/render/openresty/types.go +++ b/pkg/render/openresty/types.go @@ -135,15 +135,12 @@ func DefaultPoWConfig() PoWConfig { type Route struct { ID uint `json:"id,omitempty"` SiteName string `json:"site_name,omitempty"` - Domain string `json:"domain"` Domains []string `json:"domains,omitempty"` 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"` - CertIDs []uint `json:"cert_ids,omitempty"` DomainCertIDs []uint `json:"domain_cert_ids,omitempty"` RedirectHTTP bool `json:"redirect_http"` LimitConnPerServer int `json:"limit_conn_per_server,omitempty"`