Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
efbbe86
feat(tenants): tenant.extraHostnames voor meerdere hostnamen op één t…
rubenvdlinde Oct 13, 2026
6b0bc18
fix(review): #138 f2 — Berekende primaire host niet herkend als dubbel
rjzondervan Oct 14, 2026
34c94c0
fix(review): #138 f1 — Extra host mag de host van een andere tenant c…
rjzondervan Oct 14, 2026
13687d4
fix(review): #138 f5 — Secretnamen botsen bij a-b.nl en a.b.nl
rjzondervan Oct 14, 2026
71e97bc
fix(review): #138 f6 — Hostnaam-regex accepteert localhost, 123 en la…
rjzondervan Oct 14, 2026
50934c5
fix(review): #138 f7 — Extra Ingress negeert ingress.enabled en ingre…
rjzondervan Oct 14, 2026
72bb9e3
docs(review): #138 f8 — Host verwijderen blijft in trusted_domains staan
rjzondervan Oct 14, 2026
bf29d92
fix(review): #138 f7 — pre-push: extraHostnames vereist ingress.enabled
rjzondervan Oct 14, 2026
ab064c7
fix(review): #138 f1 — pre-push: dubbele tenant.name en register één …
rjzondervan Oct 14, 2026
6da1eaa
fix(review): #138 f6 — pre-push: IP-adres als hostnaam weigeren
rjzondervan Oct 14, 2026
fd6c9c2
test(review): #138 f1 — pre-push: afhankelijkheid van dinkelland in f…
rjzondervan Oct 14, 2026
86e8c7f
docs(review): #138 f1 — pre-push: check lists bijgewerkt
rjzondervan Oct 14, 2026
a17181e
Merge pull request #142 from ConductionNL/review/pr-138-fixes
rubenvdlinde Oct 14, 2026
cb163ee
fix(extra-hostnames): #138 f3, apex-host alleen met opt-in en zonder …
rubenvdlinde Oct 14, 2026
4e449cb
docs(extra-hostnames): #138 f3 en f4, HSTS op apex-hosts en alleen Le…
rubenvdlinde Oct 14, 2026
fb50297
Merge remote-tracking branch 'origin/main' into feat/tenant-extra-hos…
rubenvdlinde Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions changelog.d/tenant-extra-hostnames.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
### Toegevoegd — 2026-10-13 (tenant.extraHostnames)

Een tenant kan nu op meer dan één hostnaam antwoorden. Nieuw optioneel veld
`tenant.extraHostnames` (lijst). De ApplicationSet `nextcloud-tenants`:

- zet elke extra host achter de primaire host in `nextcloud.trustedDomains`
(en daarmee in `NEXTCLOUD_TRUSTED_DOMAINS`);
- rendert via `extraManifests` een tweede Ingress `nextcloud-extra-hosts`,
omdat chart 8.9.0 in zijn eigen Ingress alleen `nextcloud.host` als rule zet
(`ingress.hosts` uit de ApplicationSet leest die chart niet);
- geeft elke extra host een eigen TLS-blok en dus een eigen Certificate via
HTTP-01, zodat één host met nog niet kloppende DNS de andere niet tegenhoudt;
- neemt `ingress.enabled`, annotaties, `ingressClassName`, `path`, `pathType`
en poort via de `tpl` van de chart over uit dezelfde values als de
hoofd-Ingress: geen tweede kopie van de nginx-instellingen.

**Bestaande tenants renderen byte-identiek.** Het blok wordt alleen geëmit als
de lijst gezet is. Gemeten: alle 89 tenantbestanden op main en op deze branch
gerenderd (Argo-goTemplate met sprig en `missingkey=default`, daarna
`helm template` met chart 8.9.0 en dezelfde valueFiles-volgorde), values en
manifests per tenant byte voor byte gelijk.

`validate-values.sh` controleert het nieuwe veld (lijst, geldige DNS-namen en
geen IP-adres, geen dubbele, niet de primaire host, onder `commonground.nu`
alleen de eigen namen, unieke secretnaam ≤ 253, niet samen met
`ingress.enabled: false`). Ook `tenant.hostname` mag geen IP-adres meer zijn.
Over alle tenantbestanden heen weigert hij een host die twee tenants claimen
(primaire host, `extraHostnames`, `frontend.host`, `frontend.extraHosts`) en
een `tenant.name` die in twee bestanden staat, met fixtures in
`nextcloud-platform/tests/cases/tenant-extra-hostnames-*`.

Bekende beperkingen: `trusted_domains` wordt alleen bij de eerste installatie
geschreven, en een verwijderde host blijft erin staan tot een
`occ config:system:delete trusted_domains <n>`; `charts/tenant-httproute` neemt
nog één host. Zie
`docs/ADDING-TENANT.md`.

