Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5 3d905416bc docs(mosquitto): flag the new Secret ownership coupling; drop "bootstrap" name
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review points on #450, both worth acting on.

Document the coupling ESO introduces that the Jobs did not have. With
creationPolicy implicitly Owner, ESO sets an ownerReference from each
Secret to its ExternalSecret; combined with the Kustomization's
prune: true, deleting an ExternalSecret block now DELETES THE LIVE SECRET
and with it that client's broker access. The old Jobs created Secrets with
no ownerReference, so removing a Job left its Secret untouched — that
safety net is gone, and the file now says so, including the
creationPolicy: Orphan escape hatch for detaching a credential without
losing it.

Rename mosquitto-bootstrap-credentials-externalsecrets.yaml to
mosquitto-credentials-externalsecrets.yaml. "bootstrap" described the Jobs
that no longer exist; these are steady-state. git tracks the rename, so the
archaeology the old name preserved is not lost.

Also re-verified, a second time and independently, that every OpenBao entry
still holds the live password — the reviewer correctly noted this PR's
safety rests on a fact the diff cannot show:

  mosquitto-credentials              homeassistant-password  8bbb508e  MATCH
  mosquitto-admin-credentials        password                cdd5d74d  MATCH
  mosquitto-health-credentials       password                23c10b3b  MATCH
  mosquitto-open3e-credentials       password                df67a98e  MATCH
  mosquitto-shellies-credentials     password                5ae6f4e4  MATCH
  mosquitto-sml2mqtt-credentials     password                aa13ce52  MATCH
  mosquitto-zigbee2mqtt-credentials  password                e440d3ae  MATCH

Left the wording overlap between this file's target.name comment and the
sibling evcc file alone: harmonising it would touch a file this PR
otherwise does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:14:04 +02:00
claude-botandClaude Opus 5 b24442ab71 refactor(mosquitto): move broker credentials to OpenBao, delete bootstrap Jobs
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Replaces mosquitto-bootstrap.yaml (518 lines: 7 Jobs, a ServiceAccount, a
Role and a RoleBinding) with 7 ExternalSecrets, matching the pattern
mosquitto-evcc-credentials already used in the same directory.

Each Job was ~70 lines of shell that generated a password, base64'd it,
hand-built Secret JSON with jq and POSTed it to the Kubernetes API with
curl — needing RBAC and an `apk add curl jq` on every run. That is 518
lines to do what 14 declarative lines do here.

It also could not be maintained: a Job's spec.template is immutable, so
merely changing the container image made the whole home-assistant
Kustomization fail to reconcile with "field is immutable" — which is what
killed #449. Deleting the Jobs is the only way to change them, and since
the Kustomization has prune: true, removing them from git does exactly
that rather than attempting a rejected patch.

The passwords also only ever existed in the Secret objects: not in OpenBao,
so not backed up, not centrally rotatable, and unrecoverable if a Secret
were lost. They now live alongside every other credential in this
namespace.

MIGRATION IS VALUE-PRESERVING. The live passwords were copied into OpenBao
first, each round-trip verified by sha256 — the broker has already hashed
them (mosquitto_passwd -U at startup) and every client is using them, so
generating fresh ones would have broken Home Assistant, zigbee2mqtt,
shellies, sml2mqtt, open3e and the health check at once.

Verified on the least critical credential before committing, since ESO
adopting a Secret it did not create was the one real unknown:

  ExternalSecret -> SecretSynced / Ready=True
  password hash  -> 3a145b6cc97f before, 3a145b6cc97f after (unchanged)
  labels         -> reconcile.external-secrets.io/managed=true
  ownerReferences-> ExternalSecret

So ESO adopts the existing Secret and rewrites it byte-identically;
consumers observe nothing. The Secrets carry no ownerReferences from the
Jobs, so pruning the Jobs cannot garbage-collect them.

Key names are deliberately NOT normalised: mosquitto-credentials keeps
`homeassistant-password` while the rest keep `password`. Consumers
reference those names, so unifying them would be a separate breaking
change; each OpenBao entry was seeded with the matching key.

