feat(domain): 支持通配符域名及关联功能适配 (#63)

* feat(domain): 支持通配符域名及关联功能适配

* fix(domain): preserve legacy Unicode imports and address review findings

* fix(domain): canonicalize legacy IDNs and exclude empty host labels

* fix(domain): normalize equivalent Unicode IDN spellings

---------

Co-authored-by: Ryan <63696351+Rain-kl@users.noreply.github.com>
Co-authored-by: ryan <ryan@arctel.net>
This commit is contained in:
Deqiying
2026-11-09 10:31:03 +08:00
committed by GitHub
parent 8d8b3e1ea8
commit 9a51a68206
37 changed files with 1614 additions and 113 deletions
+84
View File
@@ -0,0 +1,84 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package sync
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"math/big"
"path/filepath"
"strings"
"testing"
"github.com/Rain-kl/Wavelet/internal/apps/agent/protocol"
"github.com/Rain-kl/Wavelet/internal/apps/agent/state"
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
)
func TestSyncWildcardConfig(t *testing.T) {
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
cert := &x509.Certificate{SerialNumber: big.NewInt(1), DNSNames: []string{"*.s3.example.com"}}
der, err := x509.CreateCertificate(rand.Reader, cert, cert, publicKey, privateKey)
if err != nil {
t.Fatal(err)
}
keyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
t.Fatal(err)
}
files := []protocol.SupportFile{
{Path: "1.crt", Content: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))},
{Path: "1.key", Content: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}))},
}
for _, https := range []bool{false, true} {
t.Run(map[bool]string{false: "HTTP", true: "HTTPS"}[https], func(t *testing.T) {
doc := openrestyrender.Document{Routes: []openrestyrender.Route{{
ID: 1, SiteName: "wildcard", Domains: []string{"*.s3.example.com"},
OriginURL: "http://127.0.0.1:8080", EnableHTTPS: https, RedirectHTTP: https,
DomainCertIDs: []uint{1}, Enabled: true,
}}}
source, err := json.Marshal(doc)
if err != nil {
t.Fatal(err)
}
client := &fakeClient{config: protocol.ActiveConfigResponse{
Version: "wildcard-v1", Checksum: "wildcard-checksum", SourceConfigJSON: string(source), SupportFiles: files,
}}
dir := t.TempDir()
store := state.NewStore(filepath.Join(dir, "state.json"))
if _, err := store.EnsureNodeID(); err != nil {
t.Fatal(err)
}
manager := &fakeManager{}
service := New(client, manager, store)
if err := service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{Version: client.config.Version, Checksum: client.config.Checksum}); err != nil {
t.Fatalf("SyncOnce(wildcard, HTTPS=%t) error = %v", https, err)
}
if len(manager.applyRouteContents) != 1 {
t.Fatalf("agent apply calls = %d, want 1", len(manager.applyRouteContents))
}
content := manager.applyRouteContents[0]
if !strings.Contains(content, "server_name *.s3.example.com;") {
t.Errorf("agent apply config missing wildcard server_name: %s", content)
}
if https && !strings.Contains(content, "listen 443 ssl;") {
t.Error("agent apply config missing wildcard HTTPS server")
}
if len(client.reports) != 1 || client.reports[0].Result != ApplyResultSuccess {
t.Errorf("SyncOnce(wildcard) apply reports = %+v, want success", client.reports)
}
snapshot, err := store.Load()
if err != nil || snapshot.CurrentVersion != client.config.Version {
t.Errorf("agent applied version = %+v, error=%v, want %s", snapshot, err, client.config.Version)
}
})
}
}
@@ -0,0 +1,135 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cloudflare
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
)
func TestWildcardMemberDNSRecords(t *testing.T) {
tests := []struct {
name, mode, recordType, content string
proxied bool
}{
{name: "custom_A", mode: model.CFPointingTargetModeCustom, recordType: "A", content: "203.0.113.20"},
{name: "custom_AAAA", mode: model.CFPointingTargetModeCustom, recordType: "AAAA", content: "2001:db8::20", proxied: true},
{name: "custom_CNAME", mode: model.CFPointingTargetModeCustom, recordType: "CNAME", content: "edge.example.net", proxied: true},
{name: "node_A", recordType: "A", content: "203.0.113.10", proxied: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, originalID := setupCloudflareLogicDB(t)
state, err := repository.GetCFPointingMemberContext(ctx, originalID)
if err != nil {
t.Fatal(err)
}
state.Group.TargetMode, state.Group.RecordType, state.Group.RecordContent = tt.mode, tt.recordType, tt.content
state.Group.DefaultProxied = tt.proxied
if err := repository.SaveCFPointingGroup(ctx, &state.Group); err != nil {
t.Fatal(err)
}
if tt.mode != model.CFPointingTargetModeCustom {
if err := repository.UpdateOpenFlareNodeColumns(ctx, &state.Node, map[string]any{"ip": tt.content}); err != nil {
t.Fatal(err)
}
}
domain := &model.ZoneDomain{ZoneID: state.Zone.ID, Domain: "*.s3.example.com"}
if err := repository.CreateZoneDomain(ctx, domain); err != nil {
t.Fatal(err)
}
available, err := ListAvailableDomains(ctx)
if err != nil || len(available) != 1 || available[0].Domain != domain.Domain {
t.Fatalf("ListAvailableDomains() = %+v, %v, want wildcard domain", available, err)
}
t.Cleanup(SetDispatchTaskForTest(func(context.Context, string, []byte, string) (string, error) { return "task-1", nil }))
member, err := CreateMember(ctx, state.Group.ID, MemberCreateInput{ZoneDomainID: domain.ID})
if err != nil {
t.Fatalf("CreateMember(wildcard) error = %v", err)
}
var creates, updates, deletes atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/zones":
if got := r.URL.Query().Get("name"); got != "example.com" {
t.Errorf("FindZone name = %q, want example.com", got)
}
writeCFTestResponse(t, w, []Zone{{ID: "zone-1", Name: "example.com"}})
case "/zones/zone-1/dns_records", "/zones/zone-1/dns_records/wildcard-record":
if (r.Method == http.MethodPut || r.Method == http.MethodDelete) && r.URL.Path != "/zones/zone-1/dns_records/wildcard-record" {
t.Errorf("DNS %s path = %q, want record ID path", r.Method, r.URL.Path)
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
switch r.Method {
case http.MethodGet:
if r.URL.Path == "/zones/zone-1/dns_records/wildcard-record" {
writeCFTestResponse(t, w, DNSRecord{ID: "wildcard-record", Name: domain.Domain, Type: tt.recordType})
return
}
if r.URL.Query().Get("name") != domain.Domain || r.URL.Query().Get("type") != tt.recordType {
t.Errorf("ListRecords query = %v, want wildcard name and %s", r.URL.Query(), tt.recordType)
}
writeCFTestResponse(t, w, []DNSRecord{})
case http.MethodPost, http.MethodPut:
var input RecordInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
t.Errorf("Decode(record) error = %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
wantTTL := 300
if tt.proxied {
wantTTL = 1
}
if input.Name != domain.Domain || input.Type != tt.recordType || input.Content != tt.content || input.Proxied != tt.proxied || input.TTL != wantTTL {
t.Errorf("DNS record input = %+v, want name=%s type=%s content=%s proxied=%t TTL=%d", input, domain.Domain, tt.recordType, tt.content, tt.proxied, wantTTL)
}
if r.Method == http.MethodPost {
creates.Add(1)
} else {
updates.Add(1)
}
writeCFTestResponse(t, w, DNSRecord{ID: "wildcard-record", Name: input.Name, Type: input.Type, Content: input.Content})
case http.MethodDelete:
deletes.Add(1)
writeCFTestResponse(t, w, map[string]string{"id": "wildcard-record"})
default:
t.Errorf("unexpected method %s", r.Method)
w.WriteHeader(http.StatusMethodNotAllowed)
}
default:
t.Errorf("unexpected Cloudflare path %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
t.Cleanup(SetClientFactoryForTest(func(token string) Client {
return NewHTTPClient(token, WithBaseURL(server.URL), WithHTTPClient(server.Client()))
}))
for range 2 {
if err := ReconcileMember(ctx, member.ID); err != nil {
t.Fatalf("ReconcileMember(wildcard %s) error = %v", tt.recordType, err)
}
}
stored, err := repository.GetCFPointingMemberByID(ctx, member.ID)
if err != nil || stored.SyncStatus != model.CFMemberSyncOK || stored.CFRecordID != "wildcard-record" {
t.Fatalf("synced wildcard member = %+v, error=%v", stored, err)
}
if err := DeleteManagedRecord(ctx, member.ID); err != nil {
t.Fatalf("DeleteManagedRecord(wildcard) error = %v", err)
}
if creates.Load() != 1 || updates.Load() != 1 || deletes.Load() != 1 {
t.Errorf("DNS create/update/delete counts = %d/%d/%d, want 1/1/1", creates.Load(), updates.Load(), deletes.Load())
}
})
}
}
@@ -0,0 +1,65 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package config_version
import (
"context"
"encoding/json"
"slices"
"strings"
"testing"
oftls "github.com/Rain-kl/Wavelet/internal/apps/openflare/tls"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/zone"
"github.com/Rain-kl/Wavelet/internal/infra/config"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
)
func TestWildcardDomainConfigBundle(t *testing.T) {
t.Cleanup(setupConfigVersionTestDB(t))
ctx := context.Background()
if err := db.DB(ctx).AutoMigrate(&model.TLSCertificate{}); err != nil {
t.Fatal(err)
}
oldSecret := config.Config.App.SessionSecret
config.Config.App.SessionSecret = "wildcard-snapshot-test-secret"
t.Cleanup(func() { config.Config.App.SessionSecret = oldSecret })
certPEM, keyPEM := generateTestCertKeyPairForSnapshotForDomain(t, "*.s3.example.com")
cert, err := oftls.CreateCertificate(ctx, oftls.CertificateInput{Name: "wildcard", CertPEM: certPEM, KeyPEM: keyPEM})
if err != nil {
t.Fatal(err)
}
root, err := zone.Create(ctx, zone.Input{Domain: "example.com"})
if err != nil {
t.Fatal(err)
}
domain, err := zone.CreateDomain(ctx, root.ID, zone.DomainInput{Domain: "*.s3.example.com", CertID: &cert.ID})
if err != nil {
t.Fatalf("CreateDomain(wildcard) error = %v", err)
}
route := &model.ProxyRoute{SiteName: "wildcard-site", OriginURL: "http://origin:8080", Upstreams: `["http://origin:8080"]`, Enabled: true, EnableHTTPS: true, RedirectHTTP: true}
if err := repository.CreateProxyRouteRecord(ctx, route); err != nil {
t.Fatal(err)
}
if err := repository.ReplaceZoneDomainRouteBindings(ctx, route.ID, []uint{domain.ID}); err != nil {
t.Fatal(err)
}
bundle, err := buildCurrentConfigBundle(ctx, true)
if err != nil {
t.Fatalf("buildCurrentConfigBundle(wildcard) error = %v", err)
}
var doc openrestyrender.Document
if err := json.Unmarshal([]byte(bundle.SnapshotJSON), &doc); err != nil {
t.Fatal(err)
}
if len(doc.Routes) != 1 || !slices.Equal(doc.Routes[0].Domains, []string{domain.Domain}) || !slices.Equal(doc.Routes[0].DomainCertIDs, []uint{cert.ID}) {
t.Fatalf("snapshot routes = %+v, want wildcard domain and its certificate", doc.Routes)
}
if !strings.Contains(bundle.RouteConfig, "server_name *.s3.example.com;") || !strings.Contains(bundle.RouteConfig, "listen 443 ssl;") {
t.Errorf("wildcard bundle missing HTTPS server: %s", bundle.RouteConfig)
}
}
@@ -305,9 +305,21 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
rec := performJSONRequest(t, engine, http.MethodPost, fmt.Sprintf("%s/zones/%d/domains", apiPath(""), zoneID), map[string]any{
"domain": "*.example.com",
}, adminAuthHeaders(seed.Token))
require.Equal(t, http.StatusBadRequest, rec.Code)
errResp := decodeAPIResponse(t, rec)
assert.NotEmpty(t, errResp.ErrorMsg)
if rec.Code != http.StatusOK {
t.Fatalf("create wildcard domain status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
wildcardData := unmarshalAPIMap(t, requireAPIOK(t, rec).Data)
if wildcardData["domain"] != "*.example.com" {
t.Errorf("created domain = %v, want *.example.com", wildcardData["domain"])
}
for _, invalid := range []string{"a.*.example.com", "*.other.com", "*.example.com;"} {
rejected := performJSONRequest(t, engine, http.MethodPost, fmt.Sprintf("%s/zones/%d/domains", apiPath(""), zoneID), map[string]any{
"domain": invalid,
}, adminAuthHeaders(seed.Token))
if rejected.Code != http.StatusBadRequest || decodeAPIResponse(t, rejected).ErrorMsg == "" {
t.Errorf("create domain %q status = %d, body = %s, want 400 with error message", invalid, rejected.Code, rejected.Body.String())
}
}
rec = performJSONRequest(t, engine, http.MethodPost, fmt.Sprintf("%s/zones/%d/domains", apiPath(""), zoneID), map[string]any{
"domain": "security.example.com", "cert_id": certID,
+10 -4
View File
@@ -229,11 +229,17 @@ func routeMonitorURL(ctx context.Context, route *model.ProxyRoute) (string, erro
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
// 通配符是匹配规则而非可探测地址;混合绑定时选择具体域名。
for _, domain := range domains {
if strings.Contains(domain.Domain, "*") {
continue
}
if route.EnableHTTPS {
return "https://" + domain.Domain, nil
}
return "http://" + domain.Domain, nil
}
return "http://" + domain, nil
return "", fmt.Errorf("route %s only has wildcard domains; bind a concrete hostname for monitoring", route.SiteName)
}
func monitorPayload(id int, name, targetURL string, config *kumaConfig) map[string]any {
@@ -0,0 +1,82 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package uptimekuma
import (
"context"
"net/http/httptest"
"strings"
"testing"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
)
func TestRouteMonitorURLWildcard(t *testing.T) {
t.Cleanup(setupSyncTestDB(t))
ctx := context.Background()
route := &model.ProxyRoute{SiteName: "wildcard-site", EnableHTTPS: true}
if err := repository.CreateProxyRouteRecord(ctx, route); err != nil {
t.Fatal(err)
}
domain := &model.ZoneDomain{Domain: "*.example.com", ProxyRouteID: &route.ID}
if err := repository.CreateZoneDomain(ctx, domain); err != nil {
t.Fatal(err)
}
if got, err := routeMonitorURL(ctx, route); got != "" || err == nil || !strings.Contains(err.Error(), "wildcard") {
t.Fatalf("routeMonitorURL(wildcard only) = (%q, %v), want empty URL and wildcard error", got, err)
}
// 无可探测地址时不能调用客户端,也不能将已有监控标记为过期。
expected := syncRouteMonitors(ctx, nil, []*model.ProxyRoute{route}, nil, 0, nil)
if !expected[route.SiteName] {
t.Error("syncRouteMonitors(wildcard only) removed route from expected sites")
}
if err := repository.CreateZoneDomain(ctx, &model.ZoneDomain{Domain: "www.example.com", ProxyRouteID: &route.ID}); err != nil {
t.Fatal(err)
}
if got, err := routeMonitorURL(ctx, route); err != nil || got != "https://www.example.com" {
t.Errorf("routeMonitorURL(mixed HTTPS) = (%q, %v), want https://www.example.com", got, err)
}
route.EnableHTTPS = false
if got, err := routeMonitorURL(ctx, route); err != nil || got != "http://www.example.com" {
t.Errorf("routeMonitorURL(mixed HTTP) = (%q, %v), want http://www.example.com", got, err)
}
}
// TestSyncWildcardPreservesExistingMonitor exercises cleanup through a full sync.
func TestSyncWildcardPreservesExistingMonitor(t *testing.T) {
t.Cleanup(setupSyncTestDB(t))
ctx := context.Background()
route := &model.ProxyRoute{SiteName: "wildcard-site", Enabled: true}
if err := repository.CreateProxyRouteRecord(ctx, route); err != nil {
t.Fatal(err)
}
createRouteZoneDomain(t, ctx, route, "*.example.com")
mockSrv := newMockKumaServer(`{"98":{"id":98,"name":"wildcard-site","url":"https://old.example.com","tags":[{"id":10,"name":"OpenFlare"}]},"99":{"id":99,"name":"stale-site","tags":[{"id":10,"name":"OpenFlare"}]}}`)
server := httptest.NewServer(mockSrv)
t.Cleanup(server.Close)
setTestConfig(ctx, model.ConfigKeyUptimeKumaEnabled, "true")
setTestConfig(ctx, model.ConfigKeyUptimeKumaURL, server.URL)
setTestConfig(ctx, model.ConfigKeyUptimeKumaUsername, "admin")
setTestConfig(ctx, model.ConfigKeyUptimeKumaPassword, "password")
setTestConfig(ctx, model.ConfigKeyUptimeKumaMonitorScope, "all")
if err := SyncToUptimeKuma(ctx); err != nil {
t.Fatal(err)
}
mockSrv.mu.Lock()
posts := append([]string(nil), mockSrv.postsReceived...)
mockSrv.mu.Unlock()
deletedStale := false
for _, body := range posts {
if strings.Contains(body, `"editMonitor"`) || strings.Contains(body, `"add"`) || (strings.Contains(body, `"deleteMonitor"`) && strings.Contains(body, "98")) {
t.Errorf("SyncToUptimeKuma(wildcard only) sent monitor mutation %q", body)
}
if strings.Contains(body, `"deleteMonitor"`) && strings.Contains(body, "99") {
deletedStale = true
}
}
if !deletedStale {
t.Error("SyncToUptimeKuma did not delete stale monitor 99; cleanup must run while preserving wildcard monitor 98")
}
}
+11 -12
View File
@@ -4,16 +4,15 @@
package zone
const (
errZoneDomainRequired = "domain name must not be empty"
errZoneRootInvalid = "zone must be a valid registered root domain"
errDomainInvalid = "invalid domain name format"
errDomainWildcardUnsupported = "wildcard domain names are not supported"
errDomainOutsideZone = "domain does not belong to this zone"
errZoneNotFound = "Zone was not found"
errDomainNotFound = "Domain was not found"
errDomainExists = "domain already exists"
errCertificateNotFound = "selected certificate was not found"
errDomainBoundToRoute = "domain is bound to a proxy route; remove the binding first"
errZoneHasDomains = "root domain still contains domains; delete them all first"
errStatsRangeInvalid = "invalid time range; select 24h, 7d or 30d"
errZoneDomainRequired = "domain name must not be empty"
errZoneRootInvalid = "zone must be a valid registered root domain"
errDomainInvalid = "invalid domain name format"
errDomainOutsideZone = "domain does not belong to this zone"
errZoneNotFound = "Zone was not found"
errDomainNotFound = "Domain was not found"
errDomainExists = "domain already exists"
errCertificateNotFound = "selected certificate was not found"
errDomainBoundToRoute = "domain is bound to a proxy route; remove the binding first"
errZoneHasDomains = "root domain still contains domains; delete them all first"
errStatsRangeInvalid = "invalid time range; select 24h, 7d or 30d"
)
+27 -5
View File
@@ -13,6 +13,7 @@ import (
"strings"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/routeidentity"
"golang.org/x/net/idna"
)
// ImportReport describes the idempotent legacy migration result.
@@ -82,7 +83,7 @@ func ImportLegacyTx(ctx context.Context, tx *sql.Tx, postgres bool) (report Impo
}
for _, item := range items {
domain, normErr := normalizeDomain(item.Domain)
domain, normErr := normalizeLegacyDomain(item.Domain)
if normErr != nil {
report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s: %v", item.Domain, normErr))
continue
@@ -95,22 +96,28 @@ func ImportLegacyTx(ctx context.Context, tx *sql.Tx, postgres bool) (report Impo
var existingID uint
var existingZoneDomain string
var existingRouteID, existingCertID sql.Null[uint]
scanErr := tx.QueryRowContext(ctx, q(`
SELECT zd.id, z.domain
SELECT zd.id, z.domain, zd.proxy_route_id, zd.cert_id
FROM of_zone_domains zd
JOIN of_zones z ON z.id = zd.zone_id
WHERE zd.domain = ?
`), domain).Scan(&existingID, &existingZoneDomain)
`), domain).Scan(&existingID, &existingZoneDomain, &existingRouteID, &existingCertID)
if scanErr == nil {
if (item.ProxyRouteID != nil && existingRouteID.Valid && existingRouteID.V != *item.ProxyRouteID) ||
(item.CertID != nil && existingCertID.Valid && existingCertID.V != *item.CertID) {
report.Conflicts = append(report.Conflicts, domain+": canonical domain binding conflict")
continue
}
if existingZoneDomain != root {
report.Conflicts = append(report.Conflicts, domain+": global domain conflict")
} else if item.ProxyRouteID != nil {
} else if item.ProxyRouteID != nil || item.CertID != nil {
if _, bindErr := tx.ExecContext(ctx, q(`
UPDATE of_zone_domains
SET proxy_route_id = COALESCE(proxy_route_id, ?),
cert_id = COALESCE(cert_id, ?)
WHERE id = ?
`), *item.ProxyRouteID, nullableUint(item.CertID), existingID); bindErr != nil {
`), nullableUint(item.ProxyRouteID), nullableUint(item.CertID), existingID); bindErr != nil {
return report, bindErr
}
}
@@ -152,6 +159,21 @@ func ImportLegacyTx(ctx context.Context, tx *sql.Tx, postgres bool) (report Impo
return report, nil
}
// normalizeLegacyDomain canonicalizes historical IDNs for DNS identity and rendering.
// Certificate associations and route bindings remain attached to the imported row.
func normalizeLegacyDomain(raw string) (string, error) {
domain := strings.ToLower(strings.TrimSpace(raw))
host := strings.TrimPrefix(domain, "*.")
ascii, err := idna.Lookup.ToASCII(host)
if err != nil {
return "", errors.New(errDomainInvalid)
}
if host != domain {
ascii = "*." + ascii
}
return normalizeDomain(ascii)
}
func ensureZone(
ctx context.Context,
tx *sql.Tx,
@@ -148,3 +148,117 @@ func TestImportLegacyTxNoOpWithoutLegacyColumns(t *testing.T) {
assert.Equal(t, 0, report.Zones)
assert.Equal(t, 0, report.Domains)
}
// TestImportLegacyTxUnicodeDomains canonicalizes historical names and preserves bindings on upgrade.
func TestImportLegacyTxUnicodeDomains(t *testing.T) {
for _, source := range []string{"route", "managed"} {
t.Run(source, func(t *testing.T) {
sqlDB, cleanup := setupLegacyImportDB(t)
t.Cleanup(cleanup)
ctx := context.Background()
if _, err := sqlDB.Exec(`INSERT INTO of_tls_certificates (id, name) VALUES (7, 'cert')`); err != nil {
t.Fatal(err)
}
var err error
if source == "route" {
_, err = sqlDB.Exec(`INSERT INTO of_proxy_routes (id, domain, domains, domain_cert_ids) VALUES (3, 'api.bücher.de', '["api.bücher.de"]', '[7]')`)
} else {
_, err = sqlDB.Exec(`INSERT INTO of_managed_domains (domain, cert_id) VALUES ('api.bücher.de', 7)`)
}
if err != nil {
t.Fatal(err)
}
for attempt := range 2 {
tx, err := sqlDB.Begin()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = tx.Rollback() })
report, err := ImportLegacyTx(ctx, tx, false)
if err != nil {
t.Fatalf("ImportLegacyTx(%s, attempt=%d) = %+v, %v, want no conflicts", source, attempt, report, err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
want := 1 - attempt
if report.Zones != want || report.Domains != want {
t.Errorf("ImportLegacyTx(%s, attempt=%d) = %+v, want %d zones/domains", source, attempt, report, want)
}
}
var root, domain string
var certID uint
var routeID sql.NullInt64
if err := sqlDB.QueryRow(`SELECT z.domain, zd.domain, zd.cert_id, zd.proxy_route_id FROM of_zone_domains zd JOIN of_zones z ON z.id = zd.zone_id`).Scan(&root, &domain, &certID, &routeID); err != nil {
t.Fatal(err)
}
if root != "xn--bcher-kva.de" || domain != "api.xn--bcher-kva.de" || certID != 7 {
t.Errorf("imported %s = (%q, %q, cert=%d), want (xn--bcher-kva.de, api.xn--bcher-kva.de, cert=7)", source, root, domain, certID)
}
if (source == "route" && (!routeID.Valid || routeID.Int64 != 3)) || (source == "managed" && routeID.Valid) {
t.Errorf("imported %s route binding = %v", source, routeID)
}
var zoneID uint
if err := sqlDB.QueryRow(`SELECT id FROM of_zones`).Scan(&zoneID); err != nil {
t.Fatal(err)
}
if _, err := Create(ctx, Input{Domain: "xn--bcher-kva.de"}); err == nil {
t.Error("Create accepted an equivalent imported IDN zone")
}
if _, err := CreateDomain(ctx, zoneID, DomainInput{Domain: "api.xn--bcher-kva.de"}); err == nil {
t.Error("CreateDomain accepted an equivalent imported IDN host")
}
if _, err := CreateDomain(ctx, zoneID, DomainInput{Domain: "www.xn--bcher-kva.de"}); err != nil {
t.Errorf("CreateDomain in imported IDN zone: %v", err)
}
if _, err := normalizeDomain("api.bücher.de"); err == nil {
t.Error("normalizeDomain(api.bücher.de) accepted a new Unicode domain")
}
})
}
}
// TestImportLegacyTxIDNBindingConflict rejects equivalent names bound to different routes.
func TestImportLegacyTxIDNBindingConflict(t *testing.T) {
sqlDB, cleanup := setupLegacyImportDB(t)
t.Cleanup(cleanup)
if _, err := sqlDB.Exec(`INSERT INTO of_proxy_routes (id, domain, domains) VALUES (3, 'api.bücher.de', '["api.bücher.de"]'), (4, 'api.xn--bcher-kva.de', '["api.xn--bcher-kva.de"]')`); err != nil {
t.Fatal(err)
}
tx, err := sqlDB.Begin()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = tx.Rollback() })
report, err := ImportLegacyTx(context.Background(), tx, false)
if err == nil || len(report.Conflicts) != 1 {
t.Errorf("ImportLegacyTx(equivalent IDN with different bindings) = %+v, %v; want one conflict", report, err)
}
}
// TestNormalizeLegacyIDNRoot resolves Unicode public suffixes through their ASCII identity.
func TestNormalizeLegacyIDNRoot(t *testing.T) {
domain, err := normalizeLegacyDomain("api.example.公司.cn")
if err != nil {
t.Fatal(err)
}
root, err := zoneRoot(domain)
if err != nil || root != "example.xn--55qx5d.cn" {
t.Errorf("zoneRoot(%q) = %q, %v; want example.xn--55qx5d.cn", domain, root, err)
}
}
// TestNormalizeLegacyIDNCanonicalForms keeps Unicode normalization and wildcard identity consistent.
func TestNormalizeLegacyIDNCanonicalForms(t *testing.T) {
for _, raw := range []string{"api.bücher.de", "api.bu\u0308cher.de", "API.XN--BCHER-KVA.DE"} {
domain, err := normalizeLegacyDomain(raw)
if err != nil || domain != "api.xn--bcher-kva.de" {
t.Errorf("normalizeLegacyDomain(%q) = %q, %v; want api.xn--bcher-kva.de", raw, domain, err)
}
}
domain, err := normalizeLegacyDomain("*.bücher.de")
if err != nil || domain != "*.xn--bcher-kva.de" {
t.Errorf("normalizeLegacyDomain(*.bücher.de) = %q, %v; want *.xn--bcher-kva.de", domain, err)
}
}
+19 -7
View File
@@ -1,7 +1,7 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package zone manages registered roots and their explicit hostnames.
// Package zone manages registered roots and their exact or wildcard hostnames.
package zone
import (
@@ -18,6 +18,8 @@ import (
"gorm.io/gorm"
)
const maxDomainLength = 253
// Input is the mutable Zone payload.
type Input struct {
Domain string `json:"domain"`
@@ -45,7 +47,8 @@ type ListItem struct {
}
func zoneRoot(domain string) (string, error) {
return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(strings.TrimSpace(domain)))
domain = strings.ToLower(strings.TrimSpace(domain))
return publicsuffix.EffectiveTLDPlusOne(strings.TrimPrefix(domain, "*."))
}
func normalizeDomain(raw string) (string, error) {
@@ -53,12 +56,21 @@ func normalizeDomain(raw string) (string, error) {
if domain == "" {
return "", errors.New(errZoneDomainRequired)
}
if strings.Contains(domain, "*") {
return "", errors.New(errDomainWildcardUnsupported)
}
if strings.Contains(domain, "://") || strings.Contains(domain, "/") || strings.Contains(domain, "?") || strings.Contains(domain, "#") || strings.Contains(domain, "@") {
// 只允许最左侧完整标签为通配符;其余字符必须是 DNS 标签,不能进入配置语法。
host := strings.TrimPrefix(domain, "*.")
if len(domain) > maxDomainLength {
return "", errors.New(errDomainInvalid)
}
for _, label := range strings.Split(host, ".") {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return "", errors.New(errDomainInvalid)
}
for _, char := range label {
if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' {
return "", errors.New(errDomainInvalid)
}
}
}
if _, err := zoneRoot(domain); err != nil {
return "", errors.New(errDomainInvalid)
}
@@ -151,7 +163,7 @@ func GetOverview(ctx context.Context, id uint) (*Overview, error) {
return &Overview{Zone: *zone, Domains: domains}, nil
}
// CreateDomain adds a validated exact hostname to a Zone.
// CreateDomain adds a validated exact or wildcard hostname to a Zone.
func CreateDomain(ctx context.Context, zoneID uint, input DomainInput) (*model.ZoneDomain, error) {
zone, err := repository.GetZoneByID(ctx, zoneID)
if err != nil {
+90 -4
View File
@@ -5,6 +5,7 @@ package zone
import (
"context"
"strings"
"testing"
"time"
@@ -28,12 +29,97 @@ func setupZoneDB(t *testing.T) context.Context {
return context.Background()
}
func TestCreateZoneDomainRejectsWildcard(t *testing.T) {
func TestNormalizeDomainWildcard(t *testing.T) {
tests := []struct {
input string
want string
}{
{input: " *.Example.COM ", want: "*.example.com"},
{input: "*.s3.example.com", want: "*.s3.example.com"},
{input: "*.example.co.uk", want: "*.example.co.uk"},
{input: "API.Example.com", want: "api.example.com"},
{input: "example.com", want: "example.com"},
{input: "*"}, {input: "*.com"}, {input: "*.co.uk"},
{input: "foo*.example.com"}, {input: "a.*.example.com"},
{input: "*.*.example.com"}, {input: "**.example.com"},
{input: "*.example.com."}, {input: "*..example.com"},
{input: "*.-s3.example.com"}, {input: "*.s3-.example.com"},
{input: "*.s_3.example.com"}, {input: "*.example.com:443"},
{input: "*.example.com;"}, {input: "*.a b.example.com"},
{input: "*.a\nb.example.com"}, {input: "https://*.example.com"},
{input: "*.example.com/path"}, {input: "*.example.com?query"},
{input: "*.example.com#fragment"}, {input: "*@example.com"},
{input: "*." + strings.Repeat("a", 64) + ".example.com"},
{input: "*." + strings.Repeat(strings.Repeat("a", 63)+".", 4) + "com"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := normalizeDomain(tt.input)
if got != tt.want || (err != nil) != (tt.want == "") {
t.Errorf("normalizeDomain(%q) = (%q, %v), want %q with error=%t", tt.input, got, err, tt.want, tt.want == "")
}
})
}
}
func TestCreateAndUpdateWildcardDomain(t *testing.T) {
ctx := setupZoneDB(t)
zone, err := Create(ctx, Input{Domain: "example.com"})
require.NoError(t, err)
_, err = CreateDomain(ctx, zone.ID, DomainInput{Domain: "*.example.com"})
require.EqualError(t, err, errDomainWildcardUnsupported)
if err != nil {
t.Fatal(err)
}
item, err := CreateDomain(ctx, zone.ID, DomainInput{Domain: " *.Example.COM "})
if err != nil {
t.Fatalf("CreateDomain(wildcard) error = %v", err)
}
if item.Domain != "*.example.com" {
t.Errorf("CreateDomain(wildcard).Domain = %q, want *.example.com", item.Domain)
}
if _, err := CreateDomain(ctx, zone.ID, DomainInput{Domain: "*.example.com"}); err == nil {
t.Error("CreateDomain(duplicate wildcard) error = nil, want duplicate error")
}
// 精确域名可以和通配符共存,路由匹配优先级交给 OpenResty。
if _, err := CreateDomain(ctx, zone.ID, DomainInput{Domain: "api.example.com"}); err != nil {
t.Fatalf("CreateDomain(exact alongside wildcard) error = %v", err)
}
for _, domain := range []string{"*.s3.example.com", "www.example.com", "*.example.com"} {
updated, err := UpdateDomain(ctx, zone.ID, item.ID, DomainInput{Domain: domain})
if err != nil {
t.Fatalf("UpdateDomain(%q) error = %v", domain, err)
}
if updated.Domain != domain {
t.Errorf("UpdateDomain(%q).Domain = %q", domain, updated.Domain)
}
}
for _, domain := range []string{"*.other.com", "*.example.com.evil.net", "a.*.example.com"} {
if _, err := CreateDomain(ctx, zone.ID, DomainInput{Domain: domain}); err == nil {
t.Errorf("CreateDomain(%q) error = nil, want invalid/outside-zone error", domain)
}
if _, err := UpdateDomain(ctx, zone.ID, item.ID, DomainInput{Domain: domain}); err == nil {
t.Errorf("UpdateDomain(%q) error = nil, want invalid/outside-zone error", domain)
}
}
stored, err := repository.GetZoneDomainByZoneAndID(ctx, zone.ID, item.ID)
if err != nil || stored.Domain != "*.example.com" {
t.Fatalf("stored domain after rejected updates = %+v, error=%v, want *.example.com", stored, err)
}
}
func TestZoneRootRejectsWildcard(t *testing.T) {
ctx := setupZoneDB(t)
if _, err := Create(ctx, Input{Domain: "*.example.com"}); err == nil {
t.Error("Create(wildcard zone) error = nil, want invalid root error")
}
zone, err := Create(ctx, Input{Domain: "example.co.uk"})
if err != nil {
t.Fatal(err)
}
if _, err := Update(ctx, zone.ID, Input{Domain: "*.example.co.uk"}); err == nil {
t.Error("Update(wildcard zone) error = nil, want invalid root error")
}
if _, err := CreateDomain(ctx, zone.ID, DomainInput{Domain: "*.s3.example.co.uk"}); err != nil {
t.Errorf("CreateDomain(wildcard under multi-label public suffix) error = %v", err)
}
}
func TestDeleteDomainRejectsBoundRoute(t *testing.T) {
+3 -1
View File
@@ -161,8 +161,9 @@ func DeleteHandler(c *gin.Context) {
c.JSON(http.StatusOK, response.OKNil())
}
// CreateDomainHandler creates an explicit FQDN under a Zone.
// CreateDomainHandler creates an exact or wildcard FQDN under a Zone.
// @Summary 创建 Zone 域名
// @Description 支持具体域名或最左侧完整标签为 * 的通配符域名,例如 *.s3.example.com;域名必须属于当前 Zone。
// @Tags openflare-zone
// @Accept json
// @Produce json
@@ -192,6 +193,7 @@ func CreateDomainHandler(c *gin.Context) {
// UpdateDomainHandler updates a Zone domain.
// @Summary 更新 Zone 域名
// @Description 支持具体域名与通配符域名互相修改;通配符仅允许出现在最左侧完整标签,且域名必须属于当前 Zone。
// @Tags openflare-zone
// @Accept json
// @Produce json
@@ -0,0 +1,105 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package zone
import (
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/google/go-cmp/cmp"
)
func TestGetStatsWildcardHosts(t *testing.T) {
type totals struct {
Requests int64
Visitors int64
Bytes int64
}
tests := []struct {
name string
domains []string
want totals
buckets map[int]totals
}{
{
name: "wildcard_matches_multiple_levels_without_root",
domains: []string{"*.example.com"}, want: totals{Requests: 5, Visitors: 3, Bytes: 150},
buckets: map[int]totals{0: {Requests: 2, Visitors: 1, Bytes: 30}, 1: {Requests: 3, Visitors: 2, Bytes: 120}},
},
{
name: "nested_wildcard_excludes_its_suffix_root",
domains: []string{"*.s3.example.com"}, want: totals{Requests: 2, Visitors: 1, Bytes: 70},
buckets: map[int]totals{1: {Requests: 2, Visitors: 1, Bytes: 70}},
},
{
name: "overlapping_exact_and_wildcards_are_not_summed_twice",
domains: []string{"api.example.com", "*.example.com", "*.s3.example.com"}, want: totals{Requests: 5, Visitors: 3, Bytes: 150},
buckets: map[int]totals{0: {Requests: 2, Visitors: 1, Bytes: 30}, 1: {Requests: 3, Visitors: 2, Bytes: 120}},
},
{
name: "exact_root_can_be_included_explicitly",
domains: []string{"example.com", "*.s3.example.com"}, want: totals{Requests: 3, Visitors: 2, Bytes: 130},
buckets: map[int]totals{0: {Requests: 1, Visitors: 1, Bytes: 60}, 1: {Requests: 2, Visitors: 1, Bytes: 70}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := setupZoneDB(t)
testhelper.SetupLogStoresForTest(t)
site, err := Create(ctx, Input{Domain: "example.com"})
if err != nil {
t.Fatalf("Create(example.com) error = %v, want nil", err)
}
// 直接登记域名,隔离流量统计与业务层域名校验的回归范围。
for _, domain := range tt.domains {
if err := repository.CreateZoneDomain(ctx, &model.ZoneDomain{ZoneID: site.ID, Domain: domain}); err != nil {
t.Fatalf("CreateZoneDomain(%q) error = %v, want nil", domain, err)
}
}
base := time.Now().UTC().Truncate(time.Hour).Add(-3 * time.Hour)
rows := []*model.OpenFlareAccessLog{
{Host: "a..example.com", LoggedAt: base.Add(time.Minute), RemoteAddr: "5.5.5.5", BytesSent: 1000},
{Host: ".a.example.com", LoggedAt: base.Add(time.Minute), RemoteAddr: "5.5.5.5", BytesSent: 1000},
{Host: "api.example.com", LoggedAt: base.Add(time.Minute), RemoteAddr: "1.1.1.1", BytesSent: 10},
{Host: " Deep.API.Example.COM ", LoggedAt: base.Add(2 * time.Minute), RemoteAddr: "1.1.1.1", BytesSent: 20},
{Host: "bucket.s3.example.com", LoggedAt: base.Add(time.Hour + time.Minute), RemoteAddr: "2.2.2.2", BytesSent: 30},
{Host: "deep.bucket.s3.example.com", LoggedAt: base.Add(time.Hour + 2*time.Minute), BytesSent: 40},
{Host: "s3.example.com", LoggedAt: base.Add(time.Hour + 3*time.Minute), RemoteAddr: "3.3.3.3", BytesSent: 50},
{Host: "example.com", LoggedAt: base.Add(3 * time.Minute), RemoteAddr: "1.1.1.1", BytesSent: 60},
{Host: "evil-example.com", LoggedAt: base.Add(time.Minute), RemoteAddr: "9.9.9.9", BytesSent: 70},
{Host: "bucket.s3.example.com.evil", LoggedAt: base.Add(time.Minute), RemoteAddr: "9.9.9.9", BytesSent: 80},
{Host: "bucket.s3.example.com", LoggedAt: base.Add(-48 * time.Hour), RemoteAddr: "9.9.9.9", BytesSent: 90},
}
if err := repository.InsertOpenFlareAccessLogsBatch(ctx, rows); err != nil {
t.Fatalf("InsertOpenFlareAccessLogsBatch(wildcard fixtures) error = %v, want nil", err)
}
stats, err := GetStats(ctx, site.ID, "24h")
if err != nil {
t.Fatalf("GetStats(%d, 24h) error = %v, want nil", site.ID, err)
}
if got := (totals{Requests: stats.RequestCount, Visitors: stats.UniqueVisitors, Bytes: stats.BytesSent}); got != tt.want {
t.Errorf("GetStats(domains=%v) totals = %+v, want %+v", tt.domains, got, tt.want)
}
if !stats.Available || stats.DomainCount != len(tt.domains) {
t.Errorf("GetStats(domains=%v) available/domain_count = %t/%d, want true/%d", tt.domains, stats.Available, stats.DomainCount, len(tt.domains))
}
wantBuckets := make(map[int64]totals)
for offset, total := range tt.buckets {
wantBuckets[base.Add(time.Duration(offset)*time.Hour).Unix()] = total
}
gotBuckets := make(map[int64]totals)
for _, point := range stats.Series {
if point.RequestCount != 0 || point.UniqueVisitors != 0 || point.BytesSent != 0 {
gotBuckets[point.BucketStartedAt.Unix()] = totals{Requests: point.RequestCount, Visitors: point.UniqueVisitors, Bytes: point.BytesSent}
}
}
if diff := cmp.Diff(wantBuckets, gotBuckets); diff != "" {
t.Errorf("GetStats(domains=%v) nonempty series mismatch (-want +got):\n%s", tt.domains, diff)
}
})
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ type NodeAccessLogFilter struct {
NodeID string
RemoteAddr string
Host string
// Hosts exact-matches any host (case-insensitive). Prefer over Host for multi-domain scopes.
// Hosts 优先于 Host:具体域名精确匹配(忽略大小写),*.suffix 匹配非空、多层子域名但不含 suffix 本身。
Hosts []string
Path string
// StatusCode filters by exact HTTP status code when > 0.
+7 -4
View File
@@ -160,7 +160,9 @@ type OpenFlareAccessLogQuery struct {
NodeID string
RemoteAddr string
Host string
// Hosts exact-matches any host (case-insensitive). Prefer over Host for multi-domain scopes.
// Hosts matches case-insensitively and takes precedence over Host.
// Exact names match only that host; *.example.com matches one or more
// nonempty subdomain levels, excluding example.com itself.
Hosts []string
Path string
// StatusCode filters by exact HTTP status code when > 0.
@@ -175,9 +177,10 @@ type OpenFlareAccessLogQuery struct {
// OpenFlareAccessLogBucketQuery filters folded access log queries (v1 stub).
type OpenFlareAccessLogBucketQuery struct {
NodeID string
RemoteAddr string
Host string
NodeID string
RemoteAddr string
Host string
// Hosts follows OpenFlareAccessLogQuery.Hosts exact and wildcard matching.
Hosts []string
Path string
Since time.Time
+2 -2
View File
@@ -25,7 +25,7 @@ func (Zone) TableName() string {
return tableOfZones
}
// ZoneDomain OpenFlare Zone 下的明确域名实体。
// ZoneDomain OpenFlare Zone 下的具体或通配符域名实体。
type ZoneDomain struct {
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
ZoneID uint `json:"zone_id" gorm:"not null;index:idx_of_zone_domains_zone_id"`
@@ -41,7 +41,7 @@ func (ZoneDomain) TableName() string {
return tableOfZoneDomains
}
// ZoneDomainCount is the per-zone explicit domain count for list queries.
// ZoneDomainCount 是每个 Zone 的登记域名记录数,通配符按一条记录计数。
type ZoneDomainCount struct {
ZoneID uint `json:"zone_id" gorm:"column:zone_id"`
Count int64 `json:"count" gorm:"column:count"`
@@ -42,12 +42,28 @@ func buildNodeAccessLogFilterClause(filter NodeAccessLogFilter) (string, []any)
}
hosts := normalizeNodeAccessLogHosts(filter.Hosts)
if len(hosts) > 0 {
hostParts := make([]string, 0, len(hosts))
placeholders := make([]string, 0, len(hosts))
var exactHosts []any
for _, host := range hosts {
if suffix, wildcard := strings.CutPrefix(host, "*."); wildcard && suffix != "" {
// _% 要求非空子域名,并保留 Nginx 通配符可匹配多层子域名的语义。
hostParts = append(hostParts, "(lowerUTF8(trim(host)) LIKE ? AND trim(host) NOT LIKE '%..%' AND trim(host) NOT LIKE '.%')")
args = append(args, "_%."+util.EscapeLike(suffix))
continue
}
placeholders = append(placeholders, "?")
args = append(args, host)
exactHosts = append(exactHosts, host)
}
parts = append(parts, "lowerUTF8(trim(host)) IN ("+strings.Join(placeholders, ", ")+")")
if len(exactHosts) > 0 {
hostParts = append(hostParts, "lowerUTF8(trim(host)) IN ("+strings.Join(placeholders, ", ")+")")
args = append(args, exactHosts...)
}
hostClause := strings.Join(hostParts, " OR ")
if len(hostParts) > 1 {
hostClause = "(" + hostClause + ")"
}
parts = append(parts, hostClause)
} else if trimmed := strings.TrimSpace(filter.Host); trimmed != "" {
parts = append(parts, "host LIKE ?")
args = append(args, util.EscapeLike(trimmed)+"%")
@@ -0,0 +1,132 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package analytics
import (
"context"
"strings"
"testing"
"time"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/google/go-cmp/cmp"
)
func TestNodeAccessLogHostsWildcardSQL(t *testing.T) {
since := time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC)
until := since.Add(time.Hour)
tests := []struct {
name string
filter NodeAccessLogFilter
clause string
args []any
}{
{
name: "exact_hosts_remain_exact_and_normalized",
filter: NodeAccessLogFilter{Hosts: []string{" API.Example.com ", "api.example.com", "", "www.example.com"}},
clause: "lowerUTF8(trim(host)) IN (?, ?)",
args: []any{"api.example.com", "www.example.com"},
},
{
name: "wildcard_requires_nonempty_prefix",
filter: NodeAccessLogFilter{Hosts: []string{" *.Example.com ", "*.example.com"}},
clause: "(lowerUTF8(trim(host)) LIKE ? AND trim(host) NOT LIKE '%..%' AND trim(host) NOT LIKE '.%')",
args: []any{"_%.example.com"},
},
{
name: "nested_wildcard",
filter: NodeAccessLogFilter{Hosts: []string{"*.s3.example.com"}},
clause: "(lowerUTF8(trim(host)) LIKE ? AND trim(host) NOT LIKE '%..%' AND trim(host) NOT LIKE '.%')",
args: []any{"_%.s3.example.com"},
},
{
name: "overlapping_scopes_are_one_group_with_other_filters",
filter: NodeAccessLogFilter{
NodeID: " n1 ", Host: "ignored", Hosts: []string{"api.example.com", "*.example.com", "*.s3.example.com"},
StatusCode: 200, Since: since, Until: until,
},
clause: "node_id = ? AND ((lowerUTF8(trim(host)) LIKE ? AND trim(host) NOT LIKE '%..%' AND trim(host) NOT LIKE '.%') OR (lowerUTF8(trim(host)) LIKE ? AND trim(host) NOT LIKE '%..%' AND trim(host) NOT LIKE '.%') OR lowerUTF8(trim(host)) IN (?)) AND status_code = ? AND logged_at >= ? AND logged_at < ?",
args: []any{"n1", "_%.example.com", "_%.s3.example.com", "api.example.com", 200, since, until},
},
{
name: "wildcard_suffix_like_metacharacters_are_literal",
filter: NodeAccessLogFilter{Hosts: []string{`*.a%_\'?.example.com`}},
clause: "(lowerUTF8(trim(host)) LIKE ? AND trim(host) NOT LIKE '%..%' AND trim(host) NOT LIKE '.%')",
args: []any{`_%.a\%\_\\'?.example.com`},
},
{
name: "single_host_keeps_prefix_search",
filter: NodeAccessLogFilter{Host: " *.ex%_ ", Hosts: []string{" "}},
clause: "host LIKE ?",
args: []any{`*.ex\%\_%`},
},
{
name: "non_leading_wildcards_are_exact",
filter: NodeAccessLogFilter{Hosts: []string{"api.*.example.com", "*", "*."}},
clause: "lowerUTF8(trim(host)) IN (?, ?, ?)",
args: []any{"api.*.example.com", "*", "*."},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clause, args := buildNodeAccessLogFilterClause(tt.filter)
if clause != tt.clause {
t.Errorf("buildNodeAccessLogFilterClause(%+v) SQL = %q, want %q", tt.filter, clause, tt.clause)
}
if diff := cmp.Diff(tt.args, args); diff != "" {
t.Errorf("buildNodeAccessLogFilterClause(%+v) args mismatch (-want +got):\n%s", tt.filter, diff)
}
})
}
}
// wildcardQueryConn captures both single-row and multi-row query entry points.
type wildcardQueryConn struct{ mockConn }
func (m *wildcardQueryConn) QueryRow(_ context.Context, query string, args ...any) driver.Row {
m.queries = append(m.queries, query)
m.queryArgs = append(m.queryArgs, args)
return wildcardCountRow{}
}
type wildcardCountRow struct{ driver.Row }
func (wildcardCountRow) Scan(dest ...any) error {
for _, value := range dest {
*value.(*uint64) = 0
}
return nil
}
// TestWildcardQueryEntryPoints protects host filtering in count and bucket queries.
func TestWildcardQueryEntryPoints(t *testing.T) {
conn := &wildcardQueryConn{}
previous := db.ChConn
db.SetChConnForTest(conn)
t.Cleanup(func() { db.SetChConnForTest(previous) })
ctx := context.Background()
filter := NodeAccessLogFilter{Hosts: []string{"*.Example.com", "api.example.com"}}
if _, _, _, err := CountNodeAccessLogs(ctx, filter); err != nil {
t.Fatal(err)
}
if _, err := BucketAggregatesNodeAccessLogs(ctx, filter, 300); err != nil {
t.Fatal(err)
}
if _, err := CountBucketAggregatesNodeAccessLogs(ctx, filter, 300); err != nil {
t.Fatal(err)
}
if len(conn.queries) != 3 {
t.Fatalf("count/bucket query calls = %d, want 3", len(conn.queries))
}
for i, query := range conn.queries {
if !strings.Contains(query, "WHERE ((lowerUTF8(trim(host)) LIKE ? AND trim(host) NOT LIKE '%..%' AND trim(host) NOT LIKE '.%') OR lowerUTF8(trim(host)) IN (?))") {
t.Errorf("count/bucket SQL = %q, want grouped wildcard and exact host predicates", query)
}
if diff := cmp.Diff([]any{"_%.example.com", "api.example.com"}, conn.queryArgs[i]); diff != "" {
t.Errorf("count/bucket args mismatch (-want +got):\n%s", diff)
}
}
}
@@ -0,0 +1,135 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package logstore
import (
"context"
"slices"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics"
"github.com/google/go-cmp/cmp"
)
func TestGormNodeAccessLogWildcardHosts(t *testing.T) {
ResetForTest()
t.Cleanup(ResetForTest)
SetConfigReader(func(context.Context, string) (string, error) { return "", nil })
s := newTestGormStore(t)
ctx := context.Background()
base := time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC)
hosts := []string{
"api.example.com", " A.B.Example.COM ", "bucket.s3.example.com", "deep.bucket.s3.example.com",
"example.com", "s3.example.com", "evil-example.com", "api.example.com.evil", ".example.com",
`x.a%_\'?.example.net`, `x.abc\'?.example.net`, ".s3.other.com", "s3.other.com", "a..example.com", ".a.example.com", "a..s3.example.com",
}
rows := make([]analyticsmodel.NodeAccessLog, 0, len(hosts)+3)
for i, host := range hosts {
rows = append(rows, analyticsmodel.NodeAccessLog{
ID: uint64(i + 1), NodeID: "n1", Host: host, LoggedAt: base.Add(time.Duration(i%2)*time.Hour + time.Minute),
RemoteAddr: "1.1.1.1", BytesSent: uint64((i + 1) * 10), StatusCode: 200,
})
}
rows[2].RemoteAddr = "2.2.2.2"
rows[3].RemoteAddr = "" // 空 IP 仍计入请求与字节数,但不计入 UV。
rows = append(rows,
analyticsmodel.NodeAccessLog{ID: 100, NodeID: "other", Host: "api.example.com", LoggedAt: base.Add(time.Minute)},
analyticsmodel.NodeAccessLog{ID: 101, NodeID: "n1", Host: "api.example.com", LoggedAt: base.Add(2 * time.Hour)},
analyticsmodel.NodeAccessLog{ID: 102, NodeID: "n1", Host: "bucket.s3.example.com", LoggedAt: base.Add(-time.Second)},
)
if err := s.BatchInsertNodeAccessLogs(ctx, rows); err != nil {
t.Fatalf("BatchInsertNodeAccessLogs(wildcard fixtures) error = %v, want nil", err)
}
tests := []struct {
name string
hosts []string
host string
wantIDs []uint64
}{
{name: "exact", hosts: []string{" API.EXAMPLE.COM "}, wantIDs: []uint64{1}},
{name: "wildcard_multilevel", hosts: []string{" *.Example.com "}, wantIDs: []uint64{1, 2, 3, 4, 6}},
{name: "nested_wildcard_excludes_suffix_root", hosts: []string{"*.s3.example.com"}, wantIDs: []uint64{3, 4}},
{name: "overlap_does_not_double_count", hosts: []string{"api.example.com", "*.example.com", "*.s3.example.com", "*.EXAMPLE.COM"}, host: "ignored", wantIDs: []uint64{1, 2, 3, 4, 6}},
{name: "exact_root_and_nested_wildcard", hosts: []string{"example.com", "*.s3.example.com"}, wantIDs: []uint64{3, 4, 5}},
{name: "exact_suffix_root", hosts: []string{"example.com"}, wantIDs: []uint64{5}},
{name: "like_metacharacters_are_literal", hosts: []string{`*.a%_\'?.example.net`}, wantIDs: []uint64{10}},
{name: "empty_prefix_is_not_a_subdomain", hosts: []string{"*.s3.other.com"}, wantIDs: []uint64{}},
{name: "single_host_keeps_prefix_search", host: "api.example.com", wantIDs: []uint64{1, 8}},
{name: "single_host_does_not_enable_wildcard", host: "*.example.com", wantIDs: []uint64{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
q := model.OpenFlareAccessLogQuery{NodeID: "n1", Hosts: tt.hosts, Host: tt.host, Since: base, Until: base.Add(2 * time.Hour)}
type totals struct {
Requests int64
UniqueIP int64
Bytes int64
}
want := totals{Requests: int64(len(tt.wantIDs))}
ips := make(map[string]struct{})
wantBuckets := make(map[int64]totals)
bucketIPs := make(map[int64]map[string]struct{})
for _, id := range tt.wantIDs {
r := rows[id-1]
want.Bytes += int64(r.BytesSent)
if r.RemoteAddr != "" {
ips[r.RemoteAddr] = struct{}{}
}
epoch := r.LoggedAt.Truncate(time.Hour).Unix()
bucket := wantBuckets[epoch]
bucket.Requests++
bucket.Bytes += int64(r.BytesSent)
if bucketIPs[epoch] == nil {
bucketIPs[epoch] = make(map[string]struct{})
}
if r.RemoteAddr != "" {
bucketIPs[epoch][r.RemoteAddr] = struct{}{}
}
bucket.UniqueIP = int64(len(bucketIPs[epoch]))
wantBuckets[epoch] = bucket
}
want.UniqueIP = int64(len(ips))
total, unique, bytes, err := s.Count(ctx, q)
if err != nil {
t.Fatalf("Count(%+v) error = %v, want nil", q, err)
}
if got := (totals{total, unique, bytes}); got != want {
t.Errorf("Count(%+v) = %+v, want %+v", q, got, want)
}
listed, err := s.List(ctx, q)
if err != nil {
t.Fatalf("List(%+v) error = %v, want nil", q, err)
}
gotIDs := make([]uint64, 0, len(listed))
for _, r := range listed {
gotIDs = append(gotIDs, uint64(r.ID))
}
slices.Sort(gotIDs)
if diff := cmp.Diff(tt.wantIDs, gotIDs); diff != "" {
t.Errorf("List(%+v) IDs mismatch (-want +got):\n%s", q, diff)
}
buckets, err := s.BucketAggregates(ctx, q, 3600)
if err != nil {
t.Fatalf("BucketAggregates(%+v, 3600) error = %v, want nil", q, err)
}
gotBuckets := make(map[int64]totals)
for _, b := range buckets {
gotBuckets[b.BucketEpoch] = totals{b.RequestCount, b.UniqueIPCount, b.BytesSent}
}
if diff := cmp.Diff(wantBuckets, gotBuckets); diff != "" {
t.Errorf("BucketAggregates(%+v, 3600) mismatch (-want +got):\n%s", q, diff)
}
bucketCount, err := s.CountBuckets(ctx, q, 3600)
if err != nil {
t.Fatalf("CountBuckets(%+v, 3600) error = %v, want nil", q, err)
}
if bucketCount != int64(len(wantBuckets)) {
t.Errorf("CountBuckets(%+v, 3600) = %d, want %d", q, bucketCount, len(wantBuckets))
}
})
}
}
+22 -4
View File
@@ -814,7 +814,7 @@ func toNodeAccessLogFilter(query model.OpenFlareAccessLogQuery) analyticsmodel.N
}
// buildNodeAccessLogFilterParts 拼装节点访问日志过滤条件(node_id 等值、remote_addr/host/path
// 前缀 LIKE、hosts 走 lower(trim(host)) IN、since/until 时间窗,顺序与 applyNodeAccessLogFilter
// 前缀 LIKE、hosts 精确域名或通配符后缀、since/until 时间窗,顺序与 applyNodeAccessLogFilter
// 完全一致),返回 WHERE 片段与参数;无任何条件时返回空串与 nil。
func buildNodeAccessLogFilterParts(f analyticsmodel.NodeAccessLogFilter) (string, []any) {
var parts []string
@@ -829,8 +829,26 @@ func buildNodeAccessLogFilterParts(f analyticsmodel.NodeAccessLogFilter) (string
}
hosts := normalizeNodeAccessLogHosts(f.Hosts)
if len(hosts) > 0 {
parts = append(parts, "lower(trim(host)) IN ?")
args = append(args, hosts)
var hostParts []string
var exactHosts []string
for _, host := range hosts {
if suffix, wildcard := strings.CutPrefix(host, "*."); wildcard && suffix != "" {
// 与 CH 一致:非空子域名,可跨多层,但不匹配 suffix 根自身。
hostParts = append(hostParts, `(lower(trim(host)) LIKE ? ESCAPE '\' AND trim(host) NOT LIKE '%..%' AND trim(host) NOT LIKE '.%')`)
args = append(args, "_%."+util.EscapeLike(suffix))
continue
}
exactHosts = append(exactHosts, host)
}
if len(exactHosts) > 0 {
hostParts = append(hostParts, "lower(trim(host)) IN ?")
args = append(args, exactHosts)
}
hostClause := strings.Join(hostParts, " OR ")
if len(hostParts) > 1 {
hostClause = "(" + hostClause + ")"
}
parts = append(parts, hostClause)
} else if host := strings.TrimSpace(f.Host); host != "" {
parts = append(parts, `host LIKE ? ESCAPE '\'`)
args = append(args, util.EscapeLike(host)+"%")
@@ -858,7 +876,7 @@ func buildNodeAccessLogFilterParts(f analyticsmodel.NodeAccessLogFilter) (string
}
// applyNodeAccessLogFilter 对齐 CH 过滤语义(node_access_log_filter.go):
// node_id trim 后等值;remote_addr/host/path 前缀 LIKE;hosts 走 lower(trim(host)) IN(参数已归一化);
// node_id trim 后等值;remote_addr/host/path 前缀 LIKE;hosts 精确域名或通配符后缀(参数已归一化);
// since 闭区间 >=;until 开区间 <。
func applyNodeAccessLogFilter(q *gorm.DB, f analyticsmodel.NodeAccessLogFilter) *gorm.DB {
cond, args := buildNodeAccessLogFilterParts(f)