With Cilium at 1.20.2 the tenant gateway comments, README and one status message still explained behaviour as that of "the pin" at 1.19.5. Several of those claims no longer hold: a Gateway with two routed passthrough ports now gets an Envoy listener per port, Cilium judges a route's kinds against the listener the route names, and the shipped operator accepts an HTTPS and a passthrough listener on one port when the passthrough hostname is strictly narrower. Behaviour that is unchanged is re-anchored on the 1.20.2 source, behaviour that is gone is stated as history, and the refusal of one hostname on two passthrough ports no longer blames a Cilium limitation the shipped release does not have. Assisted-by: LLM Signed-off-by: Aleksei Sviridkin <f@lex.la>
Cozystack Tenant Gateway
Per-tenant Gateway API Gateway backed by Cilium. Installed automatically when tenant.spec.gateway=true on the publishing tenant. Other tenants under that tenant inherit through the publishing Gateway without opting in.
The chart renders one gateway.cozystack.io/v1alpha1 TenantGateway CR per tenant. The cozystack-controller reconciles the actual Gateway, per-tenant Issuer, and per-listener Certificate resources from there. Helm does not render Gateway or Certificate directly — that prevents the Helm-vs-controller race on Gateway.spec.listeners that ad-hoc HTTPRoute additions would cause.
Inheritance: when to opt in for a separate Gateway
A tenant gets its own dedicated Gateway (own LB Service, own LB IP, own Certificate) only when it explicitly asks via tenant.spec.gateway=true. Every other tenant in the tree attaches its routes to the Gateway of the nearest ancestor that does own one — same shape as _namespace.ingress inheritance today.
The apps/tenant chart writes a namespace label namespace.cozystack.io/gateway: <owner-tenant-name> onto each tenant namespace, carrying either this tenant's own name (when owning a Gateway) or the inherited ancestor name (when inheriting). The HTTPS-terminate and port-443 passthrough listeners key their allowedRoutes.namespaces.selector on that label, so the same logic permits the inheriting tenant's HTTPRoutes / TLSRoutes to attach to them. The port-80 listener and the native-port listeners from tlsPassthroughListeners do not: both pin kubernetes.io/metadata.name instead, and inheritance does not reach either. cozystack-controller separately patches the same label onto every namespace in tgw.Spec.AttachedNamespaces so cozy-* system namespaces (cert-manager, monitoring, harbor, …) reach the publishing Gateway alongside the tenant tree, garbage-collecting labels it wrote when an entry is removed from the attach list.
Owning a separate Gateway makes sense for: a tenant that needs its own LB IP (DNS already pinned, firewall rule on a specific address), a tenant whose apex is not derived from the parent (custom host, e.g. customer1.io not under the platform apex — the ancestor's cert/Issuer can't cover it), or a tenant that wants its own ACME account / cert authority. Otherwise leave gateway unset and inherit.
Cert mode: HTTP-01 (default) vs DNS-01 (opt-in) vs existing Secret vs edge
The platform-wide publishing.certificates.solver value selects how the controller sources TLS certificates for the tenant Gateway. Setting publishing.certificates.wildcardSecretName switches to a third mode (existing Secret) that overrides the solver entirely, and putting the tenant's GatewayClass in gateway.edgeTerminatedClasses switches to a fourth that issues nothing at all — see below.
The sections below are the chart's own reference. For the operator's view of the same surface — the three platform values that bound which class a tenant lands on, what a tenant writes to pick one, what moving between classes does to certificates and to the wildcard replica, and how the mechanism fails when the configured names and the installed classes disagree — see docs/gateway-classes.md.
Default — HTTP-01
Out of the box, no extra config required. The controller:
- Renders an ACME
Issuerin the tenant namespace with anhttp01.gatewayHTTPRoutesolver pointing at the tenant's own Gateway /httplistener. - Watches HTTPRoutes / TLSRoutes attached to the Gateway (parentRefs pointing at it). For each unique hostname an HTTPRoute claims, it adds a per-app HTTPS listener and a per-app
Certificate(dnsNames containing exactly that hostname). A hostname a TLS-passthrough listener answers earns neither, whether or not aTLSRouteis attached there, and a hostname only aTLSRouteclaims earns neither at all — the passthrough section below carries that rule and why it is keyed on the declaration. A route that also claims a hostname outside the reservation staysAcceptedand is served on that one. A wildcard hostname on anHTTPRouteearns neither either, whatever else claims the name: the HTTP-01 challenge issues no wildcard certificate, so no terminate listener can carry it and the route readsAccepted=FalsewithNoMatchingListenerHostnamenaming the wildcard. The exception is a route that asked for the plain-HTTP listener rather than a terminate one, by pinning port 80 or by naminghttp: that listener carries no hostname, so it serves a wildcard claim like any other and the route is refused nothing. Over TLS a wildcard is served only by the modes that terminate the whole apex. AparentRefthat pins a port other than 443 earns neither through thatparentRef, since the per-app listener is published on 443 alone; selecting the plain-HTTP listener instead, by port 80 or by naminghttp, from a namespace it admits (the publishing tenant's andcozy-cert-manager) serves the route there without a terminate listener, and selecting it from any other namespace is refused by that listener withNotAllowedByListeners. - Per-app listener naming:
https-<first-label-of-hostname>-<8 hex digits of sha256(hostname)>(e.g.https-harbor-760d5a8a); the suffix keeps two hostnames sharing a first label apart. - Per-app cert naming:
<tgw-name>-<first-label>-<same 8 hex digits>-tls.
Adding a new published app is purely a matter of deploying its HTTPRoute — no edits to _cluster.expose-services needed.
Opt-in — DNS-01
Set publishing.certificates.solver: dns01 and configure the provider under publishing.certificates.dns01.* in the platform chart values. Each provider reads its own sub-block; others are ignored.
| Provider | publishing.certificates.dns01.provider |
Required publishing.certificates.dns01.<provider> keys |
|---|---|---|
| Cloudflare | cloudflare (default) |
cloudflare.secretName, cloudflare.secretKey |
| AWS Route53 | route53 |
route53.region, route53.secretName (and route53.accessKeyID if not using IRSA) |
| DigitalOcean | digitalocean |
digitalocean.secretName |
| RFC 2136 | rfc2136 |
rfc2136.nameserver, rfc2136.tsigKeyName, rfc2136.secretName |
The platform chart writes those values into _cluster.dns01-* keys consumed by the per-tenant gateway chart, which renders them onto the TenantGateway CR. Each provider sub-block carries safe defaults for secret-key field names (api-token, secret-access-key, access-token, tsig-secret-key) so the typical opt-in path is solver: dns01 plus the provider-specific secretName (and region for route53 / nameserver+tsigKeyName for rfc2136).
DNS-01 mode renders a single wildcard Certificate covering <apex> and *.<apex>, plus the corresponding https (*.<apex>) and https-apex (<apex>) listeners. New apps published under the apex pick up the existing wildcard cert without per-listener provisioning.
For inheriting child tenants under this Gateway: the controller extends the same wildcard Certificate with <child-apex> + *.<child-apex> SANs per child, and adds one *.<child-apex> listener per child apex referencing the same cert. Child apex SANs are discovered by listing namespaces carrying namespace.cozystack.io/gateway = <owner> and reading their namespace.cozystack.io/host label. The ACME challenge must succeed for every SAN, which means the DNS provider account configured at the platform layer must be able to write TXT records under each child apex zone — for deeply-nested children that requires either zone delegation or a provider account with apex-spanning permissions.
Pick DNS-01 when you specifically want a wildcard cert (e.g. a long-lived staging cluster with many short-lived apps and tight LE rate limits). Otherwise stay on HTTP-01.
Listener-cap considerations. Gateway API caps
Gateway.spec.listenersat 64. In HTTP-01 mode, every published hostname adds one HTTPS listener, plus the mandatoryhttplistener, one extra per TLS-passthrough service, and one pertlsPassthroughListenersentry on theTenantGateway— so the per-app listeners share that budget with the rest, and a tenant that exhausts it does not get a rejected write: the controller catches the assembled total before submitting theGatewayand reports it asReady=Falsenaming the budget, so the failure lands on the object the tenant owns. DNS-01 mode collapses every hostname under the apex into one wildcard listener and is the right choice for high-fanout single-tenant deployments.
Opt-in — existing wildcard Secret (operator-provided)
Set publishing.certificates.wildcardSecretName in the platform chart values to point the tenant Gateway at a pre-existing wildcard TLS Secret instead of issuing anything via ACME. Use this when the operator already holds a wildcard certificate — purchased, or issued by a corporate CA — and wants platform services to serve under it. This mode takes precedence over solver: when wildcardSecretName is set, the solver / provider / issuer values are ignored.
The platform chart writes the value into _cluster.wildcard-secret-name (the name only — the certificate and private key never travel on the values channel), and the gateway chart renders certMode: existingSecret with wildcardSecretRef.name on the TenantGateway CR. The controller then:
- Mints no
Issuerand noCertificate. Switching into this mode from HTTP-01 or DNS-01 garbage-collects the now-unused per-tenantIssuerand any per-listener / wildcardCertificatethe controller previously owned. - Renders the same single-wildcard listener shape as DNS-01: a
https(*.<apex>) listener and ahttps-apex(<apex>) listener (plus one*.<child-apex>listener per inheriting child), all withcertificateRefspointing at the operator-supplied Secret.
The Secret must exist in the TenantGateway's own namespace, be of type kubernetes.io/tls, and cover the apex (and *.<apex>). Cross-namespace references are intentionally unsupported (no ReferenceGrant), so each per-tenant Gateway reads the Secret from its own namespace. For the root publishing tenant that is the operator-created Secret in tenant-root. For a child tenant that runs its own Gateway, the platform controller replicates the operator Secret into the tenant namespace automatically — it reads the source name from the same publishing.certificates.wildcardSecretName that drives the consumers, so a same-named replica is mirrored into every tenant namespace that owns a termination point, then garbage-collected when wildcard mode is explicitly disabled (clearing publishing.certificates.wildcardSecretName) or when a tenant stops terminating TLS. A transient absence of the source Secret or the platform values channel does not prune existing replicas. No extra operator input, and the replica carries no extra RBAC — the Gateway reads only its own-namespace copy. Replication delivers the bytes, not coverage: the certificate matches a child apex only if its SAN list does, and a single *.<apex> does not match *.<child-apex>. The controller still renders a *.<child-apex> listener bound to the Secret for each inheriting child, so when the SANs do not cover that apex, clients of the child subdomain are served the parent certificate and see a hostname-mismatch TLS error — supply a certificate whose SANs cover the child apexes you intend to serve. Like DNS-01, this mode collapses every hostname under the apex into one wildcard listener, so it is also a way to stay clear of the 64-listener cap.
Opt-in — edge termination (no certificates)
TLS termination is a property of the GatewayClass, so it is declared once per class rather than per tenant: list a class in the platform's gateway.edgeTerminatedClasses when its provider terminates TLS upstream of the Gateway — a Cloudflare Tunnel class ends TLS at the Cloudflare edge and reaches the Gateway over the tunnel. Every tenant whose Gateway lands on such a class gets certMode: edge on its TenantGateway, whether it arrived there through the platform default (gateway.className) or by choosing the class itself (tenant.spec.gatewayClass, bounded by gateway.tenantSelectableClasses). The mode wins over wildcardSecretName and over solver; the solver, provider, issuer and wildcard-secret inputs are not read. The controller then:
- Mints no
Issuer, noCertificateand no http-to-https redirect route (the edge redirects itself). Switching into this mode garbage-collects the ACME objects the controller previously owned — theIssuerand theCertificateobjects, not the TLS Secrets they issued: cert-manager runs withenableCertificateOwnerRef: false, so each Secret stays in the tenant namespace, unreferenced, until an operator removes it. That is how every mode transition already behaves, not something edge introduces. - Renders the apex, its wildcard and every inheriting child apex as plain HTTP listeners (
edge,edge-apex,edge-child-*) with the same label-basedallowedRoutesas the HTTPS listeners of the other modes, so app HTTPRoutes attach by hostname unchanged.TLSPassthroughServicesare not rendered: an HTTP-only edge cannot carry a TLS-passthrough listener, so the tenant Kubernetes API, VM export and CDI upload endpoints are not published through such a Gateway.TLSPassthroughListenersis refused rather than dropped, and the difference is which side sets the field:tlsPassthroughServicescarries a chart default on the publishing tenant, so dropping it is the only way an edge tenant renders at all, while a native-port listener is set on the CR by hand and a tenant that asked for one is better told it cannot have it here than left readingReady=Trueover a port nothing answers. Where the packages that own them create thoseTLSRouteobjects at all — each renders only whengateway.enabledis on and its name is still inpublishing.exposedServices— the routes then match no listener on the Gateway, so those endpoints are unpublished rather than undeclared. The controller says so on the routes themselves rather than leaving them to be read as healthy: every attachedTLSRoutethat declares hostnames getsAccepted=Falsewith reasonNoMatchingParentunder this controller's name, which is Gateway API's reason for aparentRefwhose port or section matches no listener on the Gateway. ATLSRoutethat declares none has this controller's entry removed instead, because the hostname it would be judged on is lent by a listener this mode does not render, and every attachedHTTPRoutehas the entry removed too, because whatever it carried was written by the HTTP-01 pass about listeners this mode does not render;dns01andexistingSecretremove both the same way. The class controller writes its own entry under its own name and this does not touch it. Moving back off the mode clears the condition again, either by judging the route afresh underhttp01or, underdns01andexistingSecretwhere the controller collects no hostname claims, by dropping the entry instead of holding an opinion it cannot support.
Listing a class here is a claim about that class and nothing validates it: name the bundled cilium and its Gateways serve every app hostname over plain HTTP on a public address, with no redirect and no certificate — exactly the cleartext-credentials case the narrow port-80 selector exists to prevent in the other modes. Only list a class whose provider terminates TLS in front of the Gateway.
The two halves of that move are not ordered against each other: the wildcard-secret controller watches the TenantGateway and prunes as soon as its certMode changes, while the Gateway itself re-renders when the tenant's HelmRelease does and the class controller reprograms after that. A tenant can therefore be briefly serving HTTPS listeners whose Secret has already gone.
Moving an existing tenant onto such a class also withdraws the operator wildcard from that tenant: its namespace stops counting as a termination point, so the replica described under existing wildcard Secret is deleted from it on the next reconcile. Its neighbours keep theirs, and so does a namespace that also owns an ingress controller — that one still terminates locally.
Route hostnames still stay inside the tenant apex; on a class that shares one edge address between all Gateways the class controller has to enforce that itself, since the platform's route-hostname policy admits routes that declare no hostnames at all.
The publishing tenant is the one tenant that cannot move itself across that line: the chart refuses to render when its selected class is edge-terminated and gateway.className is not, or the reverse. The cluster-wide ACME ClusterIssuers point their HTTP-01 solver at that tenant's Gateway, and whether the solver pins sectionName: http is decided from gateway.className alone — a tenant's own class choice does not travel on that channel. A publishing tenant that went to an edge class on its own would therefore keep a solver pinned to the http listener its Gateway no longer renders, and every HTTP-01 certificate in the cluster would stop issuing with nothing in any status naming the cause. The guard is symmetric because either direction leaves those two decisions derived from different classes. Move gateway.className instead; which namespace counts as the publishing tenant comes from publishing.ingressName.
External IP allocation
The per-tenant Gateway's auto-created LoadBalancer Service draws its IP from whatever LB allocator the cluster admin has configured at the platform layer — same shape as ingress-nginx today. Cozystack itself ships MetalLB installed but does not render any IPAddressPool / L2Advertisement / BGPAdvertisement from this chart; admins set up the allocator that suits their environment (MetalLB pool with L2 / BGP, Cilium LB-IPAM with announcer, robotlb against a cloud provider, or Service.spec.externalIPs pinning).
The tenant API stays mechanism-agnostic — no gatewayIP field, no allocator-specific manifest in the tenant chart. If a tenant needs a specific address (DNS already pinned, firewall rule, etc.), the operator pre-allocates it on the admin side: either pre-create the Service with loadBalancerIP set, or hand the tenant a reference to a named admin-managed pool. Per-Service IP uniqueness is the allocator's responsibility and works the same way as for any other LoadBalancer Service in the cluster.
Layer-4 TLS passthrough on native ports
There are two distinct passthrough forms, and they do not replace each other. The tlsPassthroughServices chart value renders one mode: Passthrough listener per named service on port 443, selected by SNI alongside the HTTPS-terminate listeners. The tlsPassthroughListeners field on the TenantGateway resource renders one listener per entry on the entry's own native port — 6379 for Redis, 27017 for a sharded MongoDB's mongos, 5432 for PostgreSQL where both ends are new enough to negotiate TLS directly — so a database engine can present its own certificate directly to clients on the port those clients already expect, while sharing the tenant Gateway's address. The engine has to speak TLS first: a protocol whose server opens with a plaintext greeting, MySQL and MariaDB on 3306 above all, deadlocks against a passthrough listener waiting for a ClientHello that never arrives, and no listener can rescue it because there is no certificate to fall back on. Which engines qualify is settled in #2815 rather than here.
Each entry names a listener, a port, and an SNI hostname. The port must be free (80 and 443 belong to the Gateway's own listeners) and distinct from every other entry; the hostname must fall within the tenant apex. Those rules are enforced at admission by CEL on the CRD, so a bad entry is refused on write rather than stalling the tenant's reconcile.
No two passthrough listeners may match the same SNI, counting the port-443 listeners tlsPassthroughServices renders and counting wildcards — *.<apex> overlaps the shipped api.<apex> listener, so a wildcard that broad is rejected on a TenantGateway that carries the defaults, which the chart writes on the publishing tenant alone. Emptying that value removes the service hostnames from the comparison but not the rule: two entries of this field still have to keep clear of each other, and a broad wildcard still covers a narrower one. Cilium before v1.19.6 selected a passthrough backend by SNI alone and could not tell two matching listeners apart even on different ports, so the overlap silently forwarded the stream to whichever one won. The shipped v1.20.2 gives each routed passthrough port its own Envoy listener, so the rule now holds as a narrowing rather than a workaround, and lifting it is tracked in #4765. This rule is one of those the controller checks rather than admission, so it surfaces as Ready=False on the TenantGateway instead of a rejected write. So are a repeated tlsPassthroughServices entry, an entry whose composed listener name or hostname runs past the 253 characters Gateway API allows, and an assembled listener count over the Gateway API cap of 64; the schema-constraints paragraph below carries why the first is judged there rather than at the write, while the last two are about values a single field's bound cannot see. An apex no listener hostname can sit within is checked by the controller as well, but the containment rule at admission refuses it first, as that paragraph says.
The listener is all this field creates. Nothing is routed until a TLSRoute attaches to it by sectionName: tls-<name> and names a backend Service, and because a passthrough listener never terminates TLS, the backend — not the Gateway — must hold the certificate for the listener's hostname. Route attachment is bounded more tightly than on the other listeners, in two ways: only TLSRoute, and only from the publishing tenant's own namespace, pinned by the kubernetes.io/metadata.name label that kube-apiserver writes. TLSRoute alone is what Cilium derives from the TLS listener protocol rather than something Gateway API guarantees: the spec leaves the protocol-to-kind mapping to the implementation and its own table pairs TLS with TCPRoute as well. The kind is left to the protocol rather than spelled out because the protocol already says TLSRoute. Before v1.19.6 a listener that spelled its kinds out had them applied to every route on the Gateway, not to itself; from that release Cilium matches the listener the route actually names. The namespace.cozystack.io/gateway label the port-443 listeners select on is stamped on every inheriting child tenant namespace, which is a reasonable attach set for a shared HTTPS port and a poor one for a database port. Narrowing is free while no chart value exposes this field; widening later is a values change, whereas narrowing after something depends on it would not be.
Three current limitations are worth knowing before designing around this, and they do not share an origin. The first is the certificate mode: the field is refused under edge, which ends TLS at the class provider and renders no TLS listener for a passthrough entry to sit beside. dns01 and existingSecret accept it. Their *.<apex> terminate listener overlaps every entry by SNI, but on port 443, and Gateway API treats listeners on different ports as compatible; the shipped Cilium (v1.20.2) gives each port its own Envoy listener when their SNI overlaps. The second is a deliberate narrowing under http01: a hostname a passthrough listener answers gets no HTTPS-terminate listener on 443, although Gateway API would serve the two on different ports, so a tenant cannot publish one name as HTTPS on 443 and as passthrough on another port. A name beneath a wildcard entry is reserved the same way. Under dns01 and existingSecret the wildcard terminate listener still serves such a name on 443, because it cannot be withdrawn per hostname. The third is a narrowing of this phase: each entry must occupy its own port, so two instances of the same engine cannot both sit on the engine's native port distinguished only by SNI. Gateway API permits that pair and the port-443 tls-<svc> listeners already are that pair; the restriction exists because nothing downstream yet makes two engines on one port testable, and lifting it means deleting a check in the controller and the matching CEL rule in the CRD, without touching a field or a type. None of the three depends on the Cilium version, and none changes the API: the first and third are validation rules, the second is a rendering rule.
Two more things belong next to those, because both bear on what a tenant sees. The first is the pair on port 443 itself. api, vm-exportproxy and cdi-uploadproxy are passed through on 443 of the tenant Gateway, beside the terminate listeners. Upstream Cilium marks an HTTPS-terminate listener and a TLS-passthrough listener on one port Conflicted whenever their hostnames overlap, so the *.<apex> listener of dns01 and existingSecret would take the three passthrough listeners down with it. The operator image this platform ships carries a patch (packages/system/cilium/images/cilium-operator/patches) that accepts the pair when the passthrough hostname is strictly narrower than the HTTPS one, and Envoy selects the exact server name ahead of the wildcard. A terminate listener for the passthrough name itself is not narrower, and it is the pair the reservation below keeps from being rendered under http01. The second is a shape this platform has not run before: a passthrough listener declared with no TLSRoute behind it. TenantGateway readiness folds in every listener's Accepted and Programmed conditions, so a listener the Gateway controller declined would take the tenant's Ready to false. It does not, and that is readable from the v1.20.2 source rather than open. setGatewayListenerStatus judges a listener on its protocol, on the route kinds its allowedRoutes names, on its certificate references and on whether its spec conflicts with another listener's, and on nothing else, so a passthrough listener with no certificateRefs and no routes is Accepted; Programmed is then written from the Gateway having an address at all, gated only on Accepted. Neither condition consults the route set.
The reason the Cilium version matters is worth stating plainly, because it also decides what the declared port does and does not buy. On v1.19.5 the whole Gateway is translated into a single Envoy listener with a TLS inspector in front, and every filter chain is selected by transport protocol and SNI alone: toFilterChainMatch in operator/pkg/model/translation/envoy_listener.go sets no destination port. A terminate chain and a passthrough chain carrying one name therefore land in the same listener whichever Gateway port declared them, which is where the two restrictions that release left behind came from. The certificate-mode one is lifted; the rule against two passthrough entries sharing an SNI across ports still stands. The same fact has a consequence that is not a restriction: the declared port is where the listener is published, not where the backend can be reached. A TLS handshake carrying postgres.<apex> selects that passthrough chain on any port the tenant's load balancer forwards, port 443 included, so narrowing a firewall to 80 and 443 does not put the engine out of reach. The hostname is the access surface; give the backend its own authentication and treat the SNI as public. Cilium v1.19.6 restores the port boundary, but conditionally, and only for a listener something actually routes to. NeedsPerPortListeners is true when the model has more than one HTTPS port, or more than one TLS-passthrough port carrying a route, or an HTTPS chain and a routed passthrough chain on different ports sharing an SNI. A passthrough listener with no TLSRoute behind it contributes no filter chain, so it counts toward none of the three, which is consistent: with no route there is nothing on that port to collide with anything. Once a route attaches, a tenant that also has a routed tls-<svc> listener on 443 has two routed passthrough ports and the listeners split on the second clause. Port 443 then carries HTTPS and passthrough listeners at once, and upstream emits two Envoy listeners with one name for it, which the agent refuses (cilium/cilium#48269); the operator image this platform ships carries the fix. A Gateway whose only routed passthrough listener is that single entry splits on the third clause instead, and only when a terminate chain on another port shares its SNI, which is the *.<apex> listener of dns01 and existingSecret beside an entry under the apex. The bump changes the diagnostic side as well, and in the opposite direction from the split: v1.19.6 adds a check, listenerPairConflict in operator/pkg/gateway-api/status_listener.go on v1.20.2, which finds an HTTPS-terminate and a TLS-passthrough listener sharing a port and an overlapping SNI from the listener specs alone and marks both Conflicted and not Accepted; from v1.20 the pair is also dropped from Envoy. A declared listener with no route on it is enough to trigger it, which on port 443 meant every tls-<svc> listener beside a terminate listener whose hostname overlaps it, the *.<apex> one of dns01 and existingSecret included. The operator image this platform ships accepts the pair when the passthrough hostname is strictly narrower than the HTTPS one (packages/system/cilium/images/cilium-operator/patches), so the tls-<svc> listeners serve beside *.<apex> on 443. A terminate listener for the passthrough name itself is not narrower; it is the pair the reservation above keeps from being rendered, and this field is kept off 443.
This field is set on the TenantGateway resource directly; the chart exposes no corresponding value yet. That matters operationally, though not the way it first looks: the TenantGateway is rendered and owned by this chart, but helm-controller runs with drift detection off here (no HelmRelease in the tree sets spec.driftDetection, and the controller carries no feature gate for it), so a kubectl edit adding tlsPassthroughListeners is not reverted on the release's next interval. Nor does the next helm upgrade take it away. Helm patches a custom resource whose Go type it does not know by merging the old rendered manifest with the new one. createPatch in pkg/kube/client.go has two paths for such an object: the two-way one is jsonpatch.CreateMergePatch(oldData, newData), which does not consult the live object at all, and the three-way one does read it. Which path runs depends on the Helm version helm-controller carries, and that is not pinned in this tree, so treat the path as unverified from here. The outcome does not turn on it: a key the chart renders in neither manifest appears in no patch on either path and is never removed. helm uninstall, a chart that starts rendering the key, and spec.upgrade.force are the ways out, and nothing in this tree sets the last of them. So the hazard runs the other way from the usual one: a hand-added entry is not something the platform will quietly revert, it is a native database port published on the tenant's shared address that stays published until somebody removes it by hand. Until the chart exposes a value, treat the field as reachable only for experimentation, and write down what you added. A stored entry also blocks a later move to certMode: edge. The CEL rule that keeps this field out of edge sits on the spec node, and ratcheting suppresses a CEL error only while the node it sits on is unchanged, so a stored tlsPassthroughListeners refuses the very write that would move the mode to edge. Moves between http01, dns01 and existingSecret pass. A switch to an edge-terminated class, the platform default or the tenant's own, reaches this chart as certMode, the write is then rejected for that tenant, and its gateway HelmRelease retries against the refusal without end, since packages/apps/tenant/templates/gateway.yaml sets remediation.retries: -1 on install and upgrade alike. That is the exit condition: the field has to be removed before the mode can move to edge.
One behaviour change reaches clusters that never set this field. A hostname a route claimed used to earn an HTTPS-terminate listener and an ACME certificate even when a passthrough listener already held that hostname, and the certificate went to a hostname whose backend presents its own. On port 443 that also put two listeners on one port and hostname — Gateway API admits the pair, because its listener uniqueness rule counts the protocol as well as the port and hostname, and its compatibility rules then call for both to report Conflicted. Cilium before v1.19.6 implements none of that: v1.19.5 writes Accepted, Programmed and ResolvedRefs on a listener and has no Conflicted condition anywhere, so the pair is admitted, translated, and reported by nothing. In HTTP-01 mode a hostname a passthrough listener holds now produces neither listener nor certificate, and a hostname only a TLSRoute claims produces neither either. The reservation is read off the TenantGateway spec alone: it holds whether or not a TLSRoute is attached to the passthrough listener and whatever that route's backends resolve to, so a route's status does not depend on how an implementation builds its filter chains. An HTTPRoute whose every hostname is reserved reads Accepted=False with NoMatchingListenerHostname naming the passthrough hostname; one that also claims a free hostname stays Accepted and is served on that one. That covers both passthrough forms: the port-443 one, where a terminate listener for the same name is a pair Cilium conflicts, and the native-port one, where the reservation is a policy that keeps an app from taking a platform name on 443. The rule does not run the other way, because the other direction never reaches it: an HTTPRoute publishing *.<apex> overlaps the shipped api.<apex> entry, but it is refused before the overlap is read. The HTTP-01 challenge issues no certificate for a wildcard name, so no terminate listener could ever carry the wildcard, and the route reads NoMatchingListenerHostname naming it whether or not a TLSRoute claims *.<apex> too. Until that refusal, the claim reached the renderer and produced a listener whose name carried the asterisk, which the Gateway CRD refuses in a listener name while admitting it in a hostname, and the apiserver then refused the whole Gateway write, with every other listener on it frozen where it stood. A wildcard published by an HTTPRoute is served only by the modes that terminate the whole apex. The other two cert modes are unaffected, because neither renders per-hostname listeners at all: the wildcard https and https-apex listeners still carry every name under the apex as a hostname, passthrough hostnames included, so nothing is shed there, and a tls-<svc> listener still answers its own name first. The shipped tlsPassthroughServices default is api, vm-exportproxy and cdi-uploadproxy, and the chart writes it only where the platform's own TLSRoutes attach: on the publishing tenant — tenant-root unless the cluster's expose-ingress says otherwise — while gateway.enabled is on, and only for the names publishing.exposedServices lists. On HTTP-01 that tenant sheds the terminate listeners for those names on upgrade, with the controller garbage-collecting the certificates that went with them. Every other tenant renders no passthrough listener from this value, so its own HTTPRoute on one of those names under its apex keeps its terminate listener and certificate. A child tenant that rendered the three passthrough listeners before the upgrade loses them, and no shipped route goes with them: the platform TLSRoutes name the publishing tenant's Gateway alone. A TLSRoute added by hand to one of those child listeners does lose its parent. To keep such a listener, set tlsPassthroughServices on that tenant's TenantGateway with a merge patch once the upgrade has removed it. From then on the chart renders the key in neither manifest there, so a later helm upgrade leaves the patch in place, with the caveats the tlsPassthroughListeners paragraph above gives for a hand-set field. Read the publishing tenant's shedding as a change of serving path rather than the removal of something idle: before the upgrade each of those hostnames has both listeners on port 443 and both filter chains carry the SNI, nothing on the objects says which one answers a connection, and Cilium v1.19.5 offers no Conflicted condition to look at, so you cannot tell from the cluster whether api.<apex> is terminated by the Gateway or passed through. After the upgrade it is passed through, always. If anything depends on the Gateway terminating one of those three names, it depends on the half of that ambiguity that goes away. The certificates are collected but their TLS Secrets are not: cert-manager here ships enableCertificateOwnerRef: false, so each shed Secret stays in the namespace with no owner, nothing renewing it and nothing removing it. That is worth knowing in both directions — three stale kubernetes.io/tls Secrets per shedding tenant to clean up by hand, and a cheap way back, since a re-created Certificate adopts a still-valid Secret rather than placing an order.
Before upgrading, check one thing about api.<apex> in particular: once the terminate listener is gone, the only certificate on that name is the management kube-apiserver's own serving certificate, so that certificate has to carry api.<apex> in its SANs. A cluster that came to the Gateway from ingress-nginx already meets this, because that path passes the same name through to the same Service and its clients have been validating the apiserver's certificate all along. A cluster that has been served by the Gateway's ACME certificate on that name does not necessarily, and it loses api.<apex> on upgrade until the SAN is there. Adding it is a change to the management cluster's apiserver rather than to anything in this chart; the alternative is the first path below, which moves the consumer to a hostname outside tlsPassthroughServices and costs nothing.
If something does depend on that terminate path, there are three ways forward and they are not equivalent. Moving the consumer to another hostname under the apex costs nothing: a name outside tlsPassthroughServices still earns its own terminate listener and ACME certificate, while the passthrough name goes on answering the platform's own TLSRoute. Switching certMode to dns01 or existingSecret, at the price of DNS-01 credentials or an operator-supplied certificate, does not bring termination back either. Neither mode renders per-hostname listeners, and their wildcard terminate listener covers these names on 443, but the tls-<svc> listener beside it answers its exact name first, so the name stays passed through. The wildcard terminates such a name only once its passthrough listener is gone, so this path ends where the next one does. Removing a service from tlsPassthroughServices does not bring its terminate listener back, and that is the point worth reading twice: the passthrough listener goes, the platform TLSRoute that attached to it by sectionName: tls-<svc> has nothing left to attach to, and the hostname is then claimed by a TLSRoute alone, which earns no terminate listener either. Nothing serves the name until something else claims it. So this path is a rewrite rather than a rollback, and it is only finished once the component is republished by an HTTPRoute on that hostname, which does earn a terminate listener and a certificate. For api that is the Kubernetes API endpoint, and passthrough means its clients hold a TLS session with the apiserver itself; terminating in front of it changes what that session authenticates against, so check what your clients present before picking this one.
Schema constraints tightened alongside this field reach objects that already exist, and every one of them is a kind the apiserver ratchets. apex is bounded at 253 characters, and tlsPassthroughServices at 62 entries each a DNS-1123 subdomain of at most 249 characters; a stored value that violates one still passes any write that leaves it unchanged, and the rule bites only once the value itself changes. apex gets no format pattern, deliberately, and that is not a statement that a mixed-case host works. It arrives verbatim from the namespace.cozystack.io/host label, which nothing normalizes and which may hold upper case, and the controller then composes listener hostnames from it that Gateway API refuses, so such a tenant's Gateway does not render with or without a pattern here. What a pattern changes is only where that lands: refused at the write it fails this HelmRelease and everything the release owns, admitted it is Ready=False on the one Gateway. An apex no listener hostname can sit within is caught only when tlsPassthroughListeners is in use, and then by the containment rule rather than by the controller: that CEL rule compares bytes as well, so a mixed-case apex with a lowercase listener hostname is refused at the write, with a message naming the hostname, and the controller's own check, which names the apex, is reached only where the CRD lags the controller. A repeated tlsPassthroughServices entry is reported by the controller as Ready=False rather than refused at admission, and that is deliberate, though not for the reason symmetry suggests. Ratcheting does reach a list-type error, and at a coarser grain than the rest: the apiserver checks an incoming object's list-type semantics only when the stored object passes them, for the whole object at once (read from pkg/registry/customresource/strategy.go in the pinned k8s.io/apiextensions-apiserver v0.37.1). So declaring the list a set would not block writes to an object already carrying a duplicate; it would stop every list on that TenantGateway from being checked at all, silently, while the duplicate the controller can name goes on being reported by name. The shipped defaults and the chart's own fail on an empty host conform to all of it, and for tlsPassthroughServices nothing else can reach the field: the only key the tenant writes into this chart's HelmRelease values is gatewayClassName, and only when the tenant chose a class, while everything else arrives through valuesFrom the cozystack-values Secret, and the platform writes no such key into that Secret, so the chart default, filtered to the publishing tenant as described above, is the only value that arrives and the bounds guard a value only an edit to this chart can change. An upgrade is a no-op.
Parameters
Common parameters
| Name | Description | Type | Value |
|---|---|---|---|
gatewayClassName |
GatewayClass for this tenant's Gateway, set from tenant.spec.gatewayClass. Empty means the platform default (_cluster.gateway-class-name, itself defaulting to the bundled cilium class). Whether the class terminates TLS itself or at its provider's edge comes from the platform's gateway.edgeTerminatedClasses, so the two cannot get out of step. |
string |
"" |
tlsPassthroughServices |
Names (from publishing.exposedServices) whose traffic is TLS-passthrough rather than TLS-terminate. For each such service a dedicated HTTPS listener with tls.mode=Passthrough is rendered on the Gateway, and the service is expected to attach a TLSRoute instead of an HTTPRoute. Rendered only on the publishing tenant, the namespace publishing.ingressName names, while gateway.enabled is on, and only for names publishing.exposedServices lists: the platform TLSRoutes for these services attach to that tenant's Gateway alone, and a passthrough listener anywhere else would reserve its name with nothing behind it. Ignored when this tenant's Gateway is on a class the platform lists in gateway.edgeTerminatedClasses: an HTTP-only edge cannot carry a TLS listener, so those services are not published through the tenant Gateway at all. |
[]string |
[api, vm-exportproxy, cdi-uploadproxy] |
Security model
Tenants in Cozystack interact with the platform exclusively through apps.cozystack.io/* resources (Tenant, Bucket, Kubernetes, …) served by cozystack-api. Tenant RBAC (cozy:tenant:* aggregated to a RoleBinding in the tenant's own namespace) does not grant write access to gateway.networking.k8s.io/*, core Namespaces, or cozystack.io/Package. Every layer below is shaped by that constraint — the security model is built around the apps.cozystack.io/* API surface, not around Gateway API admission.
The protections below split into three groups by who they defend against:
- Tenant-user-input gates — Layer 4 (
cozystack-tenant-host-policy).Tenant.spec.hostis the user-supplied field that surfaces as a security boundary at the hostname layer; it is gated on every Create / Update viacozystack-api's admission chain (pkg/registry/apps/application/rest.go). - Defense-in-depth — Layers 1, 2, 5, 6, 7, 8. These do not protect against tenant-user input (tenants don't hold the relevant RBAC). They guard against bugs in cozystack-controller / Flux, supply-chain compromise of an app chart that emits Gateway API or Ingress resources, and confused-deputy mistakes by a cluster admin. Fail-closed via
failurePolicy: Fail+validationActions: [Deny]. Layers 1-7 cover the opt-in Gateway API dataplane; Layer 8 covers the legacy Ingress dataplane, which is the default (Gateway API is off by default). - Admin-against-themselves — Layer 3 (
cozystack-gateway-attached-namespaces-policy). Rejects akubectl edit packages.cozystack.iothat would slip atenant-*entry into the platform Package'sgateway.attachedNamespaces. Layer 6 catches the same misconfiguration at helm render time.
- Namespace whitelist on listeners. Every listener carries an
allowedRoutes.namespaces.from: Selector, and there are two labels behind them. The plain-HTTP port-80 listener selects on the built-inkubernetes.io/metadata.namelabel, which kube-apiserver writes and a namespace cannot forge. The HTTPS-terminate and the port-443 TLS-passthrough listeners select onnamespace.cozystack.io/gateway, which is an ordinary namespace label written by theapps/tenantchart and by the controller, so it carries no such guarantee on its own: what keeps a namespace from applying it and attaching is thatNamespaceis cluster-scoped and no tenant role grants a verb on it, and what such a namespace could publish if it did is bounded by the route-hostname policies of Layer 2. The native-port layer-4 listeners fromtlsPassthroughListenersgo back to thekubernetes.io/metadata.namepin and name only the publishing tenant's namespace. HTTPS / TLS-passthrough listeners accept routes from the publishing tenant's namespace plusgateway.attachedNamespacesin the platform chart, which the controller labels for exactly that purpose (the default list includes thecozy-*namespaces for platform services anddefaultfor the Kubernetes API TLSRoute), and the tenant chart stamps the same label on every inheriting child tenant namespace, pointing at the nearest ancestor that owns a Gateway, so the attach set is the whole inheriting tenant subtree rather than the tenant plus the admin-listed namespaces. A namespace carrying neither the name match nor that label cannot attach anyHTTPRouteorTLSRouteto those listeners, while a child tenant receives the label at creation rather than by patching anything. UndercertMode: edgethere are no HTTPS listeners to describe: the Gateway serves port 80 only, every listener carries the same broad selector, and the narrow port-80 rule stated further down this item does not apply — see edge termination. Which class a tenant may put itself on is itself bounded:tenant.spec.gatewayClassis a tenant-writable field, so the gateway chart accepts only the platform default plus the classes listed ingateway.tenantSelectableClasses, and fails the tenant's own release otherwise. In every other mode: the plain-HTTP listener (port 80) carries a strictly narrower selector — only the tenant namespace itself andcozy-cert-manager. Both the controller-owned http→https redirect HTTPRoute and cert-manager's HTTP-01 challenge route live in the tenant namespace, the latter because cert-manager creates the Challenge, and with it the solver route, in the Certificate's own namespace; thecozy-cert-managerentry is not exercised by that path. That selector keeps routes from the othercozy-*namespaces and from inheriting child tenants off port 80; it does not keep off the publishing tenant's own app routes, which live in that same namespace and are admitted by it. Which route kinds a listener admits is settled two different ways. On port 443 it is written out: every listener there, HTTPS-terminate andtls-<svc>passthrough alike, carries the sameallowedRoutes.kindsofHTTPRouteandTLSRoute, because divergent kind sets on one port make Cilium merge the listeners and drop HTTPRoutes (cilium#45559). The native-port listeners name no kinds at all and let the derivation from the listener protocol stand, because the protocol already saysTLSRoute; beforev1.19.6Cilium turned an explicitTLSRouteinto a rejection of every HTTPRoute on the Gateway, whatever port the listener sat on. What that derivation admits is the implementation's call rather than the spec's: Gateway API leaves the protocol-to-kind mapping to the implementation and its conventional table pairsTLSwithTCPRouteas well asTLSRoute, so the port-443 listeners exclude GRPCRoute, TCPRoute and UDPRoute by naming their kinds, while the native-port ones rest on Cilium implementing neither TCPRoute nor UDPRoute. GRPCRoute reaches the port-80 listener instead, where the route-hostname VAP covers it; TCPRoute and UDPRoute carry no hostname. cozystack-gateway-hostname-policy—ValidatingAdmissionPolicyongateway.networking.k8s.io/v1 GatewayCREATE/UPDATE. ReadsnamespaceObject.metadata.labels["namespace.cozystack.io/host"]and rejects any listener hostname that is not equal to that value or a subdomain of it.matchConditionsgate the VAP to cozystack-managed namespaces only — Gateways in unrelated namespaces (e.g.kube-system) are not touched.cozystack-gateway-attached-namespaces-policy— VAP oncozystack.io/v1alpha1 PackageCREATE/UPDATE. Rejects anytenant-*entry inspec.components.platform.values.gateway.attachedNamespaces. Catches directkubectl edit packages.cozystack.iothat would bypass the helm render-time guard in layer 6.cozystack-tenant-host-policy— VAP onapps.cozystack.io/v1alpha1 TenantCREATE/UPDATE. Rejects setting or changingspec.hostunless the caller's groups containsystem:masters,system:serviceaccounts:cozy-system,system:serviceaccounts:cozy-cert-manager,system:serviceaccounts:cozy-fluxcdorsystem:serviceaccounts:kube-system. Closes the path where a tenant user setsspec.host=dashboard.example.orgon their own tenant to have the tenant chart write a hijacked label into the namespace.cozystack-namespace-host-label-policy— VAP on corev1 NamespaceCREATE/UPDATE. Rejects any set or change of thenamespace.cozystack.io/hostlabel, except by the same trusted-caller whitelist as layer 4. This closes both first-time label writes on CREATE and first-time adds on UPDATE — only cozystack/Flux service accounts (which apply the tenant chart) can stamp the label.- Render-time
failin cozystack-basics. The cozystack-basics chart fails the helm render if_cluster.gateway-attached-namespacescontains anytenant-*entry. Triggers on the helm-install path before the cluster ever sees the values — complements layer 3 which triggers atkubectl applytime. cozystack-route-hostname-policy— VAP onHTTPRoute,TLSRouteandGRPCRouteCREATE/UPDATE ingateway.networking.k8s.io, matching every version the vendored CRD bundle serves for each (pinned against the bundle byhack/route-hostname-policy-version-coverage.bats). GRPCRoute is covered because the port-80 listener leavesallowedRoutes.kindsunset, and an unset list resolves to the kinds the listener protocol supports, which for HTTP includes GRPCRoute. Scoped totenant-*namespaces (cozy-* are cluster-admin-managed and trusted to publish under any apex). Requiresspec.hostnamesto be present and non-empty, and rejects any entry that is not equal to the namespace'snamespace.cozystack.io/hostlabel or a subdomain of it. A route that names no hostname is not inert: it inherits the hostnames of every listener it attaches to, andparentRefsmay name a Gateway in another namespace, so admitting it unchecked would let it cover whatever that listener covers. A VAP cannot resolve the parent Gateway to learn which hostnames would be inherited, so the policy requires the route to name its own. The controller-owned http-to-https redirect names the tenant apex and its wildcard for this reason. Defense-in-depth against an app chart bug or supply-chain compromise that emits Gateway API resources outside the tenant's apex — tenants in Cozystack do not holdgateway.networking.k8s.io/*RBAC by design, so this is not a tenant-user defense. The within-apex cross-namespace case (a tenant chart claiming a hostname that is published by acozy-*app) is handled by the controller at reconciliation time for HTTPRoute and TLSRoute: when two such routes from different namespaces claim the same hostname, thecozy-*namespace wins and the loser receives aHostnameConflictcondition under the controller's name inStatus.Parents. Which condition a route gets follows the shape of the refusal.HostnameConflictsays another route holds the name.NoMatchingListenerHostnamesays there is no listener this route can attach to, and the message tells apart the shapes that produce it: nothing renders the name at all; the only listener carrying it is a TLS-passthrough listener, which answers the SNI but is not something anHTTPRouteattaches to, and no terminate listener was rendered beside it; the route named a listener in itsparentRefand that listener answers a different hostname; the route named a port in itsparentRefand no listener answering its hostname is published on that port, which anHTTPRoutereaches by pinning a port that is neither 443, the only port its terminate listener sits on, nor 80, which selects the plain-HTTP listener and is answered by the two shapes below rather than here; or the route is anHTTPRoutethat wanted a terminate listener and claims a wildcard under HTTP-01, which issues no wildcard certificate, so no terminate listener could carry the name; the same route selecting the plain-HTTP listener is served there and reaches none of these shapes. Read it as "nothing here will serve you", not as "no listener exists": in every shape but the first and the last the listener is right there, and on port 443 the passthrough one even listsHTTPRouteamong its kinds. The twoparentRefshapes and the wildcard are also the ones that can hit a route inside the publishing tenant, which is why none of them is folded into the namespace refusal below.NotAllowedByListenersis the third condition, and three shapes reach it, each a listener that answers the route and turns it away. A native-port listener would take the route's kind and admits routes from the tenant's own namespace alone, so a route from elsewhere is refused by a listener that would otherwise serve it. AnHTTPRoutethat pins a renderedtls-<name>listener bysectionNameis the other: that listener forwards a stream it never terminates and takesTLSRoutealone by protocol, so it cannot serve anHTTPRoutewhatever namespace the route is in. AnHTTPRoutethat selects the plain-HTTP listener, by port 80 or by naminghttp, from a namespace that listener does not admit is the third.NoMatchingParentis the fourth, and it is the shape where thesectionNameitself is wrong rather than the listener it names: a route pinned to atls-<name>listener this Gateway does not render attaches to nothing whatever answers its hostname, and the controller can say so because it renders every listener in that namespace. For anHTTPRouteit is reported whatever answers the hostname, because the section is judged before the overlap is read, and anHTTPRoutewhosesectionNameandparentRefport select different listeners,httpwith port 443 for one, reaches it the same way. For aTLSRouteit is reported where a passthrough listener answers the route's hostname, which is where that comparison is made; one pinned to an absent section on a hostname no passthrough listener answers is unserved for the plainer reason and carriesNoMatchingListenerHostnameinstead, unless anHTTPRouteclaims that hostname too: the terminate listener rendered for it leaves the name served by something, so theTLSRouteis judged like any attached route and can readAccepted=Truewhile the section it pinned does not exist. It still cannot attach to that listener, and that gap predates the withdrawal. AsectionNameoutside that prefix,httpaside, is not judged here at all: the listener families it could name are the ones this pass is still deciding, so the route is writtenAccepted=Truelike any attached route, and the class controller's own entry says whether the listener it named took it. All four hold whether or not another route also claimed the name. TwoTLSRoutes racing for a hostname a passthrough listener answers, both eligible to attach, still get the conflict, because that listener serves exactly one of them. A route carrying a conflict alongside any of the others is reported asHostnameConflict, with every cause spelled out in the message: the reason names the half an operator can act on by moving the other route. ReadAccepted=Falsehere as "at least one of this route's hostnames is not served", not as "this route serves nothing": Gateway API gives one condition per parentRef while a route may claim several hostnames, so a route claiming one withdrawn name and one ordinary name keeps the listener and the certificate for the second. The message names the hostnames that lost, and the ones it does not name are being served. GRPCRoute takes no part in that race and never carries the condition; it reaches only the hostname-less port-80 listener, which every route attaches to regardless of the hostname it claims.cozystack-ingress-hostname-policy— VAP on corenetworking.k8s.io/v1 IngressCREATE/UPDATE. Gateway API is opt-in and off by default, so in the default configuration tenant applications publish through a legacy Ingress on the shared ingress-nginx; layers 1-7 constrain tenant hostnames on the opt-in Gateway path, and this VAP adds a hostname constraint on the default Ingress path. Scoped totenant-*namespaces (cozy-* are cluster-admin-managed and trusted). A hostname onspec.rules[].hostorspec.tls[].hosts[]is allowed when it is within the namespace's ownnamespace.cozystack.io/hostapex, OR when it lies entirely outside the platform root apex (_cluster.root-host) — the second case lets a tenant route its own external custom domain (for example thekubernetesapp's Proxiedaddons.ingressNginx.hosts, which routes a user-supplied domain to a nested cluster). A hostname that falls under the platform root apex but outside the namespace's own apex is rejected; a rule with no host (an unbounded catch-all) andspec.defaultBackend(a catch-all for unmatched traffic) are also rejected. Fail-closed: atenant-*namespace missing its host label is denied, and the policy renders only when_cluster.root-hostis set. This bounds which apexes a tenant Ingress may claim under the platform domain; it does not attempt to resolve every possible hostname collision.
For tenant-root the allowed host suffix is publishing.host; for any tenant-<name> that inherits from its parent the suffix is <name>.<parent apex>. A child tenant with an independent apex (customer1.io instead of a subdomain) is handled correctly because the VAP reads the per-namespace label rather than assuming a subdomain hierarchy.
Rate limits
cert-manager issues per-listener Certificate resources in HTTP-01 mode (one per published app), or a single wildcard Certificate per tenant in DNS-01 mode. With issuerName: letsencrypt-prod (the default), every certificate counts against the Let's Encrypt rate limits:
- 50 new certificates per registered domain per week.
- 5 duplicate certificates per week for the same set of hostnames.
- 300 new orders per account per 3 hours.
A cluster where many tenants share the same apex domain can exhaust these quickly, especially in HTTP-01 mode where each published app contributes one certificate. Mitigations:
- Use
publishing.certificates.issuerName: letsencrypt-stagefor non-production clusters (staging does not count against prod quotas). - Limit the number of simultaneous tenant Gateways per cluster via the platform's package quota, or cap it via
tenant.spec.resourceQuotaswithcount/certificates.cert-manager.ioto limit how manyCertificateobjects a tenant may create. - Switch to DNS-01 to consolidate every tenant's apps under one wildcard cert (cuts cert count from N apps to 1).
- For bare-metal or air-gapped deployments consider an internal ACME server or the self-signed
ClusterIssuer(selfsigned-cluster-issuer) that ships alongside the Let's Encrypt issuers.
Recommended tenant-level quota to contain a misbehaving tenant:
apiVersion: apps.cozystack.io/v1alpha1
kind: Tenant
spec:
gateway: true
resourceQuotas:
count/certificates.cert-manager.io: "10"
The default for a fresh tenant is unlimited; operators running shared-apex multi-tenant clusters should set this explicitly (or stage it via the tenant-application default values) before opening gateway: true to non-trusted tenants.
Known limitations
- Upstream application gaps — some chart-level features (harbor ACL integrations, bucket upstream limitations) remain on ingress-nginx workflows in upstream docs; cozystack tracks those separately as upstream PRs.
- Supported ACME issuers —
publishing.certificates.issuerNamefor Gateway-based tenants must beletsencrypt-prodorletsencrypt-stage(the controller maps those names to concrete ACME server URLs). To support another ACME provider, extend the controller's renderer with an additional branch. - DNS-01 wildcards require DNS provider access for every apex level — when a deeply nested tenant (e.g.
tenant-root→alice→alice-prod) inherits DNS-01 mode, the parent's*.alice.example.orgSAN requires the parent's ACME challenge to write a TXT record under_acme-challenge.alice.example.org. If the operator hasn't delegated that subzone to the parent's DNS provider account, cert issuance for the grandchild apex stalls. HTTP-01 mode is unaffected — each per-listener challenge runs against the specific hostname. - Cilium sharing-key port-collision — operators wanting multiple per-tenant Gateways to share a single LB IP cannot do so on current Cilium: every tenant Gateway claims
443/TCP, solbipam.cilium.io/sharing-keyis inactive on port collision (cilium#21270, cilium#42756). Each Gateway → own LB IP until Cilium ships ListenerSet. Within a single Gateway, inheritance (parent + all inheriting children sharing one IP) works today. - Upstream application gaps — some chart-level features (harbor ACL integrations, bucket upstream limitations) remain on ingress-nginx workflows in upstream docs; cozystack tracks those separately as upstream PRs.
- Supported ACME issuers —
publishing.certificates.issuerNamefor Gateway-based tenants must beletsencrypt-prodorletsencrypt-stage(the controller maps those names to concrete ACME server URLs). To support another ACME provider, extend the controller's renderer with an additional branch.