Skip to content
Closed
3 changes: 2 additions & 1 deletion docs/flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ tags:
| `--kubeconfig=""` | Retrieve target cluster configuration from a Kubernetes configuration file (default: auto-detect) |
| `--request-timeout=30s` | Request timeout when calling Kubernetes APIs. 0s means no timeout |
| `--[no-]resolve-service-load-balancer-hostname` | Resolve the hostname of LoadBalancer-type Service object to IP addresses in order to create DNS A/AAAA records instead of CNAMEs |
| `--[no-]resolve-load-balancer-hostname` | Resolve the hostname of LoadBalancer addresses in status to IP addresses in order to create DNS A/AAAA records instead of CNAMEs |
| `--[no-]listen-endpoint-events` | Trigger a reconcile on changes to EndpointSlices, for Service source (default: false) |
| `--gloo-namespace=gloo-system` | The Gloo Proxy namespace; specify multiple times for multiple namespaces. (default: gloo-system) |
| `--skipper-routegroup-groupversion="zalando.org/v1"` | The resource version for skipper routegroup |
Expand Down Expand Up @@ -191,5 +192,5 @@ tags:
| `--fqdn-template=""` | A templated string that's used to generate DNS names from sources that don't define a hostname themselves, or to add a hostname suffix when paired with the fake source (optional). Accepts comma separated list for multiple global FQDN. |
| `--target-template=""` | A templated string used to generate DNS targets (IP or hostname) from sources that support it (optional). Accepts comma separated list for multiple targets. |
| `--fqdn-target-template=""` | A template that returns host:target pairs (e.g., '{{range .Object.endpoints}}{{.targetRef.name}}.svc.example.com:{{index .addresses 0}},{{end}}'). Accepts comma separated list for multiple pairs. |
| `--provider=provider` | The DNS provider where the DNS records will be created (required, options: akamai, alibabacloud, aws, aws-sd, azure, azure-dns, azure-private-dns, civo, cloudflare, coredns, digitalocean, dnsimple, exoscale, gandi, godaddy, google, inmemory, linode, ns1, oci, ovh, pdns, pihole, plural, rfc2136, scaleway, skydns, transip, webhook) |
| `--provider=provider` | The DNS provider where the DNS records will be created (required, options: akamai, alibabacloud, aws, aws-sd, azure, azure-dns, azure-private-dns, civo, cloudflare, coredns, dnsimple, exoscale, gandi, godaddy, google, inmemory, linode, ns1, oci, ovh, pdns, pihole, plural, rfc2136, scaleway, skydns, transip, webhook) |
| `--source=source` | The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: service, ingress, node, pod, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, istio-gateway, istio-virtualservice, contour-httpproxy, gloo-proxy, fake, connector, crd, empty, skipper-routegroup, openshift-route, ambassador-host, kong-tcpingress, f5-virtualserver, f5-transportserver, traefik-proxy, unstructured) |
8 changes: 8 additions & 0 deletions docs/sources/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ The targets of the DNS entries created from a \*Route are sourced from the follo
2. Otherwise, iterates over that parent Gateway's `status.addresses`,
adding each address's `value`.

If the `--resolve-load-balancer-hostname` flag is specified, any address with type
`Hostname` is queried through DNS and any resulting IP addresses are added instead of the hostname,
producing `A`/`AAAA` records rather than a `CNAME`.

If DNS resolution fails for a hostname address (e.g. the hostname is unresolvable or the DNS
server is unavailable), the error is logged the `Endpoint` of that address is skipped entirely. If all of a Gateway's addresses fail to resolve, the route will produce no
endpoints for that reconciliation cycle and will be retried on the next sync.

The targets from each parent Gateway matching the \*Route are then combined and de-duplicated.