**Apex-hosts (review f3).** Een extra host met twee labels (`gemeente.nl`)
wordt geweigerd zonder `tenant.extraHostnamesAllowApex: true`: HSTS met
includeSubDomains op een apex dwingt HTTPS af op elk subdomein van de klant.
Apex-hosts krijgen een eigen Ingress `nextcloud-extra-hosts-apex` met
`hsts-include-subdomains: "false"`; de hoofd-Ingress en de andere extra hosts
houden hun waarde. ingress-nginx leest die annotatie niet (HSTS komt uit de
controller-ConfigMap), dus live blijft includeSubDomains staan tot die
ConfigMap of de route verandert; zie `docs/ADDING-TENANT.md`. Nieuwe
render-check `nextcloud-platform/tests/render-extra-hosts.sh` in `verify.sh`.

**Certificaten (review f4).** Extra hosts krijgen alleen Let's Encrypt: hun
Ingresses erven `cert-manager.io/cluster-issuer`. Een eigen of betaald cert per
extra host kan nog niet; `tenant.frontend.tls` is het bestaande patroon.
98 changes: 97 additions & 1 deletion docs/ADDING-TENANT.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
last_reviewed: 2026-10-13
last_reviewed: 2026-10-14
owner: info@conduction.nl
---

Expand Down Expand Up @@ -249,6 +249,102 @@ soap-enabled build from `ConductionNL/nextcloud-images`.
`validate-values.sh` has no top-level key allowlist, so an `image:` block passes
validation as-is — the rules above are not machine-checked.

#### Extra hostnames on one tenant

One Nextcloud can answer on more than one hostname, for example a workplace on
`cloud.<domain>` and a public site served by an app on `www.<domain>`. Set the
primary host as usual and list the others:

```yaml
tenant:
hostname: cloud.example.nl
hostnameOverride: true
extraHostnames:
- www.example.nl
```

A bare apex such as `example.nl` is refused unless you also set
`extraHostnamesAllowApex: true`; read the HSTS point below first.

What the ApplicationSet does with it (`argo/applicationsets/nextcloud-tenants.yaml`):

| Effect | Detail |
|---|---|
| `trusted_domains` | every extra host is added to `nextcloud.trustedDomains`, after the primary host |
| routing | a second Ingress `nextcloud-extra-hosts`, because chart 8.9.0 puts only `nextcloud.host` in its own Ingress rules. Apex hosts (two labels) go into a third Ingress `nextcloud-extra-hosts-apex` |
| certificates | one TLS entry per host, secret `nextcloud-<tenant>-<host with dashes>-tls`, so one cert-manager Certificate per host via HTTP-01 (`letsencrypt-prod`). A host whose DNS is not ready does not block the others |
| annotations | taken from `ingress.annotations`, `ingress.className`, `ingress.path`, `ingress.pathType` and `service.port` through the chart's `tpl`, so they always match the main Ingress; with `ingress.enabled: false` no Ingress is rendered. The apex Ingress has one difference: `nginx.ingress.kubernetes.io/hsts-include-subdomains: "false"` |

A tenant without `extraHostnames` renders byte-identical to before; the block is
only emitted when the list is set.

Seven things to know:

