浏览代码

feat: add consul resolver and related tests for service discovery #1241

0xJacky 1 周之前
父节点
当前提交
ddd538b587

+ 222 - 0
internal/upstream/consul_resolver.go

@@ -0,0 +1,222 @@
+package upstream
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"strings"
+	"time"
+)
+
+// ConsulResolver handles DNS resolution through consul
+type ConsulResolver struct {
+	resolver string // e.g., "127.0.0.1:8600"
+}
+
+// NewConsulResolver creates a new consul resolver
+func NewConsulResolver(resolver string) *ConsulResolver {
+	return &ConsulResolver{
+		resolver: resolver,
+	}
+}
+
+// ResolveService resolves a consul service to actual IP addresses and ports
+func (cr *ConsulResolver) ResolveService(serviceURL string) ([]string, error) {
+	// Parse consul service URL (e.g., "service.consul service=redacted-net resolve")
+	serviceName := cr.extractServiceName(serviceURL)
+	if serviceName == "" {
+		return nil, fmt.Errorf("could not extract service name from: %s", serviceURL)
+	}
+
+	// Create a custom resolver that uses the consul DNS server
+	dialer := &net.Dialer{
+		Timeout: 5 * time.Second,
+	}
+
+	resolver := &net.Resolver{
+		PreferGo: true,
+		Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
+			return dialer.DialContext(ctx, network, cr.resolver)
+		},
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	// Query consul for service SRV records
+	_, srvRecords, err := resolver.LookupSRV(ctx, "", "", serviceName+".service.consul")
+	if err != nil {
+		// Fallback to A record lookup if SRV fails
+		ips, err := resolver.LookupIPAddr(ctx, serviceName+".service.consul")
+		if err != nil {
+			return nil, fmt.Errorf("failed to resolve service %s: %v", serviceName, err)
+		}
+
+		// Return IP addresses with default port (80)
+		var addresses []string
+		for _, ip := range ips {
+			addresses = append(addresses, fmt.Sprintf("%s:80", ip.IP.String()))
+		}
+		return addresses, nil
+	}
+
+	// Convert SRV records to address:port format
+	var addresses []string
+	for _, srv := range srvRecords {
+		// Resolve the target hostname to IP
+		ips, err := resolver.LookupIPAddr(ctx, srv.Target)
+		if err != nil {
+			continue // Skip this record if resolution fails
+		}
+
+		for _, ip := range ips {
+			addresses = append(addresses, fmt.Sprintf("%s:%d", ip.IP.String(), srv.Port))
+		}
+	}
+
+	if len(addresses) == 0 {
+		return nil, fmt.Errorf("no addresses found for service %s", serviceName)
+	}
+
+	return addresses, nil
+}
+
+// extractServiceName extracts the service name from consul service URL
+func (cr *ConsulResolver) extractServiceName(serviceURL string) string {
+	serviceURL = strings.TrimSpace(serviceURL)
+
+	// Handle empty input
+	if serviceURL == "" {
+		return ""
+	}
+
+	// Parse "service.consul service=redacted-net resolve" format
+	if strings.Contains(serviceURL, "service=") {
+		parts := strings.Fields(serviceURL)
+		for _, part := range parts {
+			if strings.HasPrefix(part, "service=") {
+				serviceName := strings.TrimPrefix(part, "service=")
+				// Handle edge cases like "service=" or "service=  "
+				serviceName = strings.TrimSpace(serviceName)
+				if serviceName == "" {
+					return ""
+				}
+				return serviceName
+			}
+		}
+	}
+
+	// Fallback: try to extract from hostname format like "my-service.service.consul"
+	if strings.Contains(serviceURL, ".service.consul") {
+		parts := strings.Split(serviceURL, ".")
+		if len(parts) > 0 {
+			serviceName := strings.TrimSpace(parts[0])
+			if serviceName == "" {
+				return ""
+			}
+			return serviceName
+		}
+	}
+
+	return ""
+}
+
+// TestConsulTargets performs availability test specifically for consul targets
+func TestConsulTargets(consulTargets []ProxyTarget) map[string]*Status {
+	result := make(map[string]*Status)
+
+	// Group consul targets by resolver
+	consulTargetsByResolver := make(map[string][]ProxyTarget)
+	for _, target := range consulTargets {
+		if target.Resolver != "" {
+			consulTargetsByResolver[target.Resolver] = append(consulTargetsByResolver[target.Resolver], target)
+		} else {
+			// No resolver specified, mark as offline
+			key := target.Host + ":" + target.Port
+			result[key] = &Status{
+				Online:  false,
+				Latency: 0,
+			}
+		}
+	}
+
+	// Test each resolver group
+	for resolver, targets := range consulTargetsByResolver {
+		consulResolver := NewConsulResolver(resolver)
+
+		for _, target := range targets {
+			key := target.Host + ":" + target.Port
+
+			// Try to resolve the consul service
+			addresses, err := consulResolver.ResolveService(target.ServiceURL)
+			if err != nil {
+				// If resolution fails, mark as offline
+				result[key] = &Status{
+					Online:  false,
+					Latency: 0,
+				}
+				continue
+			}
+
+			// Test the first resolved address as representative
+			if len(addresses) > 0 {
+				addressResults := AvailabilityTest(addresses[:1])
+
+				if status, exists := addressResults[addresses[0]]; exists {
+					result[key] = status
+				} else {
+					result[key] = &Status{
+						Online:  false,
+						Latency: 0,
+					}
+				}
+			} else {
+				result[key] = &Status{
+					Online:  false,
+					Latency: 0,
+				}
+			}
+		}
+	}
+
+	return result
+}
+
+// EnhancedAvailabilityTest performs availability test with consul resolution support
+// Deprecated: Use TestConsulTargets for consul targets and AvailabilityTest for regular targets
+func EnhancedAvailabilityTest(targets []ProxyTarget) map[string]*Status {
+	result := make(map[string]*Status)
+
+	// Group targets by type
+	consulTargets := make([]ProxyTarget, 0)
+	regularTargets := make([]string, 0)
+
+	for _, target := range targets {
+		if target.IsConsul && target.Resolver != "" {
+			consulTargets = append(consulTargets, target)
+		} else {
+			// Regular target - use existing format for traditional AvailabilityTest
+			key := target.Host + ":" + target.Port
+			regularTargets = append(regularTargets, key)
+		}
+	}
+
+	// Use traditional AvailabilityTest for regular targets (more efficient)
+	if len(regularTargets) > 0 {
+		regularResults := AvailabilityTest(regularTargets)
+		// Merge results
+		for k, v := range regularResults {
+			result[k] = v
+		}
+	}
+
+	// Test consul targets with DNS resolution
+	if len(consulTargets) > 0 {
+		consulResults := TestConsulTargets(consulTargets)
+		for k, v := range consulResults {
+			result[k] = v
+		}
+	}
+
+	return result
+}