## Dualstack Routes
Expand Down
2 changes: 2 additions & 0 deletions pkg/apis/externaldns/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ type Config struct {
CRDSourceKind string
ServiceTypeFilter []string
ResolveServiceLoadBalancerHostname bool
ResolveLoadBalancerHostname bool
RFC2136Host []string
RFC2136Port int
RFC2136Zone []string
Expand Down Expand Up @@ -505,6 +506,7 @@ func bindFlags(b flags.FlagBinder, cfg *Config) {
b.StringVar("kubeconfig", "Retrieve target cluster configuration from a Kubernetes configuration file (default: auto-detect)", defaultConfig.KubeConfig, &cfg.KubeConfig)
b.DurationVar("request-timeout", "Request timeout when calling Kubernetes APIs. 0s means no timeout", defaultConfig.RequestTimeout, &cfg.RequestTimeout)
b.BoolVar("resolve-service-load-balancer-hostname", "Resolve the hostname of LoadBalancer-type Service object to IP addresses in order to create DNS A/AAAA records instead of CNAMEs", false, &cfg.ResolveServiceLoadBalancerHostname)
b.BoolVar("resolve-load-balancer-hostname", "Resolve the hostname of LoadBalancer addresses in status to IP addresses in order to create DNS A/AAAA records instead of CNAMEs", false, &cfg.ResolveLoadBalancerHostname)
b.BoolVar("listen-endpoint-events", "Trigger a reconcile on changes to EndpointSlices, for Service source (default: false)", false, &cfg.ListenEndpointEvents)

// Flags related to Gloo
Expand Down
22 changes: 15 additions & 7 deletions source/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,10 @@ type gatewayRouteSource struct {

nsInformer coreinformers.NamespaceInformer

fqdnTemplate *template.Template
combineFQDNAnnotation bool
ignoreHostnameAnnotation bool
fqdnTemplate *template.Template
combineFQDNAnnotation bool
ignoreHostnameAnnotation bool
resolveLoadBalancerHostname bool
}

func newGatewayRouteSource(
Expand Down Expand Up @@ -224,9 +225,10 @@ func newGatewayRouteSource(

nsInformer: nsInformer,

fqdnTemplate: tmpl,
combineFQDNAnnotation: config.CombineFQDNAndAnnotation,
ignoreHostnameAnnotation: config.IgnoreHostnameAnnotation,
fqdnTemplate: tmpl,
combineFQDNAnnotation: config.CombineFQDNAndAnnotation,
ignoreHostnameAnnotation: config.IgnoreHostnameAnnotation,
resolveLoadBalancerHostname: config.ResolveLoadBalancerHostname,
}
return src, nil
}
Expand Down Expand Up @@ -423,7 +425,13 @@ func (c *gatewayRouteResolver) resolve(rt gatewayRoute) (map[string]endpoint.Tar
hostTargets[host] = append(hostTargets[host], override...)
if len(override) == 0 {
for _, addr := range gw.gateway.Status.Addresses {
hostTargets[host] = append(hostTargets[host], addr.Value)
if c.src.resolveLoadBalancerHostname && addr.Type != nil && *addr.Type == v1.HostnameAddressType {
for _, ip := range resolveHostnameToIPs(addr.Value) {
hostTargets[host] = append(hostTargets[host], ip)
}
} else {
hostTargets[host] = append(hostTargets[host], addr.Value)
}
}
}
match = true
Expand Down
121 changes: 121 additions & 0 deletions source/gateway_httproute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ package source

import (
"context"
"net"
"net/netip"
"testing"
"time"

Expand All @@ -32,6 +34,7 @@ import (
gatewayfake "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned/fake"

"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/internal/testutils"
logtest "sigs.k8s.io/external-dns/internal/testutils/log"
"sigs.k8s.io/external-dns/source/annotations"
)
Expand All @@ -53,6 +56,15 @@ func gatewayStatus(ips ...string) v1.GatewayStatus {
return v1.GatewayStatus{Addresses: addrs}
}

func gatewayStatusWithHostname(hostname string) v1.GatewayStatus {
typ := v1.HostnameAddressType
return v1.GatewayStatus{
Addresses: []v1.GatewayStatusAddress{
{Type: &typ, Value: hostname},
},
}
}

func httpRouteStatus(refs ...v1.ParentReference) v1.HTTPRouteStatus {
return v1.HTTPRouteStatus{RouteStatus: gwRouteStatus(refs...)}
}
Expand Down Expand Up @@ -1647,6 +1659,115 @@ func TestGatewayHTTPRouteSourceEndpoints(t *testing.T) {
"Invalid value for \"external-dns.alpha.kubernetes.io/gateway-hostname-source\" on default/invalid-annotation: \"invalid-value\". Falling back to default behavior.",
},
},
{
title: "GatewayHostnameAddressNoresolution",
config: Config{},
namespaces: namespaces("default"),
gateways: []*v1beta1.Gateway{{
ObjectMeta: objectMeta("default", "test"),
Spec: v1.GatewaySpec{
Listeners: []v1.Listener{{
Protocol: v1.HTTPProtocolType,
AllowedRoutes: allowAllNamespaces,
}},
},
Status: gatewayStatusWithHostname("lb.example.com"),
}},
routes: []*v1beta1.HTTPRoute{{
ObjectMeta: objectMeta("default", "test"),
Spec: v1.HTTPRouteSpec{
Hostnames: hostnames("test.example.internal"),
CommonRouteSpec: v1.CommonRouteSpec{
ParentRefs: []v1.ParentReference{gwParentRef("default", "test")},
},
},
Status: httpRouteStatus(gwParentRef("default", "test")),
}},
// Without the flag, hostname addresses are passed through as CNAME targets.
endpoints: []*endpoint.Endpoint{
newTestEndpointWithTTL("test.example.internal", endpoint.RecordTypeCNAME, 0, "lb.example.com"),
},
},
{
title: "GatewayHostnameAddressResolution",
config: Config{
ResolveLoadBalancerHostname: true,
},
namespaces: namespaces("default"),
gateways: []*v1beta1.Gateway{{
ObjectMeta: objectMeta("default", "test"),
Spec: v1.GatewaySpec{
Listeners: []v1.Listener{{
Protocol: v1.HTTPProtocolType,
AllowedRoutes: allowAllNamespaces,
}},
},
Status: gatewayStatusWithHostname("example.com"),
}},
routes: []*v1beta1.HTTPRoute{{
ObjectMeta: objectMeta("default", "test"),
Spec: v1.HTTPRouteSpec{
Hostnames: hostnames("test.example.internal"),
CommonRouteSpec: v1.CommonRouteSpec{
ParentRefs: []v1.ParentReference{gwParentRef("default", "test")},
},
},
Status: httpRouteStatus(gwParentRef("default", "test")),
}},
// With the flag, hostname addresses are resolved to IPs.
endpoints: func() []*endpoint.Endpoint {
ip4, _ := net.DefaultResolver.LookupNetIP(context.Background(), "ip4", "example.com")
ip6, _ := net.DefaultResolver.LookupNetIP(context.Background(), "ip6", "example.com")
all := append([]netip.Addr(nil), ip4...)
all = append(all, ip6...)
return []*endpoint.Endpoint{
{
DNSName: "test.example.internal",
RecordType: endpoint.RecordTypeA,
Targets: testutils.NewTargetsFromAddr(ip4),
RecordTTL: 0,
},
{
DNSName: "test.example.internal",
RecordType: endpoint.RecordTypeAAAA,
Targets: testutils.NewTargetsFromAddr(ip6),
RecordTTL: 0,
},
}
}(),
},
{
title: "GatewayHostnameAddressResolutionFailure",
config: Config{
ResolveLoadBalancerHostname: true,
},
namespaces: namespaces("default"),
gateways: []*v1beta1.Gateway{{
ObjectMeta: objectMeta("default", "test"),
Spec: v1.GatewaySpec{
Listeners: []v1.Listener{{
Protocol: v1.HTTPProtocolType,
AllowedRoutes: allowAllNamespaces,
}},
},
Status: gatewayStatusWithHostname("this.does.not.resolve.invalid"),
}},
routes: []*v1beta1.HTTPRoute{{
ObjectMeta: objectMeta("default", "test"),
Spec: v1.HTTPRouteSpec{
Hostnames: hostnames("test.example.internal"),
CommonRouteSpec: v1.CommonRouteSpec{
ParentRefs: []v1.ParentReference{gwParentRef("default", "test")},
},
},
Status: httpRouteStatus(gwParentRef("default", "test")),
}},
// When resolution fails the address is skipped, so no endpoints are produced.
endpoints: []*endpoint.Endpoint{},
logExpectations: []string{
`Unable to resolve "this.does.not.resolve.invalid"`,
},
},
}
for _, tt := range tests {
t.Run(tt.title, func(t *testing.T) {
Expand Down
10 changes: 2 additions & 8 deletions source/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import (
"context"
"fmt"
"maps"
"net"
"slices"
"sort"
"strings"
Expand Down Expand Up @@ -661,13 +660,8 @@ func extractLoadBalancerTargets(svc *v1.Service, resolveLoadBalancerHostname boo
}
if lb.Hostname != "" {
if resolveLoadBalancerHostname {
ips, err := net.LookupIP(lb.Hostname)
if err != nil {
log.Errorf("Unable to resolve %q: %v", lb.Hostname, err)
continue
}
for _, ip := range ips {
targets = append(targets, ip.String())
for _, ip := range resolveHostnameToIPs(lb.Hostname) {
targets = append(targets, ip)
}
} else {
targets = append(targets, lb.Hostname)
Expand Down
2 changes: 1 addition & 1 deletion source/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ func NewSourceConfig(cfg *externaldns.Config) *Config {
ForceDefaultTargets: cfg.ForceDefaultTargets,
OCPRouterName: cfg.OCPRouterName,
UpdateEvents: cfg.UpdateEvents,
ResolveLoadBalancerHostname: cfg.ResolveServiceLoadBalancerHostname,
ResolveLoadBalancerHostname: cfg.ResolveServiceLoadBalancerHostname || cfg.ResolveLoadBalancerHostname,
TraefikEnableLegacy: cfg.TraefikEnableLegacy,
TraefikDisableNew: cfg.TraefikDisableNew,
ExcludeUnschedulable: cfg.ExcludeUnschedulable,
Expand Down
16 changes: 16 additions & 0 deletions source/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ package source

import (
"fmt"
"net"
"slices"
"strings"

Expand All @@ -23,6 +24,21 @@ import (
"sigs.k8s.io/external-dns/endpoint"
)

// resolveHostnameToIPs resolves a hostname to its IP addresses via DNS.
// On failure it logs the error and returns nil, so the caller can skip the address.
func resolveHostnameToIPs(hostname string) []string {
ips, err := net.LookupIP(hostname)
if err != nil {
log.Errorf("Unable to resolve %q: %v", hostname, err)
return nil
}
result := make([]string, 0, len(ips))
for _, ip := range ips {
result = append(result, ip.String())
}
return result
}

// ParseIngress parses an ingress string in the format "namespace/name" or "name".
// It returns the namespace and name extracted from the string, or an error if the format is invalid.
// If the namespace is not provided, it defaults to an empty string.
Expand Down