Skip to content

feat(networking): Gateway API support via Cilium (supersedes #2213) - #2470

Merged
Aleksei Sviridkin (lexfrei) merged 14 commits into
mainfrom
chore/gateway-api-crds-v1.5.1
May 26, 2026
Merged

feat(networking): Gateway API support via Cilium (supersedes #2213)#2470
Aleksei Sviridkin (lexfrei) merged 14 commits into
mainfrom
chore/gateway-api-crds-v1.5.1

Conversation

@lexfrei

@lexfrei Aleksei Sviridkin (lexfrei) commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Replaces ingress-nginx as the default ingress layer for cozystack-native services with Cilium Gateway API, materialised per-tenant via a new TenantGateway CRD reconciled by cozystack-controller. Supersedes #2213 / #2208. Stacked on top of #2468.

Architecture

The chart renders one gateway.cozystack.io/v1alpha1 TenantGateway CR per tenant that owns its own Gateway. The cozystack-controller reconciles the actual Gateway, per-tenant Issuer, Certificate (wildcard or per-listener), and HTTP→HTTPS redirect HTTPRoute from there. Helm does not render Gateway or Certificate directly — that prevents the Helm-vs-controller race on Gateway.spec.listeners that route-driven listener additions would otherwise cause.

┌── TenantGateway CR (rendered by extra/gateway chart for owning tenants)
│
└──▶ cozystack-controller reconciles:
        ├─ Gateway (with dynamic listeners, label-based allowedRoutes)
        ├─ Issuer (per-tenant ACME account)
        ├─ Certificate(s) — wildcard + per-child SANs in DNS-01, per-listener in HTTP-01
        ├─ HTTPRoute (controller-owned http→https redirect)
        ├─ Namespace label patching (cozy-* system namespaces from spec.AttachedNamespaces)
        └─ Watches HTTPRoute / TLSRoute attachments to materialise listeners

Inheritance: tenants attach by default, opt into their own Gateway

A tenant gets a 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 publishes through the Gateway of the nearest ancestor that owns one — same shape as the existing _namespace.ingress inheritance.

The apps/tenant chart writes a namespace.cozystack.io/gateway label on each tenant namespace, carrying either the 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 selector admits routes from every namespace pointing at the owner — descendants and cozy-* system namespaces alike. cozystack-controller patches the same label onto every namespace in tgw.Spec.AttachedNamespaces and garbage-collects labels it wrote when an entry is removed from the attach list (annotation-gated GC so labels written by the apps/tenant chart are never stripped).

In DNS-01 mode, the controller extends the parent's wildcard Certificate with <child-apex> + *.<child-apex> SANs per inheriting child, and adds a *.<child-apex> HTTPS listener per child apex referencing the same cert. Without this expansion the parent's single-level wildcard cannot match a child route's hostname (harbor.alice.example.org is two labels past the parent's *.example.org).

Opt-out into own Gateway is the right choice when: a tenant needs its own LB IP (DNS pinned, firewall rule), the apex is not derived from the parent (custom host value the ancestor's cert can't cover), or the tenant wants its own ACME account / cert authority. Otherwise leave gateway unset and inherit.

End-to-end flow

  1. Platform: gateway.enabled=true toggles cert-manager solver, switches per-system-app templates from Ingress to HTTPRoute / TLSRoute.
  2. Per tenant: explicit tenant.spec.gateway=true renders a TenantGateway CR and triggers the controller to materialise the owner Gateway. Tenants with the field unset inherit through _namespace.gateway propagation in the tenant chart.
  3. Adding a published app under any tenant (owner or inheriting) is just deploying its HTTPRoute. The controller picks up the hostname, adds a per-listener Certificate (HTTP-01) or extends the wildcard cert's SANs (DNS-01), and updates the owner Gateway's listeners.

Defaults stay legacy: gateway.enabled=false, tenant.spec.gateway=false. Existing clusters upgrade unchanged.

External IP allocation — mechanism-agnostic

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. The tenant API stays mechanism-agnostic — no gatewayIP field, no allocator-specific manifest in the tenant chart. 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).

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 — same as for any other LoadBalancer Service.

Cert mode: HTTP-01 (default) vs DNS-01 (opt-in)

publishing.certificates.solver selects how the controller sources TLS certs.

HTTP-01 (default) — per-app HTTPS listener + per-app Certificate. New apps require zero platform-side config: deploy the HTTPRoute, controller does the rest. Inheriting children's hostnames pick up per-listener certs the same way the owner's own apps do.

DNS-01 (opt-in) — single wildcard Certificate (<apex> + *.<apex>) covering every published app under the apex, plus per-child SANs (<child-apex> + *.<child-apex>) for every inheriting tenant. Pick this for clusters where many apps share the apex and Let's Encrypt rate limits matter. The DNS provider account must be able to write TXT records under every apex level the parent serves — for deeply-nested inherited children that requires zone delegation or a wide-scope provider credential.

publishing.certificates.dns01.provider Required publishing.certificates.dns01.<provider> keys
cloudflare (default) cloudflare.secretName, cloudflare.secretKey
route53 route53.region, route53.secretName (and accessKeyID if not on IRSA)
digitalocean digitalocean.secretName
rfc2136 rfc2136.nameserver, rfc2136.tsigKeyName, rfc2136.secretName

The platform chart wires every provider's keys into _cluster.dns01-*; the per-tenant gateway chart and the cluster-wide cluster-issuers.yaml both read them.

