|
|
|
@@ -0,0 +1,179 @@
|
|
|
|
|
# Proposal: enrol the mosquitto broker credentials in Class-A rotation
|
|
|
|
|
|
|
|
|
|
**Status:** proposal, nothing implemented.
|
|
|
|
|
**Scope:** the 8 MQTT broker users and the credentials behind them.
|
|
|
|
|
|
|
|
|
|
## Problem
|
|
|
|
|
|
|
|
|
|
Every self-generated secret in this cluster rotates automatically except these.
|
|
|
|
|
`kubernetes/infrastructure/openbao/rotation/` holds 13 manifests defining 15
|
|
|
|
|
rotator CronJobs (`home-assistant.yaml` alone defines three). Five are **Class-A**
|
|
|
|
|
— authentik, crowdsec, paperless, shelly-manager and woodpecker — meaning
|
|
|
|
|
"self-generated random secret", which is exactly what a broker password is.
|
|
|
|
|
The mosquitto credentials are the only Class-A-shaped secrets not enrolled.
|
|
|
|
|
|
|
|
|
|
They are currently minted once by seven near-identical bootstrap Jobs
|
|
|
|
|
(`mosquitto-bootstrap.yaml`, 508 lines — the ServiceAccount, Role and RoleBinding
|
|
|
|
|
are inside that same file) which generate a password, hand-build Secret JSON with
|
|
|
|
|
`jq` and POST it to the Kubernetes API with `curl`. Once created they never
|
|
|
|
|
change again.
|
|
|
|
|
|
|
|
|
|
Three consequences:
|
|
|
|
|
|
|
|
|
|
1. **They never rotate.** Not on a schedule, not on compromise, not ever.
|
|
|
|
|
2. **They exist only in the Secret object** — not in OpenBao, so not covered by
|
|
|
|
|
the OpenBao snapshot CronJob, and unrecoverable if lost.
|
|
|
|
|
3. **The Jobs cannot be modified.** A Job's `spec.template` is immutable, so
|
|
|
|
|
changing even the container image makes the whole `home-assistant`
|
|
|
|
|
Kustomization fail to reconcile with `field is immutable`. Any edit requires
|
|
|
|
|
deleting or renaming the Jobs.
|
|
|
|
|
|
|
|
|
|
## Why the obvious fixes do not work
|
|
|
|
|
|
|
|
|
|
Recorded because each was tried and rejected on evidence, and the reasons are
|
|
|
|
|
not obvious from the code.
|
|
|
|
|
|
|
|
|
|
| Approach | Why it fails |
|
|
|
|
|
| --- | --- |
|
|
|
|
|
| ExternalSecrets reading hand-seeded OpenBao paths | Depends on content copied in by hand. Git cannot recreate it; a fresh OpenBao leaves the paths empty and ESO then overwrites live broker credentials with nothing. |
|
|
|
|
|
| ESO `Password` generator | **Overwrites pre-existing Secrets.** Verified: a Secret holding `KNOWN-PREEXISTING-VALUE` was replaced with a freshly generated password on first sync. Adopting it rotates the 7 Job-minted credentials at once, uncoordinated (`evcc` is already ESO-extract-backed and so unaffected). |
|
|
|
|
|
| Keep the Jobs, fix only the image | Blocked by `spec.template` immutability. Renaming the Jobs works but is a hack that recurs on every future edit. |
|
|
|
|
|
|
|
|
|
|
The common thread: each treats the credential as something to *place*, when the
|
|
|
|
|
real requirement is a credential that can be *replaced*.
|
|
|
|
|
|
|
|
|
|
## How rotation actually has to work here
|
|
|
|
|
|
|
|
|
|
The broker rebuilds its password file from scratch on **every pod start**
|
|
|
|
|
(`mosquitto-deployment.yaml`): `rm -f /mosquitto/auth/passwd` followed by
|
|
|
|
|
`mosquitto_passwd -c -b …` for each of the 8 users, sourced from env vars backed
|
|
|
|
|
by the 8 Secrets.
|
|
|
|
|
|
|
|
|
|
Two things follow, and they shape the whole design:
|
|
|
|
|
|
|
|
|
|
- **A Secret change has no effect until the broker restarts.** There is no
|
|
|
|
|
Reloader annotation on the mosquitto Deployment today, unlike the consumers of
|
|
|
|
|
the existing Class-A rotators.
|
|
|
|
|
- **There is no make-before-break.** A mosquitto user has exactly one password.
|
|
|
|
|
The LLAT rotators can mint a second token and revoke the first afterwards; a
|
|
|
|
|
broker user cannot hold old and new simultaneously. Unless a transitional
|
|
|
|
|
second user is created, broker and client must cut over together.
|
|
|
|
|
|
|
|
|
|
### Who holds each credential
|
|
|
|
|
|
|
|
|
|
This is the part that determines cutover cost, and it is not visible from the
|
|
|
|
|
manifests alone.
|
|
|
|
|
|
|
|
|
|
| Broker user | Held by | Cutover |
|
|
|
|
|
| --- | --- | --- |
|
|
|
|
|
| `health` | the broker's own liveness/readiness probes | in-cluster, same pod restart |
|
|
|
|
|
| `zigbee2mqtt` | zigbee2mqtt Deployment (`secretKeyRef`) | in-cluster, pod restart |
|
|
|
|
|
| `evcc` | evcc app; already OpenBao-backed via ESO | in-cluster, pod restart |
|
|
|
|
|
| `sml2mqtt` | edge host, from `vault_edge_sml2mqtt_mqtt_password` | re-run the edge-host play |
|
|
|
|
|
| `open3e` | edge host, from `vault_edge_open3e_mqtt_password` | re-run the edge-host play |
|
|
|
|
|
| `homeassistant` | HA MQTT integration — a config entry created in the UI | needs an API/WebSocket update, or a manual re-entry |
|
|
|
|
|
| `shellies` | Shelly device firmware | pushed per-device; `shelly-manager` is deployed and is the natural mechanism |
|
|
|
|
|
| `admin` | a workstation client, held by a human | read the new value from OpenBao |
|
|
|
|
|
|
|
|
|
|
Note `sml2mqtt` and `open3e` currently keep **the same password in two unlinked
|
|
|
|
|
places** — the Kubernetes Secret for the broker, and `ansible-vault` for the
|
|
|
|
|
client — synchronised by hand. That duplication, not any client limitation, is
|
|
|
|
|
what makes rotation feel impossible today.
|
|
|
|
|
|
|
|
|
|
## Proposed design
|
|
|
|
|
|
|
|
|
|
Follow the existing Class-A pattern exactly (`rotation/paperless.yaml` is the
|
|
|
|
|
closest model): *generate random bytes → merge-patch into OpenBao → ESO syncs the
|
|
|
|
|
Secret → restart the consumer*.
|
|
|
|
|
|
|
|
|
|
1. **One rotator CronJob per broker user**, running the existing `secret-rotator`
|
|
|
|
|
binary in random mode, writing `apps/home-assistant/mosquitto-<user>-credentials`
|
|
|
|
|
in OpenBao. Reuses the established policy/role/SA pattern; no new machinery.
|
|
|
|
|
2. **ExternalSecrets** project each OpenBao entry to the existing Secret name and
|
|
|
|
|
key, so `mosquitto-deployment.yaml` and the in-cluster consumers are unchanged.
|
|
|
|
|
Key names stay as they are (`homeassistant-password` on one, `password` on the
|
|
|
|
|
rest) — consumers reference them, so normalising is a separate change.
|
|
|
|
|
3. **Add a Reloader annotation to the mosquitto Deployment** so a Secret change
|
|
|
|
|
restarts the broker, instead of silently taking effect at some unrelated future
|
|
|
|
|
restart.
|
|
|
|
|
4. **Move the edge host off `ansible-vault` for these two values**, reading the
|
|
|
|
|
same OpenBao entry instead, so there is one source of truth per credential.
|
|
|
|
|
5. **Rotate one user at a time.** Each rotation then affects exactly one client,
|
|
|
|
|
not all eight.
|
|
|
|
|
|
|
|
|
|
### The cutover window
|
|
|
|
|
|
|
|
|
|
With no make-before-break, each rotation has a window where broker and client
|
|
|
|
|
disagree. Two options:
|
|
|
|
|
|
|
|
|
|
- **Accept a brief window** (recommended for most users). Order: rotator writes
|
|
|
|
|
OpenBao → client picks up the new value → broker restarts. MQTT clients
|
|
|
|
|
reconnect automatically; the window is seconds. Because rotation is per-user,
|
|
|
|
|
only that one client is affected.
|
|
|
|
|
|
|
|
|
|
One failure mode to expect rather than be alarmed by: the broker rolls with
|
|
|
|
|
`maxUnavailable: 0` / `maxSurge: 1`, so mid-rollout **two broker pods with
|
|
|
|
|
different passwd files sit behind the LB**. A client reconnecting during that
|
|
|
|
|
window can land on the old pod and fail auth until the rollout completes. It is
|
|
|
|
|
self-healing and lasts seconds to a minute, but it will look alarming in logs
|
|
|
|
|
during the first `health` / `zigbee2mqtt` rotation, so it is worth knowing in
|
|
|
|
|
advance.
|
|
|
|
|
- **Transitional dual user** (`<user>` and `<user>-next` in the passwd file and
|
|
|
|
|
ACL) for any credential where even a brief outage is unacceptable. More moving
|
|
|
|
|
parts; probably only worth it for `homeassistant`, which carries everything.
|
|
|
|
|
|
|
|
|
|
## Suggested phasing
|
|
|
|
|
|
|
|
|
|
Ordered so the risky work happens after the pattern is proven, and so each phase
|
|
|
|
|
is independently valuable.
|
|
|
|
|
|
|
|
|
|
1. **`health` and `zigbee2mqtt` first.** Both are entirely in-cluster and pick up
|
|
|
|
|
a new password on pod restart with no external coordination. Proves rotator →
|
|
|
|
|
OpenBao → ESO → Reloader end to end with the smallest blast radius. `health`
|
|
|
|
|
is the safest possible start: only the broker's own probes consume it.
|
|
|
|
|
2. **`evcc`.** Already OpenBao-backed via ESO; only the rotator is missing.
|
|
|
|
|
3. **`sml2mqtt` and `open3e`**, together with switching the edge host to read from
|
|
|
|
|
OpenBao. This is where the duplicate-source-of-truth problem gets fixed.
|
|
|
|
|
4. **`shellies`**, via shelly-manager pushing to devices. Needs investigation:
|
|
|
|
|
shelly-manager has its own Class-A rotator today, but that rotates
|
|
|
|
|
shelly-manager's own secret and does not touch device MQTT credentials.
|
|
|
|
|
5. **`homeassistant`** last. Highest blast radius, and the config entry is UI-created,
|
|
|
|
|
so it needs an API/WebSocket update path — the HA LLAT rotators
|
|
|
|
|
(`rotation/home-assistant.yaml`) already talk to HA over WebSocket and are the
|
|
|
|
|
obvious precedent.
|
|
|
|
|
6. **`admin`** whenever convenient; a human reads the new value from OpenBao.
|
|
|
|
|
|
|
|
|
|
Only once a user is enrolled does its bootstrap Job become dead code. Delete the
|
|
|
|
|
Jobs incrementally as their users migrate, rather than in one step — that also
|
|
|
|
|
avoids the immutability problem entirely, since the Jobs are deleted, never
|
|
|
|
|
patched.
|
|
|
|
|
|
|
|
|
|
## Open questions
|
|
|
|
|
|
|
|
|
|
- **Does `secret-rotator` random mode fit unchanged?** The existing rotators write
|
|
|
|
|
a field into an OpenBao entry; here the entry holds a single password field, so
|
|
|
|
|
it should, but this needs confirming against the binary rather than assumed.
|
|
|
|
|
- **Can shelly-manager push MQTT credentials to devices**, and does it cover both
|
|
|
|
|
Gen1 (`/settings/mqtt`) and Gen2+ (`Mqtt.SetConfig`) firmwares? If not, the
|
|
|
|
|
`shellies` user may need a different mechanism or stay manual longest.
|
|
|
|
|
- **How is the HA MQTT config entry updated programmatically?** Reusing the
|
|
|
|
|
WebSocket approach from the LLAT rotator is plausible but unverified.
|
|
|
|
|
- **Rotation cadence.** The existing rotators' schedules should be the reference
|
|
|
|
|
rather than inventing a new one.
|
|
|
|
|
- **ESO refresh latency.** Rotator-writes-OpenBao → Secret-updates →
|
|
|
|
|
Reloader-restarts is delayed by up to one `refreshInterval`; the `evcc`
|
|
|
|
|
ExternalSecret uses `1h`. That does not widen the outage window — broker and
|
|
|
|
|
client still move together — but it does stretch how long a rotation takes to
|
|
|
|
|
land, which matters for verifying one completed and for the cold-rebuild
|
|
|
|
|
ordering question above. Worth deciding whether these ExternalSecrets want a
|
|
|
|
|
shorter interval than 1h, or whether the rotator should trigger the sync
|
|
|
|
|
directly.
|
|
|
|
|
- **Bootstrap on a cold rebuild.** On a fresh cluster the rotator must create the
|
|
|
|
|
OpenBao entry before ESO can sync it; check this against
|
|
|
|
|
`kubernetes/RUNBOOK-cold-rebuild.md` so broker bring-up is not blocked.
|
|
|
|
|
|
|
|
|
|
## What this does not change
|
|
|
|
|
|
|
|
|
|
- Secret names, key names and every `secretKeyRef` in `mosquitto-deployment.yaml`.
|
|
|
|
|
- The ACL model and the per-user topic scoping.
|
|
|
|
|
- The `evcc` ExternalSecret, which already follows the target pattern.
|