diff --git a/api/v1alpha/httpproxy_types.go b/api/v1alpha/httpproxy_types.go index 1ee46a91..d834e03a 100644 --- a/api/v1alpha/httpproxy_types.go +++ b/api/v1alpha/httpproxy_types.go @@ -44,7 +44,12 @@ type HTTPProxySpec struct { // HTTPProxy. In such cases, these will be listed in the `status.hostnames` // field and do not require additional configuration by the user. // - // Wildcard hostnames are not supported at this time. + // A hostname may start with a single wildcard label, as in + // `*.s3.example.com`, where the platform offers wildcards. A wildcard + // matches names one or more labels beneath its base, its certificate + // covers names exactly one label beneath, and it needs its base or a parent + // verified by DNS TXT record. A wildcard reserves every name beneath it for + // its project. // // +kubebuilder:validation:Optional // +kubebuilder:validation:MaxItems=16 @@ -633,6 +638,26 @@ const ( HostnameConditionCertificateReady = "CertificateReady" ) +// Reasons for HostnameConditionVerified. +const ( + // HostnameVerifiedReasonVerified indicates a verified Domain covers the + // hostname. + HostnameVerifiedReasonVerified = "Verified" + + // HostnameVerifiedReasonPendingVerification indicates no verified Domain + // covers the hostname yet. + HostnameVerifiedReasonPendingVerification = "PendingVerification" + + // HostnameVerifiedReasonDNSVerificationRequired indicates a wildcard + // hostname whose base, or a parent of it, has not been verified by DNS TXT + // record. Other proofs do not cover every name beneath a wildcard. + HostnameVerifiedReasonDNSVerificationRequired = "DNSVerificationRequired" + + // HostnameVerifiedReasonWildcardNotSupported indicates a wildcard hostname + // on a platform that does not offer wildcards. + HostnameVerifiedReasonWildcardNotSupported = "WildcardNotSupported" +) + // Reasons for HostnameConditionCertificateReady. const ( // CertificateReadyReasonCertificateIssued indicates the certificate has been issued and is ready. diff --git a/config/crd/bases/networking.datumapis.com_httpproxies.yaml b/config/crd/bases/networking.datumapis.com_httpproxies.yaml index af09208f..26605864 100644 --- a/config/crd/bases/networking.datumapis.com_httpproxies.yaml +++ b/config/crd/bases/networking.datumapis.com_httpproxies.yaml @@ -133,7 +133,12 @@ spec: HTTPProxy. In such cases, these will be listed in the `status.hostnames` field and do not require additional configuration by the user. - Wildcard hostnames are not supported at this time. + A hostname may start with a single wildcard label, as in + `*.s3.example.com`, where the platform offers wildcards. A wildcard + matches names one or more labels beneath its base, its certificate + covers names exactly one label beneath, and it needs its base or a parent + verified by DNS TXT record. A wildcard reserves every name beneath it for + its project. items: description: |- Hostname is the fully qualified domain name of a network host. This matches diff --git a/docs/api/httpproxies.md b/docs/api/httpproxies.md index 5a1212c9..1568dcfa 100644 --- a/docs/api/httpproxies.md +++ b/docs/api/httpproxies.md @@ -141,7 +141,12 @@ The system may automatically generate and associate hostnames with the HTTPProxy. In such cases, these will be listed in the `status.hostnames` field and do not require additional configuration by the user. -Wildcard hostnames are not supported at this time.
+A hostname may start with a single wildcard label, as in +`*.s3.example.com`, where the platform offers wildcards. A wildcard +matches names one or more labels beneath its base, its certificate +covers names exactly one label beneath, and it needs its base or a parent +verified by DNS TXT record. A wildcard reserves every name beneath it for +its project.
false diff --git a/internal/agent/catalog.go b/internal/agent/catalog.go index ce5cd838..e9842590 100644 --- a/internal/agent/catalog.go +++ b/internal/agent/catalog.go @@ -323,6 +323,26 @@ var hostnameCatalog = []ReasonInfo{ "yours, raise it with Datum rather than searching for it.", Skill: SkillHostnameNotWorking, }, + { + Reason: networkingv1alpha.HostnameVerifiedReasonDNSVerificationRequired, + ConditionType: networkingv1alpha.HostnameConditionVerified, + Actionability: ActionabilityUser, + Scope: ScopeOneHostname, + Explanation: "This wildcard hostname needs proof, by a DNS TXT record, that you control the domain " + + "beneath it. Proof over HTTP or through a Datum DNS zone does not cover every name a wildcard serves.", + Remediation: "Publish the TXT record the status message names, on the Domain it names. " + + "Once that Domain is verified by DNS, the wildcard is admitted on its own.", + Skill: SkillDomainVerification, + }, + { + Reason: networkingv1alpha.HostnameVerifiedReasonWildcardNotSupported, + ConditionType: networkingv1alpha.HostnameConditionVerified, + Actionability: ActionabilityUser, + Scope: ScopeOneHostname, + Explanation: "Wildcard hostnames are not available on this platform, so this hostname will not serve.", + Remediation: "Replace the wildcard with the exact hostnames you need.", + Skill: SkillHostnameNotWorking, + }, { Reason: networkingv1alpha.CertificatesReadyReasonAllCertificatesReady, ConditionType: networkingv1alpha.HTTPProxyConditionCertificatesReady, diff --git a/internal/cmd/alb/spec/proxy.go b/internal/cmd/alb/spec/proxy.go index 4f67947f..e43b6b04 100644 --- a/internal/cmd/alb/spec/proxy.go +++ b/internal/cmd/alb/spec/proxy.go @@ -224,7 +224,7 @@ func toHostnames(hostnames []string) []gatewayv1.Hostname { } func validateProxy(proxy *networkingv1alpha.HTTPProxy) error { - errs := validation.ValidateHTTPProxy(proxy) + errs := validation.ValidateHTTPProxy(proxy, validation.HTTPProxyValidationOptions{}) if len(errs) == 0 { return nil } diff --git a/internal/cmd/manager/manager.go b/internal/cmd/manager/manager.go index 56768bb0..4394c996 100644 --- a/internal/cmd/manager/manager.go +++ b/internal/cmd/manager/manager.go @@ -540,7 +540,7 @@ func webhookRegistrations(mgr mcmanager.Manager, serverConfig config.NetworkServ return networkinggatewayv1webhooks.SetupBackendTLSPolicyWebhookWithManager(mgr) }}, {"HTTPProxy", true, func() error { - return networkingv1alphawebhooks.SetupHTTPProxyWebhookWithManager(mgr) + return networkingv1alphawebhooks.SetupHTTPProxyWebhookWithManager(mgr, serverConfig.Gateway) }}, {"TrafficProtectionPolicy", true, func() error { return networkingv1alphawebhooks.SetupTrafficProtectionPolicyWebhookWithManager(mgr) diff --git a/internal/controller/domain_controller.go b/internal/controller/domain_controller.go index d9f318ce..b5483f25 100644 --- a/internal/controller/domain_controller.go +++ b/internal/controller/domain_controller.go @@ -312,6 +312,9 @@ func (r *DomainReconciler) reconcileVerification(ctx context.Context, reader cli apimeta.RemoveStatusCondition(&domainStatus.Conditions, networkingv1alpha.DomainConditionVerifiedDNS) apimeta.RemoveStatusCondition(&domainStatus.Conditions, networkingv1alpha.DomainConditionVerifiedHTTP) apimeta.RemoveStatusCondition(&domainStatus.Conditions, networkingv1alpha.DomainConditionVerifiedDNSZone) + if r.Config.Gateway.CertificateService.Enabled { + recordVerificationMethod(domainStatus, verifiedDNSCondition, verifiedHTTPCondition) + } // When verified, no future verification timer is needed nextAttempt = time.Time{} } @@ -335,6 +338,19 @@ func (r *DomainReconciler) reconcileVerification(ctx context.Context, reader cli return nextAttempt } +// recordVerificationMethod keeps the condition for the method that proved +// ownership, so a consumer can tell DNS proof from HTTP proof after the +// verification scaffolding is cleared. DNS wins when both passed. +func recordVerificationMethod(domainStatus *networkingv1alpha.DomainStatus, verifiedDNS, verifiedHTTP *metav1.Condition) { + if verifiedDNS.Status == metav1.ConditionTrue { + apimeta.SetStatusCondition(&domainStatus.Conditions, *verifiedDNS) + return + } + if verifiedHTTP.Status == metav1.ConditionTrue { + apimeta.SetStatusCondition(&domainStatus.Conditions, *verifiedHTTP) + } +} + var dnsZoneListGVK = schema.GroupVersionKind{ Group: "dns.networking.miloapis.com", Version: versionV1Alpha1, diff --git a/internal/controller/domain_controller_test.go b/internal/controller/domain_controller_test.go index 25572928..9e2722b2 100644 --- a/internal/controller/domain_controller_test.go +++ b/internal/controller/domain_controller_test.go @@ -116,8 +116,50 @@ func TestDomainVerification(t *testing.T) { reconcileCount int // registryLookupDomain allows a test to control Domain.status.nameservers via reconcileRegistration. registryLookupDomain func(ctx context.Context, domain string, opts registrydata.LookupOptions) (*registrydata.DomainResult, error) + certificateService bool assert func(t *testing.T, domain *networkingv1alpha.Domain, result ctrl.Result) }{ + { + name: "dns record verification is remembered with the certificate service on", + certificateService: true, + lookupTXT: func(ctx context.Context, name string) ([]string, error) { + return []string{"test"}, nil + }, + httpGet: func(ctx context.Context, url string) ([]byte, *http.Response, error) { + return []byte("test"), &http.Response{StatusCode: http.StatusOK}, nil + }, + domain: newDomain(upstreamNamespace.Name, "dns-verify-remembered", func(domain *networkingv1alpha.Domain) { + domain.Status.Verification = &networkingv1alpha.DomainVerificationStatus{ + DNSRecord: networkingv1alpha.DNSVerificationRecord{Name: "test", Type: "TXT", Content: "test"}, + HTTPToken: networkingv1alpha.HTTPVerificationToken{URL: "test", Body: "test"}, + } + }), + assert: func(t *testing.T, domain *networkingv1alpha.Domain, result ctrl.Result) { + assert.True(t, apimeta.IsStatusConditionTrue(domain.Status.Conditions, networkingv1alpha.DomainConditionVerified)) + assert.True(t, apimeta.IsStatusConditionTrue(domain.Status.Conditions, networkingv1alpha.DomainConditionVerifiedDNS)) + assert.Nil(t, apimeta.FindStatusCondition(domain.Status.Conditions, networkingv1alpha.DomainConditionVerifiedHTTP)) + assert.Nil(t, domain.Status.Verification, "a verified domain must not show its verification scaffolding") + }, + }, + { + name: "http token verification is remembered with the certificate service on", + certificateService: true, + lookupTXT: func(ctx context.Context, name string) ([]string, error) { + return []string{}, &net.DNSError{IsNotFound: true} + }, + httpGet: func(ctx context.Context, url string) ([]byte, *http.Response, error) { + return []byte("test"), &http.Response{StatusCode: http.StatusOK}, nil + }, + domain: newDomain(upstreamNamespace.Name, "http-verify-remembered", func(domain *networkingv1alpha.Domain) { + domain.Status.Verification = &networkingv1alpha.DomainVerificationStatus{ + HTTPToken: networkingv1alpha.HTTPVerificationToken{URL: "test", Body: "test"}, + } + }), + assert: func(t *testing.T, domain *networkingv1alpha.Domain, result ctrl.Result) { + assert.True(t, apimeta.IsStatusConditionTrue(domain.Status.Conditions, networkingv1alpha.DomainConditionVerifiedHTTP)) + assert.Nil(t, apimeta.FindStatusCondition(domain.Status.Conditions, networkingv1alpha.DomainConditionVerifiedDNS)) + }, + }, { name: "verification details added to status", domain: newDomain(upstreamNamespace.Name, "test"), @@ -541,9 +583,11 @@ func TestDomainVerification(t *testing.T) { mgr := &fakeMockManager{cl: fakeUpstreamClient} + reconcilerConfig := operatorConfig + reconcilerConfig.Gateway.CertificateService.Enabled = tt.certificateService reconciler := &DomainReconciler{ mgr: mgr, - Config: operatorConfig, + Config: reconcilerConfig, timeNow: tt.timeNow, httpGet: tt.httpGet, diff --git a/internal/controller/gateway_controller.go b/internal/controller/gateway_controller.go index 98cc0d90..0cdddebc 100644 --- a/internal/controller/gateway_controller.go +++ b/internal/controller/gateway_controller.go @@ -302,7 +302,7 @@ func (r *GatewayReconciler) ensureDownstreamGateway( return result, nil } - verifiedHostnames, claimedHostnames, notClaimedHostnames, err := r.ensureHostnamesClaimed( + verifiedHostnames, claimedHostnames, hostnameRefusals, err := r.ensureHostnamesClaimed( ctx, upstreamClusterName, upstreamClient, @@ -473,7 +473,7 @@ func (r *GatewayReconciler) ensureDownstreamGateway( downstreamGateway, downstreamStrategy, verifiedHostnames, - notClaimedHostnames, + hostnameRefusals, listenerCertHealth, ) @@ -1414,9 +1414,9 @@ func (r *GatewayReconciler) ensureHostnamesClaimed( upstreamClient client.Client, upstreamGateway *gatewayv1.Gateway, downstreamGateway *gatewayv1.Gateway, -) (verifiedHostnames, claimedHostnames []string, notClaimedHostnames map[string]string, err error) { +) (verifiedHostnames, claimedHostnames []string, refusals map[string]hostnameRefusal, err error) { - verifiedHostnames, err = r.ensureHostnameVerification(ctx, upstreamClient, upstreamGateway, downstreamGateway) + verifiedHostnames, refusals, err = r.ensureHostnameVerification(ctx, upstreamClient, upstreamGateway, downstreamGateway) if err != nil { return nil, nil, nil, err } @@ -1425,7 +1425,6 @@ func (r *GatewayReconciler) ensureHostnamesClaimed( upstreamGatewayReferenceName := fmt.Sprintf("%s/%s/%s", upstreamClusterName, upstreamGateway.Namespace, upstreamGateway.Name) project := hostnameClaimProject(upstreamClusterName) - notClaimedHostnames = map[string]string{} // Track each hostname in a ConfigMap in the downstream control plane. // This will need to be adjusted as the number of hostnames grows to be large, @@ -1450,7 +1449,7 @@ func (r *GatewayReconciler) ensureHostnamesClaimed( claimExists := !hostnameConfigMap.CreationTimestamp.IsZero() if claimExists && hostnameConfigMap.Data[jsonKeyOwner] != upstreamGatewayReferenceName { - notClaimedHostnames[hostname] = hostnameInUseMessage(hostname) + refusals[hostname] = hostnameInUseRefusal(hostname) continue } @@ -1464,7 +1463,7 @@ func (r *GatewayReconciler) ensureHostnamesClaimed( return nil, nil, nil, err } if conflict.found() { - notClaimedHostnames[hostname] = subtreeConflictMessage(hostname, conflict) + refusals[hostname] = hostnameRefusal{reason: networkingv1alpha.HostnameInUseReason, message: subtreeConflictMessage(hostname, conflict)} continue } } @@ -1490,7 +1489,7 @@ func (r *GatewayReconciler) ensureHostnamesClaimed( if err := downstreamClient.Create(ctx, &hostnameConfigMap); err != nil { if apierrors.IsConflict(err) { - notClaimedHostnames[hostname] = hostnameInUseMessage(hostname) + refusals[hostname] = hostnameInUseRefusal(hostname) continue } return nil, nil, nil, err @@ -1531,11 +1530,14 @@ func (r *GatewayReconciler) ensureHostnamesClaimed( slices.Sort(claimedHostnames) - return verifiedHostnames, claimedHostnames, notClaimedHostnames, nil + return verifiedHostnames, claimedHostnames, refusals, nil } -func hostnameInUseMessage(hostname string) string { - return fmt.Sprintf("The hostname %q is already attached to a resource.", hostname) +func hostnameInUseRefusal(hostname string) hostnameRefusal { + return hostnameRefusal{ + reason: networkingv1alpha.HostnameInUseReason, + message: fmt.Sprintf("The hostname %q is already attached to a resource.", hostname), + } } func (r *GatewayReconciler) isDatumManagedGatewayHostname(upstreamGateway *gatewayv1.Gateway, hostname string) bool { @@ -1577,9 +1579,10 @@ func (r *GatewayReconciler) ensureHostnameVerification( upstreamClient client.Client, upstreamGateway *gatewayv1.Gateway, downstreamGateway *gatewayv1.Gateway, -) ([]string, error) { +) ([]string, map[string]hostnameRefusal, error) { logger := log.FromContext(ctx) + refusals := map[string]hostnameRefusal{} gatewayDefaultHostname := r.gatewayCanonicalHostname(upstreamGateway) // Get a unique set of hostnames currently declared on the upstream gateway. @@ -1640,7 +1643,7 @@ func (r *GatewayReconciler) ensureHostnameVerification( if r.Config.Gateway.DisableHostnameVerification { verifiedHostnamesSlice := hostnames.UnsortedList() slices.Sort(verifiedHostnamesSlice) - return verifiedHostnamesSlice, nil + return verifiedHostnamesSlice, refusals, nil } // List all Domains in the same namespace as the upstream gateway. A field @@ -1649,7 +1652,7 @@ func (r *GatewayReconciler) ensureHostnameVerification( var domainList networkingv1alpha.DomainList if err := upstreamClient.List(ctx, &domainList, client.InNamespace(upstreamGateway.Namespace)); err != nil { - return nil, fmt.Errorf("failed listing domains: %w", err) + return nil, nil, fmt.Errorf("failed listing domains: %w", err) } logger.Info("processing domains in same namespace", "domain_count", len(domainList.Items)) @@ -1660,6 +1663,27 @@ func (r *GatewayReconciler) ensureHostnameVerification( if addressHostnames.Has(hostname) { continue } + + if strings.HasPrefix(hostname, "*.") { + verifiedHostnames.Delete(hostname) + if !r.Config.Gateway.CertificateService.Enabled { + refusals[hostname] = hostnameRefusal{ + reason: networkingv1alpha.HostnameVerifiedReasonWildcardNotSupported, + message: fmt.Sprintf("The wildcard %q cannot be served: wildcard hostnames are not available on this platform.", hostname), + } + continue + } + ownership := checkWildcardOwnership(hostname, domainList.Items) + if ownership.proven { + verifiedHostnames.Insert(hostname) + continue + } + refusals[hostname] = ownership.refusal + if ownership.createDomain != "" { + domainsToCreate.Insert(ownership.createDomain) + } + continue + } foundMatchingDomain := false for _, domain := range domainList.Items { if hostname == domain.Spec.DomainName || strings.HasSuffix(hostname, "."+domain.Spec.DomainName) { @@ -1702,7 +1726,7 @@ func (r *GatewayReconciler) ensureHostnameVerification( } if err := upstreamClient.Create(ctx, domain); client.IgnoreAlreadyExists(err) != nil { - return nil, fmt.Errorf("failed creating domain: %w", err) + return nil, nil, fmt.Errorf("failed creating domain: %w", err) } logger.Info("domain created", "domain", domain.Name) @@ -1712,7 +1736,7 @@ func (r *GatewayReconciler) ensureHostnameVerification( verifiedHostnamesSlice := verifiedHostnames.UnsortedList() slices.Sort(verifiedHostnamesSlice) - return verifiedHostnamesSlice, nil + return verifiedHostnamesSlice, refusals, nil } // gatewayCanonicalHostname returns the managed canonical hostname for a gateway. @@ -2052,7 +2076,7 @@ func (r *GatewayReconciler) ensureDownstreamGatewayHTTPRoutes( downstreamGateway *gatewayv1.Gateway, downstreamStrategy downstreamclient.ResourceStrategy, verifiedHostnames []string, - notClaimedHostnames map[string]string, + refusals map[string]hostnameRefusal, listenerCertHealth map[gatewayv1.SectionName]listenerCertStatus, ) (result Result) { logger := log.FromContext(ctx) @@ -2147,7 +2171,7 @@ func (r *GatewayReconciler) ensureDownstreamGatewayHTTPRoutes( result = result.Merge(httpRouteResult) } - logger.Info("updating listener status", "verified_hostnames", verifiedHostnames, "not_claimed_hostnames", notClaimedHostnames) + logger.Info("updating listener status", "verified_hostnames", verifiedHostnames, "refused_hostnames", len(refusals)) currentListenerStatus := map[gatewayv1.SectionName]gatewayv1.ListenerStatus{} for _, listener := range upstreamGateway.Status.Listeners { @@ -2202,20 +2226,25 @@ func (r *GatewayReconciler) ensureDownstreamGatewayHTTPRoutes( if listener.Hostname != nil { + refusal, refused := refusals[string(*listener.Hostname)] if !slices.Contains(verifiedHostnames, string(*listener.Hostname)) { hostnameProblem = true acceptedCondition.Status = metav1.ConditionFalse acceptedCondition.Reason = networkingv1alpha.UnverifiedHostnamesPresent acceptedCondition.Message = fmt.Sprintf("The hostname %q has not been verified. Check status of Domains in the same namespace.", *listener.Hostname) + if refused { + acceptedCondition.Reason = refusal.reason + acceptedCondition.Message = refusal.message + } programmedCondition.Status = metav1.ConditionFalse programmedCondition.Reason = acceptedCondition.Reason programmedCondition.Message = acceptedCondition.Message - } else if message, refused := notClaimedHostnames[string(*listener.Hostname)]; refused { + } else if refused { hostnameProblem = true acceptedCondition.Status = metav1.ConditionFalse - acceptedCondition.Reason = networkingv1alpha.HostnameInUseReason - acceptedCondition.Message = message + acceptedCondition.Reason = refusal.reason + acceptedCondition.Message = refusal.message programmedCondition.Status = metav1.ConditionFalse programmedCondition.Reason = acceptedCondition.Reason diff --git a/internal/controller/hostname_claims_test.go b/internal/controller/hostname_claims_test.go index 0c77f28b..ba706faa 100644 --- a/internal/controller/hostname_claims_test.go +++ b/internal/controller/hostname_claims_test.go @@ -105,9 +105,10 @@ func claimHostnames(t *testing.T, enabled bool, project string, downstream clien &networkingv1alpha.Domain{ ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "example.com"}, Spec: networkingv1alpha.DomainSpec{DomainName: "example.com"}, - Status: networkingv1alpha.DomainStatus{Conditions: []metav1.Condition{{ - Type: networkingv1alpha.DomainConditionVerified, Status: metav1.ConditionTrue, Reason: networkingv1alpha.DomainReasonVerified, - }}}, + Status: networkingv1alpha.DomainStatus{Conditions: []metav1.Condition{ + {Type: networkingv1alpha.DomainConditionVerified, Status: metav1.ConditionTrue, Reason: networkingv1alpha.DomainReasonVerified}, + {Type: networkingv1alpha.DomainConditionVerifiedDNS, Status: metav1.ConditionTrue, Reason: networkingv1alpha.DomainReasonVerified}, + }}, }, ).Build() @@ -121,8 +122,12 @@ func claimHostnames(t *testing.T, enabled bool, project string, downstream clien } gw := claimingGateway(hostnames...) - _, all, refused, err := r.ensureHostnamesClaimed(context.Background(), project, upstream, gw, &gatewayv1.Gateway{}) + _, all, refusals, err := r.ensureHostnamesClaimed(context.Background(), project, upstream, gw, &gatewayv1.Gateway{}) require.NoError(t, err) + refused = map[string]string{} + for h, refusal := range refusals { + refused[h] = refusal.message + } for _, h := range all { if !strings.HasSuffix(h, ".datumproxy.net") { claimed = append(claimed, h) @@ -259,7 +264,7 @@ func TestSubtreeAwareHostnameClaims(t *testing.T) { }) } -func refusedHostnames(refusals map[string]string) []string { +func refusedHostnames[V any](refusals map[string]V) []string { if len(refusals) == 0 { return nil } diff --git a/internal/controller/httpproxy_controller.go b/internal/controller/httpproxy_controller.go index 0e19566f..4b140bd2 100644 --- a/internal/controller/httpproxy_controller.go +++ b/internal/controller/httpproxy_controller.go @@ -260,7 +260,7 @@ func (r *HTTPProxyReconciler) Reconcile(ctx context.Context, req mcreconcile.Req } } - if errs := validation.ValidateHTTPProxy(&httpProxy); len(errs) > 0 { + if errs := validation.ValidateHTTPProxy(&httpProxy, validation.HTTPProxyValidationOptions{Hostnames: validation.CustomHostnameOptions(r.Config.Gateway)}); len(errs) > 0 { acceptedCondition.Status = metav1.ConditionFalse acceptedCondition.Reason = networkingv1alpha.HTTPProxyReasonInvalid acceptedCondition.Message = fmt.Sprintf("The HTTPProxy is invalid and cannot be programmed: %s", errs.ToAggregate()) @@ -605,6 +605,7 @@ func (r *HTTPProxyReconciler) reconcileHTTPProxyHostnameStatus( nonAcceptedHostnames := sets.New[string]() inUseHostnames := sets.New[string]() inUseMessages := map[string]string{} + unverified := map[string]metav1.Condition{} for _, listener := range gateway.Spec.Listeners { if listener.Hostname == nil { // Should only happen shortly after creation, before the default hostnames @@ -627,6 +628,7 @@ func (r *HTTPProxyReconciler) reconcileHTTPProxyHostnameStatus( inUseMessages[string(*listener.Hostname)] = listenerAcceptedCondition.Message } else { nonAcceptedHostnames.Insert(string(*listener.Hostname)) + unverified[string(*listener.Hostname)] = *listenerAcceptedCondition } } else { nonAcceptedHostnames.Insert(string(*listener.Hostname)) @@ -692,8 +694,12 @@ func (r *HTTPProxyReconciler) reconcileHTTPProxyHostnameStatus( dnsStatuses := r.buildDNSStatuses(ctx, cl, gateway, httpProxyCopy.Generation) certificateStatuses := r.buildCertificateStatuses(ctx, cl, clusterName, gateway, httpProxyCopy) dnsRecordStatuses, recheckRouting := r.buildDNSRecordStatuses(ctx, cl, gateway, httpProxyCopy) + var verificationStatuses []networkingv1alpha.HostnameStatus + if r.Config.Gateway.CertificateService.Enabled { + verificationStatuses = buildVerificationStatuses(httpProxyCopy, acceptedHostnames, inUseHostnames, unverified) + } previousHostnameStatuses := httpProxyCopy.Status.HostnameStatuses - httpProxyCopy.Status.HostnameStatuses = mergeHostnameStatuses(availabilityStatuses, dnsStatuses, certificateStatuses, dnsRecordStatuses) + httpProxyCopy.Status.HostnameStatuses = mergeHostnameStatuses(availabilityStatuses, verificationStatuses, dnsStatuses, certificateStatuses, dnsRecordStatuses) preserveHostnameConditionTransitions(httpProxyCopy.Status.HostnameStatuses, previousHostnameStatuses) r.setCertificatesReadyCondition(httpProxyCopy, certificateStatuses, gateway) @@ -1400,6 +1406,44 @@ func buildAvailabilityStatuses( return statuses } +// buildVerificationStatuses reports, per custom hostname, whether its +// ownership is proven, and if not, the gateway's reason: a wildcard without +// DNS proof says so rather than reading like any unverified hostname. +func buildVerificationStatuses( + httpProxy *networkingv1alpha.HTTPProxy, + accepted sets.Set[gatewayv1.Hostname], + inUse sets.Set[string], + unverified map[string]metav1.Condition, +) []networkingv1alpha.HostnameStatus { + statuses := make([]networkingv1alpha.HostnameStatus, 0, len(httpProxy.Spec.Hostnames)) + for _, hostname := range httpProxy.Spec.Hostnames { + condition := metav1.Condition{ + Type: networkingv1alpha.HostnameConditionVerified, + ObservedGeneration: httpProxy.Generation, + } + listenerCondition, refused := unverified[string(hostname)] + switch { + case accepted.Has(hostname) || inUse.Has(string(hostname)): + condition.Status = metav1.ConditionTrue + condition.Reason = networkingv1alpha.HostnameVerifiedReasonVerified + condition.Message = "Ownership of this hostname is verified" + case refused: + condition.Status = metav1.ConditionFalse + condition.Reason = listenerCondition.Reason + if condition.Reason == networkingv1alpha.UnverifiedHostnamesPresent { + condition.Reason = networkingv1alpha.HostnameVerifiedReasonPendingVerification + } + condition.Message = listenerCondition.Message + default: + continue + } + hs := networkingv1alpha.HostnameStatus{Hostname: string(hostname)} + apimeta.SetStatusCondition(&hs.Conditions, condition) + statuses = append(statuses, hs) + } + return statuses +} + // explainInUseHostnames carries the gateway's reason a hostname could not be // claimed onto its Available condition, so a hostname refused for sitting // under another project's wildcard says so. diff --git a/internal/controller/httpproxy_dns_records.go b/internal/controller/httpproxy_dns_records.go index f71c9af9..7e0bea5c 100644 --- a/internal/controller/httpproxy_dns_records.go +++ b/internal/controller/httpproxy_dns_records.go @@ -310,17 +310,24 @@ func (r *HTTPProxyReconciler) certificateRecords( } // ownershipRecord returns the TXT record that would verify the most specific -// Domain covering the hostname, while no covering Domain is verified yet. +// Domain covering the hostname, while no covering Domain proves it yet. A +// wildcard needs a Domain verified by DNS; any verified Domain will do for an +// exact hostname. func ownershipRecord(hostname string, domains []networkingv1alpha.Domain) (networkingv1alpha.HostnameDNSRecord, bool) { + base, wildcard := strings.CutPrefix(hostname, "*.") var pending *networkingv1alpha.Domain for i := range domains { d := &domains[i] - if !domainCoversHostname(d.Spec.DomainName, hostname) { + if !domainCoversHostname(d.Spec.DomainName, base) { continue } - if apimeta.IsStatusConditionTrue(d.Status.Conditions, networkingv1alpha.DomainConditionVerified) { + verified := apimeta.IsStatusConditionTrue(d.Status.Conditions, networkingv1alpha.DomainConditionVerified) + if (wildcard && provenByDNS(d)) || (!wildcard && verified) { return networkingv1alpha.HostnameDNSRecord{}, false } + if verified { + continue + } if d.Status.Verification == nil || d.Status.Verification.DNSRecord.Name == "" { continue } diff --git a/internal/controller/wildcard_ownership.go b/internal/controller/wildcard_ownership.go new file mode 100644 index 00000000..8ad1c440 --- /dev/null +++ b/internal/controller/wildcard_ownership.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "fmt" + "strings" + + apimeta "k8s.io/apimachinery/pkg/api/meta" + + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +// hostnameRefusal says why a hostname was not admitted, for the listener's +// Accepted condition and the hostname's status. +type hostnameRefusal struct { + reason string + message string +} + +// provenByDNS reports whether a Domain was verified by a DNS TXT record. A +// Datum DNS zone does not count yet: any project can create a zone for any +// domain, and a zone proves ownership only once Datum serves it and the +// registry delegates to it. An HTTP token does not count either, since anyone +// who can serve one name beneath a wildcard can serve the token. +func provenByDNS(domain *networkingv1alpha.Domain) bool { + return apimeta.IsStatusConditionTrue(domain.Status.Conditions, networkingv1alpha.DomainConditionVerified) && + apimeta.IsStatusConditionTrue(domain.Status.Conditions, networkingv1alpha.DomainConditionVerifiedDNS) +} + +// wildcardOwnership decides whether a wildcard's base, or a parent of it, is +// proven by DNS. When it is not, it explains why and names a Domain to create +// so the user has a TXT record to publish. +type wildcardOwnership struct { + proven bool + refusal hostnameRefusal + createDomain string +} + +func checkWildcardOwnership(hostname string, domains []networkingv1alpha.Domain) wildcardOwnership { + base := strings.TrimPrefix(hostname, "*.") + + var verified, pending *networkingv1alpha.Domain + baseDomainExists := false + for i := range domains { + d := &domains[i] + if !domainCoversHostname(d.Spec.DomainName, base) { + continue + } + if d.Spec.DomainName == base { + baseDomainExists = true + } + if provenByDNS(d) { + return wildcardOwnership{proven: true} + } + mostSpecific := func(current *networkingv1alpha.Domain) bool { + return current == nil || len(d.Spec.DomainName) > len(current.Spec.DomainName) + } + if apimeta.IsStatusConditionTrue(d.Status.Conditions, networkingv1alpha.DomainConditionVerified) { + if mostSpecific(verified) { + verified = d + } + } else if mostSpecific(pending) { + pending = d + } + } + + need := fmt.Sprintf("The wildcard %q needs DNS proof that you control %s or a parent domain. ", hostname, base) + result := wildcardOwnership{refusal: hostnameRefusal{reason: networkingv1alpha.HostnameVerifiedReasonDNSVerificationRequired}} + + if verified != nil { + need += fmt.Sprintf("Domain %q was verified %s. ", verified.Spec.DomainName, verificationMethodPhrase(verified)) + } + + switch { + case pending != nil: + result.refusal.message = need + fmt.Sprintf("Publish the DNS TXT record shown on Domain %q; HTTP verification does not count for wildcards.", pending.Spec.DomainName) + case !baseDomainExists: + result.createDomain = base + if verified == nil { + result.createDomain = registrableDomain(base) + } + result.refusal.message = need + fmt.Sprintf("Publish the DNS TXT record shown on Domain %q to prove it.", result.createDomain) + default: + result.refusal.message = need + fmt.Sprintf("Add a Domain for a parent of %s and publish the DNS TXT record shown on it.", base) + } + + return result +} + +func verificationMethodPhrase(domain *networkingv1alpha.Domain) string { + switch { + case apimeta.IsStatusConditionTrue(domain.Status.Conditions, networkingv1alpha.DomainConditionVerifiedHTTP): + return "over HTTP, which does not prove control of every name beneath it" + case apimeta.IsStatusConditionTrue(domain.Status.Conditions, networkingv1alpha.DomainConditionVerifiedDNSZone): + return "through a Datum DNS zone, which does not yet count as proof for wildcards" + default: + return "before the platform recorded how, so it does not count as DNS proof" + } +} + +func registrableDomain(hostname string) string { + parts := strings.Split(hostname, ".") + if len(parts) < 2 { + return hostname + } + return strings.Join(parts[len(parts)-2:], ".") +} diff --git a/internal/controller/wildcard_ownership_test.go b/internal/controller/wildcard_ownership_test.go new file mode 100644 index 00000000..36d674ad --- /dev/null +++ b/internal/controller/wildcard_ownership_test.go @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" + "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" + "go.datum.net/network-services-operator/internal/config" +) + +func domainProvenBy(name string, method string) networkingv1alpha.Domain { + d := networkingv1alpha.Domain{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name}, + Spec: networkingv1alpha.DomainSpec{DomainName: name}, + Status: networkingv1alpha.DomainStatus{Conditions: []metav1.Condition{{ + Type: networkingv1alpha.DomainConditionVerified, Status: metav1.ConditionTrue, Reason: networkingv1alpha.DomainReasonVerified, + }}}, + } + if method != "" { + d.Status.Conditions = append(d.Status.Conditions, metav1.Condition{Type: method, Status: metav1.ConditionTrue, Reason: networkingv1alpha.DomainReasonVerified}) + } + return d +} + +func domainPending(name string) networkingv1alpha.Domain { + return networkingv1alpha.Domain{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name}, + Spec: networkingv1alpha.DomainSpec{DomainName: name}, + Status: networkingv1alpha.DomainStatus{Conditions: []metav1.Condition{{ + Type: networkingv1alpha.DomainConditionVerified, Status: metav1.ConditionFalse, Reason: networkingv1alpha.DomainReasonPendingVerification, + }}}, + } +} + +func TestCheckWildcardOwnership(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + domains []networkingv1alpha.Domain + wantProven bool + wantMessage []string + wantCreation string + }{ + { + name: "a parent verified by DNS TXT proves the wildcard", + domains: []networkingv1alpha.Domain{domainProvenBy("example.com", networkingv1alpha.DomainConditionVerifiedDNS)}, + wantProven: true, + }, + { + name: "the base itself verified by DNS TXT proves the wildcard", + domains: []networkingv1alpha.Domain{domainProvenBy("s3.example.com", networkingv1alpha.DomainConditionVerifiedDNS)}, + wantProven: true, + }, + { + name: "HTTP token verification is refused and a Domain for the base is offered", + domains: []networkingv1alpha.Domain{domainProvenBy("example.com", networkingv1alpha.DomainConditionVerifiedHTTP)}, + wantMessage: []string{`Domain "example.com" was verified over HTTP`, `Publish the DNS TXT record shown on Domain "s3.example.com"`}, + wantCreation: "s3.example.com", + }, + { + name: "a Datum DNS zone is refused until zone claims settle what it proves", + domains: []networkingv1alpha.Domain{domainProvenBy("example.com", networkingv1alpha.DomainConditionVerifiedDNSZone)}, + wantMessage: []string{"through a Datum DNS zone, which does not yet count"}, + wantCreation: "s3.example.com", + }, + { + name: "a Domain verified before the method was recorded is refused", + domains: []networkingv1alpha.Domain{domainProvenBy("example.com", "")}, + wantMessage: []string{"before the platform recorded how"}, + wantCreation: "s3.example.com", + }, + { + name: "a pending Domain is the one to verify", + domains: []networkingv1alpha.Domain{domainProvenBy("example.com", networkingv1alpha.DomainConditionVerifiedHTTP), domainPending("s3.example.com")}, + wantMessage: []string{`Publish the DNS TXT record shown on Domain "s3.example.com"; HTTP verification does not count`}, + }, + { + name: "a base verified over HTTP points at a parent", + domains: []networkingv1alpha.Domain{domainProvenBy("s3.example.com", networkingv1alpha.DomainConditionVerifiedHTTP)}, + wantMessage: []string{"Add a Domain for a parent of s3.example.com"}, + }, + { + name: "no Domain at all creates one for the registrable domain", + wantMessage: []string{`Domain "example.com"`}, + wantCreation: "example.com", + }, + { + name: "a DNS-verified sibling proves nothing", + domains: []networkingv1alpha.Domain{domainProvenBy("s4.example.com", networkingv1alpha.DomainConditionVerifiedDNS), domainProvenBy("photos.s3.example.com", networkingv1alpha.DomainConditionVerifiedDNS)}, + wantCreation: "example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := checkWildcardOwnership("*.s3.example.com", tt.domains) + assert.Equal(t, tt.wantProven, got.proven) + assert.Equal(t, tt.wantCreation, got.createDomain) + if tt.wantProven { + return + } + assert.Equal(t, networkingv1alpha.HostnameVerifiedReasonDNSVerificationRequired, got.refusal.reason) + for _, m := range tt.wantMessage { + assert.Contains(t, got.refusal.message, m) + } + }) + } +} + +func TestWildcardVerification(t *testing.T) { + t.Parallel() + + verify := func(t *testing.T, enabled bool, downstream *gatewayv1.Gateway, domains ...networkingv1alpha.Domain) ([]string, map[string]hostnameRefusal, client.Client) { + t.Helper() + objects := make([]client.Object, 0, len(domains)) + for i := range domains { + objects = append(objects, &domains[i]) + } + upstream := fake.NewClientBuilder().WithScheme(claimsTestScheme(t)).WithObjects(objects...).Build() + r := &GatewayReconciler{Config: config.NetworkServicesOperator{Gateway: config.GatewayConfig{ + TargetDomain: "datumproxy.net", + CertificateService: config.CertificateServiceConfig{Enabled: enabled}, + }}} + verified, refusals, err := r.ensureHostnameVerification(context.Background(), upstream, claimingGateway("*.s3.example.com", "www.example.com"), downstream) + require.NoError(t, err) + return verified, refusals, upstream + } + + t.Run("HTTP token proof admits exact hostnames but not the wildcard", func(t *testing.T) { + t.Parallel() + verified, refusals, upstream := verify(t, true, &gatewayv1.Gateway{}, domainProvenBy("example.com", networkingv1alpha.DomainConditionVerifiedHTTP)) + + assert.Contains(t, verified, "www.example.com") + assert.NotContains(t, verified, "*.s3.example.com") + assert.Equal(t, networkingv1alpha.HostnameVerifiedReasonDNSVerificationRequired, refusals["*.s3.example.com"].reason) + + var created networkingv1alpha.Domain + require.NoError(t, upstream.Get(context.Background(), client.ObjectKey{Namespace: "default", Name: "s3.example.com"}, &created)) + assert.Equal(t, "s3.example.com", created.Spec.DomainName) + }) + + t.Run("zone proof does not admit the wildcard", func(t *testing.T) { + t.Parallel() + verified, refusals, _ := verify(t, true, &gatewayv1.Gateway{}, domainProvenBy("example.com", networkingv1alpha.DomainConditionVerifiedDNSZone)) + + assert.NotContains(t, verified, "*.s3.example.com") + assert.Contains(t, refusals["*.s3.example.com"].message, "Datum DNS zone") + }) + + t.Run("DNS TXT proof admits the wildcard", func(t *testing.T) { + t.Parallel() + verified, refusals, _ := verify(t, true, &gatewayv1.Gateway{}, domainProvenBy("example.com", networkingv1alpha.DomainConditionVerifiedDNS)) + + assert.Contains(t, verified, "*.s3.example.com") + assert.Empty(t, refusals) + }) + + t.Run("a wildcard already serving keeps no grace once its proof is gone", func(t *testing.T) { + t.Parallel() + serving := &gatewayv1.Gateway{Spec: gatewayv1.GatewaySpec{Listeners: []gatewayv1.Listener{{ + Name: "http-hostname-0", Hostname: ptr.To(gatewayv1.Hostname("*.s3.example.com")), + }}}} + verified, _, _ := verify(t, true, serving, domainProvenBy("example.com", networkingv1alpha.DomainConditionVerifiedHTTP)) + + assert.NotContains(t, verified, "*.s3.example.com") + }) + + t.Run("wildcards are refused with the certificate service off", func(t *testing.T) { + t.Parallel() + verified, refusals, _ := verify(t, false, &gatewayv1.Gateway{}, domainProvenBy("example.com", networkingv1alpha.DomainConditionVerifiedDNS)) + + assert.NotContains(t, verified, "*.s3.example.com") + assert.Equal(t, networkingv1alpha.HostnameVerifiedReasonWildcardNotSupported, refusals["*.s3.example.com"].reason) + }) +} + +func TestBuildVerificationStatuses(t *testing.T) { + t.Parallel() + + proxy := &networkingv1alpha.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{Generation: 4}, + Spec: networkingv1alpha.HTTPProxySpec{Hostnames: []gatewayv1.Hostname{"*.s3.example.com", "www.example.com", "new.example.com", "taken.example.com"}}, + } + statuses := buildVerificationStatuses(proxy, + sets.New[gatewayv1.Hostname]("www.example.com"), + sets.New("taken.example.com"), + map[string]metav1.Condition{ + "*.s3.example.com": {Reason: networkingv1alpha.HostnameVerifiedReasonDNSVerificationRequired, Message: "needs DNS proof"}, + "new.example.com": {Reason: networkingv1alpha.UnverifiedHostnamesPresent, Message: "not verified"}, + }, + ) + + byName := map[string]metav1.Condition{} + for _, hs := range statuses { + byName[hs.Hostname] = hs.Conditions[0] + } + assert.Equal(t, metav1.ConditionFalse, byName["*.s3.example.com"].Status) + assert.Equal(t, networkingv1alpha.HostnameVerifiedReasonDNSVerificationRequired, byName["*.s3.example.com"].Reason) + assert.Equal(t, "needs DNS proof", byName["*.s3.example.com"].Message) + assert.Equal(t, networkingv1alpha.HostnameVerifiedReasonPendingVerification, byName["new.example.com"].Reason) + assert.Equal(t, metav1.ConditionTrue, byName["www.example.com"].Status) + assert.Equal(t, metav1.ConditionTrue, byName["taken.example.com"].Status) +} + +func TestOwnershipRecordForWildcards(t *testing.T) { + t.Parallel() + + pending := domainPending("s3.example.com") + pending.Status.Verification = &networkingv1alpha.DomainVerificationStatus{ + DNSRecord: networkingv1alpha.DNSVerificationRecord{Name: "datum-custom-hostname.s3.example.com", Type: "TXT", Content: "token"}, + } + httpVerified := domainProvenBy("example.com", networkingv1alpha.DomainConditionVerifiedHTTP) + + record, ok := ownershipRecord("*.s3.example.com", []networkingv1alpha.Domain{httpVerified, pending}) + require.True(t, ok, "a wildcard without DNS proof lists the TXT record that would prove it") + assert.Equal(t, "datum-custom-hostname.s3.example.com", record.Name) + + _, ok = ownershipRecord("www.example.com", []networkingv1alpha.Domain{httpVerified, pending}) + assert.False(t, ok, "an exact hostname is satisfied by HTTP proof") + + _, ok = ownershipRecord("*.s3.example.com", []networkingv1alpha.Domain{domainProvenBy("example.com", networkingv1alpha.DomainConditionVerifiedDNS), pending}) + assert.False(t, ok) +} diff --git a/internal/validation/gateway_validation.go b/internal/validation/gateway_validation.go index dbefeda7..a822e7a1 100644 --- a/internal/validation/gateway_validation.go +++ b/internal/validation/gateway_validation.go @@ -4,7 +4,6 @@ import ( "fmt" "slices" - "k8s.io/apimachinery/pkg/util/validation" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/utils/ptr" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" @@ -103,7 +102,7 @@ func validateListeners(gateway *gatewayv1.Gateway, fldPath *field.Path, opts Gat } else if l.Hostname == nil { allErrs = append(allErrs, field.Required(listenerPath.Child("hostname"), fmt.Sprintf("must be set to %q or a custom hostname", opts.GatewayDNSAddressFunc(gateway)))) } else if !opts.SkipHostnameFQDNValidation { - allErrs = append(allErrs, validation.IsFullyQualifiedDomainName(listenerPath.Child("hostname"), string(*l.Hostname))...) + allErrs = append(allErrs, ValidateCustomHostname(listenerPath.Child("hostname"), string(*l.Hostname), opts.Hostnames)...) } if !slices.Contains(opts.ValidPortNumbers, int(l.Port)) { @@ -187,6 +186,7 @@ type GatewayValidationOptions struct { GatewayDNSAddressFunc func(gateway *gatewayv1.Gateway) string ClusterName string SkipHostnameFQDNValidation bool + Hostnames HostnameOptions } type validPortNumbers []int diff --git a/internal/validation/hostname_validation.go b/internal/validation/hostname_validation.go new file mode 100644 index 00000000..64cbc5fe --- /dev/null +++ b/internal/validation/hostname_validation.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package validation + +import ( + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/apimachinery/pkg/util/validation/field" + + "go.datum.net/network-services-operator/internal/config" +) + +// HostnameOptions says which custom hostnames are acceptable. +type HostnameOptions struct { + // AllowWildcards admits a single leading wildcard label, as in + // "*.s3.example.com". + AllowWildcards bool + + // PlatformDomains are the domains the platform names its own hostnames + // under. No wildcard may sit beneath them. + PlatformDomains []string +} + +// CustomHostnameOptions derives the hostname rules from operator settings: +// wildcards ride on the certificate service, which issues their certificates. +func CustomHostnameOptions(gatewayConfig config.GatewayConfig) HostnameOptions { + return HostnameOptions{ + AllowWildcards: gatewayConfig.CertificateService.Enabled, + PlatformDomains: gatewayConfig.ManagedTargetDomains(), + } +} + +// ValidateCustomHostname checks a hostname a user attaches to a load balancer. +func ValidateCustomHostname(fldPath *field.Path, hostname string, opts HostnameOptions) field.ErrorList { + base, wildcard := strings.CutPrefix(hostname, "*.") + if !wildcard || !opts.AllowWildcards { + return validation.IsFullyQualifiedDomainName(fldPath, hostname) + } + + if strings.Contains(base, "*") || len(validation.IsFullyQualifiedDomainName(fldPath, base)) > 0 { + return field.ErrorList{field.Invalid(fldPath, hostname, + `a wildcard must be a single leading "*." label followed by a domain with at least two labels, e.g. "*.example.com"`)} + } + + for _, platform := range opts.PlatformDomains { + if base == platform || strings.HasSuffix(base, "."+platform) { + return field.ErrorList{field.Invalid(fldPath, hostname, + fmt.Sprintf("wildcards under %s are managed by the platform and cannot be attached", platform))} + } + } + + return nil +} diff --git a/internal/validation/hostname_validation_test.go b/internal/validation/hostname_validation_test.go new file mode 100644 index 00000000..f6f10483 --- /dev/null +++ b/internal/validation/hostname_validation_test.go @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package validation + +import ( + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" + "go.datum.net/network-services-operator/internal/config" +) + +func TestValidateCustomHostname(t *testing.T) { + t.Parallel() + + wildcards := HostnameOptions{AllowWildcards: true, PlatformDomains: []string{"datumproxy.net", "prism.global.datum-dns.net"}} + + tests := []struct { + hostname string + opts HostnameOptions + wantErr string + }{ + {hostname: "app.example.com", opts: wildcards}, + {hostname: "*.s3.example.com", opts: wildcards}, + {hostname: "*.example.com", opts: wildcards}, + {hostname: "*.s3.example.com", wantErr: "a lowercase RFC 1123 subdomain"}, + {hostname: "*.com", opts: wildcards, wantErr: `a single leading "*." label`}, + {hostname: "*.*.example.com", opts: wildcards, wantErr: `a single leading "*." label`}, + {hostname: "foo.*.example.com", opts: wildcards, wantErr: "a lowercase RFC 1123 subdomain"}, + {hostname: "*example.com", opts: wildcards, wantErr: "a lowercase RFC 1123 subdomain"}, + {hostname: "*.datumproxy.net", opts: wildcards, wantErr: "wildcards under datumproxy.net are managed by the platform"}, + {hostname: "*.abc.datumproxy.net", opts: wildcards, wantErr: "wildcards under datumproxy.net are managed by the platform"}, + {hostname: "*.x.prism.global.datum-dns.net", opts: wildcards, wantErr: "managed by the platform"}, + {hostname: "*.notdatumproxy.net", opts: wildcards}, + } + + for _, tt := range tests { + t.Run(tt.hostname, func(t *testing.T) { + t.Parallel() + errs := ValidateCustomHostname(field.NewPath("spec", "hostnames").Index(0), tt.hostname, tt.opts) + if tt.wantErr == "" { + assert.Empty(t, errs) + return + } + if assert.Len(t, errs, 1) { + assert.Contains(t, errs[0].Error(), tt.wantErr) + } + }) + } +} + +func TestCustomHostnameOptionsFollowTheCertificateService(t *testing.T) { + t.Parallel() + + off := CustomHostnameOptions(config.GatewayConfig{TargetDomain: "datumproxy.net"}) + assert.False(t, off.AllowWildcards) + + on := CustomHostnameOptions(config.GatewayConfig{ + TargetDomain: "datumproxy.net", + LegacyTargetDomains: []string{"prism.global.datum-dns.net"}, + CertificateService: config.CertificateServiceConfig{Enabled: true}, + }) + assert.True(t, on.AllowWildcards) + assert.Equal(t, []string{"datumproxy.net", "prism.global.datum-dns.net"}, on.PlatformDomains) +} + +func TestValidateHTTPProxyWildcardHostnames(t *testing.T) { + t.Parallel() + + proxy := &networkingv1alpha.HTTPProxy{ + ObjectMeta: metav1.ObjectMeta{Name: "s3"}, + Spec: networkingv1alpha.HTTPProxySpec{Hostnames: []gatewayv1.Hostname{"*.s3.example.com"}}, + } + + assert.NotEmpty(t, ValidateHTTPProxy(proxy, HTTPProxyValidationOptions{}), "wildcards stay refused without the certificate service") + assert.Empty(t, ValidateHTTPProxy(proxy, HTTPProxyValidationOptions{Hostnames: HostnameOptions{AllowWildcards: true}})) +} + +func TestValidateGatewayWildcardListener(t *testing.T) { + t.Parallel() + + gateway := &gatewayv1.Gateway{ + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "test-gateway-class", + Listeners: []gatewayv1.Listener{{ + Name: "http-hostname-0", + Protocol: gatewayv1.HTTPProtocolType, + Port: 80, + Hostname: ptr.To(gatewayv1.Hostname("*.s3.example.com")), + }}, + }, + } + opts := GatewayValidationOptions{ + ValidPortNumbers: []int{80, 443}, + ValidProtocolTypes: map[int][]gatewayv1.ProtocolType{80: {gatewayv1.HTTPProtocolType}}, + } + + assert.NotEmpty(t, ValidateGateway(gateway, opts), "wildcard listeners stay refused without the certificate service") + + opts.Hostnames = HostnameOptions{AllowWildcards: true} + assert.Empty(t, ValidateGateway(gateway, opts)) +} diff --git a/internal/validation/httpproxy_validation.go b/internal/validation/httpproxy_validation.go index 0a75e1a1..6d29c3fd 100644 --- a/internal/validation/httpproxy_validation.go +++ b/internal/validation/httpproxy_validation.go @@ -22,7 +22,13 @@ const ( maxPortNumber = 65535 ) -func ValidateHTTPProxy(httpProxy *networkingv1alpha.HTTPProxy) field.ErrorList { +// HTTPProxyValidationOptions carries the operator settings that change what an +// HTTPProxy may declare. +type HTTPProxyValidationOptions struct { + Hostnames HostnameOptions +} + +func ValidateHTTPProxy(httpProxy *networkingv1alpha.HTTPProxy, opts HTTPProxyValidationOptions) field.ErrorList { allErrs := field.ErrorList{} @@ -30,7 +36,7 @@ func ValidateHTTPProxy(httpProxy *networkingv1alpha.HTTPProxy) field.ErrorList { hostnames := sets.New[gatewayv1.Hostname]() for i, hostname := range httpProxy.Spec.Hostnames { hostnamePath := hostnamesPath.Index(i).Child("hostname") - allErrs = append(allErrs, validation.IsFullyQualifiedDomainName(hostnamePath, string(hostname))...) + allErrs = append(allErrs, ValidateCustomHostname(hostnamePath, string(hostname), opts.Hostnames)...) if hostnames.Has(hostname) { allErrs = append(allErrs, field.Duplicate(hostnamePath, hostname)) } else { diff --git a/internal/validation/httpproxy_validation_test.go b/internal/validation/httpproxy_validation_test.go index e945d840..4873b8f0 100644 --- a/internal/validation/httpproxy_validation_test.go +++ b/internal/validation/httpproxy_validation_test.go @@ -624,7 +624,7 @@ func TestValidateHTTPProxy(t *testing.T) { if scenario.proxy.Name == "" { scenario.proxy.Name = "test" } - errs := ValidateHTTPProxy(scenario.proxy) + errs := ValidateHTTPProxy(scenario.proxy, HTTPProxyValidationOptions{}) delta := cmp.Diff(scenario.expectedErrors, errs, cmpopts.IgnoreFields(field.Error{}, "BadValue", "Detail")) if delta != "" { t.Errorf("Testcase %s - expected errors '%v', got '%v', diff: '%v'", name, scenario.expectedErrors, errs, delta) diff --git a/internal/webhook/v1/gateway_webhook.go b/internal/webhook/v1/gateway_webhook.go index f98fc4fd..45506e6f 100644 --- a/internal/webhook/v1/gateway_webhook.go +++ b/internal/webhook/v1/gateway_webhook.go @@ -35,6 +35,7 @@ func SetupGatewayWebhookWithManager(mgr mcmanager.Manager, config config.Network ValidProtocolTypes: config.Gateway.ValidProtocolTypes, GatewayDNSAddressFunc: config.Gateway.GatewayDNSAddress, SkipHostnameFQDNValidation: config.Gateway.DisableHostnameVerification, + Hostnames: validation.CustomHostnameOptions(config.Gateway), } return ctrl.NewWebhookManagedBy(mgr.GetLocalManager(), &gatewayv1.Gateway{}). diff --git a/internal/webhook/v1alpha/httpproxy_webhook.go b/internal/webhook/v1alpha/httpproxy_webhook.go index 42dded68..13db962c 100644 --- a/internal/webhook/v1alpha/httpproxy_webhook.go +++ b/internal/webhook/v1alpha/httpproxy_webhook.go @@ -13,6 +13,7 @@ import ( mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" + "go.datum.net/network-services-operator/internal/config" "go.datum.net/network-services-operator/internal/display" "go.datum.net/network-services-operator/internal/validation" webhookutil "go.datum.net/network-services-operator/internal/webhook" @@ -21,9 +22,9 @@ import ( // nolint:unused // SetupHTTPProxyWebhookWithManager registers the webhook for HTTPProxy in the manager. -func SetupHTTPProxyWebhookWithManager(mgr mcmanager.Manager) error { +func SetupHTTPProxyWebhookWithManager(mgr mcmanager.Manager, gatewayConfig config.GatewayConfig) error { return ctrl.NewWebhookManagedBy(mgr.GetLocalManager(), &networkingv1alpha.HTTPProxy{}). - WithValidator(&HTTPProxyCustomValidator{mgr: mgr}). + WithValidator(&HTTPProxyCustomValidator{mgr: mgr, opts: validation.HTTPProxyValidationOptions{Hostnames: validation.CustomHostnameOptions(gatewayConfig)}}). WithDefaulter(&HTTPProxyCustomDefaulter{}). Complete() } @@ -55,7 +56,8 @@ func oldHTTPProxy(ctx context.Context) *networkingv1alpha.HTTPProxy { // +kubebuilder:webhook:path=/validate-networking-datumapis-com-v1alpha-httpproxy,mutating=false,failurePolicy=fail,sideEffects=None,groups=networking.datumapis.com,resources=httpproxies,verbs=create;update,versions=v1alpha,name=vhttpproxy-v1alpha.kb.io,admissionReviewVersions=v1 type HTTPProxyCustomValidator struct { - mgr mcmanager.Manager + mgr mcmanager.Manager + opts validation.HTTPProxyValidationOptions } var _ admission.Validator[*networkingv1alpha.HTTPProxy] = &HTTPProxyCustomValidator{} @@ -72,7 +74,7 @@ func (v *HTTPProxyCustomValidator) ValidateCreate(ctx context.Context, httpProxy // // For now, validate any HTTPProxy based on this operator's validation rules. - if errs := validation.ValidateHTTPProxy(httpProxy); len(errs) > 0 { + if errs := validation.ValidateHTTPProxy(httpProxy, v.opts); len(errs) > 0 { return nil, errors.NewInvalid(httpProxy.GetObjectKind().GroupVersionKind().GroupKind(), httpProxy.GetName(), errs) } @@ -89,7 +91,7 @@ func (v *HTTPProxyCustomValidator) ValidateUpdate(ctx context.Context, oldHTTPPr return nil, nil } - if errs := validation.ValidateHTTPProxy(newHTTPProxy); len(errs) > 0 { + if errs := validation.ValidateHTTPProxy(newHTTPProxy, v.opts); len(errs) > 0 { return nil, errors.NewInvalid(oldHTTPProxy.GetObjectKind().GroupVersionKind().GroupKind(), newHTTPProxy.GetName(), errs) } diff --git a/internal/webhook/v1alpha/httpproxy_webhook_wildcard_test.go b/internal/webhook/v1alpha/httpproxy_webhook_wildcard_test.go new file mode 100644 index 00000000..0dcf3013 --- /dev/null +++ b/internal/webhook/v1alpha/httpproxy_webhook_wildcard_test.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package v1alpha + +import ( + "context" + "strings" + "testing" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + "go.datum.net/network-services-operator/internal/config" + "go.datum.net/network-services-operator/internal/validation" +) + +func TestHTTPProxyValidateCreateWildcardHostnames(t *testing.T) { + validatorFor := func(enabled bool) *HTTPProxyCustomValidator { + gatewayConfig := config.GatewayConfig{ + TargetDomain: "datumproxy.net", + CertificateService: config.CertificateServiceConfig{Enabled: enabled}, + } + return &HTTPProxyCustomValidator{opts: validation.HTTPProxyValidationOptions{Hostnames: validation.CustomHostnameOptions(gatewayConfig)}} + } + + tests := []struct { + name string + enabled bool + hostname string + wantError string + }{ + {name: "wildcard refused with the certificate service off", hostname: "*.s3.example.com", wantError: "spec.hostnames[0].hostname"}, + {name: "wildcard admitted with the certificate service on", enabled: true, hostname: "*.s3.example.com"}, + {name: "platform wildcard refused", enabled: true, hostname: "*.datumproxy.net", wantError: "managed by the platform"}, + {name: "two wildcard labels refused", enabled: true, hostname: "*.*.example.com", wantError: `single leading "*." label`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + proxy := httpProxyWithEndpoint("https://origin.example.com") + proxy.Spec.Hostnames = []gatewayv1.Hostname{gatewayv1.Hostname(tt.hostname)} + + _, err := validatorFor(tt.enabled).ValidateCreate(context.Background(), proxy) + if tt.wantError == "" { + if err != nil { + t.Fatalf("ValidateCreate() = %v, want no error", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf("ValidateCreate() = %v, want an error containing %q", err, tt.wantError) + } + }) + } +}