Browse Source

enhance(upstream): proxy target parsing from multiple configs

0xJacky 2 weeks ago
parent
commit
e7e15bbde4

+ 12 - 0
api/upstream/upstream.go

@@ -27,6 +27,18 @@ func GetAvailability(c *gin.Context) {
 	c.JSON(http.StatusOK, result)
 }
 
+// GetUpstreamDefinitions returns all upstream definitions for debugging
+func GetUpstreamDefinitions(c *gin.Context) {
+	service := upstream.GetUpstreamService()
+
+	result := gin.H{
+		"upstreams":        service.GetAllUpstreamDefinitions(),
+		"last_update_time": service.GetLastUpdateTime(),
+	}
+
+	c.JSON(http.StatusOK, result)
+}
+
 // AvailabilityWebSocket handles WebSocket connections for real-time availability monitoring
 func AvailabilityWebSocket(c *gin.Context) {
 	var upGrader = websocket.Upgrader{

+ 23 - 7
internal/site/list.go

@@ -5,6 +5,7 @@ import (
 	"os"
 
 	"github.com/0xJacky/Nginx-UI/internal/config"
+	"github.com/0xJacky/Nginx-UI/internal/upstream"
 	"github.com/0xJacky/Nginx-UI/model"
 )
 
@@ -47,13 +48,28 @@ func GetSiteConfigs(ctx context.Context, options *ListOptions, sites []*model.Si
 func buildConfig(fileName string, fileInfo os.FileInfo, status config.ConfigStatus, envGroupID uint64, envGroup *model.EnvGroup) config.Config {
 	indexedSite := GetIndexedSite(fileName)
 
-	// Convert proxy targets
-	proxyTargets := make([]config.ProxyTarget, len(indexedSite.ProxyTargets))
-	for i, target := range indexedSite.ProxyTargets {
-		proxyTargets[i] = config.ProxyTarget{
-			Host: target.Host,
-			Port: target.Port,
-			Type: target.Type,
+	// Convert proxy targets, expanding upstream references
+	var proxyTargets []config.ProxyTarget
+	upstreamService := upstream.GetUpstreamService()
+
+	for _, target := range indexedSite.ProxyTargets {
+		// Check if target.Host is an upstream name
+		if upstreamDef, exists := upstreamService.GetUpstreamDefinition(target.Host); exists {
+			// Replace with upstream servers
+			for _, server := range upstreamDef.Servers {
+				proxyTargets = append(proxyTargets, config.ProxyTarget{
+					Host: server.Host,
+					Port: server.Port,
+					Type: server.Type,
+				})
+			}
+		} else {
+			// Regular proxy target
+			proxyTargets = append(proxyTargets, config.ProxyTarget{
+				Host: target.Host,
+				Port: target.Port,
+				Type: target.Type,
+			})
 		}
 	}
 

+ 158 - 0
internal/site/upstream_expansion_test.go

@@ -0,0 +1,158 @@
+package site
+
+import (
+	"os"
+	"testing"
+	"time"
+
+	"github.com/0xJacky/Nginx-UI/internal/config"
+	"github.com/0xJacky/Nginx-UI/internal/upstream"
+)
+
+func TestBuildConfig_UpstreamExpansion(t *testing.T) {
+	// Setup upstream service with test data
+	service := upstream.GetUpstreamService()
+	service.ClearTargets() // Clear any existing data
+
+	// Add test upstream definitions
+	webBackendServers := []upstream.ProxyTarget{
+		{Host: "192.168.1.100", Port: "8080", Type: "upstream"},
+		{Host: "192.168.1.101", Port: "8080", Type: "upstream"},
+		{Host: "::1", Port: "8080", Type: "upstream"},
+	}
+	service.UpdateUpstreamDefinition("web_backend", webBackendServers, "test.conf")
+
+	apiBackendServers := []upstream.ProxyTarget{
+		{Host: "api1.example.com", Port: "3000", Type: "upstream"},
+		{Host: "api2.example.com", Port: "3000", Type: "upstream"},
+	}
+	service.UpdateUpstreamDefinition("api_backend", apiBackendServers, "test.conf")
+
+	// Create a mock indexed site with proxy targets that reference upstreams
+	IndexedSites["test_site"] = &SiteIndex{
+		Path:    "test_site",
+		Content: "test content",
+		Urls:    []string{"example.com"},
+		ProxyTargets: []ProxyTarget{
+			{Host: "web_backend", Port: "80", Type: "proxy_pass"},          // This should be expanded
+			{Host: "api_backend", Port: "80", Type: "proxy_pass"},          // This should be expanded
+			{Host: "direct.example.com", Port: "8080", Type: "proxy_pass"}, // This should remain as-is
+		},
+	}
+
+	// Create mock file info
+	fileInfo := &mockFileInfo{
+		name:    "test_site",
+		size:    1024,
+		modTime: time.Now(),
+		isDir:   false,
+	}
+
+	// Call buildConfig
+	result := buildConfig("test_site", fileInfo, config.StatusEnabled, 0, nil)
+
+	// Verify the results
+	expectedTargetCount := 6 // 3 from web_backend + 2 from api_backend + 1 direct
+	if len(result.ProxyTargets) != expectedTargetCount {
+		t.Errorf("Expected %d proxy targets, got %d", expectedTargetCount, len(result.ProxyTargets))
+		for i, target := range result.ProxyTargets {
+			t.Logf("Target %d: Host=%s, Port=%s, Type=%s", i, target.Host, target.Port, target.Type)
+		}
+	}
+
+	// Check for specific targets
+	expectedHosts := map[string]bool{
+		"192.168.1.100":      false,
+		"192.168.1.101":      false,
+		"::1":                false,
+		"api1.example.com":   false,
+		"api2.example.com":   false,
+		"direct.example.com": false,
+	}
+
+	for _, target := range result.ProxyTargets {
+		if _, exists := expectedHosts[target.Host]; exists {
+			expectedHosts[target.Host] = true
+		}
+	}
+
+	// Verify all expected hosts were found
+	for host, found := range expectedHosts {
+		if !found {
+			t.Errorf("Expected to find host %s in proxy targets", host)
+		}
+	}
+
+	// Verify that upstream names are not present in the final targets
+	for _, target := range result.ProxyTargets {
+		if target.Host == "web_backend" || target.Host == "api_backend" {
+			t.Errorf("Upstream name %s should have been expanded, not included directly", target.Host)
+		}
+	}
+
+	// Clean up
+	delete(IndexedSites, "test_site")
+}
+
+func TestBuildConfig_NoUpstreamExpansion(t *testing.T) {
+	// Test case where proxy targets don't reference any upstreams
+	IndexedSites["test_site_no_upstream"] = &SiteIndex{
+		Path:    "test_site_no_upstream",
+		Content: "test content",
+		Urls:    []string{"example.com"},
+		ProxyTargets: []ProxyTarget{
+			{Host: "direct1.example.com", Port: "8080", Type: "proxy_pass"},
+			{Host: "direct2.example.com", Port: "9000", Type: "proxy_pass"},
+			{Host: "::1", Port: "3000", Type: "proxy_pass"},
+		},
+	}
+
+	fileInfo := &mockFileInfo{
+		name:    "test_site_no_upstream",
+		size:    1024,
+		modTime: time.Now(),
+		isDir:   false,
+	}
+
+	result := buildConfig("test_site_no_upstream", fileInfo, config.StatusEnabled, 0, nil)
+
+	// Should have exactly 3 targets, unchanged
+	if len(result.ProxyTargets) != 3 {
+		t.Errorf("Expected 3 proxy targets, got %d", len(result.ProxyTargets))
+	}
+
+	expectedTargets := []config.ProxyTarget{
+		{Host: "direct1.example.com", Port: "8080", Type: "proxy_pass"},
+		{Host: "direct2.example.com", Port: "9000", Type: "proxy_pass"},
+		{Host: "::1", Port: "3000", Type: "proxy_pass"},
+	}
+
+	for i, expected := range expectedTargets {
+		if i >= len(result.ProxyTargets) {
+			t.Errorf("Missing target %d", i)
+			continue
+		}
+		actual := result.ProxyTargets[i]
+		if actual.Host != expected.Host || actual.Port != expected.Port || actual.Type != expected.Type {
+			t.Errorf("Target %d mismatch: expected %+v, got %+v", i, expected, actual)
+		}
+	}
+
+	// Clean up
+	delete(IndexedSites, "test_site_no_upstream")
+}
+
+// mockFileInfo implements os.FileInfo for testing
+type mockFileInfo struct {
+	name    string
+	size    int64
+	modTime time.Time
+	isDir   bool
+}
+
+func (m *mockFileInfo) Name() string       { return m.name }
+func (m *mockFileInfo) Size() int64        { return m.size }
+func (m *mockFileInfo) Mode() os.FileMode  { return 0644 }
+func (m *mockFileInfo) ModTime() time.Time { return m.modTime }
+func (m *mockFileInfo) IsDir() bool        { return m.isDir }
+func (m *mockFileInfo) Sys() interface{}   { return nil }

+ 1 - 1
internal/stream/index_test.go

@@ -39,7 +39,7 @@ func TestScanForStream(t *testing.T) {
 server {
     listen 1234-1236;
     resolver 8.8.8.8 valid=1s;
-    proxy_pass example.com:$server_port;
+    proxy_pass example.com:8080;
 }`
 
 	// Test with a valid stream config path

+ 23 - 7
internal/stream/list.go

@@ -5,6 +5,7 @@ import (
 	"os"
 
 	"github.com/0xJacky/Nginx-UI/internal/config"
+	"github.com/0xJacky/Nginx-UI/internal/upstream"
 	"github.com/0xJacky/Nginx-UI/model"
 )
 
@@ -47,13 +48,28 @@ func GetStreamConfigs(ctx context.Context, options *ListOptions, streams []*mode
 func buildConfig(fileName string, fileInfo os.FileInfo, status config.ConfigStatus, envGroupID uint64, envGroup *model.EnvGroup) config.Config {
 	indexedStream := GetIndexedStream(fileName)
 
-	// Convert proxy targets
-	proxyTargets := make([]config.ProxyTarget, len(indexedStream.ProxyTargets))
-	for i, target := range indexedStream.ProxyTargets {
-		proxyTargets[i] = config.ProxyTarget{
-			Host: target.Host,
-			Port: target.Port,
-			Type: target.Type,
+	// Convert proxy targets, expanding upstream references
+	var proxyTargets []config.ProxyTarget
+	upstreamService := upstream.GetUpstreamService()
+
+	for _, target := range indexedStream.ProxyTargets {
+		// Check if target.Host is an upstream name
+		if upstreamDef, exists := upstreamService.GetUpstreamDefinition(target.Host); exists {
+			// Replace with upstream servers
+			for _, server := range upstreamDef.Servers {
+				proxyTargets = append(proxyTargets, config.ProxyTarget{
+					Host: server.Host,
+					Port: server.Port,
+					Type: server.Type,
+				})
+			}
+		} else {
+			// Regular proxy target
+			proxyTargets = append(proxyTargets, config.ProxyTarget{
+				Host: target.Host,
+				Port: target.Port,
+				Type: target.Type,
+			})
 		}
 	}
 

+ 156 - 0
internal/stream/upstream_expansion_test.go

@@ -0,0 +1,156 @@
+package stream
+
+import (
+	"os"
+	"testing"
+	"time"
+
+	"github.com/0xJacky/Nginx-UI/internal/config"
+	"github.com/0xJacky/Nginx-UI/internal/upstream"
+)
+
+func TestBuildConfig_UpstreamExpansion(t *testing.T) {
+	// Setup upstream service with test data
+	service := upstream.GetUpstreamService()
+	service.ClearTargets() // Clear any existing data
+
+	// Add test upstream definitions
+	tcpBackendServers := []upstream.ProxyTarget{
+		{Host: "192.168.1.100", Port: "3306", Type: "upstream"},
+		{Host: "192.168.1.101", Port: "3306", Type: "upstream"},
+		{Host: "::1", Port: "3306", Type: "upstream"},
+	}
+	service.UpdateUpstreamDefinition("tcp_backend", tcpBackendServers, "test.conf")
+
+	udpBackendServers := []upstream.ProxyTarget{
+		{Host: "dns1.example.com", Port: "53", Type: "upstream"},
+		{Host: "dns2.example.com", Port: "53", Type: "upstream"},
+	}
+	service.UpdateUpstreamDefinition("udp_backend", udpBackendServers, "test.conf")
+
+	// Create a mock indexed stream with proxy targets that reference upstreams
+	IndexedStreams["test_stream"] = &StreamIndex{
+		Path:    "test_stream",
+		Content: "test content",
+		ProxyTargets: []upstream.ProxyTarget{
+			{Host: "tcp_backend", Port: "80", Type: "proxy_pass"},          // This should be expanded
+			{Host: "udp_backend", Port: "80", Type: "proxy_pass"},          // This should be expanded
+			{Host: "direct.example.com", Port: "8080", Type: "proxy_pass"}, // This should remain as-is
+		},
+	}
+
+	// Create mock file info
+	fileInfo := &mockFileInfo{
+		name:    "test_stream",
+		size:    1024,
+		modTime: time.Now(),
+		isDir:   false,
+	}
+
+	// Call buildConfig
+	result := buildConfig("test_stream", fileInfo, config.StatusEnabled, 0, nil)
+
+	// Verify the results
+	expectedTargetCount := 6 // 3 from tcp_backend + 2 from udp_backend + 1 direct
+	if len(result.ProxyTargets) != expectedTargetCount {
+		t.Errorf("Expected %d proxy targets, got %d", expectedTargetCount, len(result.ProxyTargets))
+		for i, target := range result.ProxyTargets {
+			t.Logf("Target %d: Host=%s, Port=%s, Type=%s", i, target.Host, target.Port, target.Type)
+		}
+	}
+
+	// Check for specific targets
+	expectedHosts := map[string]bool{
+		"192.168.1.100":      false,
+		"192.168.1.101":      false,
+		"::1":                false,
+		"dns1.example.com":   false,
+		"dns2.example.com":   false,
+		"direct.example.com": false,
+	}
+
+	for _, target := range result.ProxyTargets {
+		if _, exists := expectedHosts[target.Host]; exists {
+			expectedHosts[target.Host] = true
+		}
+	}
+
+	// Verify all expected hosts were found
+	for host, found := range expectedHosts {
+		if !found {
+			t.Errorf("Expected to find host %s in proxy targets", host)
+		}
+	}
+
+	// Verify that upstream names are not present in the final targets
+	for _, target := range result.ProxyTargets {
+		if target.Host == "tcp_backend" || target.Host == "udp_backend" {
+			t.Errorf("Upstream name %s should have been expanded, not included directly", target.Host)
+		}
+	}
+
+	// Clean up
+	delete(IndexedStreams, "test_stream")
+}
+
+func TestBuildConfig_NoUpstreamExpansion(t *testing.T) {
+	// Test case where proxy targets don't reference any upstreams
+	IndexedStreams["test_stream_no_upstream"] = &StreamIndex{
+		Path:    "test_stream_no_upstream",
+		Content: "test content",
+		ProxyTargets: []upstream.ProxyTarget{
+			{Host: "direct1.example.com", Port: "8080", Type: "proxy_pass"},
+			{Host: "direct2.example.com", Port: "9000", Type: "proxy_pass"},
+			{Host: "::1", Port: "3000", Type: "proxy_pass"},
+		},
+	}
+
+	fileInfo := &mockFileInfo{
+		name:    "test_stream_no_upstream",
+		size:    1024,
+		modTime: time.Now(),
+		isDir:   false,
+	}
+
+	result := buildConfig("test_stream_no_upstream", fileInfo, config.StatusEnabled, 0, nil)
+
+	// Should have exactly 3 targets, unchanged
+	if len(result.ProxyTargets) != 3 {
+		t.Errorf("Expected 3 proxy targets, got %d", len(result.ProxyTargets))
+	}
+
+	expectedTargets := []config.ProxyTarget{
+		{Host: "direct1.example.com", Port: "8080", Type: "proxy_pass"},
+		{Host: "direct2.example.com", Port: "9000", Type: "proxy_pass"},
+		{Host: "::1", Port: "3000", Type: "proxy_pass"},
+	}
+
+	for i, expected := range expectedTargets {
+		if i >= len(result.ProxyTargets) {
+			t.Errorf("Missing target %d", i)
+			continue
+		}
+		actual := result.ProxyTargets[i]
+		if actual.Host != expected.Host || actual.Port != expected.Port || actual.Type != expected.Type {
+			t.Errorf("Target %d mismatch: expected %+v, got %+v", i, expected, actual)
+		}
+	}
+
+	// Clean up
+	delete(IndexedStreams, "test_stream_no_upstream")
+}
+
+// mockFileInfo implements os.FileInfo for testing
+type mockFileInfo struct {
+	name    string
+	size    int64
+	modTime time.Time
+	isDir   bool
+}
+
+func (m *mockFileInfo) Name() string       { return m.name }
+func (m *mockFileInfo) Size() int64        { return m.size }
+func (m *mockFileInfo) Mode() os.FileMode  { return 0644 }
+func (m *mockFileInfo) ModTime() time.Time { return m.modTime }
+func (m *mockFileInfo) IsDir() bool        { return m.isDir }
+func (m *mockFileInfo) Sys() interface{}   { return nil }

+ 188 - 0
internal/upstream/ipv6_test.go

@@ -0,0 +1,188 @@
+package upstream
+
+import (
+	"testing"
+)
+
+func TestParseAddressOnly_IPv6Support(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected ProxyTarget
+	}{
+		// IPv6 with brackets and port
+		{
+			name:  "IPv6 with brackets and port",
+			input: "[::1]:8080",
+			expected: ProxyTarget{
+				Host: "::1",
+				Port: "8080",
+			},
+		},
+		{
+			name:  "IPv6 full address with brackets and port",
+			input: "[2001:db8::1]:9000",
+			expected: ProxyTarget{
+				Host: "2001:db8::1",
+				Port: "9000",
+			},
+		},
+		// IPv6 with brackets without port
+		{
+			name:  "IPv6 with brackets without port",
+			input: "[::1]",
+			expected: ProxyTarget{
+				Host: "::1",
+				Port: "80",
+			},
+		},
+		{
+			name:  "IPv6 full address with brackets without port",
+			input: "[2001:db8::1]",
+			expected: ProxyTarget{
+				Host: "2001:db8::1",
+				Port: "80",
+			},
+		},
+		// IPv6 without brackets
+		{
+			name:  "IPv6 localhost without brackets",
+			input: "::1",
+			expected: ProxyTarget{
+				Host: "::1",
+				Port: "80",
+			},
+		},
+		{
+			name:  "IPv6 full address without brackets",
+			input: "2001:db8::1",
+			expected: ProxyTarget{
+				Host: "2001:db8::1",
+				Port: "80",
+			},
+		},
+		{
+			name:  "IPv6 link-local with interface",
+			input: "fe80::1%eth0",
+			expected: ProxyTarget{
+				Host: "fe80::1%eth0",
+				Port: "80",
+			},
+		},
+		// IPv4 tests
+		{
+			name:  "IPv4 with port",
+			input: "192.168.1.1:8080",
+			expected: ProxyTarget{
+				Host: "192.168.1.1",
+				Port: "8080",
+			},
+		},
+		{
+			name:  "IPv4 without port",
+			input: "192.168.1.1",
+			expected: ProxyTarget{
+				Host: "192.168.1.1",
+				Port: "80",
+			},
+		},
+		// Hostname tests
+		{
+			name:  "Hostname with port",
+			input: "example.com:8080",
+			expected: ProxyTarget{
+				Host: "example.com",
+				Port: "8080",
+			},
+		},
+		{
+			name:  "Hostname without port",
+			input: "example.com",
+			expected: ProxyTarget{
+				Host: "example.com",
+				Port: "80",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := parseAddressOnly(tt.input)
+			if result.Host != tt.expected.Host {
+				t.Errorf("parseAddressOnly(%q).Host = %q, want %q", tt.input, result.Host, tt.expected.Host)
+			}
+			if result.Port != tt.expected.Port {
+				t.Errorf("parseAddressOnly(%q).Port = %q, want %q", tt.input, result.Port, tt.expected.Port)
+			}
+		})
+	}
+}
+
+func TestParseProxyTargetsFromRawContent_IPv6Support(t *testing.T) {
+	config := `
+upstream backend_ipv6 {
+    server [::1]:8080;
+    server [2001:db8::1]:9000;
+    server 192.168.1.100:8080;
+}
+
+upstream backend_mixed {
+    server [::1]:8080;
+    server 192.168.1.100:8080;
+    server example.com:9000;
+}
+
+server {
+    listen 80;
+    location / {
+        proxy_pass http://[::1]:8080;
+    }
+    location /api {
+        proxy_pass http://backend_ipv6;
+    }
+}
+`
+
+	targets := ParseProxyTargetsFromRawContent(config)
+
+	// Expected targets (after deduplication):
+	// - [::1]:8080 (appears in both upstreams and proxy_pass, but deduplicated)
+	// - [2001:db8::1]:9000 (from backend_ipv6)
+	// - 192.168.1.100:8080 (appears in both upstreams, but deduplicated)
+	// - example.com:9000 (from backend_mixed)
+	// - [::1]:8080 proxy_pass (different type, so not deduplicated)
+	expectedCount := 5
+	if len(targets) != expectedCount {
+		t.Errorf("Expected %d targets, got %d", expectedCount, len(targets))
+		for i, target := range targets {
+			t.Logf("Target %d: Host=%s, Port=%s, Type=%s", i, target.Host, target.Port, target.Type)
+		}
+	}
+
+	// Check for IPv6 targets
+	hasIPv6Localhost := false
+	hasIPv6Full := false
+	hasIPv4 := false
+
+	for _, target := range targets {
+		if target.Host == "::1" && target.Port == "8080" {
+			hasIPv6Localhost = true
+		}
+		if target.Host == "2001:db8::1" && target.Port == "9000" {
+			hasIPv6Full = true
+		}
+		if target.Host == "192.168.1.100" && target.Port == "8080" {
+			hasIPv4 = true
+		}
+	}
+
+	if !hasIPv6Localhost {
+		t.Error("Expected to find IPv6 localhost target [::1]:8080")
+	}
+	if !hasIPv6Full {
+		t.Error("Expected to find IPv6 full address target [2001:db8::1]:9000")
+	}
+	if !hasIPv4 {
+		t.Error("Expected to find IPv4 target 192.168.1.100:8080")
+	}
+}

+ 97 - 26
internal/upstream/service.go

@@ -1,10 +1,13 @@
 package upstream
 
 import (
+	"maps"
+	"slices"
 	"sync"
 	"time"
 
 	"github.com/0xJacky/Nginx-UI/internal/cache"
+	"github.com/uozi-tech/cosy/logger"
 )
 
 // TargetInfo contains proxy target information with source config
@@ -14,15 +17,26 @@ type TargetInfo struct {
 	LastSeen   time.Time `json:"last_seen"`
 }
 
+// UpstreamDefinition contains upstream block information
+type UpstreamDefinition struct {
+	Name       string        `json:"name"`
+	Servers    []ProxyTarget `json:"servers"`
+	ConfigPath string        `json:"config_path"`
+	LastSeen   time.Time     `json:"last_seen"`
+}
+
 // UpstreamService manages upstream availability testing
 type UpstreamService struct {
 	targets         map[string]*TargetInfo // key: host:port
 	availabilityMap map[string]*Status     // key: host:port
 	configTargets   map[string][]string    // configPath -> []targetKeys
-	targetsMutex    sync.RWMutex
-	lastUpdateTime  time.Time
-	testInProgress  bool
-	testMutex       sync.Mutex
+	// Public upstream definitions storage
+	Upstreams      map[string]*UpstreamDefinition // key: upstream name
+	upstreamsMutex sync.RWMutex
+	targetsMutex   sync.RWMutex
+	lastUpdateTime time.Time
+	testInProgress bool
+	testMutex      sync.Mutex
 }
 
 var (
@@ -37,6 +51,7 @@ func GetUpstreamService() *UpstreamService {
 			targets:         make(map[string]*TargetInfo),
 			availabilityMap: make(map[string]*Status),
 			configTargets:   make(map[string][]string),
+			Upstreams:       make(map[string]*UpstreamDefinition),
 			lastUpdateTime:  time.Now(),
 		}
 	})
@@ -50,11 +65,17 @@ func init() {
 
 // scanForProxyTargets is the callback function for cache scanner
 func scanForProxyTargets(configPath string, content []byte) error {
-	// Parse proxy targets from config content
-	targets := ParseProxyTargetsFromRawContent(string(content))
+	logger.Debug("scanForProxyTargets", configPath)
+	// Parse proxy targets and upstream definitions from config content
+	result := ParseProxyTargetsAndUpstreamsFromRawContent(string(content))
 
 	service := GetUpstreamService()
-	service.updateTargetsFromConfig(configPath, targets)
+	service.updateTargetsFromConfig(configPath, result.ProxyTargets)
+
+	// Update upstream definitions
+	for upstreamName, servers := range result.Upstreams {
+		service.UpdateUpstreamDefinition(upstreamName, servers, configPath)
+	}
 
 	return nil
 }
@@ -74,11 +95,8 @@ func (s *UpstreamService) updateTargetsFromConfig(configPath string, targets []P
 				isOnlyConfig := true
 				for otherConfig, otherKeys := range s.configTargets {
 					if otherConfig != configPath {
-						for _, otherKey := range otherKeys {
-							if otherKey == key {
-								isOnlyConfig = false
-								break
-							}
+						if slices.Contains(otherKeys, key) {
+							isOnlyConfig = false
 						}
 						if !isOnlyConfig {
 							break
@@ -195,11 +213,11 @@ func (s *UpstreamService) PerformAvailabilityTest() {
 	s.targetsMutex.RUnlock()
 
 	if targetCount == 0 {
-		// logger.Debug("No targets to test")
+		logger.Debug("No targets to test")
 		return
 	}
 
-	// logger.Debug("Performing availability test for", targetCount, "unique targets")
+	logger.Debug("Performing availability test for", targetCount, "unique targets")
 
 	// Separate targets into traditional and consul groups from the start
 	s.targetsMutex.RLock()
@@ -224,18 +242,14 @@ func (s *UpstreamService) PerformAvailabilityTest() {
 	if len(regularTargetKeys) > 0 {
 		// logger.Debug("Testing", len(regularTargetKeys), "traditional targets")
 		regularResults := AvailabilityTest(regularTargetKeys)
-		for k, v := range regularResults {
-			results[k] = v
-		}
+		maps.Copy(results, regularResults)
 	}
 
 	// Test consul targets using consul-specific logic
 	if len(consulTargets) > 0 {
 		// logger.Debug("Testing", len(consulTargets), "consul targets")
 		consulResults := TestDynamicTargets(consulTargets)
-		for k, v := range consulResults {
-			results[k] = v
-		}
+		maps.Copy(results, consulResults)
 	}
 
 	// Update availability map
@@ -249,14 +263,17 @@ func (s *UpstreamService) PerformAvailabilityTest() {
 // ClearTargets clears all targets (useful for testing or reloading)
 func (s *UpstreamService) ClearTargets() {
 	s.targetsMutex.Lock()
+	s.upstreamsMutex.Lock()
 	defer s.targetsMutex.Unlock()
+	defer s.upstreamsMutex.Unlock()
 
 	s.targets = make(map[string]*TargetInfo)
 	s.availabilityMap = make(map[string]*Status)
 	s.configTargets = make(map[string][]string)
+	s.Upstreams = make(map[string]*UpstreamDefinition)
 	s.lastUpdateTime = time.Now()
 
-	// logger.Debug("Cleared all proxy targets")
+	// logger.Debug("Cleared all proxy targets and upstream definitions")
 }
 
 // GetLastUpdateTime returns the last time targets were updated
@@ -273,6 +290,63 @@ func (s *UpstreamService) GetTargetCount() int {
 	return len(s.targets)
 }
 
+// UpdateUpstreamDefinition updates or adds an upstream definition
+func (s *UpstreamService) UpdateUpstreamDefinition(name string, servers []ProxyTarget, configPath string) {
+	s.upstreamsMutex.Lock()
+	defer s.upstreamsMutex.Unlock()
+
+	s.Upstreams[name] = &UpstreamDefinition{
+		Name:       name,
+		Servers:    servers,
+		ConfigPath: configPath,
+		LastSeen:   time.Now(),
+	}
+}
+
+// GetUpstreamDefinition returns an upstream definition by name
+func (s *UpstreamService) GetUpstreamDefinition(name string) (*UpstreamDefinition, bool) {
+	s.upstreamsMutex.RLock()
+	defer s.upstreamsMutex.RUnlock()
+
+	upstream, exists := s.Upstreams[name]
+	if !exists {
+		return nil, false
+	}
+
+	// Return a copy to avoid race conditions
+	return &UpstreamDefinition{
+		Name:       upstream.Name,
+		Servers:    append([]ProxyTarget(nil), upstream.Servers...),
+		ConfigPath: upstream.ConfigPath,
+		LastSeen:   upstream.LastSeen,
+	}, true
+}
+
+// GetAllUpstreamDefinitions returns a copy of all upstream definitions
+func (s *UpstreamService) GetAllUpstreamDefinitions() map[string]*UpstreamDefinition {
+	s.upstreamsMutex.RLock()
+	defer s.upstreamsMutex.RUnlock()
+
+	result := make(map[string]*UpstreamDefinition)
+	for name, upstream := range s.Upstreams {
+		result[name] = &UpstreamDefinition{
+			Name:       upstream.Name,
+			Servers:    append([]ProxyTarget(nil), upstream.Servers...),
+			ConfigPath: upstream.ConfigPath,
+			LastSeen:   upstream.LastSeen,
+		}
+	}
+	return result
+}
+
+// IsUpstreamName checks if a given name is a known upstream
+func (s *UpstreamService) IsUpstreamName(name string) bool {
+	s.upstreamsMutex.RLock()
+	defer s.upstreamsMutex.RUnlock()
+	_, exists := s.Upstreams[name]
+	return exists
+}
+
 // RemoveConfigTargets removes all targets associated with a specific config file
 func (s *UpstreamService) RemoveConfigTargets(configPath string) {
 	s.targetsMutex.Lock()
@@ -284,11 +358,8 @@ func (s *UpstreamService) RemoveConfigTargets(configPath string) {
 			isUsedByOthers := false
 			for otherConfig, otherKeys := range s.configTargets {
 				if otherConfig != configPath {
-					for _, otherKey := range otherKeys {
-						if otherKey == key {
-							isUsedByOthers = true
-							break
-						}
+					if slices.Contains(otherKeys, key) {
+						isUsedByOthers = true
 					}
 					if isUsedByOthers {
 						break

+ 41 - 5
internal/upstream/proxy_parser.go → internal/upstream/upstream_parser.go

@@ -24,9 +24,22 @@ type UpstreamContext struct {
 	Resolver string
 }
 
+// ParseResult contains the results of parsing nginx configuration
+type ParseResult struct {
+	ProxyTargets []ProxyTarget
+	Upstreams    map[string][]ProxyTarget // upstream name -> servers
+}
+
 // ParseProxyTargetsFromRawContent parses proxy targets from raw nginx configuration content
 func ParseProxyTargetsFromRawContent(content string) []ProxyTarget {
+	result := ParseProxyTargetsAndUpstreamsFromRawContent(content)
+	return result.ProxyTargets
+}
+
+// ParseProxyTargetsAndUpstreamsFromRawContent parses both proxy targets and upstream definitions from raw nginx configuration content
+func ParseProxyTargetsAndUpstreamsFromRawContent(content string) *ParseResult {
 	var targets []ProxyTarget
+	upstreams := make(map[string][]ProxyTarget)
 
 	// First, collect all upstream names and their contexts
 	upstreamNames := make(map[string]bool)
@@ -61,14 +74,21 @@ func ParseProxyTargetsFromRawContent(content string) []ProxyTarget {
 			serverRegex := regexp.MustCompile(`(?m)^\s*server\s+([^;]+);`)
 			serverMatches := serverRegex.FindAllStringSubmatch(upstreamContent, -1)
 
+			var upstreamServers []ProxyTarget
 			for _, serverMatch := range serverMatches {
 				if len(serverMatch) >= 2 {
 					target := parseServerAddress(strings.TrimSpace(serverMatch[1]), "upstream", ctx)
 					if target.Host != "" {
 						targets = append(targets, target)
+						upstreamServers = append(upstreamServers, target)
 					}
 				}
 			}
+
+			// Store upstream definition
+			if len(upstreamServers) > 0 {
+				upstreams[upstreamName] = upstreamServers
+			}
 		}
 	}
 
@@ -106,7 +126,10 @@ func ParseProxyTargetsFromRawContent(content string) []ProxyTarget {
 		}
 	}
 
-	return deduplicateTargets(targets)
+	return &ParseResult{
+		ProxyTargets: deduplicateTargets(targets),
+		Upstreams:    upstreams,
+	}
 }
 
 // parseProxyPassURL parses a proxy_pass or grpc_pass URL and extracts host and port
@@ -238,10 +261,11 @@ func isConsulServiceDiscovery(serverAddr string) bool {
 }
 
 // parseAddressOnly parses just the address portion without consul-specific logic
+// Supports both IPv4 and IPv6 addresses
 func parseAddressOnly(addr string) ProxyTarget {
-	// Handle IPv6 addresses
+	// Handle IPv6 addresses with brackets
 	if strings.HasPrefix(addr, "[") {
-		// IPv6 format: [::1]:8080
+		// IPv6 format: [::1]:8080 or [2001:db8::1]:8080
 		if idx := strings.LastIndex(addr, "]:"); idx != -1 {
 			host := addr[1:idx]
 			port := addr[idx+2:]
@@ -250,7 +274,7 @@ func parseAddressOnly(addr string) ProxyTarget {
 				Port: port,
 			}
 		}
-		// IPv6 without port: [::1]
+		// IPv6 without port: [::1] or [2001:db8::1]
 		host := strings.Trim(addr, "[]")
 		return ProxyTarget{
 			Host: host,
@@ -258,7 +282,19 @@ func parseAddressOnly(addr string) ProxyTarget {
 		}
 	}
 
-	// Handle IPv4 addresses and hostnames
+	// Check if this might be an IPv6 address without brackets
+	// IPv6 addresses contain multiple colons
+	colonCount := strings.Count(addr, ":")
+	if colonCount > 1 {
+		// This is likely an IPv6 address without brackets and without port
+		// e.g., ::1, 2001:db8::1, fe80::1%eth0
+		return ProxyTarget{
+			Host: addr,
+			Port: "80",
+		}
+	}
+
+	// Handle IPv4 addresses and hostnames with port
 	if strings.Contains(addr, ":") {
 		parts := strings.Split(addr, ":")
 		if len(parts) == 2 {

+ 0 - 0
internal/upstream/proxy_parser_test.go → internal/upstream/upstream_parser_test.go