- **An apex host gets HSTS with includeSubDomains, so it needs an opt-in.**
Every host on our ingress-nginx answers with
`Strict-Transport-Security: max-age=31536000; includeSubDomains` (measured on
`dinkelland.commonground.nu`). On an apex like `example.nl` that means a
browser that visits it once forces HTTPS on every `*.example.nl` for a year,
including internal or http-only subdomains of the customer. Neither we nor the
customer can undo that in a browser that already has it. `validate-values.sh`
therefore refuses an extra host with two labels unless the tenant sets
`tenant.extraHostnamesAllowApex: true`. Set it only when the owner of the zone
has accepted that every subdomain must work over HTTPS.
- **The apex Ingress asks for no includeSubDomains, but ingress-nginx does not
read that today.** `nextcloud-extra-hosts-apex` carries
`hsts-include-subdomains: "false"`; the main Ingress and the other extra hosts
keep their value. ingress-nginx has no per-Ingress HSTS annotations: `hsts`,
`hsts-max-age` and `hsts-include-subdomains` are settings of the controller
ConfigMap. That is why the live max-age is 31536000 (the ConfigMap default)
and not the 15552000 in `values/common.yaml` (see `docs/GATEWAY-API.md`). The
ConfigMap `nginx-ingress-nginx-controller` is in no repo (Helm release `nginx`,
see `cluster-infra/scripts/tls-sigalgs-apply.sh`). So with the opt-in, the
apex host does get includeSubDomains until one of these changes: the
controller ConfigMap sets `hsts-include-subdomains: "false"` (for every host
on the cluster), the apex Ingress gets a `configuration-snippet` that sets the
header itself (only if the controller allows snippet annotations, not
verified), or the apex host moves to a Gateway API route whose `route.hsts`
leaves includeSubDomains out (`charts/tenant-httproute` does that per tenant,
but takes one host). The nginx sidecar in the pod also sends its own header,
`max-age=15768000; includeSubDomains; preload` (`values/common.yaml`,
`nginx.config.default`). ingress-nginx replaces it today, but a route that
does not set HSTS itself lets it through.
- **Extra hosts only get Let's Encrypt.** Both extra Ingresses inherit
`cert-manager.io/cluster-issuer: letsencrypt-prod` from `ingress.annotations`,
so cert-manager creates and owns the certificate of every extra host. A paid
or own certificate per extra host is not possible yet: a certificate you put
in `nextcloud-<tenant>-<host with dashes>-tls` yourself is overwritten by
cert-manager, and a CAA record that allows only the customer's own CA leaves
the Certificate on `Ready=False` while nginx serves its fake certificate. The
existing pattern for a customer certificate is `tenant.frontend.tls`
(`secretName` plus `issuer: none`, an Ingress without the cert-manager
annotation; see `tenant-tubbergen-prod.yaml`). `extraHostnames` has no such
option; a host that needs its own certificate cannot be an extra host for now.

