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)
+ }
+ })
+ }
+}