123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683 |
- package upstream
- import (
- "context"
- "fmt"
- "net"
- "sort"
- "strings"
- "testing"
- )
- // MockDNSServer simulates DNS responses for testing
- type MockDNSServer struct {
- srvRecords map[string][]*net.SRV
- aRecords map[string][]net.IPAddr
- }
- // NewMockDNSServer creates a mock DNS server for testing
- func NewMockDNSServer() *MockDNSServer {
- return &MockDNSServer{
- srvRecords: make(map[string][]*net.SRV),
- aRecords: make(map[string][]net.IPAddr),
- }
- }
- // AddSRVRecord adds a SRV record to the mock DNS server
- func (m *MockDNSServer) AddSRVRecord(domain string, priority, weight uint16, port uint16, target string) {
- m.srvRecords[domain] = append(m.srvRecords[domain], &net.SRV{
- Priority: priority,
- Weight: weight,
- Port: port,
- Target: target,
- })
- }
- // AddARecord adds an A record to the mock DNS server
- func (m *MockDNSServer) AddARecord(domain string, ip string) {
- m.aRecords[domain] = append(m.aRecords[domain], net.IPAddr{
- IP: net.ParseIP(ip),
- })
- }
- // MockResolver is a custom resolver that uses our mock DNS server
- type MockResolver struct {
- mockServer *MockDNSServer
- }
- // LookupSRV simulates SRV record lookup with proper priority sorting
- func (mr *MockResolver) LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) {
- domain := name
- if service != "" || proto != "" {
- domain = fmt.Sprintf("_%s._%s.%s", service, proto, name)
- }
- if records, exists := mr.mockServer.srvRecords[domain]; exists {
- // Sort SRV records by priority (lowest first), then by weight (highest first)
- // This follows RFC 2782 and nginx behavior
- sortedRecords := make([]*net.SRV, len(records))
- copy(sortedRecords, records)
- sort.Slice(sortedRecords, func(i, j int) bool {
- if sortedRecords[i].Priority != sortedRecords[j].Priority {
- return sortedRecords[i].Priority < sortedRecords[j].Priority
- }
- // For same priority, higher weight comes first (but this is simplified for testing)
- return sortedRecords[i].Weight > sortedRecords[j].Weight
- })
- return "", sortedRecords, nil
- }
- return "", nil, fmt.Errorf("no SRV records for %s", domain)
- }
- // LookupIPAddr simulates A record lookup
- func (mr *MockResolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
- if records, exists := mr.mockServer.aRecords[host]; exists {
- return records, nil
- }
- return nil, fmt.Errorf("no A records for %s", host)
- }
- // TestParseServiceURL tests the parseServiceURL function with nginx compliance
- func TestParseServiceURL(t *testing.T) {
- tests := []struct {
- name string
- input string
- expectedErr bool
- expected *ServiceInfo
- }{
- {
- name: "Valid nginx service URL - simple service name",
- input: "backend.example.com service=http resolve",
- expected: &ServiceInfo{
- Hostname: "backend.example.com",
- ServiceName: "http",
- },
- },
- {
- name: "Valid nginx service URL - service name with underscores",
- input: "backend.example.com service=_http._tcp resolve",
- expected: &ServiceInfo{
- Hostname: "backend.example.com",
- ServiceName: "_http._tcp",
- },
- },
- {
- name: "Valid nginx service URL - service name with dots",
- input: "example.com service=server1.backend resolve",
- expected: &ServiceInfo{
- Hostname: "example.com",
- ServiceName: "server1.backend",
- },
- },
- {
- name: "Consul service example",
- input: "service.consul service=web-service resolve",
- expected: &ServiceInfo{
- Hostname: "service.consul",
- ServiceName: "web-service",
- },
- },
- {
- name: "Empty input",
- input: "",
- expectedErr: true,
- },
- {
- name: "Missing resolve parameter",
- input: "backend.example.com service=http",
- expectedErr: true,
- },
- {
- name: "Missing service parameter",
- input: "backend.example.com resolve",
- expectedErr: true,
- },
- {
- name: "Empty service name",
- input: "backend.example.com service= resolve",
- expectedErr: true,
- },
- {
- name: "Only hostname",
- input: "backend.example.com",
- expectedErr: true,
- },
- }
- resolver := NewDynamicResolver("127.0.0.1:8600")
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result, err := resolver.parseServiceURL(tt.input)
- if tt.expectedErr {
- if err == nil {
- t.Errorf("Expected error but got none")
- }
- return
- }
- if err != nil {
- t.Errorf("Unexpected error: %v", err)
- return
- }
- if result.Hostname != tt.expected.Hostname {
- t.Errorf("Expected hostname %s, got %s", tt.expected.Hostname, result.Hostname)
- }
- if result.ServiceName != tt.expected.ServiceName {
- t.Errorf("Expected service name %s, got %s", tt.expected.ServiceName, result.ServiceName)
- }
- })
- }
- }
- // TestConstructSRVDomain tests SRV domain construction according to nginx.org rules
- func TestConstructSRVDomain(t *testing.T) {
- tests := []struct {
- name string
- input *ServiceInfo
- expected string
- rule string
- }{
- {
- name: "Rule 1: Service name without dots - http",
- input: &ServiceInfo{
- Hostname: "backend.example.com",
- ServiceName: "http",
- },
- expected: "_http._tcp.backend.example.com",
- rule: "nginx rule 1: no dots, add TCP protocol",
- },
- {
- name: "Rule 1: Service name without dots - https",
- input: &ServiceInfo{
- Hostname: "api.example.com",
- ServiceName: "https",
- },
- expected: "_https._tcp.api.example.com",
- rule: "nginx rule 1: no dots, add TCP protocol",
- },
- {
- name: "Rule 1: Service name without dots - mysql",
- input: &ServiceInfo{
- Hostname: "db.example.com",
- ServiceName: "mysql",
- },
- expected: "_mysql._tcp.db.example.com",
- rule: "nginx rule 1: no dots, add TCP protocol",
- },
- {
- name: "Rule 2: Service name with dots - _http._tcp",
- input: &ServiceInfo{
- Hostname: "backend.example.com",
- ServiceName: "_http._tcp",
- },
- expected: "_http._tcp.backend.example.com",
- rule: "nginx rule 2: contains dots, join directly",
- },
- {
- name: "Rule 2: Service name with dots - server1.backend",
- input: &ServiceInfo{
- Hostname: "example.com",
- ServiceName: "server1.backend",
- },
- expected: "server1.backend.example.com",
- rule: "nginx rule 2: contains dots, join directly",
- },
- {
- name: "Rule 2: Complex service name with underscores and dots",
- input: &ServiceInfo{
- Hostname: "dc1.consul",
- ServiceName: "_api._tcp.production",
- },
- expected: "_api._tcp.production.dc1.consul",
- rule: "nginx rule 2: contains dots, join directly",
- },
- {
- name: "Consul example - simple service",
- input: &ServiceInfo{
- Hostname: "service.consul",
- ServiceName: "web",
- },
- expected: "_web._tcp.service.consul",
- rule: "nginx rule 1: no dots, add TCP protocol",
- },
- }
- resolver := NewDynamicResolver("127.0.0.1:8600")
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := resolver.constructSRVDomain(tt.input)
- if result != tt.expected {
- t.Errorf("Expected SRV domain %s, got %s (rule: %s)", tt.expected, result, tt.rule)
- }
- })
- }
- }
- // TestNginxOfficialExamples tests the exact examples from nginx.org documentation
- func TestNginxOfficialExamples(t *testing.T) {
- tests := []struct {
- name string
- nginxConfig string
- expectedQuery string
- description string
- }{
- {
- name: "Official Example 1",
- nginxConfig: "backend.example.com service=http resolve",
- expectedQuery: "_http._tcp.backend.example.com",
- description: "To look up _http._tcp.backend.example.com SRV record",
- },
- {
- name: "Official Example 2",
- nginxConfig: "backend.example.com service=_http._tcp resolve",
- expectedQuery: "_http._tcp.backend.example.com",
- description: "Service name already contains dots, join directly",
- },
- {
- name: "Official Example 3",
- nginxConfig: "example.com service=server1.backend resolve",
- expectedQuery: "server1.backend.example.com",
- description: "Service name contains dots, join directly",
- },
- }
- resolver := NewDynamicResolver("127.0.0.1:8600")
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- serviceInfo, err := resolver.parseServiceURL(tt.nginxConfig)
- if err != nil {
- t.Fatalf("Failed to parse nginx config: %v", err)
- }
- result := resolver.constructSRVDomain(serviceInfo)
- if result != tt.expectedQuery {
- t.Errorf("nginx.org example failed: expected %s, got %s (%s)",
- tt.expectedQuery, result, tt.description)
- }
- })
- }
- }
- // TestSRVRecordResolutionWithMockDNS tests actual SRV record resolution using mock DNS
- func TestSRVRecordResolutionWithMockDNS(t *testing.T) {
- // Create mock DNS server
- mockDNS := NewMockDNSServer()
- // Add SRV records for _http._tcp.backend.example.com
- mockDNS.AddSRVRecord("_http._tcp.backend.example.com", 10, 60, 8080, "web1.backend.example.com")
- mockDNS.AddSRVRecord("_http._tcp.backend.example.com", 10, 40, 8080, "web2.backend.example.com")
- mockDNS.AddSRVRecord("_http._tcp.backend.example.com", 20, 100, 8080, "web3.backend.example.com")
- // Add A records for the targets
- mockDNS.AddARecord("web1.backend.example.com", "192.168.1.10")
- mockDNS.AddARecord("web2.backend.example.com", "192.168.1.11")
- mockDNS.AddARecord("web3.backend.example.com", "192.168.1.12")
- t.Run("SRV record resolution", func(t *testing.T) {
- mockResolver := &MockResolver{mockServer: mockDNS}
- // Test SRV lookup
- _, srvRecords, err := mockResolver.LookupSRV(context.Background(), "", "", "_http._tcp.backend.example.com")
- if err != nil {
- t.Fatalf("SRV lookup failed: %v", err)
- }
- if len(srvRecords) != 3 {
- t.Errorf("Expected 3 SRV records, got %d", len(srvRecords))
- }
- // Verify priority ordering (lowest priority first) and weight ordering (highest weight first within same priority)
- expectedPriorities := []uint16{10, 10, 20}
- expectedWeights := []uint16{60, 40, 100} // For priorities [10, 10, 20], weights should be [60, 40, 100]
- expectedTargets := []string{"web1.backend.example.com", "web2.backend.example.com", "web3.backend.example.com"}
- for i, srv := range srvRecords {
- if srv.Priority != expectedPriorities[i] {
- t.Errorf("Expected priority %d at index %d, got %d", expectedPriorities[i], i, srv.Priority)
- }
- if srv.Weight != expectedWeights[i] {
- t.Errorf("Expected weight %d at index %d, got %d", expectedWeights[i], i, srv.Weight)
- }
- if srv.Target != expectedTargets[i] {
- t.Errorf("Expected target %s at index %d, got %s", expectedTargets[i], i, srv.Target)
- }
- }
- // Test A record resolution for each target
- for _, srv := range srvRecords {
- ips, err := mockResolver.LookupIPAddr(context.Background(), srv.Target)
- if err != nil {
- t.Errorf("A record lookup failed for %s: %v", srv.Target, err)
- continue
- }
- if len(ips) != 1 {
- t.Errorf("Expected 1 IP for %s, got %d", srv.Target, len(ips))
- }
- }
- })
- }
- // TestSRVPriorityHandling tests nginx SRV priority handling as per nginx.org documentation
- func TestSRVPriorityHandling(t *testing.T) {
- // Create mock DNS server
- mockDNS := NewMockDNSServer()
- // Add SRV records with different priorities to test primary/backup server logic
- // Priority 5 (highest priority / primary servers)
- mockDNS.AddSRVRecord("_http._tcp.app.example.com", 5, 100, 8080, "primary1.app.example.com")
- mockDNS.AddSRVRecord("_http._tcp.app.example.com", 5, 50, 8080, "primary2.app.example.com")
- // Priority 10 (backup servers)
- mockDNS.AddSRVRecord("_http._tcp.app.example.com", 10, 80, 8080, "backup1.app.example.com")
- // Priority 15 (lower priority backup servers)
- mockDNS.AddSRVRecord("_http._tcp.app.example.com", 15, 200, 8080, "backup2.app.example.com")
- // Add A records
- mockDNS.AddARecord("primary1.app.example.com", "10.0.1.1")
- mockDNS.AddARecord("primary2.app.example.com", "10.0.1.2")
- mockDNS.AddARecord("backup1.app.example.com", "10.0.2.1")
- mockDNS.AddARecord("backup2.app.example.com", "10.0.3.1")
- t.Run("SRV priority handling", func(t *testing.T) {
- mockResolver := &MockResolver{mockServer: mockDNS}
- // Test SRV lookup
- _, srvRecords, err := mockResolver.LookupSRV(context.Background(), "", "", "_http._tcp.app.example.com")
- if err != nil {
- t.Fatalf("SRV lookup failed: %v", err)
- }
- if len(srvRecords) != 4 {
- t.Errorf("Expected 4 SRV records, got %d", len(srvRecords))
- }
- // According to nginx.org: "Highest-priority SRV records (records with the same lowest-number priority value)
- // are resolved as primary servers, the rest of SRV records are resolved as backup servers"
- expectedOrder := []struct {
- priority uint16
- weight uint16
- target string
- serverType string
- }{
- {5, 100, "primary1.app.example.com", "primary"}, // Highest priority (lowest number)
- {5, 50, "primary2.app.example.com", "primary"}, // Same priority, lower weight
- {10, 80, "backup1.app.example.com", "backup"}, // Lower priority (backup)
- {15, 200, "backup2.app.example.com", "backup"}, // Lowest priority (backup)
- }
- for i, srv := range srvRecords {
- expected := expectedOrder[i]
- if srv.Priority != expected.priority {
- t.Errorf("Record %d: expected priority %d, got %d", i, expected.priority, srv.Priority)
- }
- if srv.Weight != expected.weight {
- t.Errorf("Record %d: expected weight %d, got %d", i, expected.weight, srv.Weight)
- }
- if srv.Target != expected.target {
- t.Errorf("Record %d: expected target %s, got %s", i, expected.target, srv.Target)
- }
- // Log the server type for documentation
- t.Logf("Record %d: Priority %d, Weight %d, Target %s (%s server)",
- i, srv.Priority, srv.Weight, srv.Target, expected.serverType)
- }
- // Verify primary servers come first (lowest priority numbers)
- primaryCount := 0
- for _, srv := range srvRecords {
- if srv.Priority == 5 { // Primary servers have priority 5
- primaryCount++
- } else {
- break // Once we hit a non-primary, all following should be backups
- }
- }
- if primaryCount != 2 {
- t.Errorf("Expected 2 primary servers at the beginning, got %d", primaryCount)
- }
- })
- }
- // TestARecordFallback tests A record fallback when SRV lookup fails
- func TestARecordFallback(t *testing.T) {
- mockDNS := NewMockDNSServer()
- // Only add A record, no SRV record
- mockDNS.AddARecord("_http._tcp.backend.example.com", "192.168.1.100")
- t.Run("A record fallback", func(t *testing.T) {
- mockResolver := &MockResolver{mockServer: mockDNS}
- // SRV lookup should fail
- _, srvRecords, err := mockResolver.LookupSRV(context.Background(), "", "", "_http._tcp.backend.example.com")
- if err == nil {
- t.Error("Expected SRV lookup to fail")
- }
- if len(srvRecords) != 0 {
- t.Errorf("Expected 0 SRV records, got %d", len(srvRecords))
- }
- // A record lookup should succeed
- ips, err := mockResolver.LookupIPAddr(context.Background(), "_http._tcp.backend.example.com")
- if err != nil {
- t.Fatalf("A record lookup failed: %v", err)
- }
- if len(ips) != 1 {
- t.Errorf("Expected 1 IP, got %d", len(ips))
- }
- expectedIP := "192.168.1.100"
- if ips[0].IP.String() != expectedIP {
- t.Errorf("Expected IP %s, got %s", expectedIP, ips[0].IP.String())
- }
- })
- }
- // TestComplexNginxScenarios tests more complex real-world nginx scenarios
- func TestComplexNginxScenarios(t *testing.T) {
- tests := []struct {
- name string
- nginxLine string
- expectedSRV string
- scenario string
- }{
- {
- name: "Load balancer with HTTP service",
- nginxLine: "api.microservices.local service=http resolve",
- expectedSRV: "_http._tcp.api.microservices.local",
- scenario: "Microservices API load balancing",
- },
- {
- name: "Database connection",
- nginxLine: "db.cluster.local service=mysql resolve",
- expectedSRV: "_mysql._tcp.db.cluster.local",
- scenario: "Database cluster connection",
- },
- {
- name: "WebSocket service",
- nginxLine: "chat.app.local service=ws resolve",
- expectedSRV: "_ws._tcp.chat.app.local",
- scenario: "WebSocket service discovery",
- },
- {
- name: "Custom protocol with dots",
- nginxLine: "service.consul service=_grpc._tcp resolve",
- expectedSRV: "_grpc._tcp.service.consul",
- scenario: "gRPC service via Consul",
- },
- {
- name: "Multi-level service hierarchy",
- nginxLine: "consul.local service=api.v1.production resolve",
- expectedSRV: "api.v1.production.consul.local",
- scenario: "Multi-level service naming",
- },
- {
- name: "Kubernetes style service",
- nginxLine: "cluster.local service=_http._tcp.nginx.default resolve",
- expectedSRV: "_http._tcp.nginx.default.cluster.local",
- scenario: "Kubernetes service discovery",
- },
- }
- resolver := NewDynamicResolver("127.0.0.1:8600")
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- serviceInfo, err := resolver.parseServiceURL(tt.nginxLine)
- if err != nil {
- t.Fatalf("Failed to parse nginx line: %v", err)
- }
- result := resolver.constructSRVDomain(serviceInfo)
- if result != tt.expectedSRV {
- t.Errorf("Scenario '%s' failed: expected %s, got %s",
- tt.scenario, tt.expectedSRV, result)
- }
- })
- }
- }
- // TestBackwardCompatibility tests backward compatibility with old format
- func TestBackwardCompatibility(t *testing.T) {
- tests := []struct {
- name string
- input string
- expected string
- }{
- {
- name: "New nginx format should work",
- input: "backend.example.com service=http resolve",
- expected: "http",
- },
- {
- name: "New nginx format with dots",
- input: "example.com service=_http._tcp resolve",
- expected: "_http._tcp",
- },
- {
- name: "Old consul format should still work as fallback",
- input: "test-service.service.consul",
- expected: "test-service",
- },
- {
- name: "Invalid format should return empty",
- input: "invalid format without proper structure",
- expected: "",
- },
- }
- resolver := NewDynamicResolver("127.0.0.1:8600")
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := resolver.extractServiceName(tt.input)
- if result != tt.expected {
- t.Errorf("Expected %s, got %s", tt.expected, result)
- }
- })
- }
- }
- // TestDynamicTargetsFunction tests the TestDynamicTargets function
- func TestDynamicTargetsFunction(t *testing.T) {
- t.Run("Valid dynamic targets", func(t *testing.T) {
- targets := []ProxyTarget{
- {
- Host: "service.consul",
- Port: "dynamic",
- Type: "upstream",
- Resolver: "127.0.0.1:8600",
- IsConsul: true,
- ServiceURL: "backend.example.com service=http resolve",
- },
- }
- results := TestDynamicTargets(targets)
- if len(results) != 1 {
- t.Errorf("Expected 1 result, got %d", len(results))
- }
- key := "service.consul:dynamic"
- if _, found := results[key]; !found {
- t.Errorf("Expected result for key %s not found", key)
- }
- })
- t.Run("Target without resolver should be offline", func(t *testing.T) {
- targets := []ProxyTarget{
- {
- Host: "service.consul",
- Port: "dynamic",
- Type: "upstream",
- IsConsul: true,
- ServiceURL: "backend.example.com service=http resolve",
- // No resolver specified
- },
- }
- results := TestDynamicTargets(targets)
- key := "service.consul:dynamic"
- if status, found := results[key]; found {
- if status.Online {
- t.Error("Expected target without resolver to be offline")
- }
- if status.Latency != 0 {
- t.Errorf("Expected latency 0 for offline target, got %.2f", status.Latency)
- }
- } else {
- t.Errorf("Expected result for key %s", key)
- }
- })
- }
- // TestIntegrationWithProxyParser tests integration with the proxy parser
- func TestIntegrationWithProxyParser(t *testing.T) {
- config := `upstream web-backend {
- zone upstream_web 128k;
- resolver 127.0.0.1:8600 valid=5s;
- resolver_timeout 2s;
- server backend.example.com service=http resolve;
- }
- server {
- listen 80;
- server_name example.com;
- location / {
- proxy_pass http://web-backend;
- }
- }`
- targets := ParseProxyTargetsFromRawContent(config)
- // Should find the dynamic DNS target
- found := false
- for _, target := range targets {
- if target.IsConsul && strings.Contains(target.ServiceURL, "service=http") {
- found = true
- // Verify the target is correctly parsed
- if target.Resolver != "127.0.0.1:8600" {
- t.Errorf("Expected resolver 127.0.0.1:8600, got %s", target.Resolver)
- }
- if target.ServiceURL != "backend.example.com service=http resolve" {
- t.Errorf("Expected service URL 'backend.example.com service=http resolve', got %s", target.ServiceURL)
- }
- break
- }
- }
- if !found {
- t.Error("Dynamic DNS target not found in parsed config")
- }
- }
|