Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ceaea0a4d |
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user