Files
nginx-ui/internal/upstream/dynamic_resolver.go

333 lines
10 KiB
Go

package upstream
// Package upstream provides DNS resolution and availability testing for nginx upstream targets,
// with special support for dynamic service discovery and nginx-style SRV record resolution.
//
// # SRV Record Resolution (nginx.org compliant)
//
// This package implements nginx's SRV record resolution rules as documented at nginx.org.
// The service=name parameter enables resolving of DNS SRV records and sets the service name.
//
// Rules for SRV record construction:
//
// 1. If the service name does not contain a dot ("."), then the RFC-compliant name is constructed
// and the TCP protocol is added to the service prefix.
// Example: "backend.example.com service=http resolve" -> "_http._tcp.backend.example.com"
//
// 2. If the service name contains one or more dots, then the name is constructed by joining
// the service prefix and the server name.
// Example: "backend.example.com service=_http._tcp resolve" -> "_http._tcp.backend.example.com"
// Example: "example.com service=server1.backend resolve" -> "server1.backend.example.com"
//
// # Dynamic DNS Integration
//
// The resolver supports various DNS interfaces for service discovery:
// - DNS servers (e.g., Consul DNS on 127.0.0.1:8600, CoreDNS, etc.)
// - Service registration and health checking
// - SRV record-based load balancing with proper priority handling
//
// # Usage Examples
//
// // Create resolver with DNS server
// resolver := NewDynamicResolver("127.0.0.1:8600")
//
// // Resolve nginx-style service URL
// addresses, err := resolver.ResolveService("backend.example.com service=http resolve")
// // This queries "_http._tcp.backend.example.com" SRV records
//
// // Resolve with dotted service name
// addresses, err := resolver.ResolveService("example.com service=server1.backend resolve")
// // This queries "server1.backend.example.com" SRV records
import (
"context"
"fmt"
"net"
"strings"
"time"
)
// DynamicResolver handles DNS resolution through dynamic DNS servers
type DynamicResolver struct {
resolver string // e.g., "127.0.0.1:8600"
}
// NewDynamicResolver creates a new dynamic resolver
func NewDynamicResolver(resolver string) *DynamicResolver {
return &DynamicResolver{
resolver: resolver,
}
}
// ServiceInfo contains parsed service information from nginx config
type ServiceInfo struct {
Hostname string // e.g., "backend.example.com" or "service.consul"
ServiceName string // e.g., "http", "_http._tcp", "server1.backend"
}
// ResolveService resolves a nginx service to actual IP addresses and ports
func (dr *DynamicResolver) ResolveService(serviceURL string) ([]string, error) {
// Parse service URL to extract hostname and service name
serviceInfo, err := dr.parseServiceURL(serviceURL)
if err != nil {
return nil, fmt.Errorf("failed to parse service URL %s: %v", serviceURL, err)
}
// Create a custom resolver that uses the 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, dr.resolver)
},
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Construct SRV query domain according to nginx rules
srvDomain := dr.constructSRVDomain(serviceInfo)
// Query for service SRV records
_, srvRecords, err := resolver.LookupSRV(ctx, "", "", srvDomain)
if err != nil {
// Fallback to A record lookup if SRV fails
ips, err := resolver.LookupIPAddr(ctx, srvDomain)
if err != nil {
return nil, fmt.Errorf("failed to resolve service %s: %v", srvDomain, 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", srvDomain)
}
return addresses, nil
}
// parseServiceURL parses nginx service URL and extracts hostname and service name
func (dr *DynamicResolver) parseServiceURL(serviceURL string) (*ServiceInfo, error) {
serviceURL = strings.TrimSpace(serviceURL)
if serviceURL == "" {
return nil, fmt.Errorf("empty service URL")
}
// Parse nginx format: "hostname service=servicename resolve"
parts := strings.Fields(serviceURL)
if len(parts) < 3 {
return nil, fmt.Errorf("invalid service URL format: %s", serviceURL)
}
hostname := parts[0]
var serviceName string
// Find service=name parameter
for _, part := range parts[1:] {
if strings.HasPrefix(part, "service=") {
serviceName = strings.TrimPrefix(part, "service=")
serviceName = strings.TrimSpace(serviceName)
break
}
}
if serviceName == "" {
return nil, fmt.Errorf("service parameter not found in: %s", serviceURL)
}
return &ServiceInfo{
Hostname: hostname,
ServiceName: serviceName,
}, nil
}
// constructSRVDomain constructs SRV query domain according to nginx.org rules
func (dr *DynamicResolver) constructSRVDomain(serviceInfo *ServiceInfo) string {
// According to nginx.org documentation:
// 1. If service name does not contain a dot ("."), then RFC-compliant name is constructed
// and TCP protocol is added to the service prefix.
// Example: service=http -> _http._tcp.hostname
// 2. If service name contains one or more dots, then the name is constructed by joining
// the service prefix and the server name.
// Example: service=_http._tcp -> _http._tcp.hostname
// Example: service=server1.backend -> server1.backend.hostname
if !strings.Contains(serviceInfo.ServiceName, ".") {
// Case 1: No dots - construct RFC-compliant name with TCP protocol
return fmt.Sprintf("_%s._tcp.%s", serviceInfo.ServiceName, serviceInfo.Hostname)
} else {
// Case 2: Contains dots - join service prefix and hostname
return fmt.Sprintf("%s.%s", serviceInfo.ServiceName, serviceInfo.Hostname)
}
}
// extractServiceName extracts the service name from service URL
// Deprecated: Use parseServiceURL instead for proper nginx-style parsing
func (dr *DynamicResolver) extractServiceName(serviceURL string) string {
serviceInfo, err := dr.parseServiceURL(serviceURL)
if err != nil {
// Fallback to old parsing logic for backward compatibility
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 ""
}
return serviceInfo.ServiceName
}
// TestDynamicTargets performs availability test specifically for dynamic DNS targets
func TestDynamicTargets(dynamicTargets []ProxyTarget) map[string]*Status {
result := make(map[string]*Status)
// Group dynamic targets by resolver
dynamicTargetsByResolver := make(map[string][]ProxyTarget)
for _, target := range dynamicTargets {
if target.Resolver != "" {
dynamicTargetsByResolver[target.Resolver] = append(dynamicTargetsByResolver[target.Resolver], target)
} else {
// No resolver specified, mark as offline
key := formatSocketAddress(target.Host, target.Port)
result[key] = &Status{
Online: false,
Latency: 0,
}
}
}
// Test each resolver group
for resolver, targets := range dynamicTargetsByResolver {
dynamicResolver := NewDynamicResolver(resolver)
for _, target := range targets {
key := formatSocketAddress(target.Host, target.Port)
// Try to resolve the service
addresses, err := dynamicResolver.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 dynamic DNS resolution support
// Deprecated: Use TestDynamicTargets for dynamic targets and AvailabilityTest for regular targets
func EnhancedAvailabilityTest(targets []ProxyTarget) map[string]*Status {
result := make(map[string]*Status)
// Group targets by type
dynamicTargets := make([]ProxyTarget, 0)
regularTargets := make([]string, 0)
for _, target := range targets {
if target.IsConsul && target.Resolver != "" {
dynamicTargets = append(dynamicTargets, target)
} else {
// Regular target - use properly formatted socket address for traditional AvailabilityTest
key := formatSocketAddress(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 dynamic targets with DNS resolution
if len(dynamicTargets) > 0 {
dynamicResults := TestDynamicTargets(dynamicTargets)
for k, v := range dynamicResults {
result[k] = v
}
}
return result
}