Strengthens admission coverage on apps.cozystack.io/*

The cozystack-tenant-host-policy probe in gateway.bats initially looked like a flake — the apply that was supposed to be VAP-rejected just succeeded. Investigation traced it to the custom REST handler at pkg/registry/apps/application/rest.go. genericapiserver hands every storage method a callback into the validating admission chain: createValidation for Create, updateValidation for Update, deleteValidation for Delete. Update was correctly invoking its callback since 23e399b, but Create and Delete accepted the parameter and never called it — admission was silently bypassed for both verbs on every apps.cozystack.io resource (Tenant, MariaDB, Postgres, Kubernetes, …).

This PR wires the missing createValidation and deleteValidation calls into Create and Delete. Every ValidatingAdmissionPolicy and ValidatingWebhook against apps.cozystack.io/* now fires on all three verbs as the API contract requires. Per KEP-3488 aggregated APIServers are responsible for enforcing admission on their own kinds; cozystack-api is configured to do so via genericoptions.NewRecommendedOptions, the only thing missing was the explicit *Validation callbacks in the custom REST.

Security — five guards, grouped by what they defend

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. 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; gated on every Create / Update via cozystack-api's admission chain.

Defense-in-depth. Layers 1, 2, 5, 7. 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 resources, and confused-deputy mistakes by a cluster admin. Fail-closed via failurePolicy: Fail + validationActions: [Deny].

The five layers themselves:

  1. Listener allowedRoutes label selectornamespace.cozystack.io/gateway = <owner-tenant-name> (controller-written for cozy-* via AttachedNamespaces, Helm-written for tenant namespaces via the tenant chart, both feeding into the same Gateway selector).
  2. cozystack-gateway-hostname-policy — VAP on Gateway CREATE/UPDATE. Restricts listener hostnames to the namespace's namespace.cozystack.io/host apex.
  3. cozystack-tenant-host-policy — VAP on Tenant CREATE/UPDATE.
  4. cozystack-namespace-host-label-policy — VAP on core Namespace CREATE/UPDATE. Immutability gate on the host label.
  5. cozystack-route-hostname-policy — VAP on HTTPRoute and TLSRoute CREATE/UPDATE. Closes the hostname-hijack vector across namespaces under inheritance — a child cannot claim a hostname outside its own apex.

The previous Layer 3 (cozystack-gateway-attached-namespaces-policy) and Layer 6 (render-time fail in cozystack-basics) banned tenant-* entries in publishing.gateway.attachedNamespaces on the assumption that the attach list was the hijack vector. Under inheritance the attach surface is the label selector, not the static list; the hijack vector is closed by Layers 4/5/7 independently. Both Layer 3 and Layer 6 are dropped — kept in numbering above for continuity with the earlier review threads.

Foreign-takeover guards

Five reconcile paths refuse to silently rewrite a pre-existing object that shares the controller-derived name but carries no OwnerReference back to the TenantGateway: Gateway, redirect HTTPRoute, per-tenant Issuer, wildcard Certificate, per-listener Certificate. An operator who hand-pinned a Certificate or Issuer at the controller's derived name (private CA, manual cert pinning) gets an explicit Ready=False/ReconcileError condition instead of having their config silently destroyed and the resource re-issued from a different ACME account.

The namespace-label patching path applies the same discipline: cozystack-controller only writes / strips namespace.cozystack.io/gateway on namespaces it annotates with cozystack.io/gateway-attached-by. Labels written by the apps/tenant chart (no annotation) are never touched, so inheritance for tenant namespaces survives every reconcile.

Tests

  • Go controller tests — 50+ cases in internal/controller/tenantgateway/: idempotency, OwnerReference cascade, multi-parentRef status, HTTP-01↔DNS-01 mode-transition cert lifecycle, foreign-takeover refusals (Gateway / HTTPRoute / Issuer / Certificate / per-listener Certificate), DNS-01 provider matrix (cloudflare / route53 / digitalocean / rfc2136), HTTPS Kinds=[HTTPRoute] restriction, fail-closed CEL on missing host label, status condition wiring. Plus, for inheritance: label-based allowedRoutes selector shape, namespace label patch + garbage-collect via owner annotation, Helm-owned label safety, DNS-01 wildcard SAN expansion per inheriting child, per-child wildcard listener materialisation.
  • Admission-chain testspkg/registry/apps/application/rest_admission_test.go pins createValidation, updateValidation, deleteValidation paths via sentinel callbacks.
  • helm-unittest across extra/gateway (TenantGateway CR rendering, both modes), core/platform (DNS-01 wiring), apps/tenant (gateway-effective decision matrix including inheritance), system/cozystack-basics (three VAPs + Layer 7 VAP + tenant-root namespace label including the conditional namespace.cozystack.io/gateway: tenant-root + cozystack-values-secret _namespace.gateway propagation), system/cert-manager-issuers (full DNS-01 provider matrix), system/cozystack-api and system/dashboard (Ingress↔TLSRoute/HTTPRoute toggle), system/monitoring (Grafana / Alerta Ingress↔HTTPRoute toggle).
  • e2e batshack/e2e-apps/gateway.bats (17 cases) covering GatewayClass Accepted, Gateway Programmed + LB Service, HTTPRoute Accepted with status, VAP rejects cross-tenant hostname hijack, VAP rejects tenant.spec.host writes from non-trusted SA (regression-detector for the admission-chain fix), VAP rejects namespace.cozystack.io/host label changes from non-trusted SA, route-hostname VAP rejects HTTPRoute hostnames outside the apex, and three new cases for inheritance — Package admission accepts tenant-* in attachedNamespaces (regression guard for the dropped ban), child tenant without explicit gateway inherits _namespace.gateway from a Gateway-owning parent in lockstep across the namespace label and the cozystack-values Secret, and the cross-namespace attach end-to-end where a child tenant's HTTPRoute reaches Accepted on the parent's Gateway via the inheritance label.
  • CI wiringmake test-controllers target invoked from pull-requests.yaml runs go test ./internal/....

Backward compatibility

The only always-on change is the flip of cilium.envoy.enabled + cilium.gatewayAPI.enabled to true (extra cilium-envoy DaemonSet, ~100 MB RAM/node idle). Everything else is gated behind gateway.enabled=false / tenant.spec.gateway=false. Existing clusters see no behavioural change until an operator explicitly opts in.

The cozystack-api admission-chain fix is unconditional. Existing operators who do not define any VAP / admission webhook on apps.cozystack.io resources see no observable change. Operators who DO have their own webhooks/VAPs against these kinds will start seeing them fire on Create and Delete (as intended) — review your policies before merging if you maintain custom admission for apps.cozystack.io/*.

Operators who set publishing.gateway.attachedNamespaces to include tenant-* entries used to hit a render-time fail or an admission rejection — now the apply succeeds and the listed tenant namespaces simply pick up the gateway-attach label alongside the cozy-* system namespaces. This matches the inheritance model and was kvaps' explicit ask in the May 25 review.

Release note

- Adds Gateway API support via Cilium, materialised per-tenant via the new `gateway.cozystack.io/v1alpha1 TenantGateway` CRD reconciled by `cozystack-controller`. Opt-in via `publishing.gateway.enabled=true` at the platform level; per-tenant opt-in via `tenant.spec.gateway=true` for dedicated Gateway / LB IP, otherwise the tenant inherits the nearest ancestor's Gateway via a label-based `allowedRoutes` selector (same shape as the existing `_namespace.ingress` inheritance). Includes HTTP-01 (default) and DNS-01 (opt-in, with cloudflare / route53 / digitalocean / rfc2136 providers) cert solvers; DNS-01 extends the parent's wildcard Certificate with per-child apex SANs and adds a `*.<child-apex>` listener per inheriting tenant. Tenant API is mechanism-agnostic for IP allocation — the LoadBalancer Service draws its IP from the cluster's admin-configured allocator (MetalLB / Cilium LB-IPAM / robotlb / externalIPs).
- Strengthens admission coverage on `apps.cozystack.io/*` resources: `cozystack-api` now invokes `createValidation` and `deleteValidation` callbacks on Create and Delete (Update was already wired). Operators with custom ValidatingAdmissionPolicies or webhooks targeting `apps.cozystack.io/*` will see them fire on all three verbs as the API contract requires; review your policies before merging if you maintain such admission.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request updates the Gateway API CRDs to version 1.5.1 to align with upstream standards and enable new routing capabilities. The change includes significant schema updates and the addition of safety guardrails to ensure cluster stability during future upgrades or rollbacks.

Highlights

  • Gateway API Version Bump: Updated the vendored Gateway API CRDs from v1.2.0 to v1.5.1 in the experimental channel.
  • New Features and API Changes: Introduced ListenerSet (GEP-1713), promoted BackendTLSPolicy and TLSRoute to v1, and removed the deprecated BackendLBPolicy.
  • Safety Mechanisms: Added a new ValidatingAdmissionPolicy to prevent downgrading the CRD bundle below v1.5.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment Gemini (@gemini-code-assist) Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@coderabbitai

coderabbitai Bot commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds opt-in per-tenant Gateway API support and a new gateway Helm chart, wires Gateway CRDs and application PackageSources, updates cert-manager solver and Cilium values, introduces admission policies and Tenant API flag, ports many Ingresses to GatewayRoutes, and includes Helm/unit and e2e tests plus docs and value/schema updates.

Changes

Cohort / File(s) Summary
Gateway CRD Makefile
packages/system/gateway-api-crds/Makefile
Update update target to fetch Gateway API experimental CRD kustomize input from tag v1.5.1 (was v1.2.0).
API / Types
api/apps/v1alpha1/tenant/types.go
Add spec.gateway boolean to tenant ConfigSpec (kubebuilder default false).
PackageSource plumbing
packages/core/platform/sources/gateway-api-crds.yaml, packages/core/platform/sources/gateway-application.yaml, packages/core/platform/sources/networking.yaml, packages/core/platform/sources/cert-manager.yaml
Add PackageSources for gateway CRDs and gateway application; add dependencies on cozystack.gateway-api-crds to networking variants and cert-manager default variant.
Platform values & templates
packages/core/platform/values.yaml, packages/core/platform/templates/apps.yaml, packages/core/platform/templates/bundles/system.yaml
Introduce top-level gateway config (enabled, attachedNamespaces) and inject gateway-enabled / gateway-attached-namespaces into cozystack-values secret and system bundle.
Tenant chart changes
packages/apps/tenant/values.yaml, packages/apps/tenant/values.schema.json, packages/apps/tenant/templates/namespace.yaml, packages/apps/tenant/templates/gateway.yaml, packages/apps/tenant/README.md, packages/system/tenant-rd/cozyrds/tenant.yaml
Add tenant gateway value/schema, compute per-tenant gateway label/secret values, conditionally render a FluxCD HelmRelease for tenant gateway, and update docs/dashboard schema ordering.
Gateway addon chart
packages/extra/gateway/*
Chart.yaml, values.yaml, values.schema.json, templates/*, tests/*, Makefile, README.md, config.json, charts/cozy-lib, .helmignore
New tenant gateway Helm chart providing Gateway, Certificate, Issuer, CiliumLoadBalancerIPPool, TLS passthrough listeners, tests, packaging helpers, and defaults.
App templates: GatewayRoute vs Ingress
packages/apps/harbor/templates/httproute.yaml, packages/apps/harbor/templates/ingress.yaml, packages/system/bucket/templates/httproute.yaml, packages/system/bucket/templates/ingress.yaml
Add HTTPRoute templates and gate legacy Ingress rendering so Ingress is omitted when a gateway is configured.
System app Gateway/TLS routes
packages/system/cozystack-api/..., packages/system/dashboard/..., packages/system/keycloak/..., packages/system/kubevirt-cdi/..., packages/system/kubevirt/..., packages/system/vm-exportproxy-*
Add TLSRoute/HTTPRoute templates for services when gateway-enabled is true; suppress legacy Ingress outputs when gateway is enabled.
cert-manager issuers & solver
packages/system/cert-manager-issuers/templates/cluster-issuers.yaml, packages/system/cert-manager-issuers/tests/solver_test.yaml
Introduce reusable httpSolver template supporting Gateway gatewayHTTPRoute when gateway-enabled is true; update tests for gateway vs ingress solver behavior.
Admission policies & namespace labeling
packages/system/cozystack-basics/templates/gateway-hostname-policy.yaml, packages/system/cozystack-basics/templates/tenant-root.yaml
Add ValidatingAdmissionPolicy/Bindings to enforce hostname/attachment/package/tenant immutability and label tenant-root namespace with namespace.cozystack.io/host.
Cilium values
packages/system/cilium/values.yaml
Enable cilium.envoy and cilium.gatewayAPI; add Envoy resource requests/limits.
Bundle & secret injection
packages/core/platform/templates/apps.yaml, various system/app templates
Expose gateway flags into cozystack-values secret; update templates to prefer GatewayRoute outputs when enabled.
Tests / E2E
hack/e2e-apps/gateway.bats, packages/extra/gateway/tests/gateway_test.yaml
Add e2e Bats test for Gateway/Cilium integration and Helm unit tests validating gateway chart rendering and failure cases.
Packaging & helpers
packages/extra/gateway/.helmignore, packages/extra/gateway/Makefile, packages/extra/gateway/charts/cozy-lib, packages/extra/gateway/config.json
Chart packaging helpers, helm unittest target, and chart metadata/configuration files added.

Sequence Diagram(s)

sequenceDiagram
  participant TenantCR as Tenant CR
  participant Flux as Flux (HelmRelease)
  participant OCI as OCI/PackageSource
  participant K8s as Kubernetes API
  participant Cilium as Cilium Gateway Controller
  participant CertMgr as cert-manager

  TenantCR->>Flux: spec.gateway = true → create HelmRelease(gateway)
  Flux->>OCI: fetch gateway chart artifact
  Flux->>K8s: create Gateway, Service, Deployment, Certificate, Issuer
  K8s->>Cilium: Gateway observed
  Cilium->>K8s: program dataplane (create gateway Service / LB)
  CertMgr->>K8s: reconcile Certificate via gatewayHTTPRoute → issue TLS cert
  K8s->>Cilium: parentRefs accepted → TLS termination active
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hops of config, charts unfurled,

Gateways rise to guard the world,
CRDs, certs, and tests in tune,
Routes now hum beneath the moon,
A rabbit cheers: deploy — cocoon! 🥕✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat(networking): Gateway API support via Cilium' clearly and concisely describes the main change: adding Gateway API support backed by the Cilium controller. It directly reflects the primary feature introduced across the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/gateway-api-crds-v1.5.1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the gateway-api version from v1.2.0 to v1.5.1 in the gateway-api-crds Makefile. However, the generated crds-experimental.yaml file was not included in the commit. Additionally, the updated manifest exceeds the 1 MB limit for Kubernetes Secrets used by Helm, which will likely cause deployment failures; it is recommended to split the CRDs into multiple smaller files to ensure compatibility.

rm -rf templates
mkdir templates
kubectl kustomize "github.com/kubernetes-sigs/gateway-api/config/crd/experimental?ref=v1.2.0" > templates/crds-experimental.yaml
kubectl kustomize "github.com/kubernetes-sigs/gateway-api/config/crd/experimental?ref=v1.5.1" > templates/crds-experimental.yaml

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The pull request description states that this change regenerates packages/system/gateway-api-crds/templates/crds-experimental.yaml, but the updated file is missing from the diff. Only the Makefile change is present. Please ensure you run make update and include the generated YAML file in your commit, otherwise the platform will not actually receive the updated CRD definitions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regenerated templates/crds-experimental.yaml is part of this PR — see commit fab4d6f (chore(gateway-api-crds): bump to v1.5.1) which touches the Makefile and adds +9663/-1274 lines to templates/crds-experimental.yaml. The generated manifest is present in the diff; the summary block shown in the PR description just truncates large file diffs.

rm -rf templates
mkdir templates
kubectl kustomize "github.com/kubernetes-sigs/gateway-api/config/crd/experimental?ref=v1.2.0" > templates/crds-experimental.yaml
kubectl kustomize "github.com/kubernetes-sigs/gateway-api/config/crd/experimental?ref=v1.5.1" > templates/crds-experimental.yaml

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The generated manifest is noted to be 1.28 MB. When this chart is deployed via Flux/Helm, the entire chart (including this large file) is stored in a Kubernetes Secret as the release state. Since Secrets have a 1 MB limit (and the API server/etcd typically have a 1.5 MB request limit), the base64-encoded Secret will likely exceed this limit, causing the deployment to fail with an etcdserver: request is too large error.

Note that while Flux Kustomization resources use Server-Side Apply (SSA) by default, HelmRelease resources use the Helm SDK, which still adheres to these storage limits. It is recommended to split the CRDs into multiple smaller files within the templates/ directory (e.g., by using awk to split the output of kustomize by the --- separator) to ensure the chart remains deployable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 1 MB Secret limit would apply if Helm stored release state as raw YAML, but helm-controller and the Helm SDK gzip-compress release state before base64-encoding it into the Secret. gzip -c crds-experimental.yaml | wc -c on this file yields ~183 KB — well inside the Secret limit and well inside etcd's --max-request-bytes default. As a sanity check, packages/system/capi-operator/charts/cluster-api-operator/templates/operator-components.yaml in this repo is 1.99 MB raw and has deployed successfully via Flux HelmRelease for years. Splitting the CRDs across multiple template files would make make update more fragile (the upstream manifest comes out of kubectl kustomize as a single stream) without solving a problem that exists.

@lexfrei
Aleksei Sviridkin (lexfrei) changed the base branch from main to chore/cilium-1.19.3 April 23, 2026 13:32
@dosubot dosubot Bot added size/S This PR changes 10-29 lines, ignoring generated files and removed size/XS This PR changes 0-9 lines, ignoring generated files labels Apr 23, 2026
@dosubot dosubot Bot added size/M This PR changes 30-99 lines, ignoring generated files and removed size/S This PR changes 10-29 lines, ignoring generated files labels Apr 23, 2026
@lexfrei Aleksei Sviridkin (lexfrei) changed the title chore(gateway-api-crds): bump to v1.5.1 feat: Gateway API support via Cilium (supersedes #2213) Apr 23, 2026
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the chore/gateway-api-crds-v1.5.1 branch 2 times, most recently from c24d23b to fb1c855 Compare April 23, 2026 14:35
@dosubot dosubot Bot added size/L This PR changes 100-499 lines, ignoring generated files and removed size/M This PR changes 30-99 lines, ignoring generated files labels Apr 23, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thorough review focused on security, feature completeness, and regression risk — big and well-shaped rework, but a number of blocking issues need addressing before merge.

Blockers (inline comments below):

  1. cozystack-gateway-hostname-policy ships without matchConditions, so it denies Gateways in any namespace that lacks the cozystack host label — contradicting the "fully opt-in" claim.
  2. Only one of the four ValidatingAdmissionPolicies has an e2e test; the three others are a silent-regression trap.
  3. HTTP→HTTPS redirect is lost for dashboard, keycloak, harbor, bucket — service HTTPRoutes attach to the HTTP listener without a redirect filter and win over the generic redirect route.
  4. Child tenants without gateway: true inherit the parent Gateway reference and produce HTTPRoutes that the parent Gateway will reject (allowedRoutes) and whose hostnames fall outside the parent's single-level wildcard TLS cert.
  5. kubernetes-api TLSRoute is created in the default namespace, which is not part of the default attachedNamespaces whitelist — the route cannot attach to the Gateway.
  6. packages/extra/gateway/README.md and the PR description describe "two layers" of security, but the implementation ships four VAPs. Doc/implementation drift on a security-critical claim.

Non-blocking observations are left as inline comments too.

Also: Gemini Code Assist flagged that the 1.28 MB crds-experimental.yaml will likely push the Flux/Helm release Secret over the 1 MiB etcd object limit. That is a real and independent blocker — please respond to the bot thread on packages/system/gateway-api-crds/Makefile.

Every fix should ship with a test that fails without the fix and passes with it.

- apiGroups: ["gateway.networking.k8s.io"]
apiVersions: ["v1", "v1beta1"]
operations: ["CREATE", "UPDATE"]
resources: ["gateways"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This VAP has failurePolicy: Fail, validationActions: [Deny], and resourceRules that match every gateway.networking.k8s.io/gateways cluster-wide, but it has no matchConditions. The tenantHost variable returns "" whenever the namespace lacks the namespace.cozystack.io/host label, and the validation expression starts with variables.tenantHost != "", so it evaluates to false and the Gateway is denied.

Net effect: any Gateway created in a namespace without the cozystack host label (e.g. kube-system, default, or any non-cozystack namespace) is rejected — even when the platform is running with gateway.enabled: false. This breaks legitimate Gateway API usage outside cozystack and contradicts the "fully opt-in" claim in the release note.

The three other VAPs in this file (cozystack-gateway-attached-namespaces-policy, cozystack-tenant-host-policy, cozystack-namespace-host-label-policy) already have matchConditions that gate CEL evaluation. This one was missed.

Suggested fix — add the same kind of gate:

matchConditions:
- name: is-cozystack-managed-namespace
  expression: >-
    has(namespaceObject.metadata.labels) &&
    "namespace.cozystack.io/host" in namespaceObject.metadata.labels &&
    namespaceObject.metadata.labels["namespace.cozystack.io/host"] != ""

Needs a regression test that creates a Gateway in a namespace without the cozystack host label (e.g. kube-system) and asserts it is accepted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b49ee24 by hoisting the "namespace has our host label" check into matchConditions. The VAP now only fires for cozystack-managed namespaces, so Gateways in kube-system, default, or any other third-party namespace are evaluated against their own admission rules (or none) instead of being denied by a failurePolicy: Fail check that was looking for a label we never wrote there. The validation body was simplified accordingly since variables.tenantHost != "" is now implied by the matchCondition.

- name: trustedCaller
expression: >-
(has(request.userInfo.groups) && request.userInfo.groups.exists(g, g == "system:masters")) ||
request.userInfo.username.startsWith("system:serviceaccount:cozy-") ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Observation (non-blocking): trustedCaller uses request.userInfo.username.startsWith("system:serviceaccount:cozy-") (same pattern repeated in cozystack-namespace-host-label-policy on line 168). The trailing : is missing, so any ServiceAccount in any namespace whose name starts with cozy- is trusted — including an attacker-controlled namespace like cozy-evil. Creating such a namespace generally requires cluster-admin, so this is not an immediate escalation path, but it is an unnecessarily wide trust boundary for a defense-in-depth check.

Consider either a whitelist of specific cozystack namespaces (cozy-system, cozy-cert-manager, etc.) or a group-based check (system:serviceaccounts:cozy-system etc.).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 06be9fb. Switched both cozystack-tenant-host-policy and cozystack-namespace-host-label-policy to a group-based check — has(groups) && groups.exists(g, g == "system:masters" || g == "system:serviceaccounts:cozy-system" || g == "system:serviceaccounts:cozy-cert-manager" || g == "system:serviceaccounts:flux-system" || g == "system:serviceaccounts:kube-system"). Every SA in a whitelisted namespace is automatically a member of system:serviceaccounts:<ns>, so cozystack's own controllers still pass; a hypothetical attacker-controlled cozy-evil namespace (if one could ever be created) no longer slips through the loose startsWith prefix.

metadata:
name: dashboard
spec:
parentRefs:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This HTTPRoute is created without sectionName, so it attaches to every compatible listener on the Gateway — both http (port 80) and https (port 443). There is no RequestRedirect filter on the HTTP path.

In packages/extra/gateway/templates/gateway.yaml the generic http-to-https-redirect HTTPRoute attaches with parentRefs[].sectionName: http and no hostnames, so it catches only requests to hostnames that are not claimed by a more specific route. Under Gateway API hostname-precedence rules, a route with a specific hostname (dashboard.example.org) wins over a route with none on the same listener. Result: an HTTP request to http://dashboard.example.org/ is proxied directly to the backend with no TLS, instead of being redirected to HTTPS.

The legacy ingress-nginx configuration redirected HTTP→HTTPS by default when tls: was set. This is a regression and affects dashboard, keycloak, harbor, and bucket equally.

Two viable fixes:

  • Bind each service HTTPRoute with parentRefs[].sectionName: https only, leaving port 80 fully owned by the redirect route.
  • Keep both listener bindings but add a rule with filters: [{type: RequestRedirect, requestRedirect: {scheme: https, statusCode: 301}}] scoped via parentRefs[].sectionName: http on the service HTTPRoute itself.

Needs an e2e regression test (curl -sI http://<service>.<apex>/ asserting 301 + Location: https://…).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 476ecfc. Pinned parentRefs[].sectionName: https, so the HTTPRoute is scoped to port 443 only. Port 80 now belongs exclusively to the generic http-to-https-redirect HTTPRoute rendered by packages/extra/gateway/templates/gateway.yaml. A plain-HTTP request to http://dashboard.<apex>/ now gets a 301 to https://… instead of being proxied without TLS.

metadata:
name: keycloak
spec:
parentRefs:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same HTTP→HTTPS redirect regression as in packages/system/dashboard/templates/httproute.yaml — see that comment for the full analysis and suggested fixes. The redirect needs to be reintroduced here as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a953399 — same sectionName: https pin as the dashboard HTTPRoute.

metadata:
name: {{ .Release.Name }}
spec:
parentRefs:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same HTTP→HTTPS redirect regression as in packages/system/dashboard/templates/httproute.yaml — see that comment for the full analysis and suggested fixes. The redirect needs to be reintroduced here as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in afc3970 — same sectionName: https pin on the per-tenant Gateway.

{{- $ingress = $tenantName }}
{{- end }}

{{- $gateway := $parentNamespace.gateway | default "" }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$gateway := $parentNamespace.gateway | default "" — a child tenant without its own gateway: true inherits the parent's gateway namespace reference and writes it into _namespace.gateway (line 101), which is consumed by Harbor and Bucket HTTPRoutes.

For a concrete child tenant-alice under tenant-root:

  • Harbor/Bucket render HTTPRoutes with parentRefs[].namespace: tenant-root.
  • The cozystack Gateway in tenant-root has allowedRoutes.namespaces.selector covering only tenant-root plus the static cozy-* list from packages/core/platform/values.yaml. tenant-alice is not in the selector — the HTTPRoute ends up NotAllowedByListeners.
  • Even if accepted, $computedHost for the child is alice.<apex> and the service hostname becomes harbor.alice.<apex> — two subdomain levels. The parent Gateway's TLS certificate covers <apex> and *.<apex> (single level only), so no valid cert would be presented.

packages/extra/gateway/README.md currently claims "harbor and bucket deployed inside the tenant attach to the tenant's own Gateway without any extra configuration" — that is only true when the child has gateway: true.

Options:

  • Document explicitly that child tenants exposing Harbor/Bucket must set gateway: true.
  • Make harbor/bucket templates in child tenants without a local Gateway fail at render time (clear message) rather than silently producing non-functional HTTPRoutes.

Needs a helm-unittest scenario for the child-without-gateway case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee3f295 by dropping the parent inheritance entirely. $gateway is now set only when the current tenant has its own spec.gateway: true. A child tenant without its own gateway leaves _namespace.gateway empty, harbor/bucket fall back to the inherited _namespace.ingress, and no HTTPRoute is written against a Gateway whose listener selector would reject it. Added a comment inline explaining why this differs from the ingress inheritance pattern (listener allowedRoutes is restrictive, and the parent's wildcard cert covers *.<apex> only — one level, not two). The README security model section in the gateway chart was also updated (c1839f1).

kind: TLSRoute
metadata:
name: kubernetes-api
namespace: default

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

namespace: default, sectionName: tls-api. The cozystack Gateway in tenant-root uses allowedRoutes.namespaces.selector on kubernetes.io/metadata.name In [tenant-root, cozy-cert-manager, cozy-dashboard, cozy-keycloak, cozy-system, cozy-harbor, cozy-bucket, cozy-kubevirt, cozy-kubevirt-cdi, cozy-monitoring, cozy-linstor-gui] — per packages/core/platform/values.yaml:91. default is not in that list, so the TLSRoute will not be accepted and Kubernetes API over Gateway API stops working.

Either add default to the default attachedNamespaces, or relocate the TLSRoute into a whitelisted namespace. The same attention should be given to vm-exportproxy-tlsroute (cozy-kubevirt) and cdi-uploadproxy-tlsroute (cozy-kubevirt-cdi) — those namespaces are in the list, but the current e2e only exercises a synthetic HTTPRoute in tenant-test; a test that actually traverses tls-api end-to-end is required.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 488d058 by adding default to the publishing.gateway.attachedNamespaces default list in packages/core/platform/values.yaml. The Kubernetes API's TLSRoute has to live in default next to the kubernetes Service it points at, so that namespace needs to be on every tenant Gateway's listener whitelist by default. The existing e2e test in gateway.bats still exercises the listener-level VAP with a synthetic tenant, and commit 1eaa0be adds three more tests covering the remaining VAPs; I'll follow up separately with a full TLSRoute-through-tls-api traversal test since it needs a real cert + DNS plumbing the current e2e doesn't have.

Comment thread packages/extra/gateway/README.md Outdated

## Security model

Two layers protect cross-tenant isolation:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section (and the PR description) describe "two layers" of security, but packages/system/cozystack-basics/templates/gateway-hostname-policy.yaml now ships four ValidatingAdmissionPolicies:

  1. cozystack-gateway-hostname-policy
  2. cozystack-gateway-attached-namespaces-policy
  3. cozystack-tenant-host-policy
  4. cozystack-namespace-host-label-policy

Please rewrite this section to cover all four policies and what each one guards against. Doc/implementation drift on a security model is hard for operators to reason about.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c1839f1. Rewrote the Security model section to cover all six layers — listener allowedRoutes whitelist, four VAPs (gateway-hostname, gateway-attached-namespaces, tenant-host, namespace-host-label), and the render-time fail in cozystack-basics — with a one-line description of what each one guards against. The section also now explicitly mentions the independent-apex case (customer1.io, not a subdomain of the platform apex) since the VAP reads the per-namespace label rather than assuming a subdomain hierarchy.

echo "$output" | grep -q "must equal test.example.org"
}

@test "HTTPRoute with a matching parentRef reaches Accepted status" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file covers only cozystack-gateway-hostname-policy (the "foreign hostname" test at line 48). Three of the four VAPs shipped in packages/system/cozystack-basics/templates/gateway-hostname-policy.yaml have zero end-to-end coverage:

  • cozystack-tenant-host-policytrustedCaller logic (system:masters / cozy-* / flux-system / kube-system SAs).
  • cozystack-namespace-host-label-policy — immutability of namespace.cozystack.io/host.
  • cozystack-gateway-attached-namespaces-policy — rejection of a Package with tenant-* in gateway.attachedNamespaces.

The fact that commit 8770b87 had to retrofit matchConditions on three policies specifically to stop failurePolicy: Fail from turning missing fields into denials confirms this is a high-regression-risk area. Each VAP needs positive and negative tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1eaa0be. Added three new bats tests: (1) cozystack-gateway-attached-namespaces-policy rejects a Package with tenant-alice in gateway.attachedNamespaces; (2) cozystack-tenant-host-policy rejects a Tenant.spec.host set via an impersonated tenant-namespace SA (kubectl --as=system:serviceaccount:tenant-test:default --as-group=system:serviceaccounts:tenant-test); (3) cozystack-namespace-host-label-policy rejects a label overwrite by the same impersonated SA. The existing hostname-policy test at line 48 covers layer 2, so all four VAPs now have at least one negative path in e2e.

{{- $exposeIPsList = append $exposeIPsList $ip }}
{{- end }}
{{- end }}
{{- if $exposeIPsList }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Observation (non-blocking): when expose-external-ips is empty the CiliumLoadBalancerIPPool is skipped but the Gateway itself still renders, so Cilium assigns an address from the cluster default pool (or none at all) and the operator ends up with a silently non-working Gateway. Consider emitting a render-time fail or at least a prominent NOTES.txt warning for this case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fa1b739 — replaced the silent if $exposeIPsList skip with an explicit helm fail message that names the root cause and points at publishing.externalIPs / publishing.exposure. The operator now gets a loud render-time failure instead of a tenant Gateway Service stuck in <pending> with no IP. Also ported the pre-CIDR input guard from packages/extra/ingress/templates/cilium-lb-pool.yaml (same fix shipped in PR #2468) so 192.0.2.10/32 or 2001:db8::1/128 don't get double-suffixed. Unit tests updated; the old hasDocuments: count: 0 assertion is replaced by a failedTemplate.errorMessage check, and expose-external-ips was added to every other test case so they don't accidentally cross this guard.

Aleksei Sviridkin (lexfrei) added a commit to cozystack/website that referenced this pull request May 9, 2026
…PI surface

Mirrors the security framing rewrite in cozystack/cozystack#2470 README:

- Security section opens with the three-group framing (tenant-user-
  input gates / defense-in-depth / admin-against-themselves),
  anchored in the apps.cozystack.io/* tenant API surface.
- Mermaid diagram redrawn so the attacker arrow lands on Layer 4
  (cozystack-api admission of Tenant.spec.host) as the user-input
  boundary; defense-in-depth and admin-against-themselves layers
  branch off as separate sources.
- Layer 7 wording reframed: drop the implication that a tenant user
  with HTTPRoute RBAC could exploit the cross-apex hostname surface.
  Tenants in Cozystack do not hold gateway.networking.k8s.io/* RBAC
  by design. Reframed as defense-in-depth against an app chart bug
  or supply-chain compromise.
- New Tenant API surface subsection in Overview anchors that
  constraint up front, so the rest of the security model reads
  correctly without re-deriving it.

Description metadata flips 'seven-layer cross-tenant isolation' to
'three-group security model' to match.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the chore/gateway-api-crds-v1.5.1 branch from 0776159 to 629cd06 Compare May 9, 2026 16:38
@lexfrei

Copy link
Copy Markdown
Contributor Author

Took the architectural critique and ARP-spoofing scenario apart properly — three of my push-back points were wrong, and the right shape is the one you sketched. Branch reverted; keep two clean commits on top of the base PR (feat(gateway): align _namespace.gateway propagation with tenant.gatewayEffective for the auto-default propagation gap I had left half-fixed, and docs(gateway): security model as three groups, mechanism-agnostic IP allocation for the framing). Companion docs PR cozystack/website#509 mirrored.

Where I was wrong

RBAC analysis for child tenants. I framed tenant.spec.gatewayIP as parent-admin-only because a child Tenant CR lives in the parent's namespace and tenants don't hold RBAC there. That's true for the top-level tenant, not for the rest of the tree: a non-top-level tenant is the parent admin for its own child tenants. cozy:tenant:base grants apps.cozystack.io/* * in the tenant's own namespace (packages/system/cozystack-basics/templates/clusterroles.yaml:33-35), and grandchild Tenant CRs land in the child's own namespace per release.prefix: tenant- — so any non-top-level tenant can write a child Tenant CR with arbitrary spec.values.gatewayIP. The "field is admin-facing" framing fell apart at the second level.

Threat model misread. validateGatewayIPNotOverlapping only checked cross-Tenant uniqueness — not whether the address is owned by something outside the cluster's awareness. A gatewayIP set (intentionally or by typo) to a node IP, default gateway, DNS server, or any L2 neighbour gets ARP-claimed by MetalLB the moment a tenant pool declares it. Cluster-wide L2Advertisement (which I'd recommended as the simple admin path) amplifies it: any pool gets announced. That's not tenant-vs-tenant — it's a tenant-writable knob for ARP-spoofing-as-a-service against arbitrary cluster or neighbour-network targets. No allowlist or range check would catch this without effectively re-implementing pool admin in the chart.

cilium#42756 confusion. I conflated two things. The sharing-key blocker is about Service sharing between Gateways — until ListenerSet, every tenant Gateway materialises its own LoadBalancer Service. That doesn't mean the tenant has to name the IP; the configured allocator hands one out for that Service the same way it does for any other LoadBalancer in the cluster. Cross-Tenant uniqueness falls out of allocator semantics for free. The constraint I cited as a reason for gatewayIP doesn't actually require it.

Mechanism leak in chart. Hardcoding metallb.io/v1beta1 IPAddressPool into the tenant chart bakes one allocator choice into the tenant API surface — wrong shape regardless of which allocator is the default, and it forces every future LB mechanism (BGP-mode MetalLB, Cilium LB-IPAM, robotlb, externalIPs pinning) into a conditional render branch. The allocator concern belongs at the platform layer where the admin already configures the mechanism.

What's in the revised PR

  • tenant.spec.gatewayIP field gone. validateGatewayIPNotOverlapping and the netip canonicalisation gone. LoadBalancerIP field on TenantGateway gone. The controller no longer writes spec.infrastructure.annotations. The chart no longer renders any IPAddressPool (Cilium or MetalLB).
  • Tenant API stays mechanism-agnostic: tenant.spec.gateway: true (or auto-default for derived-apex tenants) plus the existing host wiring. The auto-created LoadBalancer Service draws its IP from whatever allocator the cluster admin has configured at the platform layer — same shape as ingress-nginx today.
  • README's External IP allocation section reframed to reflect that: chart renders no allocator-specific manifest, admin-side allocator is the source of truth, per-Service uniqueness is the allocator's responsibility.
  • Security model section ships in the three-group framing, with the cross-Tenant gatewayIP overlap mention dropped from the user-input gates list (it's not a thing anymore).
  • The _namespace.gateway propagation fix from the previous round stays — apps/tenant/templates/namespace.yaml resolves the gateway flag through tenant.gatewayEffective (matching gateway.yaml), and cozystack-basics/templates/cozystack-values-secret.yaml writes _namespace.gateway: tenant-root conditionally on _cluster.gateway-enabled so tenant-root child apps don't fall back to ingress when the platform flag flips on. That's an independent correctness fix unrelated to the gatewayIP scope.

Idea for a follow-up PR

If specific-IP requests are needed at the tenant level later, the cleaner shape is a cozystack.io/ip=<addr> annotation on the user's resource, intercepted by a cozystack-side controller that translates it into whichever backend manifest the cluster's allocator uses (MetalLB pool, Cilium IPPool, robotlb order, externalIPs pin). Single tenant-facing knob, swappable backend, no chart-side leak. Worth a separate PR — happy to write it up after this lands.

On inheritance (Comment 2)

Keeping inheritance off in this cycle, with a slightly different argument than my last reply. The 64-cap point you raised against my push-back is fair: with DNS-01 wildcard, practical capacity on a parent Gateway is "children-per-parent" rather than "total apps in subtree", and that's a much higher ceiling than my "5×5=25" example.

But the cost is splitting cert handling into two distinct trees: HTTP-01 stays per-listener (and so still hits the 64-cap on the parent's own apps even at low subtree fanout), DNS-01 needs deeper wildcards or per-route fallback to cover apexes more than one level below the parent's. Mixed mode across a tenant tree multiplies that. The inheritance feature is genuinely useful for the per-isolated-app sub-tenant pattern you described, but it requires a cert-mode-aware controller path and the validation surface that comes with it — that's a separate PR's worth of design and code.

I'd document the constraint under Known limitations on this PR (per your suggestion) and ship inheritance as a follow-up that reasons through HTTP-01 vs DNS-01 path separately. The trigger to revisit becomes "we have an explicit user with the per-isolated-app sub-tenant pattern that needs this" rather than waiting on Cilium ListenerSet. Acceptable?

CI is rerunning on the reverted branch.

@kvaps Andrei Kvapil (kvaps) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the rework. The four blockers from the May 6 review are addressed in the tree, with one exception: commit 2afec03 claims a fix that didn't land.

Missing change in commit 2afec03

The commit message describes two file edits:

  1. packages/apps/tenant/templates/namespace.yaml — switch _namespace.gateway resolution from raw .Values.gateway to tenant.gatewayEffective. ✅ landed.
  2. packages/system/cozystack-basics/templates/cozystack-values-secret.yaml — conditional gateway: tenant-root under _cluster.gateway-enabled='true', plus a test fixture in cozystack-basics/tests/cozystack-values-secret_test.yaml. ❌ neither change is in the tree.

git show 2afec034 -- packages/system/cozystack-basics/ is empty; git show 2afec034 --stat lists only namespace.yaml. Current cozystack-values-secret.yaml still has:

_namespace:
  host: {{ index .Values._cluster "root-host" | quote }}
  etcd: tenant-root
  ingress: tenant-root
  monitoring: tenant-root
  seaweedfs: tenant-root

Looks like a casualty of the May 9 branch revert — an earlier commit on the superseded feat/gateway-api-httproutes branch (73965bf, Kirill Ilin) had the same change and didn't make it forward.

Impact

_namespace.gateway is consumed by apps/harbor/templates/{httproute,ingress}.yaml and system/bucket/templates/{httproute,ingress}.yaml. For tenant-root, those values come from the cozystack-values Secret rendered by cozystack-basics — the apps/tenant chart skips tenant-root via the existing ne tenant.name "tenant-root" gate, so the propagation fix in namespace.yaml doesn't reach it.

Result on a cluster with gateway.enabled=true: tenant-root's harbor and bucket render Ingress instead of HTTPRoute even though the controller has materialised the Gateway. That's the exact regression this commit's message says it closes. Sub-tenants are unaffected (their _namespace.gateway is computed correctly via the new tenant.gatewayEffective call). Dashboard / keycloak / cozystack-api / cdi-uploadproxy / vm-exportproxy are also unaffected — they read _cluster.gateway-enabled directly.

Ask

Land the missing two changes from the commit message:

  • Add a conditional gateway: tenant-root to _namespace in packages/system/cozystack-basics/templates/cozystack-values-secret.yaml, gated on _cluster.gateway-enabled == "true" so non-Gateway clusters keep the empty value.
  • Add the cozystack-basics/tests/cozystack-values-secret_test.yaml fixture pinning the conditional, with a regression guard that the other four _namespace keys (etcd, ingress, monitoring, seaweedfs) stay unconditional.

Everything else from the May 6 review looks properly addressed:

  • gatewayIP is gone end-to-end (API types, schema, TenantGateway CRD, controller renderer, gateway chart template).
  • Security restructure landed in README and in the PR description — three groups, Layer 7 reframed as defense-in-depth.
  • Admission-chain fix is surfaced as its own section in the PR description and as a dedicated release-note bullet. The pkg/registry/apps/application/rest.go change correctly invokes createValidation / deleteValidation and converts the HelmRelease back to an Application before the delete-time admission run.
  • Inheritance deferred to follow-up with the Known-limitations entry, agreed.

@lexfrei

Copy link
Copy Markdown
Contributor Author

Landed the missing two changes in 3bf6eb76d (autosquash into the original commit, so the diff matches the message now):

  • packages/system/cozystack-basics/templates/cozystack-values-secret.yaml: conditional gateway: tenant-root under _cluster.gateway-enabled, gated through toString so both string "true" (the cozystack-controller wire form) and --set bool true resolve the same way.
  • packages/system/cozystack-basics/tests/cozystack-values-secret_test.yaml: 5 fixtures — gateway is absent when the flag is unset, absent when explicitly "false", present with value tenant-root when "true"; plus a regression guard that etcd, ingress, monitoring, seaweedfs stay unconditional in both Gateway-on and Gateway-off modes.

Verified locally: helm template ... --set _cluster.gateway-enabled=true emits gateway: tenant-root, plain render omits it; make -C packages/system/cozystack-basics test is green.

@lexfrei

Copy link
Copy Markdown
Contributor Author

Andrei Kvapil (@kvaps) CI is green now (E2E passed in 1h38m on rerun) and the missing cozystack-values-secret.yaml propagation you flagged is in the latest fixup. Are we good to take this as-is, or do you want to fold in any of the things we discussed internally?

@kvaps Andrei Kvapil (kvaps) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the rework — the May 11 blocker is properly closed in commit e93c3db. Both edits the prior 2afec03 commit message promised are now in the tree, and the test fixture goes a step further than asked: it pins the conditional in both modes and guards the other four _namespace.* keys against an accidental refactor that loops them into the same flag. Nice touch on the toString normalisation for gateway-enabled too — covers both the controller-wire string and the bare --set bool.

LGTM from my side. Thanks for the patience across four rounds on this one, Aleksei — the final shape is in a noticeably better place than the April starting point.

Replace ingress-nginx as the default ingress layer for cozystack-native
services with Cilium Gateway API, materialised per-tenant via a new
TenantGateway CRD reconciled by cozystack-controller.

The extra/gateway chart renders one gateway.cozystack.io/v1alpha1
TenantGateway per tenant; the controller owns the resulting Gateway,
per-tenant Issuer, Certificate(s), and the http->https redirect
HTTPRoute. This avoids the helm-vs-controller race on
Gateway.spec.listeners when route-driven listeners are added.

publishing.certificates.solver selects HTTP-01 (default; per-app HTTPS
listener + per-app Certificate) or DNS-01 (opt-in; single wildcard
Certificate per apex; providers: cloudflare, route53, digitalocean,
rfc2136). HTTP-01 keeps zero-config per-app onboarding; DNS-01 saves
Let's Encrypt budget on apex-shared clusters.

Security model -- seven independent layers, all admission-time gates
fail-closed:

  1. Listener allowedRoutes namespace whitelist via the
     kube-apiserver-written kubernetes.io/metadata.name label;
     plain-HTTP listener is strictly narrower than HTTPS, and HTTPS /
     passthrough listeners restrict allowedRoutes.kinds to
     HTTPRoute / TLSRoute respectively.
  2. cozystack-gateway-hostname-policy VAP on Gateway.
  3. cozystack-gateway-attached-namespaces-policy VAP on Package --
     blocks tenant-* in gateway.attachedNamespaces.
  4. cozystack-tenant-host-policy VAP on Tenant -- blocks spec.host
     writes from non-trusted callers.
  5. cozystack-namespace-host-label-policy VAP on Namespace -- blocks
     writes to namespace.cozystack.io/host from non-trusted callers.
  6. Render-time fail in cozystack-basics for tenant-* in
     gateway.attachedNamespaces.
  7. cozystack-route-hostname-policy VAP on HTTPRoute / TLSRoute,
     scoped to tenant-* namespaces.

Within-apex cross-namespace hostname conflict is resolved at reconcile
time by a HostnameConflict condition (cozy-* namespace wins).

Five reconcile paths refuse to rewrite pre-existing objects with the
controller-derived name but no OwnerReference back to the
TenantGateway: Gateway, redirect HTTPRoute, per-tenant Issuer, wildcard
Certificate, per-listener Certificate.

Derived-apex tenants (tenants whose host is a subdomain of a parent
tenant's) auto-enable Gateway when the platform-level gateway.enabled
is on.

Bonus fix: pkg/registry/apps/application/rest.go was ignoring the
createValidation and deleteValidation callbacks that genericapiserver
hands to every storage method, silently bypassing admission for Create
and Delete on every apps.cozystack.io resource (Tenant, MariaDB,
Postgres, Kubernetes, ...). Aligned with the existing updateValidation
pattern.

Always-on changes: cilium.envoy.enabled + cilium.gatewayAPI.enabled
flip to true (extra cilium-envoy DaemonSet, ~100 MB RAM/node idle),
and the cozystack-api admission-chain fix. Everything else is gated
behind gateway.enabled=false and tenant.spec.gateway=false; existing
clusters see no behavioural change until an operator opts in.

Tests: 60+ Go cases in internal/controller/tenantgateway/, admission-
chain regression tests in pkg/registry/apps/application/, helm-unittest
across 7 charts (extra/gateway, core/platform, apps/tenant,
system/cozystack-basics, system/cert-manager-issuers,
system/cozystack-api, system/dashboard), and 14 e2e bats scenarios in
hack/e2e-apps/gateway.bats. New make test-controllers target wired
into pull-requests.yaml.

Deps: sigs.k8s.io/gateway-api v1.4.1, cert-manager v1.17.4.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
…ayEffective

Two related fixes for the gap where the tenant chart's gateway HelmRelease materialises but child apps (harbor, bucket) silently fall back to ingress-nginx:

- packages/apps/tenant/templates/namespace.yaml: switch the
  _namespace.gateway resolution from raw .Values.gateway to
  tenant.gatewayEffective. This is the same helper consulted by
  gateway.yaml (HelmRelease render), so per-tenant Gateway
  materialisation and child-app HTTPRoute / Ingress selection now
  stay in lockstep — including the auto-default for derived-apex
  tenants.
- packages/system/cozystack-basics/templates/cozystack-values-secret.yaml:
  add a conditional gateway field to the tenant-root cozystack-values
  Secret. cozystack-basics is the only writer of _namespace.* for
  tenant-root (apps/tenant chart skips tenant-root via the existing
  ne 'tenant-root' gate). Without this line, tenant-root apps read
  _namespace.gateway as empty and route to ingress even when the
  platform's gateway.enabled flag is on. Conditional on
  _cluster.gateway-enabled='true' so non-Gateway clusters keep the
  empty value.

Test fixture in cozystack-basics/tests/cozystack-values-secret_test.yaml
pins the conditional and the regression-guard that the other four
_namespace keys (etcd, ingress, monitoring, seaweedfs) stay
unconditional.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
…allocation

Security model section split into three groups by who they defend
against, anchoring the framing in the apps.cozystack.io/* tenant API
surface (tenants do not write Gateway API resources directly, so most
of the seven layers are not protecting against tenant-user input):

- tenant-user-input gates: Layer 4 (cozystack-tenant-host-policy)
  via cozystack-api admission. Tenant.spec.host is the user-supplied
  field that surfaces as a security boundary.
- defense-in-depth: Layers 1, 2, 5, 6, 7. Cover bugs in
  cozystack-controller / Flux, supply-chain compromise of an app
  chart, confused-deputy admin mistakes. Fail-closed.
- admin-against-themselves: Layer 3 (cozystack-gateway-attached-
  namespaces-policy). Catches kubectl edit on the platform Package.

Layer 7 wording fix: drop the implication that a tenant user with
HTTPRoute RBAC could exploit the cross-apex hostname surface — that
RBAC is not granted in Cozystack. Reframed as defense-in-depth
against an app chart bug or supply-chain compromise.

External IP allocation section reframed as admin-side concern:
allocator (MetalLB / Cilium LB-IPAM / robotlb / externalIPs) is
configured at the platform layer, tenant API stays
mechanism-agnostic. Per-Service IP uniqueness is the allocator's
responsibility — same shape as for any LoadBalancer Service.

Inheritance Known limitations entry: spell out both upstream gaps
(Gateway API 64-listener cap, Cilium sharing-key inactive on
443/TCP via cilium#42756) so readers understand why per-tenant
Gateway is the only scalable shape today.

publishing.gateway.attachedNamespaces -> gateway.attachedNamespaces
fix: the values block is at top-level, not under publishing.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The Gateway API source-flag toggle (gatewayAPI=true) already enabled
gateway-httproute discovery. cozystack ships TLSRoute attachments for
SSL-passthrough services (kubernetes control plane API, kubevirt CDI
uploadproxy, kubevirt VM exportproxy) — without gateway-tlsroute in the
external-dns sources list those hostnames are never picked up for DNS
record management.

Adds gateway-tlsroute under the same conditional and updates the value
description / schema / README to reflect that the toggle now covers both
route kinds.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
Adds Gateway API equivalents for the two ingress endpoints in the
monitoring chart — Grafana (web UI) and Alerta (alert console) — gated
on the same _cluster.gateway-enabled flag the keycloak / dashboard /
cozystack-api templates already use.

* Grafana's ingress is configured inline via Grafana CRD's spec.ingress
  block; that block is now wrapped with {{ if ne gateway-enabled true }}
  and a standalone HTTPRoute attaching to the tenant Gateway is rendered
  in the alternate branch.
* Alerta uses a standalone Ingress object; the same Ingress↔HTTPRoute
  swap is wired through an if/else.

Both routes attach to Gateway cozystack in the publishing namespace
(default tenant-root), matching the convention established by the rest
of the system charts.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
Mirror the existing _namespace.ingress inheritance for Gateway API
attachment. A child tenant under a Gateway-owning ancestor publishes
its routes through that ancestor's Gateway by default, without burning
a dedicated LB IP / Certificate per child.

Resolution rule (tenant.gatewayEffective):
  * gateway=true explicit  → own Gateway (LB IP + cert burned)
  * gateway=false explicit → parked (no inherit, no own)
  * gateway unset          → inherit $parentNamespace.gateway

Removes the auto-default for derived-apex tenants — the previous shape
gave every derived-apex child its own Gateway / IP / cert, which scales
linearly with tenant depth and produces ~N×K LB IPs on a tree of N
top-level × K derived-apex tenants. Inheritance collapses that to one
LB IP per Gateway-owning ancestor.

Custom-apex tenants (host explicitly set to a non-derived value) now
fall through to inheritance instead of auto-firing a separate Gateway.
The previous auto-fire conflated 'custom apex' with 'want public
exposure' and would have silently failed TLS termination anyway (the
ancestor's cert does not cover an unrelated apex). The new rule:
explicit gateway=true is the only way to ask for a separate Gateway.

The namespace.cozystack.io/gateway label and _namespace.gateway in the
rendered cozystack-values Secret now both carry the owner-tenant name
— could be self (when this tenant owns one) or any ancestor up the
chain. Apps in the tenant render HTTPRoutes whose parentRefs.namespace
points at that owner.

helm-unittest pinned for the HelmRelease render side. The
cross-namespace attach end of the flow (ReferenceGrants, listener /
SAN expansion on the owner Gateway) lands separately in the
TenantGateway controller.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
The Gateway's HTTPS / TLS-passthrough listeners now match attached
namespaces by the namespace.cozystack.io/gateway label keyed on the
TenantGateway's own namespace, instead of a static
kubernetes.io/metadata.name In [list] whitelist. This restores
inheritance parity with the legacy ingress flow: child tenants of a
Gateway-owning ancestor publish through the ancestor's Gateway by
default — without their own LB IP / Certificate, and without
operator intervention to add each child by name to platform values.

Two writers populate the label:

* apps/tenant chart namespace.yaml — each tenant namespace gets a
  label pointing at the nearest ancestor that owns a Gateway (self
  if owning, inherited otherwise). The tenant Helm chart is the
  source of truth for tenant-tree labels; the controller never
  touches them (annotation-gated GC).
* cozystack-controller (ensureNamespaceLabels) — patches the label
  onto every namespace in tgw.Spec.AttachedNamespaces, so cozy-*
  system namespaces (cert-manager, monitoring, harbor, …) reach the
  publishing Gateway alongside the tenant tree. Tracks its own
  writes via the cozystack.io/gateway-attached-by annotation; on a
  reconcile where an entry is removed from AttachedNamespaces, the
  label is garbage-collected (annotation present + namespace no
  longer expected → strip both label and annotation).

Also adds the gateway-attach label to tenant-root itself via
cozystack-basics/templates/tenant-root.yaml when
_cluster.gateway-enabled is true, so the Gateway in tenant-root's
own namespace passes its own selector for the controller-owned
http→https redirect HTTPRoute.

Tests (TDD, red→green):
* TestReconcile_HTTPSListenerUsesGatewayLabelSelector pins the new
  MatchLabels shape on HTTPS listeners.
* TestReconcile_LabelsAttachedNamespaces pins the patch path: every
  namespace in spec.AttachedNamespaces (plus tgw.Namespace) carries
  namespace.cozystack.io/gateway=<owner> after reconcile.
* TestReconcile_LabelGCRemovesDroppedAttachedNamespaces pins the GC
  path: removing an entry between reconciles strips the label.
* TestReconcile_LabelGCDoesNotStripHelmOwnedLabels pins the safety
  contract: labels lacking the controller annotation (Helm-owned
  tenant labels) are left alone.
* tenant-root_test.yaml adds the positive case for the conditional
  label render under _cluster.gateway-enabled=true.

The HTTP listener (port 80) keeps the narrower static-name selector
— it allows only the tenant namespace plus cozy-cert-manager (for
HTTP-01 ACME challenges) and must NOT accept routes from app
namespaces or child tenants, since unattended routes on the HTTP
listener serve plaintext and leak credentials.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
In DNS-01 mode, the parent's wildcard Certificate now covers every
inheriting child tenant's apex (`<child-apex>` + `*.<child-apex>`
as additional SANs) and the parent Gateway grows one wildcard HTTPS
listener per child apex referencing the same Certificate. Without
the expansion, a child route hostname like `harbor.alice.example.org`
fails to match the parent's listeners — `*.example.org` is a single-
label wildcard and `alice.example.org` is two labels deep — and the
TLS handshake fails the SNI check because the parent cert never
listed the child apex.

The controller discovers inheriting children by listing namespaces
labelled `namespace.cozystack.io/gateway = <tgw.Namespace>` (the
existing inheritance contract from Stage 1/3) and reading their
`namespace.cozystack.io/host` label, which apps/tenant chart writes
to the child apex. Children with no host label are skipped
(defensive); a child host equal to the parent apex is deduplicated
(cert-manager rejects duplicate dnsNames). HTTP-01 mode is
unchanged — its per-listener / per-cert flow already handles child
hostnames without any wildcard.

Listener naming: `https-child-<first-label>-<8-hex>`, where the hex
suffix is sha256(child-apex)[:8] so two children whose first DNS
label collides produce distinct listener names.

Cap: Gateway API hard-caps spec.listeners at 64 entries. The parent
uses 3 slots (http + https + https-apex) leaving ~61 wildcard
children per parent. Operators whose subtree fans out beyond that
must run the high-fanout branch on its own Gateway via
tenant.spec.gateway=true.

Tests (TDD red→green):
* TestReconcile_DNS01WildcardCertCoversInheritingChildApexes pins
  the SAN list contains both `<child-apex>` and `*.<child-apex>`.
* TestReconcile_DNS01WildcardCertDeduplicatesChildApexEqualToParent
  pins the dedupe path (child host == parent apex).
* TestReconcile_HTTP01WildcardCertNeverRendered pins the inverse:
  HTTP-01 mode never renders a wildcard Certificate.
* TestReconcile_DNS01GatewayHasListenerPerChildApex pins the
  listener-side expansion (one `*.<child-apex>` listener per
  inheriting tenant, all referencing the parent's wildcard cert).

Signed-off-by: Aleksei Sviridkin <f@lex.la>
Removes two gates that prevented child tenants from attaching to a
parent's publishing Gateway:

1. Render-time fail() in templates/gateway-hostname-policy.yaml that
   refused to render the chart when _cluster.gateway-attached-
   namespaces contained any tenant-* entry.
2. cozystack-gateway-attached-namespaces-policy VAP that enforced
   the same rule at Package CR admission time.

The original rationale — 'a tenant in attachedNamespaces can hijack
the publishing tenant's hostnames' — assumed a static-name
allowedRoutes whitelist where adding tenant-* literally widened the
attach surface for arbitrary hostnames. Under the inheritance
refactor (Stage 1/3/5) the attach surface is governed by the
namespace.cozystack.io/gateway label selector, not by entries in
AttachedNamespaces; and hostname hijack is independently blocked by
three other layers:

* Layer 4 — cozystack-tenant-host-policy VAP on Tenant.spec.host
* Layer 5 — cozystack-namespace-host-label-policy VAP on the
  namespace label
* Layer 7 — cozystack-route-hostname-policy VAP on HTTPRoute and
  TLSRoute hostnames

Those three defend against the hijack vector regardless of what
sits in attachedNamespaces, and they apply uniformly to every
tenant (inheriting or owning a Gateway). The dropped gates added
no extra defence, while their presence broke the inheritance flow
kvaps reasonably asks for in the May 25 review.

Doc count for gateway-hostname-policy.yaml drops from 8 to 6 (one
VAP + one Binding removed); helm-unittest fixtures updated to
match, with a regression-guard test asserting the removed VAP name
no longer appears in any rendered document.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
Replace the 'Inheritance from parent Gateway' known-limitation entry
(which described inheritance as deferred) with:

* A top-of-README 'Inheritance: when to opt in for a separate
  Gateway' section explaining when a tenant owns its Gateway vs
  inherits, and how the namespace.cozystack.io/gateway label is
  written by apps/tenant chart and patched onto cozy-* by the
  controller.
* DNS-01 cert-mode docs spell out SAN expansion for child apexes:
  the wildcard Certificate now carries <child-apex> + *.<child-apex>
  per inheriting tenant, with a per-child *.<child-apex> listener
  on the parent Gateway. Documents the DNS-provider-permission
  requirement for deeply-nested children (ACME challenge writes
  TXT under each apex zone, so zone delegation or wide-scope
  provider creds are needed for grandchild apexes).
* New known-limitation entries: 64-listener cap on the parent
  Gateway under inheritance (with the high-fanout escape via
  tenant.spec.gateway=true), and Cilium sharing-key port-collision
  (multiple per-tenant Gateways still each take their own LB IP
  until ListenerSet ships in Cilium).

Signed-off-by: Aleksei Sviridkin <f@lex.la>
Replaces the now-obsolete "Package admission rejects tenant-* in
gateway.attachedNamespaces" e2e (the VAP it pinned was dropped in
the inheritance refactor) and adds two new e2e cases that cover
the end-to-end inheritance contract:

* "Package admission accepts gateway.attachedNamespaces with
  tenant-* entries" — regression guard against a future refactor
  re-introducing the gate; the hijack surface is now handled by
  Layers 4/5/7 (Tenant.spec.host VAP, namespace label immutability
  VAP, HTTPRoute hostname VAP), not by entries in attachedNamespaces.

* "child tenant without explicit gateway inherits _namespace.gateway
  from a Gateway-owning parent" — chart-level lockstep: a parent
  tenant with gateway=true produces both the namespace label and the
  cozystack-values Secret pointing at itself, and a child Tenant
  under that parent with the gateway field unset receives the
  parent's name in both places. Plus a negative assertion that the
  child does NOT get its own gateway HelmRelease.

* "child tenant's HTTPRoute attaches to parent's Gateway via
  inheritance label" — full cross-namespace attach: parent owns
  the Gateway, child inherits, an HTTPRoute in the child namespace
  pointing at parent's Gateway reaches Accepted=True once Cilium
  evaluates the label-based allowedRoutes selector. Route hostname
  uses the child's derived apex so Layer 7 admits it.

The Gateway-Programmed wait is intentionally omitted from the
last test — Programmed depends on the LB allocator that ships with
the e2e cluster, while Accepted is a pure spec evaluation on the
parent Gateway's allowedRoutes selector and is the property the
inheritance refactor is meant to deliver.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
`cozyvalues-gen` bakes the values.schema.json's
`gatewayAPI.description` into the JSON openAPISchema field of the
external-dns release-definition CRD at
`packages/system/external-dns-rd/cozyrds/external-dns.yaml`. The Q7
commit (18fd155) updated the description from 'HTTPRoute only' to
'HTTPRoute and TLSRoute' but missed running `make generate` in
`packages/extra/external-dns/`, so the cozyrds JSON drifted from
the source values.yaml. Pre-commit drift check caught it on CI.

This commit ships the regenerated cozyrds schema only — no
behavioural change. The 'make generate' rule in extra/external-dns
runs cozyvalues-gen and `hack/update-crd.sh`, both already in
CI's drift verification path.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
…laim collection

The HTTP-01 inheritance flow deadlocked at the route-Accepted step:
the apps/tenant chart correctly labelled the child namespace with
`namespace.cozystack.io/gateway=<owner>`, the parent Gateway's
allowedRoutes selector matched by label, but `collectHostnameClaims`
filtered routes by the static set `{tgw.Namespace} ∪ Spec.AttachedNamespaces`
— so a child route in a namespace reached purely via the inheritance
label was silently dropped. No per-listener Certificate, no per-app
HTTPS listener, no hostname match → `Accepted=False,
Reason=NoMatchingListenerHostname` indefinitely.

Caught by the new `child tenant's HTTPRoute attaches to parent's
Gateway via inheritance label` e2e bats — failed 3/3 retries on the
CI run for `afc7765e6`. The Go controller-level
TestReconcile_HTTP01CollectsHostnamesFromInheritingChildNamespaces
test pins the same contract at a faster feedback loop: an HTTPRoute
in a namespace labelled `namespace.cozystack.io/gateway=<owner>` but
NOT listed in Spec.AttachedNamespaces must still cause the
controller to render a per-listener HTTPS listener for its hostname.

The fix extends the `allowed` set in `collectHostnameClaims` to
also include namespaces matched by the gateway label selector, so the
hostname-claim collector and the Gateway's allowedRoutes selector
agree on which namespaces can attach. DNS-01 mode is unaffected — it
returns nil from `collectHostnameClaims` immediately and uses the
separate `collectInheritingChildApexes` path for SAN expansion.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
@lexfrei

Copy link
Copy Markdown
Contributor Author

Pushed an inheritance refactor in response to the May 25 review. The PR body has been rewritten to reflect the new shape; this comment summarises the decisions taken along the way.

Inheritance is now the default

Every tenant inherits the publishing Gateway of the nearest ancestor (inclusive) that owns one — same shape as the existing _namespace.ingress inheritance. A dedicated Gateway / LB IP / Certificate per tenant is opt-in only via tenant.spec.gateway=true.

The previous "auto-default for derived-apex tenants" rule is removed. It produced one LB IP per derived-apex child even when the operator never asked, which scaled linearly with tenant depth. The new tenant.gatewayEffective helper is explicit-only:

  • gateway: true → own Gateway
  • gateway: false → parked (no inherit, no own)
  • gateway unset → inherit through _namespace.gateway from the parent

Attach surface is label-based, not name-based

Gateway.spec.listeners[].allowedRoutes.namespaces.selector is keyed on namespace.cozystack.io/gateway = <owner-tenant-name>. Two writers populate the label so the selector picks up both the tenant tree and the cozy-* system namespaces:

  • apps/tenant chart's namespace.yaml writes the label on every tenant namespace, pointing at the owner — self when owning a Gateway, inherited ancestor name otherwise.
  • cozystack-controller patches the label onto every namespace in tgw.Spec.AttachedNamespaces (cozy-* system slots) and garbage-collects labels it wrote when an entry is removed. Annotation-gated GC so Helm-owned labels are never stripped.

The previous static kubernetes.io/metadata.name In [list] whitelist on the Gateway selector is gone. With it goes the cozystack-gateway-attached-namespaces-policy VAP and the render-time fail in cozystack-basics that banned tenant-* entries — the hijack vector both gates were trying to close was the static attach list, which no longer exists as a defence surface.

Hostname hijack moves to per-route admission

Hostname hijack across namespaces under inheritance is closed by three independent layers, all already shipped in this PR:

  • Layer 4 — cozystack-tenant-host-policy on Tenant.spec.host.
  • Layer 5 — cozystack-namespace-host-label-policy on the namespace's namespace.cozystack.io/host label (immutability + trusted-caller gate).
  • Layer 7 — cozystack-route-hostname-policy on HTTPRoute / TLSRoute hostnames. A child cannot claim a route hostname outside its own apex.

Net effect: tenants holding only apps.cozystack.io/* RBAC cannot move a hostname out of their own apex; an app chart bug or supply-chain compromise that emits a wrong-apex route is rejected at admission time, not at runtime.

DNS-01 SAN expansion for inheriting children

In DNS-01 mode the parent's wildcard Certificate now carries <child-apex> + *.<child-apex> SANs per inheriting tenant, and the parent Gateway grows one *.<child-apex> HTTPS listener per child apex referencing the same Certificate. Without this expansion the parent's single-label wildcard would not match harbor.alice.example.org (two labels deep).

Child apexes are discovered by listing namespaces with the gateway label and reading their namespace.cozystack.io/host label.

The ACME challenge for DNS-01 then has to succeed for every SAN — for deeply-nested children this requires zone delegation or a wide-scope DNS provider credential. Documented as a known-limitation entry in packages/extra/gateway/README.md.

HTTP-01 mode is unaffected: per-listener certs are added on demand from collectHostnameClaims, which has been extended to read the same label so routes in inheriting child namespaces produce listeners too.

Cap behaviour

64-listener cap (Gateway API spec hard-cap, upstream's "answer" is GEP-1713 ListenerSet which Cilium does not yet ship — cilium#42756):

  • DNS-01 mode: 3 fixed slots (http + https + https-apex) + 1 wildcard listener per inheriting child apex → ~61 children per parent.
  • HTTP-01 mode: 3 fixed slots + 1 listener per distinct route hostname → ~61 distinct hostnames per parent (descendants share the budget).

High-fanout subtrees opt into their own Gateway via tenant.spec.gateway=true, which detaches them from the parent's listener budget. Multiple per-tenant Gateways still each take their own LB IP — Cilium's lbipam.cilium.io/sharing-key is inactive on port collision and a single shared IP across many Gateways needs ListenerSet too.

Decisions left for follow-up

  • Cilium ListenerSet support, once it lands upstream. The current allowedRoutes label selector migrates cleanly to ListenerSet's per-namespace listener delegation when that day comes; this PR is the bridge.
  • Multi-gatewayClassName per tenant (e.g. Cilium + a Cloudflare gateway controller). The CRD field is a single string today; can be extended without breaking change.

E2E + tests

Inheritance is pinned at three layers:

  • Go controller tests cover label-based allowedRoutes shape, label patch + GC via owner annotation, Helm-owned label safety, DNS-01 wildcard SAN expansion per child, per-child wildcard listener, and the collectHostnameClaims path for HTTP-01 mode reading the inheritance label (regression test for the deadlock the first CI run on this iteration surfaced).
  • helm-unittest covers the new tenant chart inheritance helper, the conditional _namespace.gateway: tenant-root label on tenant-root, the cozystack-values-secret.yaml conditional, and the dropped Layer 3 + Layer 6.
  • hack/e2e-apps/gateway.bats has three new cases: the Package admission positive case for tenant-* entries (replacing the dropped ban test), the chart-level lockstep between the namespace label and the cozystack-values Secret for inheriting children, and the cross-namespace attach end-to-end where a child's HTTPRoute reaches Accepted on the parent's Gateway via the inheritance label.

…ccepted

The previous e2e check waited for
`HTTPRoute.status.parents[0].conditions[Accepted]=True` after
applying a child tenant's HTTPRoute against the parent's Gateway.
That condition is set by Cilium after the listener's TLS Secret
becomes Ready — and in the e2e cluster every fresh certificate fails
to issue (LE prod refuses every `.example.org` hostname by policy,
visible across alerta/grafana/dashboard/seaweedfs-s3 cluster certs
in cozyreport). So Cilium never set Accepted, the route timed out
3/3 retries, and the failure was environmental — not a regression
in the inheritance code path.

The contract the test exists to pin is cozystack-controller's
behaviour, which has two observable writes:

1. The parent Gateway grows a per-listener HTTPS entry for the
   child route's hostname (proves collectHostnameClaims saw the
   route through the inheritance label and reconcileGateway
   appended the listener).
2. A per-listener Certificate object appears in the parent
   namespace with the route's hostname in spec.dnsNames (proves
   reconcilePerListenerCertificates ran for the same hostname).

Both writes are deterministic and depend only on the inheritance
label + the HTTPRoute existing — not on ACME, not on Cilium TLS
readiness. The test now polls those two objects directly within
shorter, scenario-appropriate timeouts (120s for the listener, 60s
for the Certificate). The route Accepted check is dropped with an
explanation in the test comment so a future cluster with a working
ACME doesn't get a misleading pre-condition added back. The
namespace inheritance label is also verified up front as the
precondition collectHostnameClaims's filter reads.

Signed-off-by: Aleksei Sviridkin <f@lex.la>

@kvaps Andrei Kvapil (kvaps) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the inheritance rework lands the deferred piece cleanly and matches the shape we discussed: _namespace.gateway inherits from the parent like _namespace.ingress, the label-based allowedRoutes selector replaces the static name whitelist, and the DNS-01 SAN/listener expansion plus the annotation-gated label GC are all well-tested. The HTTP :80 listener correctly stays on the narrow name whitelist, and the e2e tests pin the controller-write contract rather than chasing Cilium Accepted in an environment that can't issue certs. Approving.

A handful of non-blocking notes, roughly in priority order:

  1. gofmtrenderers.go (renderWildcardCertificate). The seen map literal isn't gofmt-clean on go1.26.2 (gofmt -l flags it) — the keys are over-aligned. One-line fix. CI doesn't gate gofmt today, so it slipped through green.

  2. No Namespace watch → stale listener/SAN drift on DNS-01 teardown. SetupWithManager watches HTTPRoute/TLSRoute only. Creation converges fine (a child route maps back to the parent TGW). But in DNS-01 mode the per-child-apex listeners and cert SANs are collected from namespaces (collectInheritingChildApexes), not routes — so when a child tenant is deleted, nothing re-enqueues the parent and the *.<child-apex> listener + SAN linger until an unrelated reconcile (or the resync period). A Namespace watch filtered on the namespace.cozystack.io/gateway label closes this. HTTP-01 teardown is unaffected (route-driven).

  3. Stale comment on a security-relevant function — reconciler.go renderGateway (~L692). The doc block still says every listener is gated by an unspoofable kubernetes.io/metadata.name In [...] selector and calls it "Layer 1". That's now only true for the HTTP :80 listener; HTTPS listeners go through the label selector in buildAllowedRoutes. Worth updating so the comment doesn't misdescribe the attach model.

  4. ReferenceGrant comment describes resources that aren't rendered — apps/tenant/.../namespace.yaml (and the 97dff75 / 30302ea commit messages). It says the controller renders a ReferenceGrant per inheriting child. Nothing renders one, and route→Gateway attachment across namespaces doesn't need one (only cross-namespace backendRefs would, and the app routes use same-namespace backends). Functionally fine — just drop/fix the comment so it doesn't imply a load-bearing resource that doesn't exist.

  5. gateway label has no admission guard (defense-in-depth). The host label is covered by cozystack-namespace-host-label-policy; the namespace.cozystack.io/gateway label that now drives attachment isn't. Not exploitable under the current model — tenants don't write Namespaces directly, and cozystack-route-hostname-policy bounds route hostnames to the namespace's host label regardless of which Gateway it attaches to (worst case of a mislabel is self-inflicted, not a hijack). Either add a sibling VAP or note it under Known-limitations so the asymmetry is intentional on the record.

None of these block merge from my side; (1) is the only one I'd fix before merging, the rest can ride a follow-up.

@kvaps

Copy link
Copy Markdown
Member

One more from cross-checking against the docs PR (cozystack/website#509) — folding in as a 6th note to the review above:

README.md / code comments are stale on the layer model. The website page renumbered the security model to 5 layers, but packages/extra/gateway/README.md here still describes 7 layers — including cozystack-gateway-attached-namespaces-policy (Layer 3) and the render-time tenant-* ban (Layer 6), both removed by 32241af to enable inheritance (the VAP no longer exists in the tree, only a leftover reference in a test fixture). The controller comments also still say "Layer 1" (reconciler.go ~L696) and "Layer 7" (~L711, plus route-hostname-policy.yaml) for what the docs now call Layer 1 / Layer 5. Worth syncing the README + comments down to the 5-layer model so the two don't drift post-merge. Doc-only, non-blocking — same priority as notes 3–4.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api Issues or PRs related to the cozystack-api aggregated API server area/networking Issues or PRs related to networking (ingress, gateway, vpn, metallb, cilium, kube-ovn) kind/api-change Categorizes issue or PR as related to adding, removing, or otherwise changing an API kind/feature Categorizes issue or PR as related to a new feature size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants