matrix-synapse: strict schema, ingress gate and null-safe hardening

Three changes, across both workloads (Synapse StatefulSet and delegation
nginx Deployment).

1. values.schema.json (#68): additionalProperties: false on every
   chart-owned object level, including the whole matrix.delegation block
   with its own contexts, probes and resources.

2. Ingress gate (#93): the Ingress template is wrapped in
   {{- if .Values.ingress.enabled }} and the key is added to values.yaml
   with default true, so behaviour is unchanged.

   The domain checkpoint found a second reader: the delegation nginx
   serves ingress.domain in its /.well-known/matrix/{server,client}
   endpoints, and read it without a guard. Gating alone would therefore
   have published '{"m.server": ":443"}' and base_url "https://" - a
   federation-breaking answer that renders and starts cleanly. Both reads
   are now required(), so that case fails loudly. Only clusterIssuer
   actually becomes unnecessary when the Ingress is off.

3. Null-safety for hardening, probes and resources (#99) for both
   workloads. The rebuilt probes keep each workload's own endpoint:
   Synapse /health, delegation /.well-known/matrix/server - the endpoint
   that workload exists to serve - including its shorter 3s timeout. The
   delegation pod context keeps having no fsGroup, because it mounts no
   volume that needs one.

The nginx checksum/config annotation from #82 is untouched.

storage stays deliberately not null-safe: an erased block yields an
invalid PVC, which is the loud class #99 excludes.

Rendering is byte-identical to 6.0.0, both with the CI values (which
enable delegation) and with the production bundle values.
This commit is contained in:
jklein committed 2026-08-23 22:05:56 +02:00
1 parent 1ef8c7d42e
commit 1ddb5f457e
8 files changed
+484 -15

No files matched your search

+224
View File
@@ -114,3 +114,227 @@ template-chart is the reference implementation.
1
{{- end -}}
{{- end -}}
{{/*
Merge a user-supplied values sub-tree onto a dict of chart defaults so that a
null / empty override falls back to the defaults instead of erasing them.
Input: dict "defaults" <dict> "given" <dict|nil> "path" <string, optional
values path used in error messages>. Output: YAML of the merged dict (consume
it with fromYaml).
Why this exists (issue #99): Helm merges values maps recursively, but an
override key written without children — e.g. a leftover
securityContext:
after deleting its last remaining sub-key — is YAML null. Where the chart
defines a default, Helm treats that null as a deletion of the default; the
template then renders "securityContext: null": no readOnlyRootFilesystem, no
dropped capabilities, no allowPrivilegeEscalation: false. The workload starts
and reports healthy while being unhardened, so nothing points at the mistake.
The same shape erases a probe (it disappears) or the resources block (no
requests, no computed limits). This helper defines the opposite semantics: an
empty block means "chart defaults apply".
Rules:
- a nil "given" is treated as an empty dict (all defaults apply).
- a key whose override value is null keeps the default (nulls never delete).
- a key present in both as a map is merged recursively, so a partially
filled block only overrides the keys it actually sets.
- a scalar override for a key the defaults define as a map is rejected with
a message naming the values path, instead of failing later with a template
field error.
- any other set value wins as given, including an explicit false or 0 —
Helm's "default" and sprig's "mergeOverwrite" would both swallow those
zero values, hence the explicit kindIs "invalid" nil check.
- keys only present in the override are passed through unchanged.
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because charts
in this repo deliberately have no dependencies.
*/}}
{{- define "matrix-synapse.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
{{- $given := .given -}}
{{- $path := .path | default "value" -}}
{{- if kindIs "invalid" $given -}}
{{- $given = dict -}}
{{- end -}}
{{- if not (kindIs "map" $given) -}}
{{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}}
{{- end -}}
{{- $out := deepCopy $defaults -}}
{{- range $key, $value := $given -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $default := index $defaults $key -}}
{{- if and (kindIs "map" $value) (kindIs "map" $default) -}}
{{- $_ := set $out $key (fromYaml (include "matrix-synapse.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}}
{{- else if kindIs "map" $default -}}
{{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}}
{{- else -}}
{{- $_ := set $out $key $value -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- toYaml $out -}}
{{- end -}}
{{/*
Pick one sub-block out of a values block that may itself be null.
Input: dict "block" <dict|nil> "key" <string> "path" <string>.
Returns YAML of dict "value" <the sub-block, may be nil>, after rejecting a
non-mapping block with a message that names the values path. Needed because
".Values.matrix.securityContext" can itself be erased.
*/}}
{{- define "matrix-synapse.subBlock" -}}
{{- $block := .block -}}
{{- if kindIs "invalid" $block -}}
{{- $block = dict -}}
{{- end -}}
{{- if not (kindIs "map" $block) -}}
{{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}}
{{- end -}}
{{- toYaml (dict "value" (index $block .key)) -}}
{{- end -}}
{{/*
The effective security contexts, probes and resources of BOTH workloads
(issue #99) - the Synapse StatefulSet and the delegation nginx Deployment.
The defaults below MUST stay in sync with the corresponding blocks in
values.yaml, which stays the documented, user-facing place for them; this copy
is only the fallback for the case where an override has erased the block.
Synapse specifics a rebuilt default must not lose:
- uid/gid 1000 matches the previous gosu default, so existing PVC data stays
accessible. The upstream image only drops privileges itself when started as
root; with an explicit non-root runAsUser it runs directly as that user.
- the probes hit /health on the named "http" port and set scheme HTTP
explicitly.
- the startup budget is 10s x 30 = 300s (migrations, cache warmup).
Delegation nginx specifics:
- it runs as the image's unprivileged nginx user (uid 101) and its pod
context deliberately has NO fsGroup - it mounts no volume that needs one.
- its probes hit /.well-known/matrix/server, which is the endpoint the whole
workload exists for, with a shorter 3s timeout.
*/}}
{{- define "matrix-synapse.podSecurityContext" -}}
{{- $given := (fromYaml (include "matrix-synapse.subBlock" (dict "block" . "key" "pod" "path" "matrix.securityContext"))).value -}}
{{- $defaults := dict
"runAsNonRoot" true
"runAsUser" 1000
"runAsGroup" 1000
"fsGroup" 1000
"fsGroupChangePolicy" "Always" -}}
{{- include "matrix-synapse.defaultedDict" (dict "defaults" $defaults "given" $given "path" "matrix.securityContext.pod") -}}
{{- end -}}
{{- define "matrix-synapse.containerSecurityContext" -}}
{{- $given := (fromYaml (include "matrix-synapse.subBlock" (dict "block" . "key" "container" "path" "matrix.securityContext"))).value -}}
{{- $defaults := dict
"allowPrivilegeEscalation" false
"readOnlyRootFilesystem" true
"runAsNonRoot" true
"capabilities" (dict "drop" (list "ALL")) -}}
{{- include "matrix-synapse.defaultedDict" (dict "defaults" $defaults "given" $given "path" "matrix.securityContext.container") -}}
{{- end -}}
{{- define "matrix-synapse.probeHttpGet" -}}
{{- dict "path" "/health" "port" "http" "scheme" "HTTP" | toYaml -}}
{{- end -}}
{{- define "matrix-synapse.livenessProbe" -}}
{{- $defaults := dict
"httpGet" (fromYaml (include "matrix-synapse.probeHttpGet" .))
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "matrix-synapse.defaultedDict" (dict "defaults" $defaults "given" . "path" "matrix.livenessProbe") -}}
{{- end -}}
{{- define "matrix-synapse.readinessProbe" -}}
{{- $defaults := dict
"httpGet" (fromYaml (include "matrix-synapse.probeHttpGet" .))
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "matrix-synapse.defaultedDict" (dict "defaults" $defaults "given" . "path" "matrix.readinessProbe") -}}
{{- end -}}
{{- define "matrix-synapse.startupProbe" -}}
{{- $defaults := dict
"httpGet" (fromYaml (include "matrix-synapse.probeHttpGet" .))
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 30 -}}
{{- include "matrix-synapse.defaultedDict" (dict "defaults" $defaults "given" . "path" "matrix.startupProbe") -}}
{{- end -}}
{{- define "matrix-synapse.effectiveResources" -}}
{{- $defaults := dict
"limitFactor" 3
"requests" (dict "memory" "1Gi" "cpu" "1" "ephemeralStorage" "1Gi") -}}
{{- $merged := fromYaml (include "matrix-synapse.defaultedDict" (dict "defaults" $defaults "given" . "path" "matrix.resources")) -}}
{{- include "matrix-synapse.resources" $merged -}}
{{- end -}}
{{- define "matrix-synapse.delegation.podSecurityContext" -}}
{{- $given := (fromYaml (include "matrix-synapse.subBlock" (dict "block" . "key" "pod" "path" "matrix.delegation.securityContext"))).value -}}
{{- $defaults := dict
"runAsNonRoot" true
"runAsUser" 101
"runAsGroup" 101 -}}
{{- include "matrix-synapse.defaultedDict" (dict "defaults" $defaults "given" $given "path" "matrix.delegation.securityContext.pod") -}}
{{- end -}}
{{- define "matrix-synapse.delegation.containerSecurityContext" -}}
{{- $given := (fromYaml (include "matrix-synapse.subBlock" (dict "block" . "key" "container" "path" "matrix.delegation.securityContext"))).value -}}
{{- $defaults := dict
"allowPrivilegeEscalation" false
"readOnlyRootFilesystem" true
"runAsNonRoot" true
"capabilities" (dict "drop" (list "ALL")) -}}
{{- include "matrix-synapse.defaultedDict" (dict "defaults" $defaults "given" $given "path" "matrix.delegation.securityContext.container") -}}
{{- end -}}
{{- define "matrix-synapse.delegation.probeHttpGet" -}}
{{- dict "path" "/.well-known/matrix/server" "port" "http" "scheme" "HTTP" | toYaml -}}
{{- end -}}
{{- define "matrix-synapse.delegation.livenessProbe" -}}
{{- $defaults := dict
"httpGet" (fromYaml (include "matrix-synapse.delegation.probeHttpGet" .))
"periodSeconds" 10
"timeoutSeconds" 3
"failureThreshold" 3 -}}
{{- include "matrix-synapse.defaultedDict" (dict "defaults" $defaults "given" . "path" "matrix.delegation.livenessProbe") -}}
{{- end -}}
{{- define "matrix-synapse.delegation.readinessProbe" -}}
{{- $defaults := dict
"httpGet" (fromYaml (include "matrix-synapse.delegation.probeHttpGet" .))
"periodSeconds" 10
"timeoutSeconds" 3
"failureThreshold" 3 -}}
{{- include "matrix-synapse.defaultedDict" (dict "defaults" $defaults "given" . "path" "matrix.delegation.readinessProbe") -}}
{{- end -}}
{{- define "matrix-synapse.delegation.startupProbe" -}}
{{- $defaults := dict
"httpGet" (fromYaml (include "matrix-synapse.delegation.probeHttpGet" .))
"periodSeconds" 5
"timeoutSeconds" 3
"failureThreshold" 30 -}}
{{- include "matrix-synapse.defaultedDict" (dict "defaults" $defaults "given" . "path" "matrix.delegation.startupProbe") -}}
{{- end -}}
{{- define "matrix-synapse.delegation.effectiveResources" -}}
{{- $defaults := dict
"limitFactor" 3
"requests" (dict "memory" "64Mi" "cpu" "125m" "ephemeralStorage" "50Mi") -}}
{{- $merged := fromYaml (include "matrix-synapse.defaultedDict" (dict "defaults" $defaults "given" . "path" "matrix.delegation.resources")) -}}
{{- include "matrix-synapse.resources" $merged -}}
{{- end -}}
@@ -26,12 +26,12 @@ data:
listen 8080;
location /.well-known/matrix/server {
return 200 '{"m.server": "{{ .Values.ingress.domain }}:443"}';
return 200 '{"m.server": "{{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}:443"}';
add_header Content-Type application/json;
}
location /.well-known/matrix/client {
return 200 '{"m.homeserver": {"base_url": "https://{{ .Values.ingress.domain }}"}}';
return 200 '{"m.homeserver": {"base_url": "https://{{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}"}}';
add_header Content-Type application/json;
add_header "Access-Control-Allow-Origin" *;
}
@@ -24,12 +24,12 @@ spec:
spec:
automountServiceAccountToken: false
securityContext:
{{- toYaml .Values.matrix.delegation.securityContext.pod | nindent 8 }}
{{- include "matrix-synapse.delegation.podSecurityContext" .Values.matrix.delegation.securityContext | nindent 8 }}
containers:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-delegation-nginx
image: "{{ .Values.matrix.delegation.image.repository }}:{{ .Values.matrix.delegation.image.tag }}"
securityContext:
{{- toYaml .Values.matrix.delegation.securityContext.container | nindent 12 }}
{{- include "matrix-synapse.delegation.containerSecurityContext" .Values.matrix.delegation.securityContext | nindent 12 }}
ports:
- containerPort: 8080
name: http
@@ -43,13 +43,13 @@ spec:
- name: tmp
mountPath: /tmp
resources:
{{- include "matrix-synapse.resources" .Values.matrix.delegation.resources | nindent 12 }}
{{- include "matrix-synapse.delegation.effectiveResources" .Values.matrix.delegation.resources | nindent 12 }}
readinessProbe:
{{- toYaml .Values.matrix.delegation.readinessProbe | nindent 12 }}
{{- include "matrix-synapse.delegation.readinessProbe" .Values.matrix.delegation.readinessProbe | nindent 12 }}
livenessProbe:
{{- toYaml .Values.matrix.delegation.livenessProbe | nindent 12 }}
{{- include "matrix-synapse.delegation.livenessProbe" .Values.matrix.delegation.livenessProbe | nindent 12 }}
startupProbe:
{{- toYaml .Values.matrix.delegation.startupProbe | nindent 12 }}
{{- include "matrix-synapse.delegation.startupProbe" .Values.matrix.delegation.startupProbe | nindent 12 }}
volumes:
- name: tmp
emptyDir: {}
@@ -1,3 +1,4 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -41,3 +42,4 @@ spec:
- hosts:
- {{ required "A Domain/Host must be provided" .Values.ingress.domain }}
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
{{- end }}
@@ -18,12 +18,12 @@ spec:
spec:
automountServiceAccountToken: false
securityContext:
{{- toYaml .Values.matrix.securityContext.pod | nindent 8 }}
{{- include "matrix-synapse.podSecurityContext" .Values.matrix.securityContext | nindent 8 }}
containers:
- name: "{{ .Release.Name }}-{{ .Chart.Name }}-container"
image: "ghcr.io/element-hq/synapse:{{ .Chart.AppVersion }}"
securityContext:
{{- toYaml .Values.matrix.securityContext.container | nindent 12 }}
{{- include "matrix-synapse.containerSecurityContext" .Values.matrix.securityContext | nindent 12 }}
env:
- name: SYNAPSE_SERVER_NAME
value: "{{ required "You must provide a Server-Name" .Values.matrix.serverName }}"
@@ -56,13 +56,13 @@ spec:
containerPort: 8008
protocol: TCP
readinessProbe:
{{- toYaml .Values.matrix.readinessProbe | nindent 12 }}
{{- include "matrix-synapse.readinessProbe" .Values.matrix.readinessProbe | nindent 12 }}
livenessProbe:
{{- toYaml .Values.matrix.livenessProbe | nindent 12 }}
{{- include "matrix-synapse.livenessProbe" .Values.matrix.livenessProbe | nindent 12 }}
startupProbe:
{{- toYaml .Values.matrix.startupProbe | nindent 12 }}
{{- include "matrix-synapse.startupProbe" .Values.matrix.startupProbe | nindent 12 }}
resources:
{{- include "matrix-synapse.resources" .Values.matrix.resources | nindent 12 }}
{{- include "matrix-synapse.effectiveResources" .Values.matrix.resources | nindent 12 }}
volumeMounts:
- name: matrix-synapse-volume
mountPath: /data