Files
Aleksei Sviridkin 4b2507b7a1 docs(gateway): align gateway prose with the code
The comment on the shared CEL string said what the hostname requirement
is but not what it is worth per kind. Two of the three schemas leave
spec.hostnames optional at every version the vendored bundle serves,
so for HTTPRoute and GRPCRoute this policy is the only rejection a
hostname-less route meets; tlsroutes requires the field at v1 and
v1alpha3 and not at v1alpha2, so there the policy closes the gap for
as long as v1alpha2 is served. State that where the requirement is
stated.

The comment on the port-80 allowedRoutes filter credited the
cert-manager namespace entry for letting ACME complete, which the
paragraph above it retracts: the solver route lives in the tenant
namespace, and that is what keeps ACME working. The same sentence
claimed the filter keeps app routes off port 80, and the Layer 1
paragraph of the gateway README claimed it too. It does not. A tenant
that owns its Gateway is its own gateway namespace, so its app routes
are admitted by this very filter; what it excludes is cozy-* and
inheriting children. Both copies now say that.

Nothing said why naming the apex on the redirect costs no coverage,
which is the question a reader has once the route stops matching every
host. It holds because of the other half of this change: the port-80
listener admits routes only from the tenant namespace and
cert-manager's, and the tightened route policy requires every route
there to declare hostnames inside the apex. An inheriting child does
not widen that set either, and the comment now says so at the point
the hostnames are chosen: the gateway hostname policy compares every
listener against the host label of the Gateway's own namespace, so a
child apex outside it never gets its listener, the Gateway write
carrying it being refused at admission whole, in reconcileGateway and
before this route is rendered, while a child apex under this one
passes that check and the wildcard already covers it.

The empty-apex guard in renderHTTPRedirect, and the test pinning it,
both read as though the guard is what spares an operator an unreadable
schema error. It is, but the condition stated for it named the number
of attached routes, which does not enter into it. In HTTP-01 the
listener set is built from route hostnames alone, so reconcileGateway
never reads the apex ahead of the guard however many routes attach.
DNS-01 and existingSecret render an apex listener, and any
tlsPassthroughServices entry builds <service>.<apex>, so in those the
operator meets the equivalent error against the Gateway first. State
that condition once, at the guard, and let the test defer to it rather
than carry a second copy that can drift; the test stays green either
way because the fake client does not validate against the CRD schema.

