From bd6aa525516a4a4e254aa9ffea4a46a28bd03cf6 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 2 Oct 2026 16:40:28 -0500 Subject: [PATCH 1/2] feat: list each hostname's DNS records on status The portal and datumctl had no single place to learn which DNS records a custom hostname still needs: the certificate CNAME lived in a condition message, the ownership TXT on the Domain, and the routing record nowhere. Each HTTPProxy hostname now lists the records it depends on, who publishes them, and whether each takes effect, behind the certificate service flag. Key changes: - add hostnameStatuses[].dnsRecords with name, type, content, purpose, managedBy and state - routing: a platform record counts only while the Datum DNS zone is the one the registry delegates to; a user record counts once public DNS resolves it to the canonical hostname or its addresses - certificate: the _acme-challenge CNAME for the hostname, present once the certificate service reports the delegation resolves; records for any other name in the certificate's status are ignored - ownership: the pending Domain's TXT record until a covering Domain is verified - recheck every five minutes while a user routing record is missing, with lookups cached for a minute --- api/v1alpha/httpproxy_types.go | 95 ++++ api/v1alpha/zz_generated.deepcopy.go | 20 + .../networking.datumapis.com_httpproxies.yaml | 60 +++ docs/api/httpproxies.md | 82 ++++ internal/controller/httpproxy_controller.go | 21 +- internal/controller/httpproxy_dns_records.go | 351 +++++++++++++++ .../controller/httpproxy_dns_records_test.go | 416 ++++++++++++++++++ 7 files changed, 1040 insertions(+), 5 deletions(-) create mode 100644 internal/controller/httpproxy_dns_records.go create mode 100644 internal/controller/httpproxy_dns_records_test.go diff --git a/api/v1alpha/httpproxy_types.go b/api/v1alpha/httpproxy_types.go index 88ab9434..1ee46a91 100644 --- a/api/v1alpha/httpproxy_types.go +++ b/api/v1alpha/httpproxy_types.go @@ -444,8 +444,103 @@ type HostnameStatus struct { // +listMapKey=type // +optional Conditions []metav1.Condition `json:"conditions,omitempty"` + + // DNSRecords lists every DNS record this hostname depends on, who publishes + // it, and whether it is in place. A record is Present only once it takes + // effect on the Internet. Records the user publishes stay listed while the + // hostname needs them, so this list alone says what is left to do. + // + // +listType=atomic + // +kubebuilder:validation:MaxItems=16 + // +optional + DNSRecords []HostnameDNSRecord `json:"dnsRecords,omitempty"` +} + +// HostnameDNSRecord is one DNS record a hostname depends on. +type HostnameDNSRecord struct { + // Name is the fully qualified name of the record, without a trailing dot. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MaxLength=253 + Name string `json:"name"` + + // Type is the DNS record type. ALIAS stands for a CNAME at a zone apex, + // which DNS providers offer as ALIAS, ANAME or CNAME flattening. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=CNAME;ALIAS;TXT + Type string `json:"type"` + + // Content is the value the record must hold. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MaxLength=512 + Content string `json:"content"` + + // Purpose says what the record is for. + // + // +kubebuilder:validation:Required + Purpose HostnameDNSRecordPurpose `json:"purpose"` + + // ManagedBy says who publishes the record: the user at their DNS provider, + // or the platform in a Datum DNS zone that serves the domain. + // + // +kubebuilder:validation:Required + ManagedBy HostnameDNSRecordManager `json:"managedBy"` + + // State says whether the record is in place. + // + // +kubebuilder:validation:Required + State HostnameDNSRecordState `json:"state"` } +// HostnameDNSRecordPurpose says what a DNS record is for. +// +// +kubebuilder:validation:Enum=Routing;Certificate;Ownership +type HostnameDNSRecordPurpose string + +const ( + // HostnameDNSRecordPurposeRouting points the hostname at the platform. + HostnameDNSRecordPurposeRouting HostnameDNSRecordPurpose = "Routing" + + // HostnameDNSRecordPurposeCertificate delegates the ACME DNS challenge for + // the hostname to the platform, so certificates issue before traffic moves. + HostnameDNSRecordPurposeCertificate HostnameDNSRecordPurpose = "Certificate" + + // HostnameDNSRecordPurposeOwnership proves ownership of the hostname's + // domain. It is listed until the domain is verified. + HostnameDNSRecordPurposeOwnership HostnameDNSRecordPurpose = "Ownership" +) + +// HostnameDNSRecordManager says who publishes a DNS record. +// +// +kubebuilder:validation:Enum=User;Platform +type HostnameDNSRecordManager string + +const ( + // HostnameDNSRecordManagedByUser means the user publishes the record at + // their DNS provider. + HostnameDNSRecordManagedByUser HostnameDNSRecordManager = "User" + + // HostnameDNSRecordManagedByPlatform means the platform publishes the record + // in a Datum DNS zone that serves the domain. + HostnameDNSRecordManagedByPlatform HostnameDNSRecordManager = "Platform" +) + +// HostnameDNSRecordState says whether a DNS record is in place. +// +// +kubebuilder:validation:Enum=Present;Missing +type HostnameDNSRecordState string + +const ( + // HostnameDNSRecordPresent means the record takes effect on the Internet. + HostnameDNSRecordPresent HostnameDNSRecordState = "Present" + + // HostnameDNSRecordMissing means the record is absent, wrong, or held in a + // zone that does not serve the domain. + HostnameDNSRecordMissing HostnameDNSRecordState = "Missing" +) + // HTTPProxyStatus defines the observed state of HTTPProxy. type HTTPProxyStatus struct { // Addresses lists the network addresses that have been bound to the diff --git a/api/v1alpha/zz_generated.deepcopy.go b/api/v1alpha/zz_generated.deepcopy.go index a4f1d80e..89b48e83 100644 --- a/api/v1alpha/zz_generated.deepcopy.go +++ b/api/v1alpha/zz_generated.deepcopy.go @@ -713,6 +713,21 @@ func (in *HTTPVerificationToken) DeepCopy() *HTTPVerificationToken { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HostnameDNSRecord) DeepCopyInto(out *HostnameDNSRecord) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostnameDNSRecord. +func (in *HostnameDNSRecord) DeepCopy() *HostnameDNSRecord { + if in == nil { + return nil + } + out := new(HostnameDNSRecord) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HostnameStatus) DeepCopyInto(out *HostnameStatus) { *out = *in @@ -723,6 +738,11 @@ func (in *HostnameStatus) DeepCopyInto(out *HostnameStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.DNSRecords != nil { + in, out := &in.DNSRecords, &out.DNSRecords + *out = make([]HostnameDNSRecord, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostnameStatus. diff --git a/config/crd/bases/networking.datumapis.com_httpproxies.yaml b/config/crd/bases/networking.datumapis.com_httpproxies.yaml index 092defb6..af09208f 100644 --- a/config/crd/bases/networking.datumapis.com_httpproxies.yaml +++ b/config/crd/bases/networking.datumapis.com_httpproxies.yaml @@ -3705,6 +3705,66 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + dnsRecords: + description: |- + DNSRecords lists every DNS record this hostname depends on, who publishes + it, and whether it is in place. A record is Present only once it takes + effect on the Internet. Records the user publishes stay listed while the + hostname needs them, so this list alone says what is left to do. + items: + description: HostnameDNSRecord is one DNS record a hostname + depends on. + properties: + content: + description: Content is the value the record must hold. + maxLength: 512 + type: string + managedBy: + description: |- + ManagedBy says who publishes the record: the user at their DNS provider, + or the platform in a Datum DNS zone that serves the domain. + enum: + - User + - Platform + type: string + name: + description: Name is the fully qualified name of the record, + without a trailing dot. + maxLength: 253 + type: string + purpose: + description: Purpose says what the record is for. + enum: + - Routing + - Certificate + - Ownership + type: string + state: + description: State says whether the record is in place. + enum: + - Present + - Missing + type: string + type: + description: |- + Type is the DNS record type. ALIAS stands for a CNAME at a zone apex, + which DNS providers offer as ALIAS, ANAME or CNAME flattening. + enum: + - CNAME + - ALIAS + - TXT + type: string + required: + - content + - managedBy + - name + - purpose + - state + - type + type: object + maxItems: 16 + type: array + x-kubernetes-list-type: atomic hostname: description: |- Hostname is the fully qualified domain name being tracked. diff --git a/docs/api/httpproxies.md b/docs/api/httpproxies.md index 34c14506..5a1212c9 100644 --- a/docs/api/httpproxies.md +++ b/docs/api/httpproxies.md @@ -4725,6 +4725,16 @@ Must be a valid RFC 1123 hostname without a trailing dot.
Standard condition types include Verified and DNSRecordProgrammed.
false + + dnsRecords + []object + + DNSRecords lists every DNS record this hostname depends on, who publishes +it, and whether it is in place. A record is Present only once it takes +effect on the Internet. Records the user publishes stay listed while the +hostname needs them, so this list alone says what is left to do.
+ + false @@ -4804,3 +4814,75 @@ with respect to the current state of the instance.
false + + +### HTTPProxy.status.hostnameStatuses[index].dnsRecords[index] +[↩ Parent](#httpproxystatushostnamestatusesindex) + + + +HostnameDNSRecord is one DNS record a hostname depends on. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
contentstring + Content is the value the record must hold.
+
true
managedByenum + ManagedBy says who publishes the record: the user at their DNS provider, +or the platform in a Datum DNS zone that serves the domain.
+
+ Enum: User, Platform
+
true
namestring + Name is the fully qualified name of the record, without a trailing dot.
+
true
purposeenum + Purpose says what the record is for.
+
+ Enum: Routing, Certificate, Ownership
+
true
stateenum + State says whether the record is in place.
+
+ Enum: Present, Missing
+
true
typeenum + Type is the DNS record type. ALIAS stands for a CNAME at a zone apex, +which DNS providers offer as ALIAS, ANAME or CNAME flattening.
+
+ Enum: CNAME, ALIAS, TXT
+
true
diff --git a/internal/controller/httpproxy_controller.go b/internal/controller/httpproxy_controller.go index f95b0d4e..a8169b87 100644 --- a/internal/controller/httpproxy_controller.go +++ b/internal/controller/httpproxy_controller.go @@ -58,6 +58,8 @@ type HTTPProxyReconciler struct { Config config.NetworkServicesOperator DownstreamCluster cluster.Cluster + + routing *routingObserver } type desiredHTTPProxyResources struct { @@ -407,7 +409,9 @@ func (r *HTTPProxyReconciler) Reconcile(ctx context.Context, req mcreconcile.Req applyPartialProgramming(ctx, desiredResources.partialProgramming, programmedCondition) - r.reconcileHTTPProxyHostnameStatus(ctx, cl.GetClient(), gateway, httpProxyCopy, string(req.ClusterName)) + if recheck := r.reconcileHTTPProxyHostnameStatus(ctx, cl.GetClient(), gateway, httpProxyCopy, string(req.ClusterName)); recheck { + return ctrl.Result{RequeueAfter: routingRecheckInterval}, nil + } return ctrl.Result{}, nil } @@ -571,21 +575,21 @@ func (r *HTTPProxyReconciler) reconcileHTTPProxyHostnameStatus( gateway *gatewayv1.Gateway, httpProxyCopy *networkingv1alpha.HTTPProxy, clusterName string, -) { +) (recheckRouting bool) { logger := log.FromContext(ctx) gatewayAcceptedCondition := apimeta.FindStatusCondition(gateway.Status.Conditions, string(gatewayv1.GatewayConditionAccepted)) if gatewayAcceptedCondition == nil { // Should never happen due to defaulting, but just in case logger.Info("accepted condition not found on gateway") - return + return false } else if gatewayAcceptedCondition.ObservedGeneration != gateway.Generation { logger.Info( "observed generation on accepted condition does not match generation on gateway, delaying processing", "gateway_generation", gateway.Generation, "condition_generation", gatewayAcceptedCondition.ObservedGeneration, ) - return + return false } logger.Info("updating hostname status") @@ -682,16 +686,22 @@ func (r *HTTPProxyReconciler) reconcileHTTPProxyHostnameStatus( availabilityStatuses := buildAvailabilityStatuses(acceptedHostnames, inUseHostnames, httpProxyCopy.Generation) dnsStatuses := r.buildDNSStatuses(ctx, cl, gateway, httpProxyCopy.Generation) certificateStatuses := r.buildCertificateStatuses(ctx, cl, clusterName, gateway, httpProxyCopy) + dnsRecordStatuses, recheckRouting := r.buildDNSRecordStatuses(ctx, cl, gateway, httpProxyCopy) previousHostnameStatuses := httpProxyCopy.Status.HostnameStatuses - httpProxyCopy.Status.HostnameStatuses = mergeHostnameStatuses(availabilityStatuses, dnsStatuses, certificateStatuses) + httpProxyCopy.Status.HostnameStatuses = mergeHostnameStatuses(availabilityStatuses, dnsStatuses, certificateStatuses, dnsRecordStatuses) preserveHostnameConditionTransitions(httpProxyCopy.Status.HostnameStatuses, previousHostnameStatuses) r.setCertificatesReadyCondition(httpProxyCopy, certificateStatuses, gateway) + + return recheckRouting } // SetupWithManager sets up the controller with the Manager. func (r *HTTPProxyReconciler) SetupWithManager(mgr mcmanager.Manager) error { r.mgr = mgr + if r.routing == nil { + r.routing = newRoutingObserver() + } builder := mcbuilder.ControllerManagedBy(mgr). For(&networkingv1alpha.HTTPProxy{}). @@ -1744,6 +1754,7 @@ func mergeHostnameStatuses(statusSets ...[]networkingv1alpha.HostnameStatus) []n for _, c := range hs.Conditions { apimeta.SetStatusCondition(&existing.Conditions, c) } + existing.DNSRecords = append(existing.DNSRecords, hs.DNSRecords...) } else { copy := hs byHostname[hs.Hostname] = © diff --git a/internal/controller/httpproxy_dns_records.go b/internal/controller/httpproxy_dns_records.go new file mode 100644 index 00000000..c44ee380 --- /dev/null +++ b/internal/controller/httpproxy_dns_records.go @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "context" + "net" + "strings" + "sync" + "time" + + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" + certificatesv1alpha1 "go.datum.net/network-services-operator/internal/certificates/v1alpha1" + dnsutil "go.datum.net/network-services-operator/internal/util/dns" + + dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1" +) + +const ( + acmeChallengeLabel = "_acme-challenge" + + routingProbeLabel = "datum-routing-probe" + + routingObservationTTL = time.Minute + + routingRecheckInterval = 5 * time.Minute + + routingLookupTimeout = 2 * time.Second + + routingObservationCacheLimit = 10000 +) + +// routingObserver reports whether a hostname the user points at the platform +// resolves there, from public DNS, and remembers the answer briefly so a burst +// of reconciles costs one set of lookups. +type routingObserver struct { + lookupCNAME func(ctx context.Context, host string) (string, error) + lookupIP func(ctx context.Context, host string) ([]net.IPAddr, error) + now func() time.Time + + mu sync.Mutex + cache map[string]routingObservation +} + +type routingObservation struct { + present bool + expires time.Time +} + +func newRoutingObserver() *routingObserver { + return &routingObserver{ + lookupCNAME: net.DefaultResolver.LookupCNAME, + lookupIP: net.DefaultResolver.LookupIPAddr, + now: time.Now, + cache: map[string]routingObservation{}, + } +} + +func (o *routingObserver) routesTo(ctx context.Context, hostname, canonical string) bool { + key := hostname + "|" + canonical + now := o.now() + + o.mu.Lock() + if seen, ok := o.cache[key]; ok && now.Before(seen.expires) { + o.mu.Unlock() + return seen.present + } + o.mu.Unlock() + + present := o.lookup(ctx, hostname, canonical) + + o.mu.Lock() + defer o.mu.Unlock() + if len(o.cache) >= routingObservationCacheLimit { + for k, v := range o.cache { + if !now.Before(v.expires) { + delete(o.cache, k) + } + } + } + o.cache[key] = routingObservation{present: present, expires: now.Add(routingObservationTTL)} + return present +} + +func (o *routingObserver) lookup(ctx context.Context, hostname, canonical string) bool { + ctx, cancel := context.WithTimeout(ctx, routingLookupTimeout) + defer cancel() + + probe := hostname + if base, ok := strings.CutPrefix(hostname, "*."); ok { + probe = routingProbeLabel + "." + base + } + + if target, err := o.lookupCNAME(ctx, probe+"."); err == nil && strings.EqualFold(strings.TrimSuffix(target, "."), canonical) { + return true + } + + probeAddrs, err := o.lookupIP(ctx, probe+".") + if err != nil || len(probeAddrs) == 0 { + return false + } + canonicalAddrs, err := o.lookupIP(ctx, canonical+".") + if err != nil { + return false + } + for _, a := range probeAddrs { + for _, b := range canonicalAddrs { + if a.IP.Equal(b.IP) { + return true + } + } + } + return false +} + +// buildDNSRecordStatuses lists, for each custom hostname, the DNS records it +// depends on and whether each takes effect. It reports whether any record the +// user publishes for routing is still missing, since nothing but time tells +// the controller when one appears. +func (r *HTTPProxyReconciler) buildDNSRecordStatuses( + ctx context.Context, + cl client.Client, + gateway *gatewayv1.Gateway, + httpProxy *networkingv1alpha.HTTPProxy, +) (statuses []networkingv1alpha.HostnameStatus, recheckRouting bool) { + if !r.Config.Gateway.CertificateService.Enabled { + return nil, false + } + + logger := log.FromContext(ctx) + canonical := httpProxy.Status.CanonicalHostname + + var domains networkingv1alpha.DomainList + if err := cl.List(ctx, &domains, client.InNamespace(httpProxy.Namespace)); err != nil { + logger.Error(err, "failed to list domains for hostname DNS records") + return nil, false + } + + httpsListeners := map[string]gatewayv1.SectionName{} + for _, l := range gateway.Spec.Listeners { + if l.Protocol == gatewayv1.HTTPSProtocolType && l.Hostname != nil { + httpsListeners[string(*l.Hostname)] = l.Name + } + } + + for _, h := range httpProxy.Spec.Hostnames { + hostname := string(h) + if r.underTargetDomain(hostname) { + continue + } + + var records []networkingv1alpha.HostnameDNSRecord + + if canonical != "" { + routing := r.routingRecord(ctx, cl, gateway, hostname, canonical, domains.Items) + if routing.ManagedBy == networkingv1alpha.HostnameDNSRecordManagedByUser && routing.State == networkingv1alpha.HostnameDNSRecordMissing { + recheckRouting = true + } + records = append(records, routing) + } + + if listener, ok := httpsListeners[hostname]; ok { + records = append(records, r.certificateRecords(ctx, cl, gateway, listener, hostname)...) + } + + if ownership, ok := ownershipRecord(hostname, domains.Items); ok { + records = append(records, ownership) + } + + if len(records) > 0 { + statuses = append(statuses, networkingv1alpha.HostnameStatus{Hostname: hostname, DNSRecords: records}) + } + } + + return statuses, recheckRouting +} + +func (r *HTTPProxyReconciler) underTargetDomain(hostname string) bool { + target := r.Config.Gateway.TargetDomain + return target != "" && (hostname == target || strings.HasSuffix(hostname, "."+target)) +} + +// routingRecord describes the record that points the hostname at the +// platform. A record the platform writes into a Datum DNS zone counts only +// while that zone is the one the domain's registry delegates to; any other +// record is judged by what public DNS answers. +func (r *HTTPProxyReconciler) routingRecord( + ctx context.Context, + cl client.Client, + gateway *gatewayv1.Gateway, + hostname, canonical string, + domains []networkingv1alpha.Domain, +) networkingv1alpha.HostnameDNSRecord { + record := networkingv1alpha.HostnameDNSRecord{ + Name: hostname, + Type: string(dnsv1alpha1.RRTypeCNAME), + Content: canonical, + Purpose: networkingv1alpha.HostnameDNSRecordPurposeRouting, + ManagedBy: networkingv1alpha.HostnameDNSRecordManagedByUser, + State: networkingv1alpha.HostnameDNSRecordMissing, + } + + if r.Config.Gateway.EnableDNSIntegration { + if present, rrType, managed := r.platformRoutingRecord(ctx, cl, gateway, hostname, domains); managed { + record.ManagedBy = networkingv1alpha.HostnameDNSRecordManagedByPlatform + record.Type = rrType + if present { + record.State = networkingv1alpha.HostnameDNSRecordPresent + } + return record + } + } + + for _, d := range domains { + if d.Spec.DomainName == hostname && d.Status.Apex { + record.Type = string(dnsv1alpha1.RRTypeALIAS) + break + } + } + + if r.routing != nil && r.routing.routesTo(ctx, hostname, canonical) { + record.State = networkingv1alpha.HostnameDNSRecordPresent + } + return record +} + +func (r *HTTPProxyReconciler) platformRoutingRecord( + ctx context.Context, + cl client.Client, + gateway *gatewayv1.Gateway, + hostname string, + domains []networkingv1alpha.Domain, +) (present bool, rrType string, managed bool) { + var recordSet dnsv1alpha1.DNSRecordSet + if err := cl.Get(ctx, client.ObjectKey{Namespace: gateway.Namespace, Name: dnsRecordSetName(gateway.Name, hostname)}, &recordSet); err != nil { + return false, "", false + } + if recordSet.Labels[labelManagedBy] != labelManagedByValue || recordSet.Annotations[annotationDNSHostname] != hostname { + return false, "", false + } + + rrType = string(recordSet.Spec.RecordType) + if !apimeta.IsStatusConditionTrue(recordSet.Status.Conditions, conditionTypeProgrammed) { + return false, rrType, true + } + + var zone dnsv1alpha1.DNSZone + if err := cl.Get(ctx, client.ObjectKey{Namespace: gateway.Namespace, Name: recordSet.Spec.DNSZoneRef.Name}, &zone); err != nil { + return false, rrType, true + } + domain, found := findDomainByName(domains, zone.Spec.DomainName) + if !found { + return false, rrType, true + } + return dnsutil.HasDNSAuthority(&domain, &zone), rrType, true +} + +// certificateRecords lists the record that delegates the hostname's ACME DNS +// challenge, when its certificate issues over DNS. Only the challenge name for +// this hostname is taken from the certificate's status. +func (r *HTTPProxyReconciler) certificateRecords( + ctx context.Context, + cl client.Client, + gateway *gatewayv1.Gateway, + listener gatewayv1.SectionName, + hostname string, +) []networkingv1alpha.HostnameDNSRecord { + var cert certificatesv1alpha1.TLSCertificate + if err := cl.Get(ctx, client.ObjectKey{Namespace: gateway.Namespace, Name: tlsCertificateName(gateway.Name, listener)}, &cert); err != nil { + return nil + } + + name := acmeChallengeLabel + "." + strings.TrimPrefix(hostname, "*.") + content := "" + for _, required := range cert.Status.RequiredDNSRecords { + if required.Purpose == certificatesv1alpha1.DNSRecordPurposeCertificate && + strings.EqualFold(strings.TrimSuffix(required.Name, "."), name) && + strings.EqualFold(required.Type, string(dnsv1alpha1.RRTypeCNAME)) { + content = required.Content + break + } + } + if content == "" && cert.Status.Issuance == certificatesv1alpha1.ChallengeTypeDNS01 { + content = cert.Status.DelegationTarget + } + if content == "" { + return nil + } + + state := networkingv1alpha.HostnameDNSRecordMissing + if delegation := apimeta.FindStatusCondition(cert.Status.Conditions, certificatesv1alpha1.ConditionDNSDelegationReady); delegation != nil && + delegation.Status == metav1.ConditionTrue { + state = networkingv1alpha.HostnameDNSRecordPresent + } + + return []networkingv1alpha.HostnameDNSRecord{{ + Name: name, + Type: string(dnsv1alpha1.RRTypeCNAME), + Content: strings.TrimSuffix(content, "."), + Purpose: networkingv1alpha.HostnameDNSRecordPurposeCertificate, + ManagedBy: networkingv1alpha.HostnameDNSRecordManagedByUser, + State: state, + }} +} + +// ownershipRecord returns the TXT record that would verify the most specific +// Domain covering the hostname, while no covering Domain is verified yet. +func ownershipRecord(hostname string, domains []networkingv1alpha.Domain) (networkingv1alpha.HostnameDNSRecord, bool) { + var pending *networkingv1alpha.Domain + for i := range domains { + d := &domains[i] + if !domainCoversHostname(d.Spec.DomainName, hostname) { + continue + } + if apimeta.IsStatusConditionTrue(d.Status.Conditions, networkingv1alpha.DomainConditionVerified) { + return networkingv1alpha.HostnameDNSRecord{}, false + } + if d.Status.Verification == nil || d.Status.Verification.DNSRecord.Name == "" { + continue + } + if pending == nil || len(d.Spec.DomainName) > len(pending.Spec.DomainName) { + pending = d + } + } + if pending == nil { + return networkingv1alpha.HostnameDNSRecord{}, false + } + + record := pending.Status.Verification.DNSRecord + return networkingv1alpha.HostnameDNSRecord{ + Name: strings.TrimSuffix(record.Name, "."), + Type: record.Type, + Content: record.Content, + Purpose: networkingv1alpha.HostnameDNSRecordPurposeOwnership, + ManagedBy: networkingv1alpha.HostnameDNSRecordManagedByUser, + State: networkingv1alpha.HostnameDNSRecordMissing, + }, true +} + +func domainCoversHostname(domainName, hostname string) bool { + if domainName == "" { + return false + } + return hostname == domainName || strings.HasSuffix(hostname, "."+domainName) +} diff --git a/internal/controller/httpproxy_dns_records_test.go b/internal/controller/httpproxy_dns_records_test.go new file mode 100644 index 00000000..e73bbd74 --- /dev/null +++ b/internal/controller/httpproxy_dns_records_test.go @@ -0,0 +1,416 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "context" + "errors" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" + certificatesv1alpha1 "go.datum.net/network-services-operator/internal/certificates/v1alpha1" + "go.datum.net/network-services-operator/internal/config" + + dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1" +) + +const testCanonicalHostname = "ruth-fourth-hrkgk.datumproxy.net" + +type fakeDNS struct { + cnames map[string]string + addrs map[string][]string + calls int +} + +func (f *fakeDNS) observer(now time.Time) *routingObserver { + return &routingObserver{ + lookupCNAME: func(_ context.Context, host string) (string, error) { + f.calls++ + if target, ok := f.cnames[host]; ok { + return target, nil + } + return "", errors.New("no such host") + }, + lookupIP: func(_ context.Context, host string) ([]net.IPAddr, error) { + f.calls++ + out := make([]net.IPAddr, 0, len(f.addrs[host])) + for _, a := range f.addrs[host] { + out = append(out, net.IPAddr{IP: net.ParseIP(a)}) + } + if len(out) == 0 { + return nil, errors.New("no such host") + } + return out, nil + }, + now: func() time.Time { return now }, + cache: map[string]routingObservation{}, + } +} + +func dnsRecordsTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + require.NoError(t, scheme.AddToScheme(s)) + require.NoError(t, gatewayv1.Install(s)) + require.NoError(t, networkingv1alpha.AddToScheme(s)) + require.NoError(t, certificatesv1alpha1.AddToScheme(s)) + require.NoError(t, dnsv1alpha1.AddToScheme(s)) + return s +} + +func dnsRecordsGateway(hostnames ...string) *gatewayv1.Gateway { + gw := &gatewayv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns", Name: "s3"}} + for i, h := range hostnames { + gw.Spec.Listeners = append(gw.Spec.Listeners, + gatewayv1.Listener{Name: gatewayv1.SectionName("http-hostname-" + string(rune('0'+i))), Protocol: gatewayv1.HTTPProtocolType, Hostname: ptr.To(gatewayv1.Hostname(h))}, + gatewayv1.Listener{Name: gatewayv1.SectionName("https-hostname-" + string(rune('0'+i))), Protocol: gatewayv1.HTTPSProtocolType, Hostname: ptr.To(gatewayv1.Hostname(h))}, + ) + } + return gw +} + +func dnsRecordsProxy(hostnames ...string) *networkingv1alpha.HTTPProxy { + p := &networkingv1alpha.HTTPProxy{ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns", Name: "s3"}} + for _, h := range hostnames { + p.Spec.Hostnames = append(p.Spec.Hostnames, gatewayv1.Hostname(h)) + } + p.Status.CanonicalHostname = testCanonicalHostname + return p +} + +func verifiedDomain(name string, mutate ...func(*networkingv1alpha.Domain)) *networkingv1alpha.Domain { + d := &networkingv1alpha.Domain{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns", Name: name}, + Spec: networkingv1alpha.DomainSpec{DomainName: name}, + Status: networkingv1alpha.DomainStatus{Conditions: []metav1.Condition{{ + Type: networkingv1alpha.DomainConditionVerified, Status: metav1.ConditionTrue, Reason: networkingv1alpha.DomainReasonVerified, + }}}, + } + for _, m := range mutate { + m(d) + } + return d +} + +func pendingDomain(name, token string) *networkingv1alpha.Domain { + return &networkingv1alpha.Domain{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns", Name: name}, + Spec: networkingv1alpha.DomainSpec{DomainName: name}, + Status: networkingv1alpha.DomainStatus{ + Conditions: []metav1.Condition{{Type: networkingv1alpha.DomainConditionVerified, Status: metav1.ConditionFalse, Reason: networkingv1alpha.DomainReasonPendingVerification}}, + Verification: &networkingv1alpha.DomainVerificationStatus{ + DNSRecord: networkingv1alpha.DNSVerificationRecord{Name: "datum-custom-hostname." + name, Type: "TXT", Content: token}, + }, + }, + } +} + +func dns01Certificate(gatewayName string, listener gatewayv1.SectionName, delegationReady bool, records ...certificatesv1alpha1.RequiredDNSRecord) *certificatesv1alpha1.TLSCertificate { + status := metav1.ConditionFalse + if delegationReady { + status = metav1.ConditionTrue + } + return &certificatesv1alpha1.TLSCertificate{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns", Name: tlsCertificateName(gatewayName, listener)}, + Status: certificatesv1alpha1.TLSCertificateStatus{ + Issuance: certificatesv1alpha1.ChallengeTypeDNS01, + DelegationTarget: "k3f9q2x7.acme-dns.example.net", + RequiredDNSRecords: records, + Conditions: []metav1.Condition{{Type: certificatesv1alpha1.ConditionDNSDelegationReady, Status: status, Reason: "Checked"}}, + }, + } +} + +func recordsByPurpose(statuses []networkingv1alpha.HostnameStatus, hostname string) map[networkingv1alpha.HostnameDNSRecordPurpose]networkingv1alpha.HostnameDNSRecord { + out := map[networkingv1alpha.HostnameDNSRecordPurpose]networkingv1alpha.HostnameDNSRecord{} + for _, hs := range statuses { + if hs.Hostname != hostname { + continue + } + for _, r := range hs.DNSRecords { + out[r.Purpose] = r + } + } + return out +} + +func TestBuildDNSRecordStatuses(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) + + datumZone := &dnsv1alpha1.DNSZone{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns", Name: "example-com"}, + Spec: dnsv1alpha1.DNSZoneSpec{DomainName: "example.com"}, + Status: dnsv1alpha1.DNSZoneStatus{ + Nameservers: []string{"ns1.datumdomains.net"}, + Conditions: []metav1.Condition{ + {Type: "Accepted", Status: metav1.ConditionTrue, Reason: "Accepted"}, + {Type: "Programmed", Status: metav1.ConditionTrue, Reason: "Programmed"}, + }, + }, + } + platformRecordSet := func(hostname string, programmed bool) *dnsv1alpha1.DNSRecordSet { + status := metav1.ConditionFalse + if programmed { + status = metav1.ConditionTrue + } + return &dnsv1alpha1.DNSRecordSet{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-ns", + Name: dnsRecordSetName("s3", hostname), + Labels: map[string]string{labelManagedBy: labelManagedByValue}, + Annotations: map[string]string{annotationDNSHostname: hostname}, + }, + Spec: dnsv1alpha1.DNSRecordSetSpec{ + DNSZoneRef: corev1.LocalObjectReference{Name: "example-com"}, + RecordType: dnsv1alpha1.RRTypeCNAME, + }, + Status: dnsv1alpha1.DNSRecordSetStatus{Conditions: []metav1.Condition{{Type: conditionTypeProgrammed, Status: status, Reason: "Test"}}}, + } + } + delegatedToDatum := func(d *networkingv1alpha.Domain) { + d.Status.Nameservers = []networkingv1alpha.Nameserver{{Hostname: "ns1.datumdomains.net"}} + } + delegatedElsewhere := func(d *networkingv1alpha.Domain) { + d.Status.Nameservers = []networkingv1alpha.Nameserver{{Hostname: "ns1.otherdns.example"}} + } + + tests := []struct { + name string + disabled bool + dnsIntegrated bool + hostnames []string + objects []client.Object + dns fakeDNS + wantRecheck bool + assert func(t *testing.T, statuses []networkingv1alpha.HostnameStatus) + }{ + { + name: "nothing is published with the certificate service off", + disabled: true, + hostnames: []string{"*.s3.example.com"}, + objects: []client.Object{verifiedDomain("example.com")}, + assert: func(t *testing.T, statuses []networkingv1alpha.HostnameStatus) { + assert.Empty(t, statuses) + }, + }, + { + name: "a routing record the user has not published is missing and rechecked", + hostnames: []string{"www.example.com"}, + objects: []client.Object{verifiedDomain("example.com")}, + wantRecheck: true, + assert: func(t *testing.T, statuses []networkingv1alpha.HostnameStatus) { + got := recordsByPurpose(statuses, "www.example.com") + assert.Equal(t, networkingv1alpha.HostnameDNSRecord{ + Name: "www.example.com", Type: "CNAME", Content: testCanonicalHostname, + Purpose: networkingv1alpha.HostnameDNSRecordPurposeRouting, ManagedBy: networkingv1alpha.HostnameDNSRecordManagedByUser, State: networkingv1alpha.HostnameDNSRecordMissing, + }, got[networkingv1alpha.HostnameDNSRecordPurposeRouting]) + assert.NotContains(t, got, networkingv1alpha.HostnameDNSRecordPurposeCertificate) + assert.NotContains(t, got, networkingv1alpha.HostnameDNSRecordPurposeOwnership) + }, + }, + { + name: "a wildcard routes when a name beneath it resolves to the canonical hostname", + hostnames: []string{"*.s3.example.com"}, + objects: []client.Object{ + verifiedDomain("example.com"), + dns01Certificate("s3", "https-hostname-0", true), + }, + dns: fakeDNS{cnames: map[string]string{"datum-routing-probe.s3.example.com.": testCanonicalHostname + "."}}, + assert: func(t *testing.T, statuses []networkingv1alpha.HostnameStatus) { + got := recordsByPurpose(statuses, "*.s3.example.com") + assert.Equal(t, "*.s3.example.com", got[networkingv1alpha.HostnameDNSRecordPurposeRouting].Name) + assert.Equal(t, networkingv1alpha.HostnameDNSRecordPresent, got[networkingv1alpha.HostnameDNSRecordPurposeRouting].State) + assert.Equal(t, networkingv1alpha.HostnameDNSRecord{ + Name: "_acme-challenge.s3.example.com", Type: "CNAME", Content: "k3f9q2x7.acme-dns.example.net", + Purpose: networkingv1alpha.HostnameDNSRecordPurposeCertificate, ManagedBy: networkingv1alpha.HostnameDNSRecordManagedByUser, State: networkingv1alpha.HostnameDNSRecordPresent, + }, got[networkingv1alpha.HostnameDNSRecordPurposeCertificate]) + }, + }, + { + name: "a flattened apex record that resolves to the platform's addresses is present", + hostnames: []string{"example.com"}, + objects: []client.Object{verifiedDomain("example.com", func(d *networkingv1alpha.Domain) { d.Status.Apex = true })}, + dns: fakeDNS{addrs: map[string][]string{ + "example.com.": {"203.0.113.10"}, + testCanonicalHostname + ".": {"203.0.113.10", "2001:db8::10"}, + }}, + assert: func(t *testing.T, statuses []networkingv1alpha.HostnameStatus) { + got := recordsByPurpose(statuses, "example.com")[networkingv1alpha.HostnameDNSRecordPurposeRouting] + assert.Equal(t, "ALIAS", got.Type) + assert.Equal(t, networkingv1alpha.HostnameDNSRecordPresent, got.State) + }, + }, + { + name: "an address that is not the platform's leaves the routing record missing", + hostnames: []string{"www.example.com"}, + objects: []client.Object{verifiedDomain("example.com")}, + dns: fakeDNS{addrs: map[string][]string{"www.example.com.": {"198.51.100.7"}, testCanonicalHostname + ".": {"203.0.113.10"}}}, + wantRecheck: true, + assert: func(t *testing.T, statuses []networkingv1alpha.HostnameStatus) { + assert.Equal(t, networkingv1alpha.HostnameDNSRecordMissing, recordsByPurpose(statuses, "www.example.com")[networkingv1alpha.HostnameDNSRecordPurposeRouting].State) + }, + }, + { + name: "the certificate record is missing until the delegation resolves", + hostnames: []string{"*.s3.example.com"}, + objects: []client.Object{ + verifiedDomain("example.com"), + dns01Certificate("s3", "https-hostname-0", false, certificatesv1alpha1.RequiredDNSRecord{ + Name: "_acme-challenge.s3.example.com", Type: "CNAME", Content: "k3f9q2x7.acme-dns.example.net.", Purpose: certificatesv1alpha1.DNSRecordPurposeCertificate, + }), + }, + dns: fakeDNS{cnames: map[string]string{"datum-routing-probe.s3.example.com.": testCanonicalHostname + "."}}, + assert: func(t *testing.T, statuses []networkingv1alpha.HostnameStatus) { + got := recordsByPurpose(statuses, "*.s3.example.com")[networkingv1alpha.HostnameDNSRecordPurposeCertificate] + assert.Equal(t, "k3f9q2x7.acme-dns.example.net", got.Content) + assert.Equal(t, networkingv1alpha.HostnameDNSRecordMissing, got.State) + }, + }, + { + name: "a required record for another name is not passed on", + hostnames: []string{"www.example.com"}, + objects: []client.Object{ + verifiedDomain("example.com"), + func() client.Object { + c := dns01Certificate("s3", "https-hostname-0", false, certificatesv1alpha1.RequiredDNSRecord{ + Name: "_acme-challenge.victim.example.org", Type: "CNAME", Content: "attacker.example.net", Purpose: certificatesv1alpha1.DNSRecordPurposeCertificate, + }) + c.Status.Issuance = certificatesv1alpha1.ChallengeTypeHTTP01 + c.Status.DelegationTarget = "" + return c + }(), + }, + wantRecheck: true, + assert: func(t *testing.T, statuses []networkingv1alpha.HostnameStatus) { + assert.NotContains(t, recordsByPurpose(statuses, "www.example.com"), networkingv1alpha.HostnameDNSRecordPurposeCertificate) + }, + }, + { + name: "a platform record counts while Datum DNS serves the domain", + dnsIntegrated: true, + hostnames: []string{"www.example.com"}, + objects: []client.Object{verifiedDomain("example.com", delegatedToDatum), datumZone, platformRecordSet("www.example.com", true)}, + assert: func(t *testing.T, statuses []networkingv1alpha.HostnameStatus) { + got := recordsByPurpose(statuses, "www.example.com")[networkingv1alpha.HostnameDNSRecordPurposeRouting] + assert.Equal(t, networkingv1alpha.HostnameDNSRecordManagedByPlatform, got.ManagedBy) + assert.Equal(t, networkingv1alpha.HostnameDNSRecordPresent, got.State) + }, + }, + { + name: "a platform record in a zone the registry does not delegate to is missing", + dnsIntegrated: true, + hostnames: []string{"www.example.com"}, + objects: []client.Object{verifiedDomain("example.com", delegatedElsewhere), datumZone, platformRecordSet("www.example.com", true)}, + assert: func(t *testing.T, statuses []networkingv1alpha.HostnameStatus) { + got := recordsByPurpose(statuses, "www.example.com")[networkingv1alpha.HostnameDNSRecordPurposeRouting] + assert.Equal(t, networkingv1alpha.HostnameDNSRecordManagedByPlatform, got.ManagedBy) + assert.Equal(t, networkingv1alpha.HostnameDNSRecordMissing, got.State) + }, + }, + { + name: "a platform record not yet programmed is missing", + dnsIntegrated: true, + hostnames: []string{"www.example.com"}, + objects: []client.Object{verifiedDomain("example.com", delegatedToDatum), datumZone, platformRecordSet("www.example.com", false)}, + assert: func(t *testing.T, statuses []networkingv1alpha.HostnameStatus) { + assert.Equal(t, networkingv1alpha.HostnameDNSRecordMissing, recordsByPurpose(statuses, "www.example.com")[networkingv1alpha.HostnameDNSRecordPurposeRouting].State) + }, + }, + { + name: "an unverified domain lists its ownership record on each hostname", + hostnames: []string{"www.example.com", "api.example.com"}, + objects: []client.Object{pendingDomain("example.com", "4f1c-token")}, + wantRecheck: true, + assert: func(t *testing.T, statuses []networkingv1alpha.HostnameStatus) { + want := networkingv1alpha.HostnameDNSRecord{ + Name: "datum-custom-hostname.example.com", Type: "TXT", Content: "4f1c-token", + Purpose: networkingv1alpha.HostnameDNSRecordPurposeOwnership, ManagedBy: networkingv1alpha.HostnameDNSRecordManagedByUser, State: networkingv1alpha.HostnameDNSRecordMissing, + } + assert.Equal(t, want, recordsByPurpose(statuses, "www.example.com")[networkingv1alpha.HostnameDNSRecordPurposeOwnership]) + assert.Equal(t, want, recordsByPurpose(statuses, "api.example.com")[networkingv1alpha.HostnameDNSRecordPurposeOwnership]) + }, + }, + { + name: "a verified domain that does not cover the hostname leaves its ownership record listed", + hostnames: []string{"www.example.com"}, + objects: []client.Object{verifiedDomain("example.org"), pendingDomain("example.com", "4f1c-token")}, + wantRecheck: true, + assert: func(t *testing.T, statuses []networkingv1alpha.HostnameStatus) { + assert.Contains(t, recordsByPurpose(statuses, "www.example.com"), networkingv1alpha.HostnameDNSRecordPurposeOwnership) + }, + }, + { + name: "platform hostnames need no records", + hostnames: []string{"foo.datumproxy.net"}, + assert: func(t *testing.T, statuses []networkingv1alpha.HostnameStatus) { + assert.Empty(t, statuses) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cl := fake.NewClientBuilder().WithScheme(dnsRecordsTestScheme(t)).WithObjects(tt.objects...).Build() + r := &HTTPProxyReconciler{ + Config: config.NetworkServicesOperator{Gateway: config.GatewayConfig{ + TargetDomain: "datumproxy.net", + EnableDNSIntegration: tt.dnsIntegrated, + CertificateService: config.CertificateServiceConfig{Enabled: !tt.disabled}, + }}, + routing: tt.dns.observer(now), + } + + statuses, recheck := r.buildDNSRecordStatuses(context.Background(), cl, dnsRecordsGateway(tt.hostnames...), dnsRecordsProxy(tt.hostnames...)) + assert.Equal(t, tt.wantRecheck, recheck) + tt.assert(t, statuses) + }) + } +} + +func TestRoutingObserverRemembersAnswers(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) + dns := &fakeDNS{cnames: map[string]string{"www.example.com.": testCanonicalHostname + "."}} + o := dns.observer(now) + + assert.True(t, o.routesTo(context.Background(), "www.example.com", testCanonicalHostname)) + calls := dns.calls + assert.True(t, o.routesTo(context.Background(), "www.example.com", testCanonicalHostname)) + assert.Equal(t, calls, dns.calls, "a second look inside the TTL must not hit DNS") + + o.now = func() time.Time { return now.Add(routingObservationTTL) } + delete(dns.cnames, "www.example.com.") + assert.False(t, o.routesTo(context.Background(), "www.example.com", testCanonicalHostname)) +} + +func TestMergeHostnameStatusesKeepsDNSRecords(t *testing.T) { + t.Parallel() + + routing := networkingv1alpha.HostnameDNSRecord{Name: "a.example.com", Type: "CNAME", Content: testCanonicalHostname, Purpose: networkingv1alpha.HostnameDNSRecordPurposeRouting} + merged := mergeHostnameStatuses( + []networkingv1alpha.HostnameStatus{{Hostname: "a.example.com", Conditions: []metav1.Condition{{Type: networkingv1alpha.HostnameConditionAvailable, Status: metav1.ConditionTrue}}}}, + []networkingv1alpha.HostnameStatus{{Hostname: "a.example.com", DNSRecords: []networkingv1alpha.HostnameDNSRecord{routing}}}, + ) + + require.Len(t, merged, 1) + assert.Len(t, merged[0].Conditions, 1) + assert.Equal(t, []networkingv1alpha.HostnameDNSRecord{routing}, merged[0].DNSRecords) +} From 2478d6d1f8043ffb01ce8490cd5d61d0ea05fbe2 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 2 Oct 2026 18:17:35 -0500 Subject: [PATCH 2/2] fix: list certificate records for wildcards only The certificate service now issues only wildcard hostnames, so an exact hostname has no ACME delegation record to publish. Skip the TLSCertificate lookup for exact hostnames so their status lists routing and ownership records only. --- internal/controller/httpproxy_dns_records.go | 2 +- .../controller/httpproxy_dns_records_test.go | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/internal/controller/httpproxy_dns_records.go b/internal/controller/httpproxy_dns_records.go index c44ee380..f71c9af9 100644 --- a/internal/controller/httpproxy_dns_records.go +++ b/internal/controller/httpproxy_dns_records.go @@ -165,7 +165,7 @@ func (r *HTTPProxyReconciler) buildDNSRecordStatuses( records = append(records, routing) } - if listener, ok := httpsListeners[hostname]; ok { + if listener, ok := httpsListeners[hostname]; ok && isSingleLabelWildcard(hostname) { records = append(records, r.certificateRecords(ctx, cl, gateway, listener, hostname)...) } diff --git a/internal/controller/httpproxy_dns_records_test.go b/internal/controller/httpproxy_dns_records_test.go index e73bbd74..17ed91da 100644 --- a/internal/controller/httpproxy_dns_records_test.go +++ b/internal/controller/httpproxy_dns_records_test.go @@ -118,13 +118,13 @@ func pendingDomain(name, token string) *networkingv1alpha.Domain { } } -func dns01Certificate(gatewayName string, listener gatewayv1.SectionName, delegationReady bool, records ...certificatesv1alpha1.RequiredDNSRecord) *certificatesv1alpha1.TLSCertificate { +func dns01Certificate(delegationReady bool, records ...certificatesv1alpha1.RequiredDNSRecord) *certificatesv1alpha1.TLSCertificate { status := metav1.ConditionFalse if delegationReady { status = metav1.ConditionTrue } return &certificatesv1alpha1.TLSCertificate{ - ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns", Name: tlsCertificateName(gatewayName, listener)}, + ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns", Name: tlsCertificateName("s3", "https-hostname-0")}, Status: certificatesv1alpha1.TLSCertificateStatus{ Issuance: certificatesv1alpha1.ChallengeTypeDNS01, DelegationTarget: "k3f9q2x7.acme-dns.example.net", @@ -223,12 +223,21 @@ func TestBuildDNSRecordStatuses(t *testing.T) { assert.NotContains(t, got, networkingv1alpha.HostnameDNSRecordPurposeOwnership) }, }, + { + name: "an exact hostname never lists a certificate record, since cert-manager issues it", + hostnames: []string{"www.example.com"}, + objects: []client.Object{verifiedDomain("example.com"), dns01Certificate(false)}, + wantRecheck: true, + assert: func(t *testing.T, statuses []networkingv1alpha.HostnameStatus) { + assert.NotContains(t, recordsByPurpose(statuses, "www.example.com"), networkingv1alpha.HostnameDNSRecordPurposeCertificate) + }, + }, { name: "a wildcard routes when a name beneath it resolves to the canonical hostname", hostnames: []string{"*.s3.example.com"}, objects: []client.Object{ verifiedDomain("example.com"), - dns01Certificate("s3", "https-hostname-0", true), + dns01Certificate(true), }, dns: fakeDNS{cnames: map[string]string{"datum-routing-probe.s3.example.com.": testCanonicalHostname + "."}}, assert: func(t *testing.T, statuses []networkingv1alpha.HostnameStatus) { @@ -270,7 +279,7 @@ func TestBuildDNSRecordStatuses(t *testing.T) { hostnames: []string{"*.s3.example.com"}, objects: []client.Object{ verifiedDomain("example.com"), - dns01Certificate("s3", "https-hostname-0", false, certificatesv1alpha1.RequiredDNSRecord{ + dns01Certificate(false, certificatesv1alpha1.RequiredDNSRecord{ Name: "_acme-challenge.s3.example.com", Type: "CNAME", Content: "k3f9q2x7.acme-dns.example.net.", Purpose: certificatesv1alpha1.DNSRecordPurposeCertificate, }), }, @@ -287,7 +296,7 @@ func TestBuildDNSRecordStatuses(t *testing.T) { objects: []client.Object{ verifiedDomain("example.com"), func() client.Object { - c := dns01Certificate("s3", "https-hostname-0", false, certificatesv1alpha1.RequiredDNSRecord{ + c := dns01Certificate(false, certificatesv1alpha1.RequiredDNSRecord{ Name: "_acme-challenge.victim.example.org", Type: "CNAME", Content: "attacker.example.net", Purpose: certificatesv1alpha1.DNSRecordPurposeCertificate, }) c.Status.Issuance = certificatesv1alpha1.ChallengeTypeHTTP01