Rotation, previously "delete the Secret AND the Job and wait for Flux", is
now: update the value in OpenBao and ESO republishes within
refreshInterval. The broker still needs a restart to re-hash, as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:02:46 +02:00
k.renner 57d333a2f1 Merge pull request 'fix(paperless): drop runtime apk add from fetch-bridge-cert' (#448) from paperless-drop-apk into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#448
2026-07-30 12:52:47 +00:00
claude-botandClaude Opus 5 a6effdd4cf fix(paperless): drop runtime apk add from fetch-bridge-cert
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Owner directive: tools belong in the image, not installed at container
start. This init container ran `apk add --no-cache openssl` on EVERY
paperless pod start.

Unlike the multus CNI installer, this one genuinely needs an image: the
openssl CLI is absent from the alpine base (verified — the base carries the
libraries but not the command), and busybox has no `s_client` equivalent,
so the STARTTLS certificate capture cannot be done with what is already
there.

Adds ci/images/alpine-openssl, named for what it is rather than for this
consumer, so anything else needing s_client/x509 from a shell script can
reuse it.

Script logic is unchanged — only the apk add line is removed and the image
swapped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:49:49 +02:00
k.renner 9c9ac712ca Merge pull request 'fix(multus): bake CNI plugins into an image, drop the node-side fetch' (#447) from cni-plugins-image into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#447
2026-07-30 12:33:49 +00:00
6 changed files with 216 additions and 513 deletions
+29
View File
@@ -188,3 +188,32 @@ steps:
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/cni-plugins:$TAG \
--destination gitea.renner.uno/gitea_admin/cni-plugins:latest
# Runtime image, not CI tooling: alpine + the openssl CLI, for shell scripts
# needing s_client/x509. Named for what it is; first consumer is the paperless
# fetch-bridge-cert init container. Replaces an `apk add openssl` that ran on
# every paperless pod start. The openssl CLI is genuinely absent from the alpine
# base, so this one needs an image rather than a busybox substitute.
- name: build-alpine-openssl
image: gcr.io/kaniko-project/executor:v1.23.2-debug
directory: ci/images/alpine-openssl
depends_on: []
when:
- event: manual
- event: push
path: ["ci/images/alpine-openssl/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/alpine-openssl:$TAG \
--destination gitea.renner.uno/gitea_admin/alpine-openssl:latest
+21
View File
@@ -0,0 +1,21 @@
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
# Alpine plus the openssl CLI. Nothing application-specific — named for what it
# is, so anything needing `openssl s_client` / `x509` from a shell script can use
# it.
#
# It exists because installing tools at container start is not acceptable here:
# `apk add` makes every start depend on network and package-index availability,
# and the resolved version can drift between restarts of an otherwise pinned
# image.
#
# Note the openssl CLI is genuinely absent from the alpine base — verified, the
# base carries the libraries but not the command — so unlike the multus CNI
# installer (where busybox wget/tar already covered the need) this one really
# does require an image rather than just dropping the apk add.
#
# First consumer: the paperless fetch-bridge-cert init container, which captures
# the Proton Mail Bridge's self-signed IMAP certificate via STARTTLS and pins it
# for paperless to trust. busybox has no s_client equivalent, so openssl is not
# substitutable there.
RUN apk add --no-cache openssl
@@ -12,7 +12,8 @@ resources:
- vscode-ingress.yaml
- prometheus-token-externalsecret.yaml
- config-sync-token-externalsecret.yaml
- mosquitto-bootstrap.yaml
# Broker credentials come from OpenBao via ESO (replaced the bootstrap Jobs).
- mosquitto-credentials-externalsecrets.yaml
- mosquitto-config.yaml
- mosquitto-certificate.yaml
- mosquitto-service.yaml
@@ -36,8 +37,9 @@ resources:
- esphome-ingress.yaml
# Zigbee2MQTT — ZHA replacement bridge. Ships scaled to 0; the cutover
# runbook (zigbee2mqtt-migration-runbook.md) scales it up. The MQTT
# `zigbee2mqtt` broker user is provisioned by mosquitto-bootstrap.yaml
# (above) and wired into mosquitto-deployment.yaml (passwd + ACL).
# `zigbee2mqtt` broker user comes from OpenBao via
# mosquitto-credentials-externalsecrets.yaml (above) and wired
# into mosquitto-deployment.yaml (passwd + ACL).
- zigbee2mqtt-coordinator-backup-externalsecret.yaml
- zigbee2mqtt-config.yaml
- zigbee2mqtt-pvc.yaml
@@ -1,508 +0,0 @@
---
# Generates mosquitto-credentials on first deploy. The password is created
# in-cluster, lives only in the Secret object, and is never written to git
# or seen by a human. The Mosquitto broker hashes it via mosquitto_passwd
# -U at startup; HA reads the same value from this Secret (configured
# manually in the HA MQTT integration UI).
#
# Idempotent: if the Secret already exists, the Job exits 0 without
# regenerating, so subsequent Flux reconciles don't rotate the password.
# To rotate, delete both the Secret and this Job; Flux will re-create
# the Job and a fresh password.
apiVersion: v1
kind: ServiceAccount
metadata:
name: mosquitto-bootstrap
namespace: home-assistant
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: mosquitto-bootstrap
namespace: home-assistant
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: mosquitto-bootstrap
namespace: home-assistant
subjects:
- kind: ServiceAccount
name: mosquitto-bootstrap
namespace: home-assistant
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: mosquitto-bootstrap
---
apiVersion: batch/v1
kind: Job
metadata:
name: mosquitto-credentials-bootstrap
namespace: home-assistant
spec:
backoffLimit: 4
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-bootstrap
spec:
serviceAccountName: mosquitto-bootstrap
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.21
command:
- sh
- -c
- |
set -eu
apk add --no-cache curl jq >/dev/null
NS=home-assistant
NAME=mosquitto-credentials
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
API=https://kubernetes.default.svc
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
echo "$NAME already exists; nothing to do."
exit 0
fi
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
body=$(jq -nc \
--arg name "$NAME" \
--arg ns "$NS" \
--arg pw "$PW_B64" \
'{apiVersion: "v1",
kind: "Secret",
metadata: {name: $name, namespace: $ns},
type: "Opaque",
data: {"homeassistant-password": $pw}}')
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -X POST \
--data "$body" \
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
echo "generated $NAME"
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
---
# Third broker user: `admin`. Full readwrite (same as HA) but kept on
# its own credential so a workstation client can hold a password the
# in-cluster HA config never sees, and rotating one doesn't affect the
# other. Retrieve with:
# kubectl -n home-assistant get secret mosquitto-admin-credentials \
# -o jsonpath='{.data.password}' | base64 -d
apiVersion: batch/v1
kind: Job
metadata:
name: mosquitto-admin-credentials-bootstrap
namespace: home-assistant
spec:
backoffLimit: 4
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-bootstrap
spec:
serviceAccountName: mosquitto-bootstrap
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.21
command:
- sh
- -c
- |
set -eu
apk add --no-cache curl jq >/dev/null
NS=home-assistant
NAME=mosquitto-admin-credentials
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
API=https://kubernetes.default.svc
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
echo "$NAME already exists; nothing to do."
exit 0
fi
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
body=$(jq -nc \
--arg name "$NAME" \
--arg ns "$NS" \
--arg pw "$PW_B64" \
'{apiVersion: "v1",
kind: "Secret",
metadata: {name: $name, namespace: $ns},
type: "Opaque",
data: {password: $pw}}')
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -X POST \
--data "$body" \
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
echo "generated $NAME"
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
---
# Second broker user: `shellies`. Separate Secret (not a second key on
# mosquitto-credentials) so each credential's lifecycle is independent —
# rotating one never touches the other, and existing clusters whose
# mosquitto-credentials was already generated for `homeassistant` only
# don't end up with a half-populated Secret.
apiVersion: batch/v1
kind: Job
metadata:
name: mosquitto-shellies-credentials-bootstrap
namespace: home-assistant
spec:
backoffLimit: 4
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-bootstrap
spec:
serviceAccountName: mosquitto-bootstrap
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.21
command:
- sh
- -c
- |
set -eu
apk add --no-cache curl jq >/dev/null
NS=home-assistant
NAME=mosquitto-shellies-credentials
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
API=https://kubernetes.default.svc
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
echo "$NAME already exists; nothing to do."
exit 0
fi
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
body=$(jq -nc \
--arg name "$NAME" \
--arg ns "$NS" \
--arg pw "$PW_B64" \
'{apiVersion: "v1",
kind: "Secret",
metadata: {name: $name, namespace: $ns},
type: "Opaque",
data: {password: $pw}}')
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -X POST \
--data "$body" \
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
echo "generated $NAME"
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
---
# Fourth broker user: `sml2mqtt` — the power-meter bridge. Publish-only
# under `sml2mqtt/#` (ACL in mosquitto-deployment.yaml). Own credential so
# its lifecycle is independent of the others. The sml2mqtt pod reads this
# password via its init container and renders it into config.yml.
apiVersion: batch/v1
kind: Job
metadata:
name: mosquitto-sml2mqtt-credentials-bootstrap
namespace: home-assistant
spec:
backoffLimit: 4
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-bootstrap
spec:
serviceAccountName: mosquitto-bootstrap
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.21
command:
- sh
- -c
- |
set -eu
apk add --no-cache curl jq >/dev/null
NS=home-assistant
NAME=mosquitto-sml2mqtt-credentials
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
API=https://kubernetes.default.svc
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
echo "$NAME already exists; nothing to do."
exit 0
fi
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
body=$(jq -nc \
--arg name "$NAME" \
--arg ns "$NS" \
--arg pw "$PW_B64" \
'{apiVersion: "v1",
kind: "Secret",
metadata: {name: $name, namespace: $ns},
type: "Opaque",
data: {password: $pw}}')
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -X POST \
--data "$body" \
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
echo "generated $NAME"
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
---
# Fifth broker user: `open3e` — the Viessmann E3 CAN bridge (Vitocal 250-A
# + Vitocharge VX3 via open3e). Scoped to `open3e/#` (ACL in
# mosquitto-deployment.yaml). Own credential so its lifecycle is
# independent of the others. Retrieve with:
# kubectl -n home-assistant get secret mosquitto-open3e-credentials \
# -o jsonpath='{.data.password}' | base64 -d
apiVersion: batch/v1
kind: Job
metadata:
name: mosquitto-open3e-credentials-bootstrap
namespace: home-assistant
spec:
backoffLimit: 4
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-bootstrap
spec:
serviceAccountName: mosquitto-bootstrap
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.21
command:
- sh
- -c
- |
set -eu
apk add --no-cache curl jq >/dev/null
NS=home-assistant
NAME=mosquitto-open3e-credentials
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
API=https://kubernetes.default.svc
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
echo "$NAME already exists; nothing to do."
exit 0
fi
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
body=$(jq -nc \
--arg name "$NAME" \
--arg ns "$NS" \
--arg pw "$PW_B64" \
'{apiVersion: "v1",
kind: "Secret",
metadata: {name: $name, namespace: $ns},
type: "Opaque",
data: {password: $pw}}')
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -X POST \
--data "$body" \
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
echo "generated $NAME"
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
---
# Broker user `health`: a dedicated, least-privilege credential used ONLY by
# mosquitto's own liveness/readiness probes (mosquitto-deployment.yaml). Its
# ACL is `write health/#` and nothing else — it cannot read any topic or
# write outside the throwaway probe subtree, so a leak is worthless (nothing
# subscribes to health/#). Kept on its own Secret, like every other user, so
# the probe never has to borrow the admin credential.
apiVersion: batch/v1
kind: Job
metadata:
name: mosquitto-health-credentials-bootstrap
namespace: home-assistant
spec:
backoffLimit: 4
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-bootstrap
spec:
serviceAccountName: mosquitto-bootstrap
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.21
command:
- sh
- -c
- |
set -eu
apk add --no-cache curl jq >/dev/null
NS=home-assistant
NAME=mosquitto-health-credentials
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
API=https://kubernetes.default.svc
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
echo "$NAME already exists; nothing to do."
exit 0
fi
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
body=$(jq -nc \
--arg name "$NAME" \
--arg ns "$NS" \
--arg pw "$PW_B64" \
'{apiVersion: "v1",
kind: "Secret",
metadata: {name: $name, namespace: $ns},
type: "Opaque",
data: {password: $pw}}')
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -X POST \
--data "$body" \
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
echo "generated $NAME"
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
---
# Broker user `zigbee2mqtt` — the Zigbee2MQTT bridge (ZHA replacement). Scoped
# to its own `zigbee2mqtt/#` subtree plus `write homeassistant/#` (so it can
# publish HA MQTT-discovery configs, exactly like the `shellies` user) and
# `read homeassistant/status` (Z2M re-publishes discovery when HA restarts —
# ACL in mosquitto-deployment.yaml). Own credential so its lifecycle is
# independent of the others. The Z2M init container reads this password and
# renders it into /app/data/secret.yaml.
apiVersion: batch/v1
kind: Job
metadata:
name: mosquitto-zigbee2mqtt-credentials-bootstrap
namespace: home-assistant
spec:
backoffLimit: 4
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-bootstrap
spec:
serviceAccountName: mosquitto-bootstrap
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.21
command:
- sh
- -c
- |
set -eu
apk add --no-cache curl jq >/dev/null
NS=home-assistant
NAME=mosquitto-zigbee2mqtt-credentials
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
API=https://kubernetes.default.svc
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
echo "$NAME already exists; nothing to do."
exit 0
fi
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
body=$(jq -nc \
--arg name "$NAME" \
--arg ns "$NS" \
--arg pw "$PW_B64" \
'{apiVersion: "v1",
kind: "Secret",
metadata: {name: $name, namespace: $ns},
type: "Opaque",
data: {password: $pw}}')
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -X POST \
--data "$body" \
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
echo "generated $NAME"
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
@@ -0,0 +1,155 @@
# The seven mosquitto broker credentials that used to be generated by bootstrap
# Jobs (mosquitto-bootstrap.yaml, now deleted), converted to the same
# OpenBao-backed pattern mosquitto-evcc-credentials already used.
#
# WHY THE JOBS WENT
# Each was ~70 lines of shell that generated a password, base64'd it, hand-built
# Secret JSON with jq and POSTed it to the Kubernetes API with curl, needing a
# ServiceAccount + Role + RoleBinding and an `apk add curl jq` on every run.
# Together: 518 lines to do what 14 declarative lines do here. They also could not
# be modified in place at all — a Job's spec.template is immutable, so changing
# even the container image made the whole Kustomization fail to reconcile.
#
# The passwords were also only ever in the Secret object: not in OpenBao, so not
# backed up, not centrally rotatable, and unrecoverable if the Secret were lost.
# Now they live in OpenBao like every other credential in this namespace.
#
# MIGRATION WAS VALUE-PRESERVING
# The existing live passwords were copied into OpenBao before this landed, and
# each round-trip was verified by sha256 — the broker has already hashed these
# (mosquitto_passwd -U at startup) and every client is using them, so generating
# fresh ones would have broken Home Assistant, zigbee2mqtt, shellies, sml2mqtt,
# open3e and the health check at once. ESO therefore rewrites each Secret with
# byte-identical content and nothing observes a change.
#
# ROTATION, which the Jobs made awkward (delete the Secret AND the Job, then wait
# for Flux), is now: update the value in OpenBao, and within refreshInterval ESO
# republishes it. The broker still needs a restart to re-hash, as before.
#
# ⚠️ NEW COUPLING, absent with the Jobs: creationPolicy is implicitly Owner, so
# ESO sets an ownerReference from each Secret to its ExternalSecret. Combined with
# the Kustomization's prune: true, DELETING AN ExternalSecret BLOCK BELOW NOW
# DELETES THE LIVE SECRET — and with it broker access for that client. The old
# Jobs created Secrets with no ownerReference, so removing a Job left its Secret
# untouched; that safety net is gone. To retire a credential deliberately, remove
# the consumer first. To move one out of ESO's control without losing it, set
# creationPolicy: Orphan on that block before deleting it.
#
# NOTE the key names are not uniform: mosquitto-credentials carries
# `homeassistant-password` while the rest carry `password`. That asymmetry came
# from the original Jobs and is preserved deliberately — the consumers reference
# those key names, so normalising them would be a separate, breaking change.
# `dataFrom.extract` copies whatever keys the OpenBao entry holds, so each entry
# was seeded with the matching key name.
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mosquitto-credentials
namespace: home-assistant
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: mosquitto-credentials # unchanged name; HA's MQTT integration reads this
dataFrom:
- extract:
key: apps/home-assistant/mosquitto-credentials
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mosquitto-admin-credentials
namespace: home-assistant
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: mosquitto-admin-credentials
dataFrom:
- extract:
key: apps/home-assistant/mosquitto-admin-credentials
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mosquitto-health-credentials
namespace: home-assistant
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: mosquitto-health-credentials
dataFrom:
- extract:
key: apps/home-assistant/mosquitto-health-credentials
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mosquitto-open3e-credentials
namespace: home-assistant
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: mosquitto-open3e-credentials
dataFrom:
- extract:
key: apps/home-assistant/mosquitto-open3e-credentials
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mosquitto-shellies-credentials
namespace: home-assistant
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: mosquitto-shellies-credentials
dataFrom:
- extract:
key: apps/home-assistant/mosquitto-shellies-credentials
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mosquitto-sml2mqtt-credentials
namespace: home-assistant
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: mosquitto-sml2mqtt-credentials
dataFrom:
- extract:
key: apps/home-assistant/mosquitto-sml2mqtt-credentials
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mosquitto-zigbee2mqtt-credentials
namespace: home-assistant
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: mosquitto-zigbee2mqtt-credentials
dataFrom:
- extract:
key: apps/home-assistant/mosquitto-zigbee2mqtt-credentials
+6 -2
View File
@@ -88,13 +88,17 @@ spec:
# trust without disabling verification.
initContainers:
- name: fetch-bridge-cert
image: alpine:3.21
# alpine + the openssl CLI, built by .woodpecker/build-ci-images.yml
# from ci/images/alpine-openssl/. Replaces `apk add --no-cache openssl`
# on every paperless pod start: tools belong in the image, not
# installed at runtime. Dated tag so a change is a reviewable diff
# here, matching the ci-ansible / alpine-jq convention.
image: gitea.renner.uno/gitea_admin/alpine-openssl:2026-07-30
command:
- sh
- -c
- |
set -e
apk add --no-cache openssl
# Wait until the bridge presents a cert via STARTTLS.
# Re-fetched on every paperless restart so a bridge cert
# rotation only requires a paperless rollout to pick up.