refactor(config): render routes from zone domains

This commit is contained in:
ryan
2026-07-12 15:03:24 +08:00
parent d0536fcdd5
commit d4d9bad74d
18 changed files with 190 additions and 283 deletions
+4
View File
@@ -26,6 +26,10 @@ sidebar: false
- 新增第一阶段 Zone 与正规化 Zone 域名数据库表及路由绑定模型,为后续以稳定 ID 管理网站与域名关联提供基础。
- 新增 Zone 管理 API 与显式历史域名导入命令,使用公共后缀列表验证注册根域和域名归属。
### 修改
- 配置快照、OpenResty 渲染、Tunnel 与 Uptime Kuma 监控改为从 Zone 域名绑定读取域名和证书,移除对反代路由旧域名/证书字段的运行时回退。
### 修复
- Docker ClickHouse 性能配置改为单文件挂载,避免覆盖镜像内置的 Docker 网络监听配置,导致宿主机无法通过 8123/9000 访问服务。
@@ -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,
@@ -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 {
@@ -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 {
@@ -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)
@@ -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
@@ -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)
@@ -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{}{}
+12 -5
View File
@@ -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()
@@ -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
}
@@ -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)
}
+10 -5
View File
@@ -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
}
@@ -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
@@ -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)
+7 -39
View File
@@ -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 {
+8 -8
View File
@@ -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 {
+6 -4
View File
@@ -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{
-3
View File
@@ -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"`