Skip to content

Commit d685fb3

Browse files
authored
fix(gateway)!: require tenant routes to declare their hostnames (#3475)
## What this PR does The tenant route hostname policy skipped the shape it most needed to check. Its CEL opened with `!has(object.spec.hostnames) ||`, so an HTTPRoute or TLSRoute declaring no hostnames satisfied the first operand and got admitted with nothing compared against the tenant apex. An empty list passed too: `has()` is true on `[]`, and `all()` over an empty list is vacuously true. Such a route is not inert. Under Gateway API it inherits the hostnames of every listener it attaches to, and `parentRefs` can name a Gateway in another namespace, so it covers whatever that listener covers. Reported in #3467. The predicate now requires the field to be present and non-empty before validating it. Both policies rendered from that template share one CEL string, so HTTPRoute and TLSRoute get the change together, and both match `CREATE` and `UPDATE`, so an existing route that drops its hostnames is denied exactly as a new one is. The issue is titled after TLSRoute, but the reachable shape is mostly HTTPRoute. TLSRoute allows a hostname-less route only on the deprecated `v1alpha2`; at `v1` and `v1alpha3` the bundle this repo vendors marks `spec.hostnames` required and rejects an empty list on `minItems: 1`, while `v1alpha2` carries neither constraint. HTTPRoute `v1` carries neither either, so it accepts both shapes. All four are checkable in `packages/system/gateway-api-crds/templates/crds-experimental.yaml`. Checking that turned up a stale claim in the template header, which said TLSRoute had no `v1` in Gateway API 1.4 to 1.5 and used it to justify the rule naming only `v1alpha2`. Gateway API 1.5.1, which this repo vendors, serves TLSRoute at `v1` as the storage version. The rule still covers every served version because `matchConstraints.matchPolicy` defaults to `Equivalent`, and that default is now written down, because setting `matchPolicy: Exact` reads like a tightening and would silently stop the policy seeing `v1` and `v1alpha3` TLSRoutes. I corrected the text and left the coverage alone: the wrong sentence predates this branch, but my change is what makes the file contradict itself, so the text is mine to fix while widening version coverage is a separate job. A `ValidatingAdmissionPolicy` has no cross-object lookup, so it cannot resolve the parent Gateway to see which hostnames a route would inherit. Requiring the route to name its own is what keeps every admitted hostname comparable against the apex. That has a cost: a route relying on listener inheritance now has to spell its hostnames out, and a tenant that hand-writes one gets a denial where it used to get an admission. ### The controller change belongs in the same PR `renderHTTPRedirect` built the http-to-https redirect HTTPRoute with no hostnames on purpose, to catch every host on port 80. It lands in a `tenant-*` namespace, so the stricter policy denies it, `reconcileHTTPToHTTPSRedirect` returns that error, and since it runs before the status update the TenantGateway never reaches Ready. That redirect is what keeps app routes attaching by hostname from serving plaintext on port 80. Neither half of this change is safe alone. The redirect now names the tenant apex and its wildcard. Both entries are needed: a wildcard hostname is a suffix match over any number of labels, so `*.<apex>` covers subdomains at every depth but not the bare `<apex>`. `renderWildcardCertificate` already builds the same pair. Coverage narrows in one direction. A request on port 80 with a Host outside the tenant apex now falls through to a 404 instead of a 301. That narrowing costs no covered host: the port-80 listener admits routes only from the Gateway's own namespace and cert-manager's, and the tightened policy requires every route in the tenant namespace to declare its hostnames inside the apex, so any host published from this namespace already falls under the apex or its wildcard. An inheriting child tenant does not widen that set either. Its hostnames would reach port 443 through a listener on this same Gateway, and `cozystack-gateway-hostname-policy` compares every listener against the host label of the Gateway's own namespace, so a child apex outside that label never gets its listener: the Gateway write carrying it is refused at admission whole, in `reconcileGateway` and before the redirect is rendered. A child apex under this one passes that check and the wildcard already covers it. That holds at merge base as well, so it is not something this branch changes. Deriving hostnames from `spec.apex` makes that field load-bearing for admission, so it gains `MinLength=1`. An empty apex used to reconcile to Ready, since the redirect declared no hostnames at all; it would now render `""` and `"*."` and be rejected on the HTTPRoute hostname schema, surfacing as a validation error naming the route rather than the field. The renderer checks it too, because the CRD and the controller binary roll out separately and an older CRD in the cluster will not carry the bound. That schema bound is a breaking change to an existing field and the API review gate flags it as one, so here is what it actually invalidates. The chart that renders these CRs already fails on an empty `_namespace.host`, and in DNS-01 or existingSecret mode an empty apex already broke earlier, in `renderGateway`, on a listener with `hostname: "*."`. Nothing that works today becomes invalid. What the bound rejects is a TenantGateway written by hand, past the chart, with an empty apex, and I would rather that be refused at admission naming the field than at reconcile time in controller logs, which is why the bound sits in the CRD as well as the renderer. Naming those hostnames also meant excluding the redirect from `collectHostnameClaims`. A route that attaches to the Gateway and declares hostnames otherwise reads as an app publishing them, and each claimed hostname becomes an HTTPS listener named after its first label. The first label of `*.<apex>` is `*`, which the Gateway CRD's listener-name pattern rejects, so the Gateway write fails and the reconcile stops before anything downstream. The exclusion matches on namespace, name and controller ownership rather than on the labels the renderer stamps: those labels are writable by anyone who can create a route, and the claim set also drives conflict resolution, so a label-keyed exception would let any route opt out of being marked `Accepted=False` when it loses a hostname race. Name and ownership are each required and each is pinned by its own test, since ownership alone would exclude any other controller-owned route and name alone would extend the exception to a route planted under that name by someone else. ### Radius Only namespaces starting with `tenant-` are matched. The three first-party TLSRoutes the platform ships, `kubernetes-api` in `default` plus `cdi-uploadproxy` and `vm-exportproxy`, set hostnames explicitly and none sits in a `tenant-*` namespace, so nothing changes for them. I went through every producer of routes in the tree, in three places rather than one. No Helm template emits an absent or empty hostname list in any configuration a tenant can reach: the vendored openbao, harbor, opencost and flux-operator route templates can render one, but each is behind a flag that defaults off with no values path to turn it on, and openbao would fail CRD validation first because its default route apiVersion requires hostnames. Exactly one Go site builds a route, the redirect fixed here. And one e2e fixture did rely on the old behaviour: the Gateway suite's parentRef probe declared no hostnames and is applied into `tenant-test`, which the policy matches, so it now reads the apex from the namespace label and names a hostname under it, the same way the neighbouring cases in that suite already did. cert-manager also writes routes into tenant namespaces, since it creates the Challenge in the Certificate's own namespace and the HTTP-01 solver route beside it. That route declares the hostname it is validating, so the policy admits it. ### Related issues Two open issues have a text half that this PR happens to settle, and neither of them closes here. #3788 reports that the port-80 allow-list contains the namespace its own comment says it excludes; the comment and the README paragraph now describe what that filter actually does, while the behavioural exit it names stays open. #3789 reports that the TLSRoute rule names only the deprecated API version; that premise is now written down in the template header, including what has to change before a bundle drops `v1alpha2`, but the rule still names `v1alpha2` alone. #3787 covers the wildcard-hostname listener name that the apiserver rejects, which is what makes the claim-set exclusion here load-bearing rather than tidy. ### Screenshots N/A (no UI changes). ### Downstream repositories I walked the trigger map in `docs/agents/contributing.md` against the diff, file by file. No package is added, renamed or removed; no `values.yaml` key, `values.schema.json` field, version enum or default moves; no `kind` or `plural` changes; no namespace or release-asset name changes; nothing under `hack/` moves, is renamed, or changes what a make target does. One row deserves a sentence rather than a blanket denial, because the diff does change a CRD schema: `spec.apex` on `gateway.cozystack.io/v1alpha1 TenantGateway` gains a `minLength` bound, which lands in the generated `gateway.cozystack.io_tenantgateways.yaml`. The provider hand-types `Package` under `cozystack.io` and `Plan`/`RestoreJob` under `backups.cozystack.io`; `gateway.cozystack.io` is not among them, and a tightening on an existing field adds no new field for it to offer typed access to. The `packages/extra/gateway/README.md` edit sits in the `## Security model` section, below the `## Parameters` block that `cozyvalues-gen` regenerates, so it survives `make generate`. - [x] No downstream repository is affected by this change - [ ] [cozystack/website](https://github.com/cozystack/website) - follow-up: - [ ] [cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack) - follow-up: - [ ] [cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack) - follow-up: - [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up: - [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up: - [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) - follow-up: - [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) - follow-up: - [ ] [cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server) - follow-up: - [ ] [cozystack/external-apps-example](https://github.com/cozystack/external-apps-example) - follow-up: - [ ] [cozystack/examples](https://github.com/cozystack/examples) - follow-up: ### Release note ```release-note fix(gateway)!: the tenant route hostname policy now requires an HTTPRoute or TLSRoute in a `tenant-*` namespace to declare a non-empty `spec.hostnames`. A route that omitted the field, or set it to an empty list, was previously admitted without any hostname being checked against the tenant apex, even though such a route inherits the hostnames of the listener it attaches to rather than matching nothing. A route in a `tenant-*` namespace relying on that inheritance is now rejected and has to name its hostnames. The controller-owned http-to-https redirect route names the tenant apex and its wildcard accordingly, so a request reaching port 80 with a Host outside the tenant apex now gets a 404 instead of a 301. `TenantGateway.spec.apex` additionally gains a `minLength: 1` bound, because the redirect derives its hostnames from that field; a TenantGateway written past the chart with an empty apex is now rejected at admission instead of failing later in the controller. ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added edge-terminated gateway support with plain-HTTP listeners and TLS passthrough handling. - HTTP-to-HTTPS redirects now explicitly cover the tenant apex and subdomains. - Gateway configurations require a non-empty apex hostname. - HTTPRoute, TLSRoute, and GRPCRoute resources must declare hostnames. - **Bug Fixes** - Invalid hostname-less routes are now rejected consistently. - Controller-managed redirects no longer trigger unnecessary hostname claims, listeners, or certificates. - Switching certificate modes now cleans up obsolete ACME resources and route status. - **Documentation** - Updated security and gateway guidance for hostname validation, edge termination, route compatibility, and certificate handling. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2 parents 7d7eb1c + 4b2507b commit d685fb3

11 files changed

Lines changed: 832 additions & 55 deletions

File tree

‎api/gateway/v1alpha1/tenantgateway_types.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,11 @@ type RFC2136DNS01 struct {
155155
// TenantGatewaySpec describes the desired state of a per-tenant Gateway.
156156
type TenantGatewaySpec struct {
157157
// Apex is the tenant's apex hostname. The Gateway listeners are
158-
// constrained to this apex and its subdomains.
158+
// constrained to this apex and its subdomains, and the
159+
// controller-owned http-to-https redirect route declares this apex
160+
// and its wildcard as its hostnames, so an empty value renders a
161+
// route the apiserver rejects on its hostname schema.
162+
// +kubebuilder:validation:MinLength=1
159163
// +required
160164
Apex string `json:"apex"`
161165

‎docs/security/threat-model.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ Cozystack has two admission enforcement points, and the distinction matters when
103103
- The **core kube-apiserver** evaluates ValidatingAdmissionPolicy for resources it serves directly — Namespaces, Gateways, HTTPRoutes/TLSRoutes/GRPCRoutes, Ingresses, and the no-delete guardrail.
104104
- **`cozystack-api` itself** evaluates the admission chain (validating webhooks and ValidatingAdmissionPolicy) for aggregated `apps.cozystack.io` resources, because those are served by the aggregated API server and never reach the core apiserver's admission chain. The `cozystack-tenant-host-policy` on `apps.cozystack.io/tenants` is therefore enforced inside `cozystack-api`.
105105

106-
Two hostname-policy details are worth stating precisely. The **HTTPRoute/TLSRoute/GRPCRoute** policies (`route-hostname-policy.yaml`) and the **legacy-Ingress** policy (`ingress-hostname-policy.yaml`) are fail-closed: a route or Ingress in a tenant namespace missing `namespace.cozystack.io/host` is denied, so an absent label cannot open those layers (the Ingress policy also denies hostless rules and `spec.defaultBackend`, and permits external custom domains outside the platform root apex). The **namespace host-label** policy is an immutability guard — it stops an untrusted caller from scrubbing or changing an already-set label, but it does not itself reject a namespace that never carried one. The **Gateway-listener** policy (`gateway-hostname-policy.yaml`) applies the same subdomain rule but its CEL short-circuits to allow when the host label is empty, so it is permissive (fail-open) for a tenant namespace that lacks the label. In normal operation the platform sets that label at tenant creation and the namespace host-label policy keeps it immutable, closing the gap in practice — but the Gateway-side guarantee is conditional on the label being present, not unconditional.
106+
Two hostname-policy details are worth stating precisely. The **HTTPRoute/TLSRoute/GRPCRoute** policies (`route-hostname-policy.yaml`) and the **legacy-Ingress** policy (`ingress-hostname-policy.yaml`) are fail-closed: a route or Ingress in a tenant namespace missing `namespace.cozystack.io/host` is denied, so an absent label cannot open those layers (the route policies also deny a route that declares no hostnames, since such a route inherits the hostnames of the listener it attaches to rather than matching nothing; the Ingress policy likewise denies hostless rules and `spec.defaultBackend`, and permits external custom domains outside the platform root apex). The **namespace host-label** policy is an immutability guard — it stops an untrusted caller from scrubbing or changing an already-set label, but it does not itself reject a namespace that never carried one. The **Gateway-listener** policy (`gateway-hostname-policy.yaml`) applies the same subdomain rule but its CEL short-circuits to allow when the host label is empty, so it is permissive (fail-open) for a tenant namespace that lacks the label. In normal operation the platform sets that label at tenant creation and the namespace host-label policy keeps it immutable, closing the gap in practice — but the Gateway-side guarantee is conditional on the label being present, not unconditional.
107107

108108
Authentication and authorization for aggregated resources are delegated to the core kube-apiserver via `system:auth-delegator` (TokenReview / SubjectAccessReview), so RBAC decisions remain centralized even though admission runs in `cozystack-api`.
109109

‎hack/e2e-chainsaw/gateway/chainsaw-test.yaml‎

Lines changed: 152 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,109 @@ spec:
487487
exit 1
488488
fi
489489
echo "$out" | grep -qi "ValidatingAdmissionPolicy"
490-
echo "$out" | grep -qi "hostnames must equal"
490+
# Matches the apex-comparison half of the message, which is common to
491+
# every denial this policy issues. Pinning the leading words instead
492+
# would break whenever the message grows a clause for another shape.
493+
echo "$out" | grep -qi "must equal the namespace"
494+
check:
495+
($error == null): true
496+
# A route that declares no hostname at all is the one shape the expression
497+
# used to skip: it opened with `!has(object.spec.hostnames) ||`, so such a
498+
# route satisfied the first operand and was admitted without a single
499+
# hostname being compared. That route is not inert — under Gateway API it
500+
# inherits the hostnames of the listener it attaches to, and parentRefs may
501+
# name a Gateway in another namespace, so it ends up covering whatever that
502+
# listener covers.
503+
#
504+
# Both empty shapes are probed because they reach the hole by different
505+
# routes and one guard does not imply the other: the field being absent is
506+
# caught by has(), while an explicitly empty list satisfies has() and then
507+
# passes because all() over an empty list is vacuously true. Dropping either
508+
# term from the CEL re-admits the whole shape, and the unit suite can only
509+
# pin that the terms are present in the expression — this step is what proves
510+
# the apiserver acts on them.
511+
#
512+
# HTTPRoute carries both cases. The TLSRoute probe is applied at v1alpha2
513+
# because v1 and v1alpha3 mark spec.hostnames required with minItems: 1,
514+
# leaving v1alpha2 the only version where a hostname-less TLSRoute reaches
515+
# admission at all. It and the GRPCRoute probe carry the absent case only,
516+
# and that is a coverage choice rather than a reachability fact: v1alpha2
517+
# sets neither constraint, and the grpcroutes schema sets neither at any
518+
# served version, so an explicitly empty list reaches admission on both
519+
# just as an absent field does. All three policies render one
520+
# byte-identical CEL string, so the HTTPRoute pair above already proves
521+
# the empty-list term end to end.
522+
# Scripted rather than an apply/expect pair so the grep can prove the VAP
523+
# did the denying: a bare "the apply failed" assertion would be satisfied
524+
# by a typo in the manifest.
525+
- name: hostname-less-route-denied
526+
try:
527+
- script:
528+
content: |
529+
set -eu
530+
# deny <case> <kind> <name> reads a manifest on stdin and expects the
531+
# VAP to reject it, mirroring the deny() helper in the Ingress suite.
532+
deny() {
533+
kubectl -n "$NAMESPACE" delete "$2" "$3" --ignore-not-found >/dev/null
534+
out=$(kubectl apply -n "$NAMESPACE" -f - 2>&1) && rc=0 || rc=$?
535+
echo "case=$1 rc=$rc out=$out"
536+
if [ "$rc" -eq 0 ]; then
537+
kubectl -n "$NAMESPACE" delete "$2" "$3" --ignore-not-found
538+
echo "BUG: admission accepted $1" >&2
539+
exit 1
540+
fi
541+
echo "$out" | grep -qi "ValidatingAdmissionPolicy"
542+
echo "$out" | grep -qi "must declare spec.hostnames"
543+
}
544+
deny httproute-hostnames-absent httproute route-nohost-probe <<'EOF'
545+
apiVersion: gateway.networking.k8s.io/v1
546+
kind: HTTPRoute
547+
metadata:
548+
name: route-nohost-probe
549+
spec:
550+
rules:
551+
- backendRefs:
552+
- name: kubernetes
553+
namespace: default
554+
port: 443
555+
EOF
556+
deny httproute-hostnames-empty httproute route-emptyhost-probe <<'EOF'
557+
apiVersion: gateway.networking.k8s.io/v1
558+
kind: HTTPRoute
559+
metadata:
560+
name: route-emptyhost-probe
561+
spec:
562+
hostnames: []
563+
rules:
564+
- backendRefs:
565+
- name: kubernetes
566+
namespace: default
567+
port: 443
568+
EOF
569+
deny tlsroute-hostnames-absent tlsroute route-tls-nohost-probe <<'EOF'
570+
apiVersion: gateway.networking.k8s.io/v1alpha2
571+
kind: TLSRoute
572+
metadata:
573+
name: route-tls-nohost-probe
574+
spec:
575+
rules:
576+
- backendRefs:
577+
- name: kubernetes
578+
namespace: default
579+
port: 443
580+
EOF
581+
deny grpcroute-hostnames-absent grpcroute route-grpc-nohost-probe <<'EOF'
582+
apiVersion: gateway.networking.k8s.io/v1
583+
kind: GRPCRoute
584+
metadata:
585+
name: route-grpc-nohost-probe
586+
spec:
587+
rules:
588+
- backendRefs:
589+
- name: kubernetes
590+
namespace: default
591+
port: 443
592+
EOF
491593
check:
492594
($error == null): true
493595
- name: own-apex-route-allowed
@@ -665,8 +767,42 @@ spec:
665767
try:
666768
- apply:
667769
file: gw-route-probe.yaml
668-
- name: assert-route-accepted
669-
try:
770+
# The route is scripted rather than declared in gw-route-probe.yaml
771+
# because it needs the tenant apex interpolated. This suite runs in
772+
# tenant-test, which cozystack-route-hostname-policy matches, so a
773+
# route naming no hostname is denied; the apex is read from the
774+
# namespace label rather than hardcoded so the suite still passes on
775+
# a dev cluster whose host differs from CI's. Same reason and same
776+
# shape as own-apex-route-allowed above.
777+
- script:
778+
content: |
779+
set -eu
780+
ns="$NAMESPACE"
781+
apex=$(kubectl get namespace "$ns" -o jsonpath='{.metadata.labels.namespace\.cozystack\.io/host}')
782+
[ -n "$apex" ] || { echo "SETUP FAILURE: $ns lacks the namespace.cozystack.io/host label" >&2; exit 1; }
783+
kubectl apply -n "$ns" -f - <<EOF
784+
apiVersion: gateway.networking.k8s.io/v1
785+
kind: HTTPRoute
786+
metadata:
787+
name: httproute-probe
788+
spec:
789+
hostnames:
790+
- "parentref-probe.${apex}"
791+
parentRefs:
792+
- name: gateway-route-probe
793+
sectionName: http
794+
rules:
795+
- matches:
796+
- path:
797+
type: PathPrefix
798+
value: /
799+
backendRefs:
800+
- name: kubernetes
801+
namespace: default
802+
port: 443
803+
EOF
804+
check:
805+
($error == null): true
670806
- assert:
671807
timeout: 3m
672808
resource:
@@ -686,6 +822,19 @@ spec:
686822
name: httproute-probe
687823
(status.parents[0].conditions[?type == 'Accepted']):
688824
- status: "True"
825+
# Creation and assertions share one step so this reclaim covers both.
826+
# It is needed because chainsaw reclaims what it applied from a file
827+
# but not what a script created, and the suite runs in one long-lived
828+
# namespace. finally runs at the end of its own step, so splitting
829+
# the assertions into a later step would delete the route before they
830+
# ran, and hanging the reclaim off that later step would leak the
831+
# route whenever this one failed after the apply.
832+
finally:
833+
- delete:
834+
ref:
835+
apiVersion: gateway.networking.k8s.io/v1
836+
kind: HTTPRoute
837+
name: httproute-probe
689838
---
690839
apiVersion: chainsaw.kyverno.io/v1alpha1
691840
kind: Test
Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,22 @@
1+
# Only the Gateway lives here. Its HTTPRoute is created by the
2+
# create-gateway-and-route step instead: the route sits in a tenant-*
3+
# namespace, so cozystack-route-hostname-policy requires it to name a
4+
# hostname inside the tenant apex, and that apex is read from the
5+
# namespace label at runtime so the suite works on any cluster. A
6+
# declarative file cannot interpolate it.
17
apiVersion: gateway.networking.k8s.io/v1
28
kind: Gateway
39
metadata:
410
name: gateway-route-probe
511
spec:
612
gatewayClassName: cilium
713
listeners:
14+
# No hostname on the listener, so it matches whatever hostname the
15+
# route declares. That is what keeps the Accepted and Programmed
16+
# assertions independent of the cluster's apex.
817
- name: http
918
protocol: HTTP
1019
port: 80
1120
allowedRoutes:
1221
namespaces:
1322
from: Same
14-
---
15-
apiVersion: gateway.networking.k8s.io/v1
16-
kind: HTTPRoute
17-
metadata:
18-
name: httproute-probe
19-
spec:
20-
parentRefs:
21-
- name: gateway-route-probe
22-
sectionName: http
23-
rules:
24-
- matches:
25-
- path:
26-
type: PathPrefix
27-
value: /
28-
backendRefs:
29-
- name: kubernetes
30-
namespace: default
31-
port: 443

‎internal/controller/tenantgateway/reconciler.go‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,10 @@ func (r *Reconciler) markFailed(ctx context.Context, tgw *gatewayv1alpha1.Tenant
234234
// 80 do not silently reach app backends. App-owned HTTPRoutes
235235
// attaching by hostname without sectionName otherwise pick up the
236236
// HTTP listener too — Harbor / dashboard / keycloak credentials in
237-
// the clear. The redirect HTTPRoute matches any host on path /.
237+
// the clear. The redirect HTTPRoute matches the tenant apex and its
238+
// subdomains on path /; it names them rather than matching every host
239+
// because a route in a tenant-* namespace has to declare hostnames
240+
// inside its own apex to pass cozystack-route-hostname-policy.
238241
//
239242
// certMode=edge is the deliberate exception: there is no https listener
240243
// to redirect to (TLS ends at the class provider's edge, which does that
@@ -373,6 +376,18 @@ func (r *Reconciler) collectHostnameClaims(ctx context.Context, tgw *gatewayv1al
373376
if _, ok := allowed[route.Namespace]; !ok {
374377
continue
375378
}
379+
// The redirect route this controller renders is infrastructure,
380+
// not a hostname publication, so it must not drive listener or
381+
// Certificate provisioning. It names the apex and its wildcard
382+
// (see renderHTTPRedirect) to satisfy the route-hostname policy.
383+
//
384+
// Before this route carried hostnames it contributed nothing to
385+
// the claim set; skipping it here restores exactly that. Letting
386+
// its hostnames through instead provisions listeners and
387+
// certificates for them, which fails.
388+
if isHTTPRedirectRoute(route, tgw) {
389+
continue
390+
}
376391
matchingRefs := allAttachingParentRefs(route.Spec.ParentRefs, route.Namespace, tgw)
377392
if len(matchingRefs) == 0 {
378393
continue
@@ -399,6 +414,10 @@ func (r *Reconciler) collectHostnameClaims(ctx context.Context, tgw *gatewayv1al
399414
if err := r.List(ctx, tlsRoutes); err != nil {
400415
return nil, fmt.Errorf("list TLSRoutes: %w", err)
401416
}
417+
// No isHTTPRedirectRoute filter here, unlike the HTTPRoute loop
418+
// above: this controller renders no TLSRoute, so there is nothing of
419+
// its own to exclude. Add the equivalent guard here alongside the
420+
// first controller-rendered TLSRoute, should one ever appear.
402421
for i := range tlsRoutes.Items {
403422
route := &tlsRoutes.Items[i]
404423
if _, ok := allowed[route.Namespace]; !ok {
@@ -560,6 +579,25 @@ func (r *Reconciler) reconcilePerListenerCertificates(ctx context.Context, tgw *
560579
return nil
561580
}
562581

582+
// isHTTPRedirectRoute reports whether a route is the http→https redirect
583+
// this controller renders for this TenantGateway. Used by
584+
// collectHostnameClaims to keep that one route out of the hostname-claim
585+
// set; see the comment at that call site for what it would otherwise
586+
// provision.
587+
//
588+
// Name and ownership are both required and each is pinned by its own
589+
// test: ownership alone would exclude any other controller-owned route
590+
// as well, and name alone would extend the exception to a route planted
591+
// under that name by someone else. The labels renderHTTPRedirect stamps
592+
// are deliberately not used, being writable by anyone who can create a
593+
// route. Requiring the owner reference is safe for the real route
594+
// because renderHTTPRedirect sets it before the route is first written.
595+
func isHTTPRedirectRoute(route *gatewayv1.HTTPRoute, tgw *gatewayv1alpha1.TenantGateway) bool {
596+
return route.Namespace == tgw.Namespace &&
597+
route.Name == httpRedirectRouteName(tgw) &&
598+
ownedByTenantGateway(route.OwnerReferences, tgw)
599+
}
600+
563601
func ownedByTenantGateway(refs []metav1.OwnerReference, tgw *gatewayv1alpha1.TenantGateway) bool {
564602
for _, ref := range refs {
565603
if ref.UID == tgw.UID && ref.Controller != nil && *ref.Controller {

0 commit comments

Comments
 (0)