- **DNS is yours when the zone is not ours.** external-dns only manages
`commonground.nu`, `openwoo.app` and `opencatalogi.nl`
(`cluster-infra/external-dns/values.yaml`). For any other zone the zone owner
creates the records by hand, pointing at the ingress-nginx load balancer, and
keeps them **DNS only** (not proxied): Cloudflare caps request bodies at 100 MB
against our 16G, and HTTP-01 has to reach the Ingress.
- **A CAA record on the zone must allow `letsencrypt.org`**, or issuance fails
(see the Let's Encrypt point above).
- **`trusted_domains` is written at first install only.** The Nextcloud image

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.

🟢 Klein — Host verwijderen blijft in trusted_domains staan

De beperking noemt alleen toevoegen. Haal je een host uit de lijst, dan prunet Argo de Ingress, maar de host blijft in config.php staan. Bij config:system:set trusted_domains <n> overschrijf je met een verkeerde <n> ook een bestaande entry. Fix: één zin over verwijderen (config:system:delete trusted_domains <n>) en het advies om eerst config:system:get trusted_domains te draaien.

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.

🔧 Voorgestelde fix in #142, commit 72bb9e3: ADDING-TENANT.md beschrijft verwijderen: eerst config:system:get trusted_domains, dan config:system:delete trusted_domains <n>. De thread blijft open tot de fix-PR gemerged is.

reads `NEXTCLOUD_TRUSTED_DOMAINS` when it installs; adding a host to a running
tenant later also needs `occ config:system:set trusted_domains <n> --value=<host>`
in the pod (same pattern as `cutover-tenant.sh`). Put the full list in the file
before the tenant is first created. Removing a host from the list works the
same way: Argo prunes the Ingress, but the host stays in `config.php` until
you run `occ config:system:delete trusted_domains <n>`. In both cases run
`occ config:system:get trusted_domains` first to find the right `<n>`: `set`
on an index that is already in use overwrites that entry.
- **Gateway API routes do not use this list.** `charts/tenant-httproute` takes one
host; a tenant with `gateway.nextcloud: true` gets a route for the primary host
only. The extra hosts stay on the nginx Ingress, and that Ingress follows
`ingress.enabled` like the chart's own: `ingress.enabled: false` would leave
them without any route, so `validate-values.sh` refuses `extraHostnames`
together with `ingress.enabled: false`.

Validate before pushing:

```bash
Expand Down
19 changes: 18 additions & 1 deletion docs/CHECKS-AND-BALANCES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
last_reviewed: 2026-08-10
last_reviewed: 2026-10-14
owner: info@conduction.nl
---

Expand Down Expand Up @@ -35,6 +35,23 @@ Before merge, CI runs `scripts/verify.sh`, which wraps:
and patterns across all tenant files
- `nextcloud-platform/scripts/smoke-checks.sh` — Helm chart renders

`validate-values.sh` also checks the optional `tenant.extraHostnames` list (a
list, valid DNS names and no IP address, no duplicates, not the primary host,
under `commonground.nu` only this tenant's own names, a unique derived TLS
secret name Kubernetes accepts, not together with `ingress.enabled: false`, and
no apex host such as `gemeente.nl` without `tenant.extraHostnamesAllowApex: true`).
`tenant.hostname` is refused as an IP address too. It also checks across all
tenant files that no host (primary, `extraHostnames`, `frontend.host`,
`frontend.extraHosts`) is claimed by two tenants, also when it is run on a
single file, and that no two tenant files share a `tenant.name`. A valid list still gives a warning, because DNS and
HTTP-01 for those hosts are outside what CI can see. Fixtures for every branch
are in `nextcloud-platform/tests/cases/tenant-extra-hostnames-*`.
`nextcloud-platform/tests/render-extra-hosts.sh` (also in `verify.sh`) renders
two of those fixtures through the ApplicationSet and chart 8.9.0 and checks the
result: an apex host lands in its own Ingress with
`hsts-include-subdomains: "false"`, the main Ingress keeps its value, and both
extra Ingresses keep the cert-manager issuer.

This catches invalid values and Helm/rendering failures early. Both are dry-run:
CI needs no cluster access and no secrets. The same gate runs locally via
`./scripts/verify.sh` before pushing.
Expand Down
16 changes: 13 additions & 3 deletions docs/GATEWAY-API.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
last_reviewed: 2026-08-17
last_reviewed: 2026-10-13
owner: info@conduction.nl
---

Expand Down Expand Up @@ -47,6 +47,14 @@ een tenant kan zijn WOO-frontend eerder migreren dan zijn Nextcloud.
Zonder `gateway:`-blok rendert er niets extra's, ook geen `enabled: false`. Een
blok dat altijd meegaat zou alle 84 Applications tegelijk laten hersyncen.

`tenant.extraHostnames` (zie `docs/ADDING-TENANT.md`) gaat hier niet mee: de
route krijgt alleen de primaire host. Een tenant met extra hostnamen blijft voor
die hosts op de nginx-Ingress tot deze chart een lijst hostnames aanneemt, met
per host een listener op de Gateway. Die Ingress (`nextcloud-extra-hosts`) volgt
`ingress.enabled`, net als de hoofd-Ingress van de chart. `validate-values.sh`
weigert daarom `extraHostnames` samen met `ingress.enabled: false`: de extra
hosts zouden dan geen enkele route meer hebben.

## Waarom dit nog niet schaalt

De frontends hebben het makkelijk: `*.openwoo.app` en `*.accept.openwoo.app`
Expand Down Expand Up @@ -89,8 +97,10 @@ De HTTPRoute komt náást de Ingress. Het verkeer verschuift **niet**: het
DNS-record blijft naar ingress-nginx wijzen, want external-dns laat een bestaand
record met rust zolang de Ingress bestaat. Gemeten 2026-08-17.

De cutover is dus het weghalen van de Ingress. Doe dat niet lichtvaardig: het
certificaat van deze host wordt via HTTP-01 over díé Ingress vernieuwd. Verdwijnt
De cutover is dus het weghalen van de Ingress. Heeft de tenant
`tenant.extraHostnames`, dan kan dat nog niet: `ingress.enabled: false` haalt ook
de Ingress van de extra hosts weg, en de validator weigert die combinatie. Doe
het ook verder niet lichtvaardig: het certificaat van deze host wordt via HTTP-01 over díé Ingress vernieuwd. Verdwijnt
hij voordat cert-manager's `--enable-gateway-api` aanstaat (die vlag staat nu
uit), dan breekt de vernieuwing stil en pas bij de eerstvolgende renewal.

Expand Down
10 changes: 9 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
last_reviewed: 2026-10-13
last_reviewed: 2026-10-14
owner: info@conduction.nl
---

Expand Down Expand Up @@ -82,6 +82,14 @@ Verwijderen doe je niet met een script uit deze repo, maar met
een `.yaml` met precies één afwijking en een `.expect` met `PASS` of de
vereiste deelstrings). `scripts/verify.sh` draait de suite mee.

`nextcloud-platform/tests/render-extra-hosts.sh` rendert twee
`extraHostnames`-fixtures zoals Argo het doet (values-blok van de
ApplicationSet, daarna `helm template` met chart 8.9.0) en controleert de
Ingresses: een apex-host krijgt een eigen Ingress met
`hsts-include-subdomains: "false"`. Ook die draait mee in `scripts/verify.sh`;
hij haalt de chart op, of gebruikt `NEXTCLOUD_CHART` als die naar een
uitgepakte chart wijst.

Dat de validator over de echte vloot groen is, zegt alleen dat de huidige
bestanden schoon zijn — niet dat een check nog aanslaat op een fout die niemand
meer maakt. Een nieuwe check hoort dus met minstens twee cases te komen.
Expand Down
82 changes: 81 additions & 1 deletion nextcloud-platform/argo/applicationsets/nextcloud-tenants.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,16 @@ spec:
{{- end -}}
{{- $appapiEnabled := default false .tenant.features.appapi -}}
{{- $phpMemoryLimit := default "2048M" .tenant.phpMemoryLimit -}}
{{- $extraHosts := default (list) .tenant.extraHostnames -}}
{{- $subHosts := list -}}
{{- $apexHosts := list -}}
{{- range $extraHosts -}}
{{- if eq (len (splitList "." .)) 2 -}}
{{- $apexHosts = append $apexHosts . -}}
{{- else -}}
{{- $subHosts = append $subHosts . -}}
{{- end -}}
{{- end -}}
fullnameOverride: "nextcloud"
commonLabels:
nextcloud.platform/tenant: "{{ .tenant.name }}"
Expand All @@ -113,6 +123,9 @@ spec:
host: "{{ $host }}"
trustedDomains:
- "{{ $host }}"
{{- range $extraHosts }}
- "{{ . }}"
{{- end }}
extraEnv:
- name: PHP_MEMORY_LIMIT
value: "{{ $phpMemoryLimit }}"
Expand Down Expand Up @@ -197,7 +210,74 @@ spec:
s3:
bucket: "nextcloud"
prefix: "{{ $name }}/"

{{- if $extraHosts }}
# tenant.extraHostnames: extra hostnamen op DEZELFDE Nextcloud.
# De chart (8.9.0) zet in zijn eigen Ingress alleen nextcloud.host
# als rule, dus extra hosts krijgen hier een tweede Ingress. Eén
# TLS-blok per host, dus één Certificate per host: een host waarvan
# DNS nog niet klopt houdt de andere niet tegen. ingress.enabled,
# annotaties, ingressClassName, path, pathType en poort komen via
# de tpl van de chart uit dezelfde values als de hoofd-Ingress,
# op dezelfde manier als templates/ingress.yaml van de chart, dus
# geen tweede kopie van de nginx-instellingen. Alleen geëmit als
# de lijst gezet is: bestaande tenants renderen byte-identiek.
#
# Apex-hosts (twee labels, zoals gemeente.nl) krijgen een eigen
# Ingress nextcloud-extra-hosts-apex met hsts-include-subdomains
# "false" over de annotaties heen: includeSubDomains op een apex
# dwingt HTTPS af op elk subdomein van de klant. De validator
# eist daarvoor tenant.extraHostnamesAllowApex: true. Let op: de
# annotatie is een intentie, ingress-nginx leest hsts-* alleen uit
# zijn ConfigMap (zie docs/ADDING-TENANT.md).
extraManifests:
{{- range $group := list (dict "naam" "nextcloud-extra-hosts" "hosts" $subHosts "apex" false) (dict "naam" "nextcloud-extra-hosts-apex" "hosts" $apexHosts "apex" true) }}
{{- if $group.hosts }}
- |
{{`{{- if .Values.ingress.enabled }}`}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $group.naam }}
labels:
app.kubernetes.io/name: nextcloud
app.kubernetes.io/part-of: nextcloud-platform
nextcloud.platform/tenant: "{{ $name }}"
{{- if $group.apex }}
{{`{{- with merge (dict "nginx.ingress.kubernetes.io/hsts-include-subdomains" "false") (.Values.ingress.annotations | default dict) }}`}}
{{- else }}
{{`{{- with .Values.ingress.annotations }}`}}
{{- end }}
annotations:
{{`{{- toYaml . | nindent 4 }}`}}
{{`{{- end }}`}}
spec:
{{`{{- with .Values.ingress.className }}`}}
ingressClassName: {{`{{ . }}`}}
{{`{{- end }}`}}
tls:
{{- range $group.hosts }}
- secretName: "nextcloud-{{ $name }}-{{ replace "." "-" . }}-tls"
hosts:
- "{{ . }}"
{{- end }}
rules:
{{- range $group.hosts }}
- host: "{{ . }}"
http:
paths:
- path: {{`{{ .Values.ingress.path }}`}}
pathType: {{`{{ .Values.ingress.pathType }}`}}
backend:
service:
name: nextcloud
port:
number: {{`{{ .Values.service.port }}`}}
{{- end }}
{{`{{- end }}`}}
{{- end }}
{{- end }}
{{- end }}

# Our GitOps repo for values files
#
# Drie refs, drie stappen:
Expand Down
Loading
Loading