diff --git a/docs/TENANTS.md b/docs/TENANTS.md index 8e2c45087..e0189fd11 100644 --- a/docs/TENANTS.md +++ b/docs/TENANTS.md @@ -64,11 +64,16 @@ 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. How a value gets *into* the tenant's path is the tenant's +business: **paste it straight into OpenBao** (the usual flow for +externally-issued credentials — e.g. ascoachingogvaner's simply.com DNS +credentials at `apps/ascoachingogvaner/simply`), or seed it from a committed +generator/`PushSecret`. The platform's contract is just two rules: nothing +sensitive sits in git in plaintext (whatever is committed is secure at rest), +and workloads consume the values **from OpenBao via `ExternalSecret`s**. - **Tenant (`deploy/`)** — own your secret end-to-end. Your `edit` RoleBinding aggregates `external-secrets-tenant-edit`, so you may create `Password` @@ -124,9 +129,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 +143,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` 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/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/kustomization.yaml b/k8s/providers/hetzner/infrastructure/controllers/kustomization.yaml index e67b35204..ff0e40c97 100644 --- a/k8s/providers/hetzner/infrastructure/controllers/kustomization.yaml +++ b/k8s/providers/hetzner/infrastructure/controllers/kustomization.yaml @@ -20,6 +20,8 @@ resources: - 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..102a65ce4 --- /dev/null +++ b/k8s/providers/hetzner/infrastructure/controllers/simply-dns-webhook/external-secret.yaml @@ -0,0 +1,28 @@ +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 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: apps/ascoachingogvaner/simply + property: account_name + - secretKey: api-key + remoteRef: + key: apps/ascoachingogvaner/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