mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-11-12 21:44:22 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user