diff --git a/internal/controller/constants.go b/internal/controller/constants.go index 6aab3931..422b85e4 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -52,6 +52,10 @@ const ( conditionTypeProgrammed = "Programmed" ) +// dnsZoneReasonPendingDomainVerification is the Accepted=False reason the DNS +// operator sets on a DNSZone it holds back until its Domain is verified. +const dnsZoneReasonPendingDomainVerification = "PendingDomainVerification" + // cert-manager condition status values used when parsing unstructured Certificate objects. const ( certManagerConditionStatusTrue = "True" diff --git a/internal/controller/domain_controller.go b/internal/controller/domain_controller.go index 541a42d3..d9f318ce 100644 --- a/internal/controller/domain_controller.go +++ b/internal/controller/domain_controller.go @@ -389,12 +389,17 @@ func (r *DomainReconciler) attemptDNSZoneVerification( // Evaluate zones; any one matching is sufficient sawNotReady := false sawReady := false + sawPendingSubdomain := false for _, z := range zones { zoneName := z.GetName() - // Must be Accepted=True and Programmed=True + // Must be Accepted=True and Programmed=True, or held back by the DNS + // operator until this Domain is verified. The DNS operator won't serve + // a zone for an unverified domain, so a zone waiting on verification + // never becomes Programmed; requiring that would deadlock. accepted := false programmed := false + pendingVerification := false if conds, found, _ := unstructured.NestedSlice(z.Object, jsonKeyStatus, "conditions"); found { for _, c := range conds { cm, ok := c.(map[string]any) @@ -406,12 +411,24 @@ func (r *DomainReconciler) attemptDNSZoneVerification( if ct == conditionTypeAccepted && cs == certManagerConditionStatusTrue { accepted = true } + if ct == conditionTypeAccepted && cs != certManagerConditionStatusTrue { + reason, _ := cm["reason"].(string) + pendingVerification = reason == dnsZoneReasonPendingDomainVerification + } if ct == conditionTypeProgrammed && cs == certManagerConditionStatusTrue { programmed = true } } } - if !accepted || !programmed { + // A waiting zone only counts at the apex. Every zone shares the same + // nameservers, and a subdomain with no delegation of its own reports its + // parent's nameservers. Without this check, anyone could verify + // sub.example.com once example.com was delegated to Datum. + if pendingVerification && !domainStatus.Apex { + sawPendingSubdomain = true + continue + } + if !pendingVerification && (!accepted || !programmed) { sawNotReady = true // Keep evaluating other zones in case one is ready. continue @@ -448,6 +465,11 @@ func (r *DomainReconciler) attemptDNSZoneVerification( verifiedDNSZoneCondition.Reason = networkingv1alpha.DomainReasonDNSZoneNotReady verifiedDNSZoneCondition.Message = "DNSZone exists but is not yet Accepted and Programmed" } + if sawPendingSubdomain && !sawReady { + verifiedDNSZoneCondition.Reason = networkingv1alpha.DomainReasonDNSZoneNotReady + verifiedDNSZoneCondition.Message = "Nameserver delegation verifies only a registered domain; " + + "verify this subdomain with the TXT record or HTTP token, or verify its parent domain" + } } func (r *DomainReconciler) attemptDNSVerification( diff --git a/internal/controller/domain_controller_test.go b/internal/controller/domain_controller_test.go index 4e745ad0..25572928 100644 --- a/internal/controller/domain_controller_test.go +++ b/internal/controller/domain_controller_test.go @@ -355,6 +355,61 @@ func TestDomainVerification(t *testing.T) { assert.True(t, apimeta.IsStatusConditionTrue(domain.Status.Conditions, networkingv1alpha.DomainConditionVerifiedDNSZone), "expected VerifiedDNSZone=True to be present") }, }, + { + name: "dnszone waiting on verification verifies apex domain", + reconcileCount: 2, + domain: newDomain(upstreamNamespace.Name, "dnszone-pending", func(domain *networkingv1alpha.Domain) { + domain.Status.Verification = &networkingv1alpha.DomainVerificationStatus{ + NextVerificationAttempt: metav1.Time{Time: time.Unix(0, 0)}, + } + }), + objects: []client.Object{pendingDNSZone(upstreamNamespace.Name, "zone-pending", "dnszone-pending")}, + registryLookupDomain: func(ctx context.Context, domain string, opts registrydata.LookupOptions) (*registrydata.DomainResult, error) { + return ®istrydata.DomainResult{ + Registration: &networkingv1alpha.Registration{}, + Nameservers: []networkingv1alpha.Nameserver{{Hostname: "ns1.provider.net."}}, + }, nil + }, + assert: func(t *testing.T, domain *networkingv1alpha.Domain, _ ctrl.Result) { + assert.True(t, domain.Status.Apex) + assert.True(t, apimeta.IsStatusConditionTrue(domain.Status.Conditions, networkingv1alpha.DomainConditionVerified)) + assert.True(t, apimeta.IsStatusConditionTrue(domain.Status.Conditions, networkingv1alpha.DomainConditionVerifiedDNSZone)) + }, + }, + { + name: "dnszone waiting on verification does not verify subdomain", + reconcileCount: 2, + domain: newDomain(upstreamNamespace.Name, "dnszone-pending-sub", func(domain *networkingv1alpha.Domain) { + domain.Spec.DomainName = "app.example.com" + domain.Status.Verification = &networkingv1alpha.DomainVerificationStatus{ + NextVerificationAttempt: metav1.Time{Time: time.Unix(0, 0)}, + } + }), + lookupTXT: func(ctx context.Context, name string) ([]string, error) { + return nil, &net.DNSError{IsNotFound: true} + }, + httpGet: func(ctx context.Context, url string) ([]byte, *http.Response, error) { + return nil, &http.Response{StatusCode: http.StatusNotFound}, nil + }, + objects: []client.Object{pendingDNSZone(upstreamNamespace.Name, "zone-pending-sub", "dnszone-pending-sub")}, + // The subdomain has no delegation of its own, so the lookup reports + // the parent's nameservers, which match the shared zone nameservers. + registryLookupDomain: func(ctx context.Context, domain string, opts registrydata.LookupOptions) (*registrydata.DomainResult, error) { + return ®istrydata.DomainResult{ + Registration: &networkingv1alpha.Registration{}, + Nameservers: []networkingv1alpha.Nameserver{{Hostname: "ns1.provider.net."}}, + }, nil + }, + assert: func(t *testing.T, domain *networkingv1alpha.Domain, _ ctrl.Result) { + assert.False(t, domain.Status.Apex) + assert.False(t, apimeta.IsStatusConditionTrue(domain.Status.Conditions, networkingv1alpha.DomainConditionVerified)) + cond := apimeta.FindStatusCondition(domain.Status.Conditions, networkingv1alpha.DomainConditionVerifiedDNSZone) + if assert.NotNil(t, cond) { + assert.Equal(t, metav1.ConditionFalse, cond.Status) + assert.Equal(t, networkingv1alpha.DomainReasonDNSZoneNotReady, cond.Reason) + } + }, + }, { name: "http token not found", lookupTXT: func(ctx context.Context, name string) ([]string, error) { @@ -584,6 +639,29 @@ func TestValidDomainGate_InvalidApex_SetsConditionAndSkipsFlows(t *testing.T) { } } +// pendingDNSZone returns a DNSZone the DNS operator is holding back until the +// named Domain is verified: Accepted=False with the waiting reason, never +// Programmed, and nameservers published from its class. +func pendingDNSZone(namespace, name, domainName string) *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "dns.networking.miloapis.com/v1alpha1", + "kind": "DNSZone", + "metadata": map[string]any{ + "name": name, + "namespace": namespace, + }, + "status": map[string]any{ + "nameservers": []any{"ns1.provider.net.", "ns2.provider.net."}, + "conditions": []any{ + map[string]any{"type": "Accepted", "status": "False", "reason": dnsZoneReasonPendingDomainVerification}, + }, + "domainRef": map[string]any{"name": domainName}, + }, + }, + } +} + func newDomain(namespace, name string, opts ...func(*networkingv1alpha.Domain)) *networkingv1alpha.Domain { domain := &networkingv1alpha.Domain{ ObjectMeta: metav1.ObjectMeta{