+ 496 - 0
internal/upstream/consul_resolver_test.go

@@ -0,0 +1,496 @@
+package upstream
+
+import (
+	"testing"
+)
+
+// TestParseProxyTargetsWithConsulResolver tests parsing of nginx config with consul DNS resolver
+func TestParseProxyTargetsWithConsulResolver(t *testing.T) {
+	config := `upstream redacted-net {
+    zone upstream_web 128k;
+    resolver 127.0.0.1:8600 valid=5s;
+    resolver_timeout 2s;
+    server service.consul service=redacted-net resolve;
+}
+server {
+    listen 80;
+    listen [::]:80;
+    server_name redacted.net;
+    location / {
+        proxy_pass http://redacted-net;
+    }
+}`
+
+	targets := ParseProxyTargetsFromRawContent(config)
+
+	// Print actual results for debugging
+	t.Logf("Found %d targets:", len(targets))
+	for i, target := range targets {
+		t.Logf("Target %d: Host=%s, Port=%s, Type=%s, Resolver=%s, IsConsul=%v",
+			i+1, target.Host, target.Port, target.Type, target.Resolver, target.IsConsul)
+	}
+
+	// Expected behavior:
+	// - Should parse "service.consul" as host with dynamic port
+	// - Should identify this as an upstream target with consul service discovery
+	// - Should capture resolver information
+	// - proxy_pass http://redacted-net should be ignored since it references upstream
+	expectedTargets := []ProxyTarget{
+		{
+			Host:       "service.consul",
+			Port:       "dynamic",
+			Type:       "upstream",
+			Resolver:   "127.0.0.1:8600",
+			IsConsul:   true,
+			ServiceURL: "service.consul service=redacted-net resolve",
+		},
+	}
+
+	if len(targets) != len(expectedTargets) {
+		t.Errorf("Expected %d targets, got %d", len(expectedTargets), len(targets))
+		return
+	}
+
+	// Create a map for easier comparison
+	targetMap := make(map[string]ProxyTarget)
+	for _, target := range targets {
+		key := target.Host + ":" + target.Port + ":" + target.Type + ":" + target.Resolver
+		if target.IsConsul {
+			key += ":consul:" + target.ServiceURL
+		}
+		targetMap[key] = target
+	}
+
+	for _, expected := range expectedTargets {
+		key := expected.Host + ":" + expected.Port + ":" + expected.Type + ":" + expected.Resolver
+		if expected.IsConsul {
+			key += ":consul:" + expected.ServiceURL
+		}
+		if _, found := targetMap[key]; !found {
+			t.Errorf("Expected target not found: %+v", expected)
+		}
+	}
+}
+
+// TestConsulResolverExtractServiceName tests service name extraction
+func TestConsulResolverExtractServiceName(t *testing.T) {
+	resolver := NewConsulResolver("127.0.0.1:8600")
+
+	tests := []struct {
+		serviceURL   string
+		expectedName string
+	}{
+		{
+			serviceURL:   "service.consul service=redacted-net resolve",
+			expectedName: "redacted-net",
+		},
+		{
+			serviceURL:   "service.consul service=web-service resolve",
+			expectedName: "web-service",
+		},
+		{
+			serviceURL:   "service.consul service=api-backend resolve",
+			expectedName: "api-backend",
+		},
+		{
+			serviceURL:   "my-service.service.consul",
+			expectedName: "my-service",
+		},
+		{
+			serviceURL:   "invalid-format",
+			expectedName: "",
+		},
+	}
+
+	for _, test := range tests {
+		result := resolver.extractServiceName(test.serviceURL)
+		if result != test.expectedName {
+			t.Errorf("extractServiceName(%q) = %q, expected %q", test.serviceURL, result, test.expectedName)
+		}
+	}
+}
+
+// TestConsulResolverResolveService tests the actual resolution functionality
+func TestConsulResolverResolveService(t *testing.T) {
+	// Test with a mock resolver that should fail (127.0.0.1:8600 is consul default but likely not running)
+	t.Run("ResolutionWithMockConsul", func(t *testing.T) {
+		resolver := NewConsulResolver("127.0.0.1:8600")
+
+		addresses, err := resolver.ResolveService("service.consul service=test-service resolve")
+
+		// We expect this to fail since there's no real consul server
+		if err == nil {
+			t.Logf("Unexpected success: resolved addresses %v (maybe there's a real consul server?)", addresses)
+		} else {
+			t.Logf("Expected failure: %v", err)
+		}
+	})
+
+	// Test with invalid service URL
+	t.Run("InvalidServiceURL", func(t *testing.T) {
+		resolver := NewConsulResolver("127.0.0.1:8600")
+
+		addresses, err := resolver.ResolveService("invalid-service-url")
+
+		if err == nil {
+			t.Errorf("Expected error for invalid service URL, got addresses: %v", addresses)
+		}
+
+		if len(addresses) != 0 {
+			t.Errorf("Expected no addresses for invalid service URL, got %v", addresses)
+		}
+	})
+
+	// Test with invalid resolver address
+	t.Run("InvalidResolverAddress", func(t *testing.T) {
+		resolver := NewConsulResolver("192.168.254.254:8600") // Unreachable IP
+
+		addresses, err := resolver.ResolveService("service.consul service=test-service resolve")
+
+		// Should fail due to unreachable resolver
+		if err == nil {
+			t.Errorf("Expected error for unreachable resolver, got addresses: %v", addresses)
+		}
+	})
+
+	// Test service name extraction edge cases
+	t.Run("ServiceNameExtractionEdgeCases", func(t *testing.T) {
+		resolver := NewConsulResolver("127.0.0.1:8600")
+
+		testCases := []struct {
+			serviceURL   string
+			expectedName string
+		}{
+			{"", ""},
+			{"service.consul", ""},
+			{"service.consul resolve", ""},
+			{"service.consul service= resolve", ""},
+			{"service.consul service=  resolve", ""}, // Empty service name
+			{"my-service.service.consul", "my-service"},
+			{"complex-service-name.service.consul", "complex-service-name"},
+		}
+
+		for _, tc := range testCases {
+			result := resolver.extractServiceName(tc.serviceURL)
+			if result != tc.expectedName {
+				t.Errorf("extractServiceName(%q) = %q, expected %q", tc.serviceURL, result, tc.expectedName)
+			}
+		}
+	})
+}
+
+// TestTestConsulTargets tests the new dedicated consul testing function
+func TestTestConsulTargets(t *testing.T) {
+	// Test 1: Valid consul targets with resolver
+	t.Run("ValidConsulTargets", func(t *testing.T) {
+		consulTargets := []ProxyTarget{
+			{
+				Host:       "service.consul",
+				Port:       "dynamic",
+				Type:       "upstream",
+				Resolver:   "127.0.0.1:8600",
+				IsConsul:   true,
+				ServiceURL: "service.consul service=test-service resolve",
+			},
+		}
+
+		results := TestConsulTargets(consulTargets)
+
+		// Should have exactly 1 result
+		if len(results) != 1 {
+			t.Errorf("Expected 1 result, got %d", len(results))
+		}
+
+		// Check the result exists with correct key
+		key := "service.consul:dynamic"
+		if status, found := results[key]; found {
+			// The status doesn't matter much (likely offline without real consul)
+			// What matters is that the function processes the target correctly
+			t.Logf("Consul target %s processed: Online=%v, Latency=%.2f", key, status.Online, status.Latency)
+		} else {
+			t.Errorf("Expected result for key %s not found", key)
+		}
+	})
+
+	// Test 2: Consul target without resolver should be marked offline
+	t.Run("ConsulTargetWithoutResolver", func(t *testing.T) {
+		consulTargets := []ProxyTarget{
+			{
+				Host: "service.consul",
+				Port: "dynamic",
+				Type: "upstream",
+				// No resolver - should be marked offline immediately
+				IsConsul:   true,
+				ServiceURL: "service.consul service=no-resolver resolve",
+			},
+		}
+
+		results := TestConsulTargets(consulTargets)
+
+		if len(results) != 1 {
+			t.Errorf("Expected 1 result, got %d", len(results))
+		}
+
+		key := "service.consul:dynamic"
+		if status, found := results[key]; found {
+			if status.Online {
+				t.Errorf("Expected consul target without resolver to be offline, but it's online")
+			}
+			if status.Latency != 0 {
+				t.Errorf("Expected latency to be 0 for offline target, got %.2f", status.Latency)
+			}
+		} else {
+			t.Errorf("Expected result for key %s not found", key)
+		}
+	})
+
+	// Test 3: Multiple consul targets with different resolvers
+	t.Run("MultipleConsulTargetsWithDifferentResolvers", func(t *testing.T) {
+		consulTargets := []ProxyTarget{
+			{
+				Host:       "web-service.consul",
+				Port:       "dynamic",
+				Type:       "upstream",
+				Resolver:   "127.0.0.1:8600",
+				IsConsul:   true,
+				ServiceURL: "service.consul service=web-service resolve",
+			},
+			{
+				Host:       "api-service.consul",
+				Port:       "dynamic",
+				Type:       "upstream",
+				Resolver:   "127.0.0.1:8500", // Different resolver
+				IsConsul:   true,
+				ServiceURL: "service.consul service=api-service resolve",
+			},
+		}
+
+		results := TestConsulTargets(consulTargets)
+
+		// Should have 2 results
+		if len(results) != 2 {
+			t.Errorf("Expected 2 results, got %d", len(results))
+		}
+
+		// Check both results exist
+		expectedKeys := []string{
+			"web-service.consul:dynamic",
+			"api-service.consul:dynamic",
+		}
+
+		for _, key := range expectedKeys {
+			if _, found := results[key]; !found {
+				t.Errorf("Expected result for key %s not found", key)
+			}
+		}
+	})
+
+	// Test 4: Empty consul targets should return empty results
+	t.Run("EmptyConsulTargets", func(t *testing.T) {
+		consulTargets := []ProxyTarget{}
+		results := TestConsulTargets(consulTargets)
+
+		if len(results) != 0 {
+			t.Errorf("Expected 0 results for empty targets, got %d", len(results))
+		}
+	})
+}
+
+// TestSimplifiedArchitecture tests the new simplified architecture
+func TestSimplifiedArchitecture(t *testing.T) {
+	service := GetUpstreamService()
+	service.ClearTargets()
+
+	// Mix of traditional and consul targets
+	mixedTargets := []ProxyTarget{
+		// Traditional targets
+		{Host: "127.0.0.1", Port: "80", Type: "upstream"},
+		{Host: "192.168.1.100", Port: "8080", Type: "upstream"},
+		// Consul targets
+		{
+			Host:       "service.consul",
+			Port:       "dynamic",
+			Type:       "upstream",
+			Resolver:   "127.0.0.1:8600",
+			IsConsul:   true,
+			ServiceURL: "service.consul service=my-service resolve",
+		},
+	}
+
+	service.updateTargetsFromConfig("test-config.conf", mixedTargets)
+
+	// Verify targets are correctly stored
+	service.targetsMutex.RLock()
+	traditionalCount := 0
+	consulCount := 0
+	for _, targetInfo := range service.targets {
+		if targetInfo.ProxyTarget.IsConsul {
+			consulCount++
+		} else {
+			traditionalCount++
+		}
+	}
+	service.targetsMutex.RUnlock()
+
+	if traditionalCount != 2 {
+		t.Errorf("Expected 2 traditional targets, got %d", traditionalCount)
+	}
+	if consulCount != 1 {
+		t.Errorf("Expected 1 consul target, got %d", consulCount)
+	}
+
+	t.Logf("Architecture correctly separated %d traditional and %d consul targets", traditionalCount, consulCount)
+
+	// Clean up
+	service.ClearTargets()
+}
+
+// TestEnhancedAvailabilityTest tests the enhanced availability testing with mixed targets
+func TestEnhancedAvailabilityTest(t *testing.T) {
+	targets := []ProxyTarget{
+		// Regular target
+		{
+			Host: "127.0.0.1",
+			Port: "22", // SSH port might be available
+			Type: "upstream",
+		},
+		// Consul target (will fail since no real consul)
+		{
+			Host:       "service.consul",
+			Port:       "dynamic",
+			Type:       "upstream",
+			Resolver:   "127.0.0.1:8600",
+			IsConsul:   true,
+			ServiceURL: "service.consul service=test-service resolve",
+		},
+		// Invalid regular target
+		{
+			Host: "192.168.254.254",
+			Port: "9999",
+			Type: "upstream",
+		},
+	}
+
+	results := EnhancedAvailabilityTest(targets)
+
+	t.Logf("Found %d test results:", len(results))
+	for key, status := range results {
+		t.Logf("Target %s: Online=%v, Latency=%.2f", key, status.Online, status.Latency)
+	}
+
+	// Verify we have results for all targets
+	expectedKeys := []string{
+		"127.0.0.1:22",
+		"service.consul:dynamic",
+		"192.168.254.254:9999",
+	}
+
+	for _, key := range expectedKeys {
+		if _, found := results[key]; !found {
+			t.Errorf("Expected result for key %s not found", key)
+		}
+	}
+
+	// Test that consul target is processed (result doesn't matter, we just verify the flow works)
+	if status, found := results["service.consul:dynamic"]; found {
+		t.Logf("Consul target processed: Online=%v, Latency=%.2f", status.Online, status.Latency)
+	}
+
+	// Test that unreachable target is properly handled
+	if status, found := results["192.168.254.254:9999"]; found {
+		// This should be offline due to unreachable IP, but we verify the function handles it
+		t.Logf("Unreachable target processed: Online=%v, Latency=%.2f", status.Online, status.Latency)
+		// Most likely offline, but we don't assume - we just verify it was processed
+	}
+}
+
+// TestTraditionalAvailabilityTestUsage tests that traditional AvailabilityTest is used when no consul targets
+func TestTraditionalAvailabilityTestUsage(t *testing.T) {
+	// Test with only traditional targets
+	targets := []ProxyTarget{
+		{
+			Host: "127.0.0.1",
+			Port: "80",
+			Type: "upstream",
+		},
+		{
+			Host: "192.168.254.254",
+			Port: "9999",
+			Type: "upstream",
+		},
+	}
+
+	// Test enhanced version with traditional targets only
+	enhancedResults := EnhancedAvailabilityTest(targets)
+
+	// Test traditional version directly
+	traditionalKeys := []string{"127.0.0.1:80", "192.168.254.254:9999"}
+	traditionalResults := AvailabilityTest(traditionalKeys)
+
+	t.Logf("Enhanced results: %d items", len(enhancedResults))
+	t.Logf("Traditional results: %d items", len(traditionalResults))
+
+	// Both should have same number of results
+	if len(enhancedResults) != len(traditionalResults) {
+		t.Errorf("Expected same number of results, enhanced=%d, traditional=%d",
+			len(enhancedResults), len(traditionalResults))
+		return
+	}
+
+	// Results should be consistent
+	for key, traditionalStatus := range traditionalResults {
+		if enhancedStatus, found := enhancedResults[key]; found {
+			if traditionalStatus.Online != enhancedStatus.Online {
+				t.Errorf("Inconsistent online status for %s: traditional=%v, enhanced=%v",
+					key, traditionalStatus.Online, enhancedStatus.Online)
+			}
+		} else {
+			t.Errorf("Key %s missing in enhanced results", key)
+		}
+	}
+
+	t.Logf("Enhanced test correctly delegated to traditional test for non-consul targets")
+}
+
+// TestUpstreamServiceSimplifiedFlow tests the new simplified flow in UpstreamService
+func TestUpstreamServiceSimplifiedFlow(t *testing.T) {
+	service := GetUpstreamService()
+	service.ClearTargets()
+
+	// Add mixed targets
+	mixedTargets := []ProxyTarget{
+		{Host: "127.0.0.1", Port: "80", Type: "upstream"},
+		{
+			Host:       "service.consul",
+			Port:       "dynamic",
+			Type:       "upstream",
+			Resolver:   "127.0.0.1:8600",
+			IsConsul:   true,
+			ServiceURL: "service.consul service=test resolve",
+		},
+	}
+
+	service.updateTargetsFromConfig("test-config.conf", mixedTargets)
+
+	// This would trigger the simplified flow
+	service.PerformAvailabilityTest()
+
+	results := service.GetAvailabilityMap()
+	t.Logf("Simplified flow generated %d results:", len(results))
+	for key, status := range results {
+		t.Logf("Result %s: Online=%v, Latency=%.2f", key, status.Online, status.Latency)
+	}
+
+	// Should have results for both targets
+	expectedKeys := []string{"127.0.0.1:80", "service.consul:dynamic"}
+	for _, key := range expectedKeys {
+		if _, found := results[key]; !found {
+			t.Errorf("Expected result for key %s not found", key)
+		}
+	}
+
+	t.Logf("Simplified architecture correctly processed mixed targets")
+
+	// Clean up
+	service.ClearTargets()
+}

