plugin/forward: add hostname resolution support for TO endpoints (#5646) (#7923)

Signed-off-by: Dmytro Alieksieiev <1865999+dragoangel@users.noreply.github.com>
This commit is contained in:
Dmytro Alieksieiev
2026-05-31 04:36:01 +02:00
committed by GitHub
parent 33c71b1554
commit ce0e5a6f39
6 changed files with 920 additions and 9 deletions

257
plugin/forward/resolve.go Normal file
View File

@@ -0,0 +1,257 @@
package forward
import (
"fmt"
"net"
"strings"
"time"
"github.com/coredns/coredns/plugin/pkg/parse"
"github.com/coredns/coredns/plugin/pkg/transport"
"github.com/miekg/dns"
)
// hostEntry represents a hostname-based TO address that needs DNS resolution.
type hostEntry struct {
hostname string // the hostname to resolve (e.g., "rbldnsd.rbldnsd.svc.cluster.local")
port string // port (e.g., "53", "853")
transport string // "dns" or "tls"
zone string // TLS server name zone (from %zone syntax)
}
// toEntry represents a single TO address from the config, preserving order.
type toEntry struct {
static bool // true for IP/file-based entries
addrs []string // for static: resolved by HostPortOrFile
entry hostEntry // for dynamic: hostname to resolve
}
// classifyToAddrs processes TO addresses in order, returning an ordered list of
// toEntries that preserves config ordering.
func classifyToAddrs(toAddrs []string) ([]toEntry, error) {
var entries []toEntry
for _, h := range toAddrs {
// Try HostPortOrFile first - this handles IPs and files
hosts, parseErr := parse.HostPortOrFile(h)
if parseErr == nil {
entries = append(entries, toEntry{static: true, addrs: hosts})
continue
}
// Only fall through to hostname parsing if the error specifically
// indicates the address is not an IP or file. Other errors (like
// "no nameservers found" from file parsing) should be propagated.
if !strings.Contains(parseErr.Error(), "not an IP address or file") {
return nil, parseErr
}
// Not an IP or file - check if it's a valid hostname
entry, ok := parseAsHostEntry(h)
if !ok {
return nil, fmt.Errorf("not an IP address, file, or valid domain: %q", h)
}
entries = append(entries, toEntry{static: false, entry: entry})
}
return entries, nil
}
// parseAsHostEntry attempts to parse a TO address as a hostname-based entry.
func parseAsHostEntry(h string) (hostEntry, bool) {
cleanH, zone := splitZone(h)
trans, host := parse.Transport(cleanH)
// Only dns and tls transports are supported for hostname resolution
if trans != transport.DNS && trans != transport.TLS {
return hostEntry{}, false
}
hostname := host
port := transport.Port
if trans == transport.TLS {
port = transport.TLSPort
}
// Check if there's a port
if h2, p, err := net.SplitHostPort(host); err == nil {
hostname = h2
port = p
}
hostname = strings.Trim(hostname, "[]")
// Validate as domain name
if _, ok := dns.IsDomainName(hostname); !ok || hostname == "" {
return hostEntry{}, false
}
// Make sure it's not actually an IP
if net.ParseIP(hostname) != nil {
return hostEntry{}, false
}
return hostEntry{
hostname: hostname,
port: port,
transport: trans,
zone: zone,
}, true
}
// expandAndDedup resolves all toEntries in order, expands hostnames to IPs,
// and deduplicates by first-seen address. Returns the deduplicated address list.
func expandAndDedup(entries []toEntry, resolvers []string) ([]string, error) {
seen := make(map[string]bool)
var result []string
for _, e := range entries {
var addrs []string
if e.static {
addrs = e.addrs
} else {
resolved, err := resolveHostEntry(e.entry, resolvers)
if err != nil {
return nil, err
}
addrs = resolved
}
for _, addr := range addrs {
// Normalize the address for dedup comparison
key := normalizeAddr(addr)
if !seen[key] {
seen[key] = true
result = append(result, addr)
}
}
}
return result, nil
}
// normalizeAddr extracts the canonical IP:port from an address string
// (stripping transport prefix and zone) for deduplication.
func normalizeAddr(addr string) string {
host, _ := splitZone(addr)
_, h := parse.Transport(host)
return h
}
// resolveHostEntry resolves a single hostname entry and returns its addresses.
func resolveHostEntry(entry hostEntry, resolvers []string) ([]string, error) {
ips, err := lookupHost(entry.hostname, resolvers)
if err != nil {
return nil, fmt.Errorf("failed to resolve %q: %v", entry.hostname, err)
}
var addrs []string
for _, ip := range ips {
addrs = append(addrs, formatResolvedAddr(ip, entry.port, entry.transport, entry.zone))
}
return addrs, nil
}
// formatResolvedAddr formats a resolved IP into an address string compatible
// with the proxy creation code in parseStanza.
func formatResolvedAddr(ip, port, trans, zone string) string {
isIPv6 := strings.Contains(ip, ":")
switch trans {
case transport.TLS:
if zone != "" {
if isIPv6 {
return transport.TLS + "://[" + ip + "%" + zone + "]:" + port
}
return transport.TLS + "://" + ip + "%" + zone + ":" + port
}
return transport.TLS + "://" + net.JoinHostPort(ip, port)
default: // transport.DNS
return net.JoinHostPort(ip, port)
}
}
// lookupHost resolves a hostname to IP addresses using the specified resolvers.
// If resolvers is empty, the system resolver (/etc/resolv.conf) is used.
func lookupHost(hostname string, resolvers []string) ([]string, error) {
if len(resolvers) == 0 {
return systemLookup(hostname)
}
return dnsLookup(hostname, resolvers)
}
// systemLookup resolves using the system resolver (/etc/resolv.conf).
func systemLookup(hostname string) ([]string, error) {
ips, err := net.LookupHost(hostname)
if err != nil {
return nil, err
}
if len(ips) == 0 {
return nil, fmt.Errorf("no addresses found for %q", hostname)
}
return ips, nil
}
// dnsLookup resolves a hostname using specific DNS resolver addresses.
// Each resolver can be a bare IP (port 53 is assumed) or an IP:port pair.
// It tries each resolver in order until one succeeds.
func dnsLookup(hostname string, resolvers []string) ([]string, error) {
c := new(dns.Client)
c.ReadTimeout = 2 * time.Second
c.WriteTimeout = 2 * time.Second
var lastErr error
for _, resolver := range resolvers {
resolverAddr := resolver
if _, _, err := net.SplitHostPort(resolver); err != nil {
resolverAddr = net.JoinHostPort(resolver, transport.Port)
}
var ips []string
// Try A records
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(hostname), dns.TypeA)
m.RecursionDesired = true
r, _, err := c.Exchange(m, resolverAddr)
if err != nil {
lastErr = err
continue
}
if r != nil {
for _, ans := range r.Answer {
if a, ok := ans.(*dns.A); ok {
ips = append(ips, a.A.String())
}
}
}
// Also try AAAA
m = new(dns.Msg)
m.SetQuestion(dns.Fqdn(hostname), dns.TypeAAAA)
m.RecursionDesired = true
r, _, err = c.Exchange(m, resolverAddr)
if err != nil {
if len(ips) > 0 {
return ips, nil // we have A records, AAAA failure is OK
}
lastErr = err
continue
}
if r != nil {
for _, ans := range r.Answer {
if aaaa, ok := ans.(*dns.AAAA); ok {
ips = append(ips, aaaa.AAAA.String())
}
}
}
if len(ips) > 0 {
return ips, nil
}
}
if lastErr != nil {
return nil, fmt.Errorf("no addresses found for %q: %v", hostname, lastErr)
}
return nil, fmt.Errorf("no addresses found for %q", hostname)
}