The e2e suite explained omitting the TLSRoute empty-list probe by the
v1 schema rejecting an empty list. Both halves of that are true and
the conclusion does not follow: the probe runs at v1alpha2, which
marks spec.hostnames neither required nor minItems, so an empty list
reaches admission there exactly as an absent field does. What makes
the omission safe is that all three policies render one byte-identical
CEL string, so the HTTPRoute pair proves that term already.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-09-04 18:08:02 +03:00
..

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 Gateway's listener allowedRoutes.namespaces.selector is keyed on that label, so the same logic permits the inheriting tenant's HTTPRoutes / TLSRoutes to attach. 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 Issuer in the tenant namespace with an http01.gatewayHTTPRoute solver pointing at the tenant's own Gateway / http listener.
  • Watches HTTPRoutes / TLSRoutes attached to the Gateway (parentRefs pointing at it). For each unique hostname seen, it adds a per-app HTTPS listener and a per-app Certificate (dnsNames containing exactly that hostname).
  • Per-app listener naming: https-<first-label-of-hostname> (e.g. https-harbor).
  • Per-app cert naming: <tgw-name>-<first-label>-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.listeners at 64. In HTTP-01 mode, every published hostname adds one HTTPS listener, plus the mandatory http listener and one extra per TLS-passthrough service — so a tenant approaching 60+ published apps on HTTP-01 hits the spec cap and the rendered Gateway fails admission. 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 Issuer and no Certificate. Switching into this mode from HTTP-01 or DNS-01 garbage-collects the now-unused per-tenant Issuer and any per-listener / wildcard Certificate the controller previously owned.
  • Renders the same single-wildcard listener shape as DNS-01: a https (*.<apex>) listener and a https-apex (<apex>) listener (plus one *.<child-apex> listener per inheriting child), all with certificateRefs pointing 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, no Certificate and no http-to-https redirect route (the edge redirects itself). Switching into this mode garbage-collects the ACME objects the controller previously owned — the Issuer and the Certificate objects, not the TLS Secrets they issued: cert-manager runs with enableCertificateOwnerRef: 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-based allowedRoutes as the HTTPS listeners of the other modes, so app HTTPRoutes attach by hostname unchanged. TLSPassthroughServices are 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. Where the packages that own them create those TLSRoute objects at all — each renders only when gateway.enabled is on and its name is still in publishing.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 attached TLSRoute gets Accepted=False with reason NoMatchingParent under this controller's name, which is Gateway API's reason for a parentRef whose port or section matches no listener on the Gateway. 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 under http01 or, under dns01 and existingSecret where 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.

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. 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.host is the user-supplied field that surfaces as a security boundary at the hostname layer; it is gated on every Create / Update via cozystack-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 a kubectl edit packages.cozystack.io that would slip a tenant-* entry into the platform Package's gateway.attachedNamespaces. Layer 6 catches the same misconfiguration at helm render time.
  1. Namespace whitelist on listeners. Every listener carries an allowedRoutes.namespaces.from: Selector, and there are two of them. The plain-HTTP port-80 listener selects on the built-in kubernetes.io/metadata.name label, which kube-apiserver writes and a namespace cannot forge. The HTTPS-terminate and TLS-passthrough listeners select on namespace.cozystack.io/gateway, which is an ordinary namespace label written by the apps/tenant chart and by the controller, so it carries no such guarantee on its own: what keeps a namespace from applying it and attaching is that Namespace is 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. HTTPS / TLS-passthrough listeners accept routes from the publishing tenant's namespace plus gateway.attachedNamespaces in the platform chart, which the controller labels for exactly that purpose (the default list includes the cozy-* namespaces for platform services and default for the Kubernetes API TLSRoute). A namespace carrying neither the name match nor that label cannot attach any HTTPRoute or TLSRoute to those listeners. Under certMode: edge there 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.gatewayClass is a tenant-writable field, so the gateway chart accepts only the platform default plus the classes listed in gateway.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 and cozy-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; the cozy-cert-manager entry is not exercised by that path. That selector keeps routes from the other cozy-* 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. Every port-443 listener, HTTPS-terminate and TLS-passthrough alike, carries the same allowedRoutes.kinds of HTTPRoute and TLSRoute (cilium#45559 collapses port-443 listeners whose kinds diverge), which keeps GRPCRoute / TCPRoute / UDPRoute from attaching there. GRPCRoute reaches the port-80 listener instead, where the route-hostname VAP covers it; TCPRoute and UDPRoute carry no hostname.
  2. cozystack-gateway-hostname-policy — ValidatingAdmissionPolicy on gateway.networking.k8s.io/v1 Gateway CREATE/UPDATE. Reads namespaceObject.metadata.labels["namespace.cozystack.io/host"] and rejects any listener hostname that is not equal to that value or a subdomain of it. matchConditions gate the VAP to cozystack-managed namespaces only — Gateways in unrelated namespaces (e.g. kube-system) are not touched.
  3. cozystack-gateway-attached-namespaces-policy — VAP on cozystack.io/v1alpha1 Package CREATE/UPDATE. Rejects any tenant-* entry in spec.components.platform.values.gateway.attachedNamespaces. Catches direct kubectl edit packages.cozystack.io that would bypass the helm render-time guard in layer 6.
  4. cozystack-tenant-host-policy — VAP on apps.cozystack.io/v1alpha1 Tenant CREATE/UPDATE. Rejects setting or changing spec.host unless the caller's groups contain system:masters, system:serviceaccounts:cozy-system, system:serviceaccounts:cozy-cert-manager, system:serviceaccounts:cozy-fluxcd or system:serviceaccounts:kube-system. Closes the path where a tenant user sets spec.host=dashboard.example.org on their own tenant to have the tenant chart write a hijacked label into the namespace.
  5. cozystack-namespace-host-label-policy — VAP on core v1 Namespace CREATE/UPDATE. Rejects any set or change of the namespace.cozystack.io/host label, 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.
  6. Render-time fail in cozystack-basics. The cozystack-basics chart fails the helm render if _cluster.gateway-attached-namespaces contains any tenant-* entry. Triggers on the helm-install path before the cluster ever sees the values — complements layer 3 which triggers at kubectl apply time.
  7. cozystack-route-hostname-policy — VAP on HTTPRoute, TLSRoute and GRPCRoute CREATE/UPDATE in gateway.networking.k8s.io, matching every version the vendored CRD bundle serves for each (pinned against the bundle by hack/route-hostname-policy-version-coverage.bats). GRPCRoute is covered because the port-80 listener leaves allowedRoutes.kinds unset, and an unset list resolves to the kinds the listener protocol supports, which for HTTP includes GRPCRoute. Scoped to tenant-* namespaces (cozy-* are cluster-admin-managed and trusted to publish under any apex). Requires spec.hostnames to be present and non-empty, and rejects any entry that is not equal to the namespace's namespace.cozystack.io/host label or a subdomain of it. A route that names no hostname is not inert: it inherits the hostnames of every listener it attaches to, and parentRefs may 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 hold gateway.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 a cozy-* app) is handled by the controller at reconciliation time for HTTPRoute and TLSRoute: when two such routes from different namespaces claim the same hostname, the cozy-* namespace wins and the loser receives a HostnameConflict condition under the controller's name in Status.Parents. 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.
  8. cozystack-ingress-hostname-policy — VAP on core networking.k8s.io/v1 Ingress CREATE/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 to tenant-* namespaces (cozy-* are cluster-admin-managed and trusted). A hostname on spec.rules[].host or spec.tls[].hosts[] is allowed when it is within the namespace's own namespace.cozystack.io/host apex, 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 the kubernetes app's Proxied addons.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) and spec.defaultBackend (a catch-all for unmatched traffic) are also rejected. Fail-closed: a tenant-* namespace missing its host label is denied, and the policy renders only when _cluster.root-host is 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-stage for 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.resourceQuotas with count/certificates.cert-manager.io to limit how many Certificate objects 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.issuerName for Gateway-based tenants must be letsencrypt-prod or letsencrypt-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.org SAN 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, so lbipam.cilium.io/sharing-key is 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.issuerName for Gateway-based tenants must be letsencrypt-prod or letsencrypt-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.