+ 107 - 26
internal/upstream/proxy_parser.go

@@ -11,17 +11,27 @@ import (
 
 // ProxyTarget represents a proxy destination
 type ProxyTarget struct {
-	Host string `json:"host"`
-	Port string `json:"port"`
-	Type string `json:"type"` // "proxy_pass" or "upstream"
+	Host       string `json:"host"`
+	Port       string `json:"port"`
+	Type       string `json:"type"`        // "proxy_pass" or "upstream"
+	Resolver   string `json:"resolver"`    // DNS resolver address (e.g., "127.0.0.1:8600")
+	IsConsul   bool   `json:"is_consul"`   // Whether this is a consul service discovery target
+	ServiceURL string `json:"service_url"` // Full service URL for consul (e.g., "service.consul service=redacted-net resolve")
+}
+
+// UpstreamContext contains upstream-level configuration
+type UpstreamContext struct {
+	Name     string
+	Resolver string
 }
 
 // ParseProxyTargetsFromRawContent parses proxy targets from raw nginx configuration content
 func ParseProxyTargetsFromRawContent(content string) []ProxyTarget {
 	var targets []ProxyTarget
 
-	// First, collect all upstream names
+	// First, collect all upstream names and their contexts
 	upstreamNames := make(map[string]bool)
+	upstreamContexts := make(map[string]*UpstreamContext)
 	upstreamRegex := regexp.MustCompile(`(?s)upstream\s+([^\s]+)\s*\{([^}]+)\}`)
 	upstreamMatches := upstreamRegex.FindAllStringSubmatch(content, -1)
 
@@ -30,14 +40,31 @@ func ParseProxyTargetsFromRawContent(content string) []ProxyTarget {
 		if len(match) >= 3 {
 			upstreamName := match[1]
 			upstreamNames[upstreamName] = true
-
 			upstreamContent := match[2]
+
+			// Create upstream context
+			ctx := &UpstreamContext{
+				Name: upstreamName,
+			}
+
+			// Extract resolver information from upstream block
+			resolverRegex := regexp.MustCompile(`(?m)^\s*resolver\s+([^;]+);`)
+			if resolverMatch := resolverRegex.FindStringSubmatch(upstreamContent); len(resolverMatch) >= 2 {
+				// Parse resolver directive (e.g., "127.0.0.1:8600 valid=5s ipv6=off")
+				resolverParts := strings.Fields(resolverMatch[1])
+				if len(resolverParts) > 0 {
+					ctx.Resolver = resolverParts[0] // Take the first part as resolver address
+				}
+			}
+
+			upstreamContexts[upstreamName] = ctx
+
 			serverRegex := regexp.MustCompile(`(?m)^\s*server\s+([^;]+);`)
 			serverMatches := serverRegex.FindAllStringSubmatch(upstreamContent, -1)
 
 			for _, serverMatch := range serverMatches {
 				if len(serverMatch) >= 2 {
-					target := parseServerAddress(strings.TrimSpace(serverMatch[1]), "upstream")
+					target := parseServerAddress(strings.TrimSpace(serverMatch[1]), "upstream", ctx)
 					if target.Host != "" {
 						targets = append(targets, target)
 					}
@@ -70,9 +97,24 @@ func ParseProxyTargetsFromRawContent(content string) []ProxyTarget {
 func parseUpstreamServers(upstream *nginx.NgxUpstream) []ProxyTarget {
 	var targets []ProxyTarget
 
+	// Create upstream context for this upstream block
+	ctx := &UpstreamContext{
+		Name: upstream.Name,
+	}
+
+	// Extract resolver from upstream directives
+	for _, directive := range upstream.Directives {
+		if directive.Directive == "resolver" {
+			resolverParts := strings.Fields(directive.Params)
+			if len(resolverParts) > 0 {
+				ctx.Resolver = resolverParts[0]
+			}
+		}
+	}
+
 	for _, directive := range upstream.Directives {
 		if directive.Directive == "server" {
-			target := parseServerAddress(directive.Params, "upstream")
+			target := parseServerAddress(directive.Params, "upstream", ctx)
 			if target.Host != "" {
 				targets = append(targets, target)
 			}
@@ -142,7 +184,7 @@ func parseProxyPassURL(proxyPass string) ProxyTarget {
 	// Handle direct address format for stream module (e.g., "127.0.0.1:8080", "backend.example.com:12345")
 	// This is used in stream configurations where proxy_pass doesn't require a protocol
 	if !strings.Contains(proxyPass, "://") {
-		target := parseServerAddress(proxyPass, "proxy_pass")
+		target := parseServerAddress(proxyPass, "proxy_pass", nil) // No upstream context for this function
 
 		// Skip if this is the HTTP challenge port used by Let's Encrypt
 		if target.Host == "127.0.0.1" && target.Port == settings.CertSettings.HTTPChallengePort {
@@ -155,8 +197,8 @@ func parseProxyPassURL(proxyPass string) ProxyTarget {
 	return ProxyTarget{}
 }
 
-// parseServerAddress parses upstream server address
-func parseServerAddress(serverAddr string, targetType string) ProxyTarget {
+// parseServerAddress parses upstream server address with upstream context
+func parseServerAddress(serverAddr string, targetType string, ctx *UpstreamContext) ProxyTarget {
 	serverAddr = strings.TrimSpace(serverAddr)
 
 	// Remove additional parameters (weight, max_fails, etc.)
@@ -166,28 +208,70 @@ func parseServerAddress(serverAddr string, targetType string) ProxyTarget {
 	}
 
 	addr := parts[0]
+	target := ProxyTarget{
+		Type: targetType,
+	}
+
+	// Add resolver information from upstream context
+	if ctx != nil && ctx.Resolver != "" {
+		target.Resolver = ctx.Resolver
+	}
 
 	// Check if the address contains Nginx variables - skip if it does
 	if strings.Contains(addr, "$") {
 		return ProxyTarget{}
 	}
 
+	// Check for consul service discovery patterns
+	if isConsulServiceDiscovery(serverAddr) {
+		target.IsConsul = true
+		target.ServiceURL = serverAddr
+
+		// Extract consul DNS host (e.g., "service.consul")
+		if strings.Contains(addr, "service.consul") {
+			target.Host = "service.consul"
+			// For consul service discovery, we use a placeholder port since the actual port is dynamic
+			target.Port = "dynamic"
+		} else {
+			// Fallback to regular parsing
+			parsed := parseAddressOnly(addr)
+			target.Host = parsed.Host
+			target.Port = parsed.Port
+		}
+
+		return target
+	}
+
+	// Regular address parsing
+	parsed := parseAddressOnly(addr)
+	target.Host = parsed.Host
+	target.Port = parsed.Port
+
+	// Skip if this is the HTTP challenge port used by Let's Encrypt
+	if target.Host == "127.0.0.1" && target.Port == settings.CertSettings.HTTPChallengePort {
+		return ProxyTarget{}
+	}
+
+	return target
+}
+
+// isConsulServiceDiscovery checks if the server address is a consul service discovery configuration
+func isConsulServiceDiscovery(serverAddr string) bool {
+	return strings.Contains(serverAddr, "service.consul") &&
+		(strings.Contains(serverAddr, "service=") || strings.Contains(serverAddr, "resolve"))
+}
+
+// parseAddressOnly parses just the address portion without consul-specific logic
+func parseAddressOnly(addr string) ProxyTarget {
 	// Handle IPv6 addresses
 	if strings.HasPrefix(addr, "[") {
 		// IPv6 format: [::1]:8080
 		if idx := strings.LastIndex(addr, "]:"); idx != -1 {
 			host := addr[1:idx]
 			port := addr[idx+2:]
-
-			// Skip if this is the HTTP challenge port used by Let's Encrypt
-			if host == "::1" && port == settings.CertSettings.HTTPChallengePort {
-				return ProxyTarget{}
-			}
-
 			return ProxyTarget{
 				Host: host,
 				Port: port,
-				Type: targetType,
 			}
 		}
 		// IPv6 without port: [::1]
@@ -195,7 +279,6 @@ func parseServerAddress(serverAddr string, targetType string) ProxyTarget {
 		return ProxyTarget{
 			Host: host,
 			Port: "80",
-			Type: targetType,
 		}
 	}
 
@@ -203,15 +286,9 @@ func parseServerAddress(serverAddr string, targetType string) ProxyTarget {
 	if strings.Contains(addr, ":") {
 		parts := strings.Split(addr, ":")
 		if len(parts) == 2 {
-			// Skip if this is the HTTP challenge port used by Let's Encrypt
-			if parts[0] == "127.0.0.1" && parts[1] == settings.CertSettings.HTTPChallengePort {
-				return ProxyTarget{}
-			}
-
 			return ProxyTarget{
 				Host: parts[0],
 				Port: parts[1],
-				Type: targetType,
 			}
 		}
 	}
@@ -220,7 +297,6 @@ func parseServerAddress(serverAddr string, targetType string) ProxyTarget {
 	return ProxyTarget{
 		Host: addr,
 		Port: "80",
-		Type: targetType,
 	}
 }
 
@@ -230,7 +306,12 @@ func deduplicateTargets(targets []ProxyTarget) []ProxyTarget {
 	var result []ProxyTarget
 
 	for _, target := range targets {
-		key := target.Host + ":" + target.Port + ":" + target.Type
+		// Create a unique key that includes resolver and consul information
+		key := target.Host + ":" + target.Port + ":" + target.Type + ":" + target.Resolver
+		if target.IsConsul {
+			key += ":consul:" + target.ServiceURL
+		}
+
 		if !seen[key] {
 			seen[key] = true
 			result = append(result, target)

+ 32 - 6
internal/upstream/service.go

@@ -201,16 +201,42 @@ func (s *UpstreamService) PerformAvailabilityTest() {
 
 	// logger.Debug("Performing availability test for", targetCount, "unique targets")
 
-	// Get target keys for testing
+	// Separate targets into traditional and consul groups from the start
 	s.targetsMutex.RLock()
-	testTargets := make([]string, 0, len(s.targets))
-	for key := range s.targets {
-		testTargets = append(testTargets, key)
+	regularTargetKeys := make([]string, 0, len(s.targets))
+	consulTargets := make([]ProxyTarget, 0, len(s.targets))
+
+	for _, targetInfo := range s.targets {
+		if targetInfo.ProxyTarget.IsConsul {
+			consulTargets = append(consulTargets, targetInfo.ProxyTarget)
+		} else {
+			// Traditional target - use host:port key format
+			key := targetInfo.ProxyTarget.Host + ":" + targetInfo.ProxyTarget.Port
+			regularTargetKeys = append(regularTargetKeys, key)
+		}
 	}
 	s.targetsMutex.RUnlock()
 
-	// Perform the actual availability test
-	results := AvailabilityTest(testTargets)
+	// Initialize results map
+	results := make(map[string]*Status)
+
+	// Test traditional targets using the original AvailabilityTest
+	if len(regularTargetKeys) > 0 {
+		// logger.Debug("Testing", len(regularTargetKeys), "traditional targets")
+		regularResults := AvailabilityTest(regularTargetKeys)
+		for k, v := range regularResults {
+			results[k] = v
+		}
+	}
+
+	// Test consul targets using consul-specific logic
+	if len(consulTargets) > 0 {
+		// logger.Debug("Testing", len(consulTargets), "consul targets")
+		consulResults := TestConsulTargets(consulTargets)
+		for k, v := range consulResults {
+			results[k] = v
+		}
+	}
 
 	// Update availability map
 	s.targetsMutex.Lock()