mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-06-01 00:50:20 +08:00
333 lines
10 KiB
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
|
|
}
|