Compare commits

...
Author SHA1 Message Date
atlas-botandClaude Opus 5.5 7ceaea0a4d feat(home-assistant): CronJob puts the MQTT client certificate into HA's config entry (REN-953)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Owner on REN-1000: no sidecar, configure HA's MQTT itself. HA takes MQTT
client certificates only through the reconfigure flow with file uploads, so
a nightly CronJob in the HA window runs that flow over the REST API with the
Secret mqtt-home-assistant-tls whenever tls.crt changed (marker ConfigMap).
HA tests the connection before saving; a failure fails the Job and keeps the
old entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 04:31:32 +00:00
3 changed files with 349 additions and 0 deletions
@@ -0,0 +1,165 @@
"""Point Home Assistant's MQTT config entry at the mTLS listener with the
current client certificate (REN-953).
HA keeps the CA, client certificate and key as PEM inside its MQTT config
entry and accepts new ones only through the config flow with file uploads.
This job runs that reconfigure flow with the files of the Secret
mqtt-home-assistant-tls (cert-manager renews it, rotationPolicy Always).
It acts only when tls.crt changed since the last successful run: the sha256
of tls.crt is kept in the ConfigMap ha-mqtt-cert-sync-state. HA tests the
connection before it saves; anything but `reconfigure_successful` fails the
job and leaves the marker untouched, so the next run retries.
Python standard library only. Never prints certificate, key or token.
"""
import hashlib
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
import uuid
HA_URL = os.environ["HA_URL"].rstrip("/")
ENTRY_ID = os.environ["ENTRY_ID"]
BROKER = os.environ["BROKER"]
PORT = int(os.environ["PORT"])
TLS_DIR = os.environ.get("TLS_DIR", "/mqtt-tls")
TOKEN = open(os.environ.get("TOKEN_FILE", "/ha-token/token")).read().strip()
STATE_CM = os.environ["STATE_CONFIGMAP"]
FORCE = os.environ.get("FORCE", "") == "1"
SA = os.environ.get("SA_DIR", "/var/run/secrets/kubernetes.io/serviceaccount")
K8S = os.environ.get("K8S_API", "https://kubernetes.default.svc")
def log(msg):
print(f"ha-mqtt-cert-sync: {msg}", flush=True)
def k8s(method, path, body=None, content_type="application/json"):
ctx = ssl.create_default_context(cafile=f"{SA}/ca.crt") if K8S.startswith("https") else None
with open(f"{SA}/token") as f:
sa_token = f.read().strip()
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(K8S + path, data=data, method=method)
req.add_header("Authorization", f"Bearer {sa_token}")
if data is not None:
req.add_header("Content-Type", content_type)
with urllib.request.urlopen(req, context=ctx, timeout=30) as resp:
return json.load(resp)
def ha(method, path, body=None, raw=None, content_type=None):
req = urllib.request.Request(HA_URL + path, method=method)
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
raw = json.dumps(body).encode()
content_type = "application/json"
if raw is not None:
req.data = raw
req.add_header("Content-Type", content_type)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.load(resp)
except urllib.error.HTTPError as err:
# The body of an HA error is a short message, never a secret.
raise SystemExit(f"HA {method} {path} -> HTTP {err.code}: {err.read()[:300]!r}")
def upload(name):
"""POST /api/file_upload (multipart, field `file`) -> file_id."""
with open(os.path.join(TLS_DIR, name), "rb") as f:
content = f.read()
boundary = uuid.uuid4().hex
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="{name}"\r\n'
"Content-Type: application/octet-stream\r\n\r\n"
).encode() + content + f"\r\n--{boundary}--\r\n".encode()
return ha("POST", "/api/file_upload", raw=body,
content_type=f"multipart/form-data; boundary={boundary}")["file_id"]
def suggested(schema, name):
"""Suggested value of a field in a serialized flow form, if any."""
for field in schema:
if field.get("name") == name:
return (field.get("description") or {}).get("suggested_value")
return None
def section(schema, name):
for field in schema:
if field.get("name") == name:
return field.get("schema", [])
return []
def main():
with open(os.path.join(TLS_DIR, "tls.crt"), "rb") as f:
digest = hashlib.sha256(f.read()).hexdigest()
cm = k8s("GET", f"/api/v1/namespaces/{os.environ['POD_NAMESPACE']}/configmaps/{STATE_CM}")
applied = (cm.get("data") or {}).get("applied-tls-crt-sha256", "")
if applied == digest and not FORCE:
log(f"certificate unchanged ({digest[:12]}), nothing to do")
return
log(f"certificate {digest[:12]} not yet in HA (last applied {applied[:12] or 'none'}), reconfiguring")
flow = ha("POST", "/api/config/config_entries/flow",
{"handler": "mqtt", "entry_id": ENTRY_ID, "show_advanced_options": True})
if flow.get("type") != "form" or flow.get("step_id") != "broker":
raise SystemExit(f"unexpected first flow step: type={flow.get('type')} step={flow.get('step_id')}")
schema = flow.get("data_schema", [])
other = section(schema, "other_settings")
other_settings = {
"set_ca_cert": "custom",
"certificate": upload("ca.crt"),
"set_client_cert": True,
"client_cert": upload("tls.crt"),
"client_key": upload("tls.key"),
"tls_insecure": False,
"transport": "tcp",
}
# Keep what the entry already has for the fields this job does not own.
for key in ("client_id", "keepalive"):
if (value := suggested(other, key)) not in (None, ""):
other_settings[key] = value
user_input = {
"broker": BROKER,
"port": PORT,
"protocol": suggested(schema, "protocol") or "5",
"other_settings": other_settings,
}
# No username/password: the broker takes the identity from the CN.
result = ha("POST", f"/api/config/config_entries/flow/{flow['flow_id']}", user_input)
if result.get("type") != "abort" or result.get("reason") != "reconfigure_successful":
try:
ha("DELETE", f"/api/config/config_entries/flow/{flow['flow_id']}")
except SystemExit:
pass
raise SystemExit(
f"reconfigure failed: type={result.get('type')} "
f"reason={result.get('reason')} errors={result.get('errors')}"
)
log(f"HA accepted the entry: {BROKER}:{PORT}, CA mqtt-ca, client certificate {digest[:12]}")
k8s("PATCH", f"/api/v1/namespaces/{os.environ['POD_NAMESPACE']}/configmaps/{STATE_CM}",
{"data": {"applied-tls-crt-sha256": digest}},
content_type="application/merge-patch+json")
log("marker updated")
if __name__ == "__main__":
try:
main()
except SystemExit as err:
if err.code not in (None, 0):
log(f"FAILED: {err.code}")
sys.exit(1)
@@ -0,0 +1,175 @@
# Home Assistant's MQTT config entry logs in with the client certificate of the
# register account `home-assistant` (REN-953, owner on REN-1000: no sidecar,
# configure HA's MQTT itself).
#
# HA keeps the CA, client certificate and key as PEM inside the config entry
# and takes new ones only through the reconfigure flow with file uploads
# (HA 2026.9.4: components/mqtt/config_flow.py, components/file_upload). This
# CronJob runs that flow with the Secret mqtt-home-assistant-tls whenever
# tls.crt changed since the last success (sha256 in ConfigMap
# ha-mqtt-cert-sync-state). cert-manager renews the certificate ~30 days
# before expiry; the next nightly run applies it, long before the old one
# expires. HA tests the connection before it saves the entry, so a bad
# certificate fails the Job and leaves HA on the previous one.
#
# Scheduled inside the HA window (02:00-05:00 Europe/Berlin): the reconfigure
# on 2026-10-08 04:17Z was followed by a ~90 s HA core restart of unknown cause.
#
# Token: an HA admin long-lived access token (config flows need admin) in
# OpenBao apps/home-assistant/mqtt-cert-sync-token, key `token`.
#
# Manual run: kubectl -n home-assistant create job --from=cronjob/ha-mqtt-cert-sync ha-mqtt-cert-sync-manual
# (set FORCE=1 in the Job to reapply an unchanged certificate).
apiVersion: v1
kind: ServiceAccount
metadata:
name: ha-mqtt-cert-sync
namespace: home-assistant
labels:
app.kubernetes.io/name: ha-mqtt-cert-sync
automountServiceAccountToken: false
---
# Read and write its own marker, nothing else.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ha-mqtt-cert-sync
namespace: home-assistant
labels:
app.kubernetes.io/name: ha-mqtt-cert-sync
rules:
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["ha-mqtt-cert-sync-state"]
verbs: ["get", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ha-mqtt-cert-sync
namespace: home-assistant
labels:
app.kubernetes.io/name: ha-mqtt-cert-sync
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: ha-mqtt-cert-sync
subjects:
- kind: ServiceAccount
name: ha-mqtt-cert-sync
namespace: home-assistant
---
# Marker of the last applied certificate. No `data` in git on purpose: the
# Job owns that field, and Flux must not reset it.
apiVersion: v1
kind: ConfigMap
metadata:
name: ha-mqtt-cert-sync-state
namespace: home-assistant
labels:
app.kubernetes.io/name: ha-mqtt-cert-sync
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: ha-mqtt-cert-sync-token
namespace: home-assistant
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: ha-mqtt-cert-sync-token
data:
- secretKey: token
remoteRef:
key: apps/home-assistant/mqtt-cert-sync-token
property: token
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: ha-mqtt-cert-sync
namespace: home-assistant
labels:
app.kubernetes.io/name: ha-mqtt-cert-sync
spec:
schedule: "30 2 * * *"
timeZone: Europe/Berlin
concurrencyPolicy: Forbid
startingDeadlineSeconds: 3600
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 0
activeDeadlineSeconds: 300
template:
metadata:
labels:
app.kubernetes.io/name: ha-mqtt-cert-sync
spec:
serviceAccountName: ha-mqtt-cert-sync
automountServiceAccountToken: true
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: sync
image: python:3.13-alpine@sha256:2d9aefe2fef018a7eb2c13064c89c71929800fd2e5dccdbf52ea5da5bb8d929a
command: ["python3", "/script/sync.py"]
env:
# HA itself (:8123), not the :8124 XFF proxy; same Service as
# the token rotators.
- name: HA_URL
value: http://home-assistant-bgp.home-assistant.svc.cluster.local:8123
- name: ENTRY_ID
value: 01KRFXD4046FEBWQ0EX0GNE418
- name: BROKER
value: mosquitto.home-assistant.svc.cluster.local
- name: PORT
value: "8884"
- name: STATE_CONFIGMAP
value: ha-mqtt-cert-sync-state
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: script
mountPath: /script
readOnly: true
- name: mqtt-tls
mountPath: /mqtt-tls
readOnly: true
- name: ha-token
mountPath: /ha-token
readOnly: true
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
volumes:
- name: script
configMap:
name: ha-mqtt-cert-sync-script
- name: mqtt-tls
secret:
secretName: mqtt-home-assistant-tls
defaultMode: 0444
- name: ha-token
secret:
secretName: ha-mqtt-cert-sync-token
defaultMode: 0444
@@ -72,7 +72,16 @@ resources:
# Register: mqtt-accounts.yaml (configMapGenerator).
- mosquitto-dynsec-test.yaml
- mqtt-account-controller.yaml
# Puts the home-assistant client certificate into HA's MQTT config entry
# (REN-953); script: ha-mqtt-cert-sync.py (configMapGenerator).
- ha-mqtt-cert-sync.yaml
configMapGenerator:
# ha-mqtt-cert-sync CronJob script. Read at each Job start, so an edit
# takes effect on the next run.
- name: ha-mqtt-cert-sync-script
namespace: home-assistant
files:
- sync.py=ha-mqtt-cert-sync.py
# nginx config for the reverse-proxy sidecar. Hash suffix disabled so
# the cm name stays stable; editing proxy.conf does not auto-roll the
# pod — restart manually with