From 593db7f7c56bd63cb435904f33a064a25744c844 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Wed, 10 Jun 2026 19:18:09 +0200 Subject: [PATCH 1/3] feat: serve tenant-owned ascoachingogvaner.dk via simply.com DNS + TLS Adds a second external-dns instance (webhook provider for simply.com), a cert-manager DNS01 solver for simply.com zones, a letsencrypt-prod solver entry selected by dnsZones, the ascoachingogvaner.dk certificate, and SNI listeners on the shared Gateway. Hostname ownership moves to the tenant repo (the hostname-replace patch is removed). Co-Authored-By: Claude Fable 5 --- .../patches/kustomization-patch.yaml | 14 ++-- .../ascoachingogvaner-dk-listeners-patch.yaml | 34 ++++++++ .../ascoachingogvaner-dk-certificate.yaml | 18 +++++ .../certificates/kustomization.yaml | 8 ++ .../letsencrypt-prod-issuer.yaml | 16 ++++ .../external-dns-simply/external-secret.yaml | 26 ++++++ .../external-dns-simply/helm-release.yaml | 81 +++++++++++++++++++ .../external-dns-simply/kustomization.yaml | 12 +++ .../external-dns-simply/networkpolicy.yaml | 44 ++++++++++ .../controllers/kustomization.yaml | 6 ++ .../simply-dns-webhook/external-secret.yaml | 25 ++++++ .../simply-dns-webhook/helm-release.yaml | 62 ++++++++++++++ .../simply-dns-webhook/helm-repository.yaml | 7 ++ .../simply-dns-webhook/kustomization.yaml | 15 ++++ .../simply-dns-webhook/networkpolicy.yaml | 25 ++++++ .../hetzner/infrastructure/kustomization.yaml | 9 +++ 16 files changed, 393 insertions(+), 9 deletions(-) create mode 100644 k8s/providers/hetzner/infrastructure/ascoachingogvaner-dk-listeners-patch.yaml create mode 100644 k8s/providers/hetzner/infrastructure/certificates/ascoachingogvaner-dk-certificate.yaml create mode 100644 k8s/providers/hetzner/infrastructure/certificates/kustomization.yaml create mode 100644 k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/external-secret.yaml create mode 100644 k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/helm-release.yaml create mode 100644 k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/kustomization.yaml create mode 100644 k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/networkpolicy.yaml create mode 100644 k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/external-secret.yaml create mode 100644 k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/helm-release.yaml create mode 100644 k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/helm-repository.yaml create mode 100644 k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/kustomization.yaml create mode 100644 k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/networkpolicy.yaml diff --git a/k8s/providers/hetzner/apps/ascoachingogvaner/patches/kustomization-patch.yaml b/k8s/providers/hetzner/apps/ascoachingogvaner/patches/kustomization-patch.yaml index 8d9c67ab9..e90a58d99 100644 --- a/k8s/providers/hetzner/apps/ascoachingogvaner/patches/kustomization-patch.yaml +++ b/k8s/providers/hetzner/apps/ascoachingogvaner/patches/kustomization-patch.yaml @@ -5,14 +5,10 @@ metadata: namespace: ascoachingogvaner spec: patches: - - target: - kind: HTTPRoute - name: ascoachingogvaner - patch: | - - op: replace - path: /spec/hostnames - value: - - ascoachingogvaner.platform.devantler.tech + # NOTE: the hostname-replace patch that used to live here is gone — the + # tenant repo now owns its public hostnames (ascoachingogvaner.dk + the + # ascoachingogvaner.platform.devantler.tech subdomain) in its own + # HTTPRoute, since the domain is tenant-specific, not platform config. # Surface this tenant on the homepage dashboard. The HTTPRoute ships # from an external OCI artifact (ghcr.io/devantler-tech/ascoachingogvaner), # so the gethomepage.dev/* annotations have to be injected here rather @@ -33,5 +29,5 @@ spec: gethomepage.dev/description: Personal/business static site. gethomepage.dev/group: Customer Sites gethomepage.dev/icon: mdi-account-tie - gethomepage.dev/href: https://ascoachingogvaner.platform.devantler.tech + gethomepage.dev/href: https://ascoachingogvaner.dk gethomepage.dev/pod-selector: app.kubernetes.io/name=ascoachingogvaner diff --git a/k8s/providers/hetzner/infrastructure/ascoachingogvaner-dk-listeners-patch.yaml b/k8s/providers/hetzner/infrastructure/ascoachingogvaner-dk-listeners-patch.yaml new file mode 100644 index 000000000..dc7cb5a0f --- /dev/null +++ b/k8s/providers/hetzner/infrastructure/ascoachingogvaner-dk-listeners-patch.yaml @@ -0,0 +1,34 @@ +# Serve the tenant-owned ascoachingogvaner.dk domain on the shared platform +# Gateway. SNI picks the most specific listener, so these terminate TLS with +# the domain's own certificate while every other hostname keeps hitting the +# hostname-less `https` listener with the ${domain} wildcard cert. JSON patch +# (not strategic merge) because Gateway is a CRD — a strategic-merge patch +# would replace the whole listeners list instead of appending. +- op: add + path: /spec/listeners/- + value: + name: https-ascoachingogvaner-dk + hostname: ascoachingogvaner.dk + port: 443 + protocol: HTTPS + tls: + mode: Terminate + certificateRefs: + - name: ascoachingogvaner-dk-tls + allowedRoutes: + namespaces: + from: All +- op: add + path: /spec/listeners/- + value: + name: https-www-ascoachingogvaner-dk + hostname: www.ascoachingogvaner.dk + port: 443 + protocol: HTTPS + tls: + mode: Terminate + certificateRefs: + - name: ascoachingogvaner-dk-tls + allowedRoutes: + namespaces: + from: All diff --git a/k8s/providers/hetzner/infrastructure/certificates/ascoachingogvaner-dk-certificate.yaml b/k8s/providers/hetzner/infrastructure/certificates/ascoachingogvaner-dk-certificate.yaml new file mode 100644 index 000000000..b3783029c --- /dev/null +++ b/k8s/providers/hetzner/infrastructure/certificates/ascoachingogvaner-dk-certificate.yaml @@ -0,0 +1,18 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: ascoachingogvaner-dk + namespace: kube-system +spec: + secretName: ascoachingogvaner-dk-tls + dnsNames: + - ascoachingogvaner.dk + - www.ascoachingogvaner.dk + issuerRef: + group: cert-manager.io + kind: ClusterIssuer + # Hardcoded (not ${issuer_name}): this zone is DNS-hosted at simply.com, + # so only the letsencrypt-prod issuer (with its simply.com DNS01 solver — + # see ../cluster-issuers/) can validate it; the Cloudflare Origin CA + # alternative doesn't apply to non-Cloudflare zones. + name: letsencrypt-prod diff --git a/k8s/providers/hetzner/infrastructure/certificates/kustomization.yaml b/k8s/providers/hetzner/infrastructure/certificates/kustomization.yaml new file mode 100644 index 000000000..f7934b10f --- /dev/null +++ b/k8s/providers/hetzner/infrastructure/certificates/kustomization.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# Prod-only certificates for tenant-owned custom domains terminated on the +# shared platform Gateway. The wildcard ${domain} certificate stays in +# bases/infrastructure/certificates/; these zones exist only on Hetzner. +resources: + - ascoachingogvaner-dk-certificate.yaml diff --git a/k8s/providers/hetzner/infrastructure/cluster-issuers/letsencrypt-prod-issuer.yaml b/k8s/providers/hetzner/infrastructure/cluster-issuers/letsencrypt-prod-issuer.yaml index 8d3bb0950..b5c0f75af 100644 --- a/k8s/providers/hetzner/infrastructure/cluster-issuers/letsencrypt-prod-issuer.yaml +++ b/k8s/providers/hetzner/infrastructure/cluster-issuers/letsencrypt-prod-issuer.yaml @@ -9,8 +9,24 @@ spec: privateKeySecretRef: name: letsencrypt-prod-account-key solvers: + # Default solver — Cloudflare hosts ${domain}. - dns01: cloudflare: apiTokenSecretRef: name: cloudflare-api-token key: api-token + # Tenant-owned zones DNS-hosted at simply.com. cert-manager picks the + # most specific matching solver, so the dnsZones selector wins for + # these zones and everything else stays on Cloudflare. Solved by the + # simply-dns-webhook controller (see + # ../controllers/simply-dns-webhook/); the groupName/solverName pair is + # fixed by that chart's RBAC. + - selector: + dnsZones: + - ascoachingogvaner.dk + dns01: + webhook: + groupName: com.github.runnerm.cert-manager-simply-webhook + solverName: simply-dns-solver + config: + secretName: simply-credentials diff --git a/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/external-secret.yaml b/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/external-secret.yaml new file mode 100644 index 000000000..6d08b914e --- /dev/null +++ b/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/external-secret.yaml @@ -0,0 +1,26 @@ +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: external-dns-simply + namespace: external-dns +spec: + refreshInterval: 1h + secretStoreRef: + name: openbao + kind: ClusterSecretStore + target: + name: external-dns-simply + creationPolicy: Owner + data: + # simply.com API credentials (control panel -> Account -> API). The + # account name is the S-number (Sxxxxxx); together they form the HTTP + # Basic auth pair for https://api.simply.com/2/. Seeded manually into + # OpenBao, like infrastructure/dns/cloudflare. + - secretKey: account-name + remoteRef: + key: infrastructure/dns/simply + property: account_name + - secretKey: api-key + remoteRef: + key: infrastructure/dns/simply + property: api_key diff --git a/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/helm-release.yaml b/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/helm-release.yaml new file mode 100644 index 000000000..83b842671 --- /dev/null +++ b/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/helm-release.yaml @@ -0,0 +1,81 @@ +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: external-dns-simply + namespace: external-dns + labels: + helm.toolkit.fluxcd.io/remediation: enabled +spec: + interval: 2m + timeout: 10m + chart: + spec: + chart: external-dns + version: 1.21.1 + sourceRef: + # Shared with the Cloudflare instance — see ../external-dns/helm-repository.yaml. + kind: HelmRepository + name: external-dns + # https://github.com/kubernetes-sigs/external-dns/blob/master/charts/external-dns/values.yaml + values: + provider: + name: webhook + webhook: + # The only external-dns provider for simply.com, listed in the official + # external-dns webhook-provider registry. Young, single-maintainer + # project, hence pinned by digest; fork into devantler-tech if it + # becomes load-bearing. + image: + repository: ghcr.io/uozalp/external-dns-simply-webhook + tag: 0.1.0@sha256:8a564b62c2dfb0089b6d51cff3198722ea72b8c7e1f4d8d057a2d1f3feb2cab4 + env: + # The image serves the webhook API *and* /healthz on PORT (default + # 8888). The chart declares containerPort 8080 (`http-webhook`) and + # probes /healthz on it, so align the image with the chart and point + # external-dns at it via --webhook-provider-url below. + - name: PORT + value: "8080" + - name: SIMPLY_ACCOUNT_NAME + valueFrom: + secretKeyRef: + name: external-dns-simply + key: account-name + - name: SIMPLY_API_KEY + valueFrom: + secretKeyRef: + name: external-dns-simply + key: api-key + # The chart leaves the sidecar's securityContext empty; mirror the main + # container's restricted defaults so validate-container-security passes. + securityContext: + privileged: false + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + capabilities: + drop: ["ALL"] + sources: + - gateway-httproute + policy: sync + registry: txt + txtOwnerId: "${domain}" + txtPrefix: "_externaldns." + # No domainFilters: the webhook negotiates the zone list with external-dns + # at startup — exactly the zones the simply.com account DNS-hosts — so + # hostnames in other zones (platform.devantler.tech, *.platform.lan) are + # ignored without per-tenant configuration here. Which records get created + # is driven entirely by the tenants' own HTTPRoute hostnames. + # simply.com advertises rate limits only via X-RateLimit headers; poll + # gently — records here change ~never. + interval: 5m + extraArgs: + - --exclude-target-net=10.0.0.0/8 + # external-dns defaults to port 8888 for webhook providers; the chart's + # sidecar contract (containerPort + probes) is 8080 — see PORT above. + - --webhook-provider-url=http://localhost:8080 + deploymentStrategy: + type: Recreate + serviceMonitor: + enabled: false diff --git a/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/kustomization.yaml b/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/kustomization.yaml new file mode 100644 index 000000000..eec1e7107 --- /dev/null +++ b/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/kustomization.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# Second external-dns instance, for zones DNS-hosted at simply.com (an +# external-dns instance speaks exactly one provider, so multi-provider DNS +# means one instance per provider — the upstream-recommended pattern). It +# shares the `external-dns` namespace and HelmRepository with the Cloudflare +# instance in ../external-dns/. +resources: + - external-secret.yaml + - helm-release.yaml + - networkpolicy.yaml diff --git a/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/networkpolicy.yaml b/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/networkpolicy.yaml new file mode 100644 index 000000000..e80dd2010 --- /dev/null +++ b/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/networkpolicy.yaml @@ -0,0 +1,44 @@ +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: allow-external-dns-simply + namespace: external-dns +spec: + # Scoped to this instance's pods — the namespace-wide allow-external-dns + # policy in ../external-dns/ pins egress to api.cloudflare.com only, so the + # simply.com instance needs its own FQDN allowance. + endpointSelector: + matchLabels: + app.kubernetes.io/instance: external-dns-simply + egress: + # Kube API for watching HTTPRoutes/Gateways + - toEntities: + - kube-apiserver + # The webhook sidecar talks to simply.com for DNS record management. + # Pinned by FQDN rather than world:443 so a compromised pod cannot reach + # arbitrary external services. matchName is exact — the v2 API lives + # exclusively on api.simply.com today. + - toFQDNs: + - matchName: "api.simply.com" + toPorts: + - ports: + - port: "443" + protocol: TCP + # DNS resolution. rules.dns activates Cilium's L7 DNS proxy so the + # toFQDNs rule above can be enforced; the visibility list is restricted + # to the names this pod actually needs to resolve (see the rationale in + # ../external-dns/networkpolicy.yaml). + - toEndpoints: + - matchLabels: + k8s:io.kubernetes.pod.namespace: kube-system + k8s-app: kube-dns + toPorts: + - ports: + - port: "53" + protocol: UDP + - port: "53" + protocol: TCP + rules: + dns: + - matchName: "api.simply.com" + - matchPattern: "*.cluster.local" diff --git a/k8s/providers/hetzner/infrastructure/controllers/kustomization.yaml b/k8s/providers/hetzner/infrastructure/controllers/kustomization.yaml index e67b35204..61e791a6a 100644 --- a/k8s/providers/hetzner/infrastructure/controllers/kustomization.yaml +++ b/k8s/providers/hetzner/infrastructure/controllers/kustomization.yaml @@ -14,12 +14,18 @@ resources: - cilium/ - coredns/ - external-dns/ + # Second external-dns instance for zones DNS-hosted at simply.com (one + # instance per provider) — drives tenant-owned domains like + # ascoachingogvaner.dk from the tenants' own HTTPRoute hostnames. + - external-dns-simply/ - flux-instance/ - hcloud-ccm/ - hcloud-csi/ - kubelet-serving-cert-approver/ - longhorn/ - origin-ca-issuer/ + # cert-manager DNS01 solver for simply.com-hosted zones (ascoachingogvaner.dk). + - simply-dns-webhook/ - snapshot-controller/ # Hetzner-local Velero extras (the volume-policy ConfigMap). The base Velero # HelmRelease is included via ../../../../bases/...; this dir only adds the diff --git a/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/external-secret.yaml b/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/external-secret.yaml new file mode 100644 index 000000000..106c27ca2 --- /dev/null +++ b/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/external-secret.yaml @@ -0,0 +1,25 @@ +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: simply-credentials + namespace: cert-manager +spec: + refreshInterval: 1h + secretStoreRef: + name: openbao + kind: ClusterSecretStore + target: + name: simply-credentials + creationPolicy: Owner + data: + # Same OpenBao entry as the external-dns-simply instance; the solver + # expects exactly these two secret keys (account-name/api-key) in the + # Secret named by the ClusterIssuer's config.secretName. + - secretKey: account-name + remoteRef: + key: infrastructure/dns/simply + property: account_name + - secretKey: api-key + remoteRef: + key: infrastructure/dns/simply + property: api_key diff --git a/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/helm-release.yaml b/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/helm-release.yaml new file mode 100644 index 000000000..6a936665a --- /dev/null +++ b/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/helm-release.yaml @@ -0,0 +1,62 @@ +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: simply-dns-webhook + namespace: cert-manager + labels: + helm.toolkit.fluxcd.io/remediation: enabled +spec: + interval: 2m + timeout: 10m + chart: + spec: + chart: simply-dns-webhook + # 1.9.0 is the newest *published* chart: v1.10.0 is tagged upstream but + # its Pages index was never redeployed, so the 1.10.0 tgz 404s. Bump + # once it actually resolves. + version: 1.9.0 + sourceRef: + kind: HelmRepository + name: simply-dns-webhook + # https://github.com/RunnerM/simply-dns-webhook/blob/master/deploy/simply-dns-webhook/values.yaml + values: + # Don't override groupName: the chart's challenge-management ClusterRole + # hardcodes this group, so a custom one breaks the solver's RBAC. + groupName: com.github.runnerm.cert-manager-simply-webhook + certManager: + namespace: cert-manager + serviceAccountName: cert-manager + postRenderers: + # The chart renders no securityContext at all (and offers no values knob), + # which fails the validate-container-security Kyverno baseline. The solver + # is a static Go binary serving HTTPS on :443, so it needs + # NET_BIND_SERVICE to bind a privileged port as non-root (the one + # capability the restricted profile permits adding). + - kustomize: + patches: + - target: + kind: Deployment + name: simply-dns-webhook + patch: | + apiVersion: apps/v1 + kind: Deployment + metadata: + name: simply-dns-webhook + spec: + template: + spec: + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: simply-dns-webhook + securityContext: + privileged: false + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + add: ["NET_BIND_SERVICE"] diff --git a/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/helm-repository.yaml b/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/helm-repository.yaml new file mode 100644 index 000000000..f524d6adc --- /dev/null +++ b/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/helm-repository.yaml @@ -0,0 +1,7 @@ +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: simply-dns-webhook + namespace: cert-manager +spec: + url: https://runnerm.github.io/simply-dns-webhook/ diff --git a/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/kustomization.yaml b/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/kustomization.yaml new file mode 100644 index 000000000..e9f969cf8 --- /dev/null +++ b/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/kustomization.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# cert-manager DNS01 webhook solver for zones DNS-hosted at simply.com — +# lets letsencrypt-prod issue certificates for tenant-owned simply.com +# domains (ascoachingogvaner.dk) the same way Cloudflare DNS01 serves +# ${domain}. Installed into the cert-manager namespace because the chart's +# RBAC bindings hardcode it (and the solver reads its credentials Secret +# from cert-manager's --cluster-resource-namespace, which is the release +# namespace of cert-manager itself). +resources: + - external-secret.yaml + - helm-release.yaml + - helm-repository.yaml + - networkpolicy.yaml diff --git a/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/networkpolicy.yaml b/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/networkpolicy.yaml new file mode 100644 index 000000000..e1e428129 --- /dev/null +++ b/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/networkpolicy.yaml @@ -0,0 +1,25 @@ +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: allow-simply-dns-webhook + namespace: cert-manager +spec: + # The solver is an aggregated API server: cert-manager reaches it *through* + # the kube-apiserver, so ingress arrives from the apiserver/host entities on + # the solver's HTTPS port (443) — the namespace-wide allow-cert-manager + # policy only admits 10250/6443 for cert-manager's own webhook. Egress + # (api.simply.com over world:443, kube-apiserver, DNS) is already covered by + # allow-cert-manager, whose endpointSelector spans the namespace. + endpointSelector: + matchLabels: + app: simply-dns-webhook + release: simply-dns-webhook + ingress: + - fromEntities: + - kube-apiserver + - remote-node + - host + toPorts: + - ports: + - port: "443" + protocol: TCP diff --git a/k8s/providers/hetzner/infrastructure/kustomization.yaml b/k8s/providers/hetzner/infrastructure/kustomization.yaml index 06d5e18c2..d5f3e542f 100644 --- a/k8s/providers/hetzner/infrastructure/kustomization.yaml +++ b/k8s/providers/hetzner/infrastructure/kustomization.yaml @@ -3,6 +3,7 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - ../../../bases/infrastructure/ + - certificates/ - cluster-issuers/ - vault-seed/ # Prod-only FinOps: a Kyverno ClusterPolicy that biases pod scheduling toward @@ -46,6 +47,14 @@ patches: # gateway-patch self-identifies (Gateway/platform/kube-system), so a path is # enough — no explicit target, matching the coroot patch below. - path: gateway-patch.yaml + # JSON6902 patches don't self-identify, so this one needs an explicit + # target. Adds HTTPS listeners for the tenant-owned ascoachingogvaner.dk + # domain — see the file header. + - path: ascoachingogvaner-dk-listeners-patch.yaml + target: + group: gateway.networking.k8s.io + kind: Gateway + name: platform # Prod-only Coroot tuning: hcloud-backed persistent storage + Slack webhook # wiring. The CR it patches comes from ../../../bases/infrastructure/coroot/ # (the `infrastructure` layer); the strategic-merge patch matches on the From 5ef4a7cb93a8b8dfedf7bb2341e5ee2e5c116852 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Wed, 10 Jun 2026 21:21:18 +0200 Subject: [PATCH 2/3] refactor: move the simply.com external-dns into the tenant's own deploy/ The controller is tenant-scoped, so the platform no longer runs an external-dns-simply instance. Instead it provisions the generic enablers the tenant's own instance needs: tenant-external-dns read capabilities (HTTPRoutes/Gateway/namespaces) bound to the tenant's external-dns SA, an egress NetworkPolicy to api.simply.com, the namespaced SecretStore with a dedicated app-ascoachingogvaner Vault role (wedding-app pattern), and the app-ascoachingogvaner-readonly policy on the ClusterSecretStore bundle so the cert-manager simply.com solver reads the same tenant-owned credential. Co-Authored-By: Claude Fable 5 --- .../external-dns-networkpolicy.yaml | 41 ++++++++++ .../ascoachingogvaner/external-dns-rbac.yaml | 52 ++++++++++++ .../apps/ascoachingogvaner/kustomization.yaml | 3 + .../apps/ascoachingogvaner/secretstore.yaml | 32 ++++++++ .../cluster-roles/kustomization.yaml | 1 + .../cluster-roles/tenant-external-dns.yaml | 43 ++++++++++ .../infrastructure/vault-config/job.yaml | 42 +++++++++- .../external-dns-simply/external-secret.yaml | 26 ------ .../external-dns-simply/helm-release.yaml | 81 ------------------- .../external-dns-simply/kustomization.yaml | 12 --- .../external-dns-simply/networkpolicy.yaml | 44 ---------- .../controllers/kustomization.yaml | 4 - .../simply-dns-webhook/external-secret.yaml | 13 +-- 13 files changed, 221 insertions(+), 173 deletions(-) create mode 100644 k8s/bases/apps/ascoachingogvaner/external-dns-networkpolicy.yaml create mode 100644 k8s/bases/apps/ascoachingogvaner/external-dns-rbac.yaml create mode 100644 k8s/bases/apps/ascoachingogvaner/secretstore.yaml create mode 100644 k8s/bases/infrastructure/cluster-roles/tenant-external-dns.yaml delete mode 100644 k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/external-secret.yaml delete mode 100644 k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/helm-release.yaml delete mode 100644 k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/kustomization.yaml delete mode 100644 k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/networkpolicy.yaml diff --git a/k8s/bases/apps/ascoachingogvaner/external-dns-networkpolicy.yaml b/k8s/bases/apps/ascoachingogvaner/external-dns-networkpolicy.yaml new file mode 100644 index 000000000..a420c17b5 --- /dev/null +++ b/k8s/bases/apps/ascoachingogvaner/external-dns-networkpolicy.yaml @@ -0,0 +1,41 @@ +# Egress for the tenant-owned external-dns pods (deployed from the tenant's +# own deploy/ artifact). The namespace-wide allow-ascoachingogvaner policy +# only opens DNS, so the controller's two upstreams are allowed here, scoped +# to its pods: the kube-apiserver (HTTPRoute/Gateway watches) and the +# simply.com API the webhook sidecar manages DNS records through — FQDN-pinned +# rather than world:443 so a compromised pod cannot reach arbitrary external +# services (same rationale as the platform external-dns policy). +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: allow-ascoachingogvaner-external-dns + namespace: ascoachingogvaner +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: external-dns + egress: + - toEntities: + - kube-apiserver + - toFQDNs: + - matchName: "api.simply.com" + toPorts: + - ports: + - port: "443" + protocol: TCP + # DNS resolution via Cilium's L7 DNS proxy, restricted to the names this + # pod actually needs to resolve (closes off DNS-tunneling exfil). + - toEndpoints: + - matchLabels: + k8s:io.kubernetes.pod.namespace: kube-system + k8s-app: kube-dns + toPorts: + - ports: + - port: "53" + protocol: UDP + - port: "53" + protocol: TCP + rules: + dns: + - matchName: "api.simply.com" + - matchPattern: "*.cluster.local" diff --git a/k8s/bases/apps/ascoachingogvaner/external-dns-rbac.yaml b/k8s/bases/apps/ascoachingogvaner/external-dns-rbac.yaml new file mode 100644 index 000000000..36af1ac3d --- /dev/null +++ b/k8s/bases/apps/ascoachingogvaner/external-dns-rbac.yaml @@ -0,0 +1,52 @@ +# RBAC for the tenant-owned external-dns instance ascoachingogvaner ships in +# its own deploy/ artifact (manages its simply.com zone from its HTTPRoute +# hostnames). The tenant creates the dedicated `external-dns` ServiceAccount +# itself; only these grants are platform territory — a tenant's `edit` role +# cannot read the shared Gateway in kube-system, list namespaces, or create +# RBAC. Capabilities live in +# k8s/bases/infrastructure/cluster-roles/tenant-external-dns.yaml. +--- +# HTTPRoute reads, limited to the tenant's own namespace. +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: ascoachingogvaner-external-dns + namespace: ascoachingogvaner +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: tenant-external-dns +subjects: + - kind: ServiceAccount + name: external-dns + namespace: ascoachingogvaner +--- +# Gateway reads, limited to kube-system (where the shared `platform` Gateway +# lives) — external-dns resolves route targets from the Gateway's LB address. +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: ascoachingogvaner-external-dns + namespace: kube-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: tenant-external-dns +subjects: + - kind: ServiceAccount + name: external-dns + namespace: ascoachingogvaner +--- +# Namespace reads (cluster-scoped informer required by the gateway source). +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: ascoachingogvaner-external-dns +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: tenant-external-dns-global +subjects: + - kind: ServiceAccount + name: external-dns + namespace: ascoachingogvaner diff --git a/k8s/bases/apps/ascoachingogvaner/kustomization.yaml b/k8s/bases/apps/ascoachingogvaner/kustomization.yaml index 6a826ea61..b8a5f2047 100644 --- a/k8s/bases/apps/ascoachingogvaner/kustomization.yaml +++ b/k8s/bases/apps/ascoachingogvaner/kustomization.yaml @@ -2,9 +2,12 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: + - external-dns-networkpolicy.yaml + - external-dns-rbac.yaml - ghcr-auth-externalsecret.yaml - namespace.yaml - rolebinding.yaml + - secretstore.yaml - serviceaccount.yaml - sync.yaml - networkpolicy.yaml diff --git a/k8s/bases/apps/ascoachingogvaner/secretstore.yaml b/k8s/bases/apps/ascoachingogvaner/secretstore.yaml new file mode 100644 index 000000000..95364e428 --- /dev/null +++ b/k8s/bases/apps/ascoachingogvaner/secretstore.yaml @@ -0,0 +1,32 @@ +# Tenant-scoped, namespaced SecretStore for ascoachingogvaner — provisioned by +# the platform so the tenant only has to add an ExternalSecret in its own +# deploy/ artifact to read secrets under its Vault path (today: the simply.com +# API credentials its tenant-owned external-dns consumes). +# +# Authenticates as the tenant's own `ascoachingogvaner` ServiceAccount via the +# dedicated `ascoachingogvaner` OpenBao Kubernetes auth role +# (k8s/bases/infrastructure/vault-config/job.yaml), which carries only the +# path-scoped `app-ascoachingogvaner` policy — read + seed-write limited to +# secret/{data,metadata}/apps/ascoachingogvaner/*. The tenant can therefore never reach +# infra or another tenant's secrets, unlike the shared cluster-scoped +# `openbao` ClusterSecretStore (which the `restrict-tenant-secret-stores` +# Kyverno policy blocks tenant ExternalSecrets from using). +apiVersion: external-secrets.io/v1 +kind: SecretStore +metadata: + name: openbao + namespace: ascoachingogvaner + labels: + app.kubernetes.io/managed-by: ksail +spec: + provider: + vault: + server: "http://openbao.openbao.svc.cluster.local:8200" + path: "secret" + version: "v2" + auth: + kubernetes: + mountPath: "kubernetes" + role: "ascoachingogvaner" + serviceAccountRef: + name: "ascoachingogvaner" diff --git a/k8s/bases/infrastructure/cluster-roles/kustomization.yaml b/k8s/bases/infrastructure/cluster-roles/kustomization.yaml index cba05029a..2410a739f 100644 --- a/k8s/bases/infrastructure/cluster-roles/kustomization.yaml +++ b/k8s/bases/infrastructure/cluster-roles/kustomization.yaml @@ -6,3 +6,4 @@ resources: - cnpg-tenant-edit.yaml - external-secrets-tenant-edit.yaml - gateway-tenant-edit.yaml + - tenant-external-dns.yaml diff --git a/k8s/bases/infrastructure/cluster-roles/tenant-external-dns.yaml b/k8s/bases/infrastructure/cluster-roles/tenant-external-dns.yaml new file mode 100644 index 000000000..e8727c6ed --- /dev/null +++ b/k8s/bases/infrastructure/cluster-roles/tenant-external-dns.yaml @@ -0,0 +1,43 @@ +# Read capabilities for a TENANT-OWNED external-dns instance (a tenant runs +# its own external-dns Deployment in its namespace to manage a tenant-owned +# DNS zone — e.g. ascoachingogvaner's simply.com domain). The gateway-httproute +# source needs three reads a tenant's `edit` RoleBinding cannot provide: +# HTTPRoutes (its own namespace), the shared Gateway (kube-system, to resolve +# the LB address routes attach to), and Namespaces (cluster-wide informer for +# listener allowedRoutes selectors). +# +# Two ClusterRoles so the grant surface stays minimal: +# - `tenant-external-dns` carries the namespaced rules and is bound via +# RoleBindings (tenant namespace → httproutes; kube-system → gateways), +# so neither resource is readable cluster-wide. +# - `tenant-external-dns-global` carries only the namespace read and is +# bound via a ClusterRoleBinding (namespaces are cluster-scoped). +# Per-tenant bindings live in the tenant's registration dir +# (k8s/bases/apps//external-dns-rbac.yaml). +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: tenant-external-dns +rules: + - apiGroups: ["gateway.networking.k8s.io"] + resources: + - gateways + - httproutes + verbs: + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: tenant-external-dns-global +rules: + - apiGroups: [""] + resources: + - namespaces + verbs: + - get + - list + - watch diff --git a/k8s/bases/infrastructure/vault-config/job.yaml b/k8s/bases/infrastructure/vault-config/job.yaml index cade440da..971149f91 100644 --- a/k8s/bases/infrastructure/vault-config/job.yaml +++ b/k8s/bases/infrastructure/vault-config/job.yaml @@ -401,6 +401,34 @@ spec: } POLICY + # Read-only view of ascoachingogvaner's app path for the shared + # ClusterSecretStore bundle: the cert-manager simply.com DNS01 + # solver consumes the tenant-owned simply.com API credentials + # (apps/ascoachingogvaner/simply) to issue the tenant's + # certificate on the shared Gateway. + bao policy write app-ascoachingogvaner-readonly - <<'POLICY' + path "secret/data/apps/ascoachingogvaner/*" { + capabilities = ["read"] + } + POLICY + + # Tenant-scoped policy for ascoachingogvaner's OWN namespaced + # SecretStore (k8s/bases/apps/ascoachingogvaner/secretstore.yaml) + # and the ExternalSecrets the tenant ships in its deploy/ artifact + # (today: the simply.com credentials for its tenant-owned + # external-dns). Read + seed-write, confined to + # secret/{data,metadata}/apps/ascoachingogvaner/* — mirrors + # app-wedding-app. Bound via the dedicated `ascoachingogvaner` + # Kubernetes auth role below. + bao policy write app-ascoachingogvaner - <<'POLICY' + path "secret/data/apps/ascoachingogvaner/*" { + capabilities = ["create", "update", "read"] + } + path "secret/metadata/apps/ascoachingogvaner/*" { + capabilities = ["create", "update", "read"] + } + POLICY + bao policy write infra-oidc-readonly - <<'POLICY' path "secret/data/infrastructure/oidc/*" { capabilities = ["read"] @@ -509,7 +537,7 @@ spec: bao write auth/kubernetes/role/external-secrets \ bound_service_account_names=external-secrets \ bound_service_account_namespaces=external-secrets \ - policies=infra-backup-readonly,infra-dns-readonly,infra-monitoring-readonly,infra-hcloud-readonly,infra-tls-readonly,infra-ghcr-readonly,app-fleetdm-readonly,app-wedding-readonly,app-umami-readonly,infra-oidc-readonly,vault-seed-write \ + policies=infra-backup-readonly,infra-dns-readonly,infra-monitoring-readonly,infra-hcloud-readonly,infra-tls-readonly,infra-ghcr-readonly,app-fleetdm-readonly,app-wedding-readonly,app-umami-readonly,app-ascoachingogvaner-readonly,infra-oidc-readonly,vault-seed-write \ ttl=1h # Dedicated role for the fleetdm VaultDynamicSecret generator. @@ -546,6 +574,18 @@ spec: policies=app-wedding-app \ ttl=1h + # Dedicated tenant role for ascoachingogvaner — same shape as + # wedding-app above. Authenticates the tenant's own + # `ascoachingogvaner` ServiceAccount (used by its namespaced + # SecretStore + deploy/ ExternalSecret) and carries ONLY the + # path-scoped `app-ascoachingogvaner` policy, confining the + # tenant to secret/{data,metadata}/apps/ascoachingogvaner/*. + bao write auth/kubernetes/role/ascoachingogvaner \ + bound_service_account_names=ascoachingogvaner \ + bound_service_account_namespaces=ascoachingogvaner \ + policies=app-ascoachingogvaner \ + ttl=1h + # Snapshot CronJob needs health check read bao write auth/kubernetes/role/vault-snapshot \ bound_service_account_names=vault-snapshot \ diff --git a/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/external-secret.yaml b/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/external-secret.yaml deleted file mode 100644 index 6d08b914e..000000000 --- a/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/external-secret.yaml +++ /dev/null @@ -1,26 +0,0 @@ -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: external-dns-simply - namespace: external-dns -spec: - refreshInterval: 1h - secretStoreRef: - name: openbao - kind: ClusterSecretStore - target: - name: external-dns-simply - creationPolicy: Owner - data: - # simply.com API credentials (control panel -> Account -> API). The - # account name is the S-number (Sxxxxxx); together they form the HTTP - # Basic auth pair for https://api.simply.com/2/. Seeded manually into - # OpenBao, like infrastructure/dns/cloudflare. - - secretKey: account-name - remoteRef: - key: infrastructure/dns/simply - property: account_name - - secretKey: api-key - remoteRef: - key: infrastructure/dns/simply - property: api_key diff --git a/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/helm-release.yaml b/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/helm-release.yaml deleted file mode 100644 index 83b842671..000000000 --- a/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/helm-release.yaml +++ /dev/null @@ -1,81 +0,0 @@ -apiVersion: helm.toolkit.fluxcd.io/v2 -kind: HelmRelease -metadata: - name: external-dns-simply - namespace: external-dns - labels: - helm.toolkit.fluxcd.io/remediation: enabled -spec: - interval: 2m - timeout: 10m - chart: - spec: - chart: external-dns - version: 1.21.1 - sourceRef: - # Shared with the Cloudflare instance — see ../external-dns/helm-repository.yaml. - kind: HelmRepository - name: external-dns - # https://github.com/kubernetes-sigs/external-dns/blob/master/charts/external-dns/values.yaml - values: - provider: - name: webhook - webhook: - # The only external-dns provider for simply.com, listed in the official - # external-dns webhook-provider registry. Young, single-maintainer - # project, hence pinned by digest; fork into devantler-tech if it - # becomes load-bearing. - image: - repository: ghcr.io/uozalp/external-dns-simply-webhook - tag: 0.1.0@sha256:8a564b62c2dfb0089b6d51cff3198722ea72b8c7e1f4d8d057a2d1f3feb2cab4 - env: - # The image serves the webhook API *and* /healthz on PORT (default - # 8888). The chart declares containerPort 8080 (`http-webhook`) and - # probes /healthz on it, so align the image with the chart and point - # external-dns at it via --webhook-provider-url below. - - name: PORT - value: "8080" - - name: SIMPLY_ACCOUNT_NAME - valueFrom: - secretKeyRef: - name: external-dns-simply - key: account-name - - name: SIMPLY_API_KEY - valueFrom: - secretKeyRef: - name: external-dns-simply - key: api-key - # The chart leaves the sidecar's securityContext empty; mirror the main - # container's restricted defaults so validate-container-security passes. - securityContext: - privileged: false - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - runAsNonRoot: true - runAsUser: 65532 - runAsGroup: 65532 - capabilities: - drop: ["ALL"] - sources: - - gateway-httproute - policy: sync - registry: txt - txtOwnerId: "${domain}" - txtPrefix: "_externaldns." - # No domainFilters: the webhook negotiates the zone list with external-dns - # at startup — exactly the zones the simply.com account DNS-hosts — so - # hostnames in other zones (platform.devantler.tech, *.platform.lan) are - # ignored without per-tenant configuration here. Which records get created - # is driven entirely by the tenants' own HTTPRoute hostnames. - # simply.com advertises rate limits only via X-RateLimit headers; poll - # gently — records here change ~never. - interval: 5m - extraArgs: - - --exclude-target-net=10.0.0.0/8 - # external-dns defaults to port 8888 for webhook providers; the chart's - # sidecar contract (containerPort + probes) is 8080 — see PORT above. - - --webhook-provider-url=http://localhost:8080 - deploymentStrategy: - type: Recreate - serviceMonitor: - enabled: false diff --git a/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/kustomization.yaml b/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/kustomization.yaml deleted file mode 100644 index eec1e7107..000000000 --- a/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/kustomization.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -# Second external-dns instance, for zones DNS-hosted at simply.com (an -# external-dns instance speaks exactly one provider, so multi-provider DNS -# means one instance per provider — the upstream-recommended pattern). It -# shares the `external-dns` namespace and HelmRepository with the Cloudflare -# instance in ../external-dns/. -resources: - - external-secret.yaml - - helm-release.yaml - - networkpolicy.yaml diff --git a/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/networkpolicy.yaml b/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/networkpolicy.yaml deleted file mode 100644 index e80dd2010..000000000 --- a/k8s/providers/hetzner/infrastructure/controllers/external-dns-simply/networkpolicy.yaml +++ /dev/null @@ -1,44 +0,0 @@ -apiVersion: cilium.io/v2 -kind: CiliumNetworkPolicy -metadata: - name: allow-external-dns-simply - namespace: external-dns -spec: - # Scoped to this instance's pods — the namespace-wide allow-external-dns - # policy in ../external-dns/ pins egress to api.cloudflare.com only, so the - # simply.com instance needs its own FQDN allowance. - endpointSelector: - matchLabels: - app.kubernetes.io/instance: external-dns-simply - egress: - # Kube API for watching HTTPRoutes/Gateways - - toEntities: - - kube-apiserver - # The webhook sidecar talks to simply.com for DNS record management. - # Pinned by FQDN rather than world:443 so a compromised pod cannot reach - # arbitrary external services. matchName is exact — the v2 API lives - # exclusively on api.simply.com today. - - toFQDNs: - - matchName: "api.simply.com" - toPorts: - - ports: - - port: "443" - protocol: TCP - # DNS resolution. rules.dns activates Cilium's L7 DNS proxy so the - # toFQDNs rule above can be enforced; the visibility list is restricted - # to the names this pod actually needs to resolve (see the rationale in - # ../external-dns/networkpolicy.yaml). - - toEndpoints: - - matchLabels: - k8s:io.kubernetes.pod.namespace: kube-system - k8s-app: kube-dns - toPorts: - - ports: - - port: "53" - protocol: UDP - - port: "53" - protocol: TCP - rules: - dns: - - matchName: "api.simply.com" - - matchPattern: "*.cluster.local" diff --git a/k8s/providers/hetzner/infrastructure/controllers/kustomization.yaml b/k8s/providers/hetzner/infrastructure/controllers/kustomization.yaml index 61e791a6a..ff0e40c97 100644 --- a/k8s/providers/hetzner/infrastructure/controllers/kustomization.yaml +++ b/k8s/providers/hetzner/infrastructure/controllers/kustomization.yaml @@ -14,10 +14,6 @@ resources: - cilium/ - coredns/ - external-dns/ - # Second external-dns instance for zones DNS-hosted at simply.com (one - # instance per provider) — drives tenant-owned domains like - # ascoachingogvaner.dk from the tenants' own HTTPRoute hostnames. - - external-dns-simply/ - flux-instance/ - hcloud-ccm/ - hcloud-csi/ diff --git a/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/external-secret.yaml b/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/external-secret.yaml index 106c27ca2..102a65ce4 100644 --- a/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/external-secret.yaml +++ b/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/external-secret.yaml @@ -12,14 +12,17 @@ spec: name: simply-credentials creationPolicy: Owner data: - # Same OpenBao entry as the external-dns-simply instance; the solver - # expects exactly these two secret keys (account-name/api-key) in the - # Secret named by the ClusterIssuer's config.secretName. + # Same OpenBao entry the tenant's own external-dns reads through its + # namespaced SecretStore — the credential is tenant-owned, so it lives + # under the tenant's apps/ path (covered here by app-ascoachingogvaner-readonly + # on the ClusterSecretStore role). The solver expects exactly these two + # secret keys (account-name/api-key) in the Secret named by the + # ClusterIssuer's config.secretName. - secretKey: account-name remoteRef: - key: infrastructure/dns/simply + key: apps/ascoachingogvaner/simply property: account_name - secretKey: api-key remoteRef: - key: infrastructure/dns/simply + key: apps/ascoachingogvaner/simply property: api_key From 4eee757def6f394f0a8816753332e9f07ab0fda2 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Wed, 10 Jun 2026 21:23:58 +0200 Subject: [PATCH 3/3] docs(tenants): document the tenant-owned external-dns registration files Co-Authored-By: Claude Fable 5 --- docs/TENANTS.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/TENANTS.md b/docs/TENANTS.md index 8e2c45087..f40d3da25 100644 --- a/docs/TENANTS.md +++ b/docs/TENANTS.md @@ -64,11 +64,15 @@ tenant ships a SOPS-encrypted Secret. ### App secrets (DB creds, API keys, … — only for tenants that need them) -A tenant gets a store + Vault role **only if it needs app secrets** — a static -site gets none. The **tenant owns its app secrets end-to-end**; the platform only -provisions the store + isolation and never seeds a tenant's app values. A value is -only ever introduced via a committed resource (a generator, or the tenant's own -push), **never written to OpenBao out of band**. +A tenant gets a store + Vault role **only if it needs app secrets** — a purely +static site with no integrations gets none. The **tenant owns its app secrets +end-to-end**; the platform only provisions the store + isolation and never seeds +a tenant's app values. A *generated* value is only ever introduced via a +committed resource (a generator, or the tenant's own push), never written to +OpenBao out of band; the one exception is an **externally-issued credential** +(a third-party API key, e.g. ascoachingogvaner's simply.com DNS credentials at +`apps/ascoachingogvaner/simply`), which the maintainer seeds into the tenant's +path once — there is nowhere to generate it from. - **Tenant (`deploy/`)** — own your secret end-to-end. Your `edit` RoleBinding aggregates `external-secrets-tenant-edit`, so you may create `Password` @@ -124,9 +128,10 @@ artifacts produced by that trusted workflow are ever reconciled onto the cluster ## 5. Register the tenant on the platform -Add `k8s/bases/apps//` — copy `ascoachingogvaner/` (a static tenant with -no app secrets) or `wedding-app/` (a tenant with app secrets + a namespaced -SecretStore) and rename — with: +Add `k8s/bases/apps//` — copy `wedding-app/` (a tenant with app secrets ++ a namespaced SecretStore) or `ascoachingogvaner/` (a static tenant that also +runs a **tenant-owned external-dns** for its custom domain, with the extra +`external-dns-*` grants below) and rename — with: | File | Purpose | |---|---| @@ -137,6 +142,8 @@ SecretStore) and rename — with: | `networkpolicy.yaml` | Cilium policy: ingress from the Gateway on the app port; egress DNS (+ CNPG/metrics if needed) | | `ghcr-auth-externalsecret.yaml` | OpenBao-backed `ExternalSecret` (shared `openbao` ClusterSecretStore, key `infrastructure/ghcr/auth`) producing the `ghcr-auth` pull secret | | `secretstore.yaml` | *Only if the tenant needs app secrets* — namespaced `SecretStore` (`kind: SecretStore`, name `openbao`) authenticating via the tenant's Vault role (mirror `wedding-app/`) | +| `external-dns-rbac.yaml` | *Only if the tenant runs its own external-dns for a tenant-owned domain* — binds the tenant's `external-dns` SA to the `tenant-external-dns(-global)` ClusterRoles (HTTPRoutes in its namespace, the shared Gateway in kube-system, namespaces) — mirror `ascoachingogvaner/` | +| `external-dns-networkpolicy.yaml` | *Same condition* — egress for the external-dns pods: kube-apiserver + the DNS provider's API, FQDN-pinned | | `sync.yaml` | `OCIRepository` (semver `>=1.0.0`, cosign `verify`) + `Kustomization` (prune, `serviceAccountName: `) | In `sync.yaml`, update the `name`/`namespace`/`url`