Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5.5 b4933d65e5 feat(edge-host-iso): trust the SSH user CA
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The build fetches the CA public key from the ssh-ca role URL and renders the
role drop-in, revocation list and root principal into the live system;
edge-install copies them to the installed system and checks sshd -T uses
the CA. Owner certificates log in as root from the first boot, with the
break-glass key as fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:29:57 +01:00
claude-botandClaude Opus 5.5 3187be1f62 chore: remove stale SSH keys
ci/woodpecker/pr/ci-scripts-test Pipeline failed
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The edge-host ISO carried krenner@fortress, ansible-runner@ci-new and
krenner@krenner-XPS-13-9310; it now carries only the break-glass key.
The netbird-server revoke task for netbird-backup@homelab-cluster goes:
the exclusive managed authorized_keys already excludes it. OpenTofu drops
the unused ssh_public_key/ssh_private_key variables and the Proxmox
provider ssh block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:20:48 +01:00
claude-botandClaude Opus 5.5 0c1fb0a229 feat: remove the Woodpecker CI SSH key
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
The woodpecker-ci@homelab key is dropped from the managed authorized_keys
and from the edge-host ISO. The Ansible pipelines that used it are deleted;
plays run from a workstation. deploy-uptime-kuma keeps only the OpenBao
token renewal cron, and deploy-infrastructure no longer installs the key
(no root-module resource uses the Proxmox provider SSH).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:18:15 +01:00
claude-botandClaude Opus 5.5 373b682902 feat(ansible): reach the internet hosts over the NetBird mesh
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
proxy-1, proxy-2 and netbird are addressed by their mesh names, so Ansible
for them runs from a mesh-connected workstation. CI cannot reach the mesh;
the pipelines targeting these hosts lose their push triggers and run only
on manual deployment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:45:46 +01:00
k.renner aad723252a Merge pull request 'fix(ansible): netbird-client downloads the release on the controller' (#1487) from netbird-client-controller-download into main
ci/woodpecker/push/deploy-netbird-client Pipeline failed
Reviewed-on: #1487
2026-10-27 15:35:13 +00:00
claude-botandClaude Opus 5.5 875fbfc730 fix(ansible): netbird-client downloads the release on the controller
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The first workstation run failed on proxy-1 with 'Network is
unreachable' fetching the release: proxy-1 is IPv6-only and github.com
has no IPv6 address. The tarball is now downloaded and checksum-verified
on the controller (~/.cache/ansible/netbird) and unarchive copies it to
the host, so the hosts need no route to GitHub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:34:37 +01:00
k.renner f56fc42c73 Merge pull request 'fix(ansible): install-netbird-client runs one host at a time' (#1486) from netbird-client-serial into main
ci/woodpecker/push/deploy-netbird-client Pipeline failed
Reviewed-on: #1486
2026-10-27 15:33:11 +00:00
claude-botandClaude Opus 5.5 c4ee6f1662 fix(ansible): install-netbird-client runs one host at a time
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
From the workstation each new SSH session asks for the YubiKey PIN and
a touch; with the default forks both proxies prompted at once. serial: 1
like the other plays that touch the proxies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:30:50 +01:00
k.renner d0413edc27 Merge pull request 'fix(ansible): drop private_key_file from ansible.cfg' (#1485) from ansible-cfg-no-key-file into main
ci/woodpecker/push/deploy-netbird-client Pipeline failed
Reviewed-on: #1485
2026-10-27 15:28:53 +00:00
claude-botandClaude Opus 5.5 30d0299073 fix(ansible): install-netbird-client gathers facts only on selected hosts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The play gathered facts on every inventory host before skipping those
without netbird_setup_key, so a workstation run opened an SSH session
(YubiKey PIN + touch) to edge-1 and netbird too, and timed out on the
decommissioned claude-ws. Facts are gathered after the skip now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:27:57 +01:00
claude-botandClaude Opus 5.5 c3566b8fc3 fix(ansible): drop private_key_file from ansible.cfg
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ansible.cfg forced ~/.ssh/id_ed25519, which does not exist on the
owner's workstation: runs from there failed with 'no such identity'
and bypassed the ssh config that picks the plugged-in YubiKey's key
and certificate. CI never read this file ('world writable directory,
ignoring it as an ansible.cfg source'); its pipelines write the CI key
to ~/.ssh/id_ed25519, ssh's default identity, so they are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:27:25 +01:00
k.renner 271aefc5d2 Merge pull request 'feat(ansible): netbird-client installs on Arch (proxy hosts)' (#1484) from netbird-client-arch into main
ci/woodpecker/push/deploy-netbird-client Pipeline failed
ci/woodpecker/push/deploy-netbird-proxy Pipeline was successful
ci/woodpecker/push/deploy-ssh-ca Pipeline was successful
Reviewed-on: #1484
2026-10-27 15:18:13 +00:00
claude-botandClaude Opus 5.5 285057bc98 feat(ansible): netbird-client installs on Arch (proxy hosts)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Step 1 of SSH-only-over-NetBird: proxy-1/2 (Arch) need a host NetBird
client with wt0 before sshd can stop listening on their public
addresses. The role was apt-only.

- Arch: release tarball from GitHub, verified against the release's
  checksums file (get_url checksum URL), binary to /usr/local/bin,
  'netbird service install'. Version netbird_client_version, 0.75.0
  like netbird-ingress; Debian/Ubuntu keep following the apt repo.
- netbird_disable_dns adds --disable-dns; set for [netbird_proxy],
  whose resolv.conf the netbird-proxy role pins.
- The service is started before 'netbird up' (the Arch path has no
  package that starts it).
- The connected check matched 'Connected' inside 'Disconnected'; it
  now matches the 'Management: Connected' line.
- 'netbird up' runs with no_log: its argv holds the setup key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:12:55 +01:00
k.renner 973c6d9e94 Merge pull request 'fix(bootstrap): portable DR secret commands, visible YubiKey prompts' (#1482) from dr-secrets-age-fixes into main
Reviewed-on: #1482
2026-10-27 14:29:03 +00:00
claude-botandClaude Opus 5.5 a5f2bc39e4 fix(bootstrap): portable DR secret commands, visible YubiKey prompts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Review of #1481 (merged with it open):
- The Job printed the operator's streaming command with the pod's
  HOME expanded (/work/.config/...), unusable on the workstation. The
  defaults stay a literal ~ now; eval expands it on the workstation.
- 'Collecting secrets' captured stderr, where age-plugin-yubikey asks
  for the PIN and touch: a silent hang. stderr stays on the terminal.
- Runbook/migration doc still named pass as the DR store in places;
  tooling list gains age + age-plugin-yubikey; the file list gains
  hetzner-cloud-api-secret.age.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:27:17 +01:00
k.renner cb7f727c1c Merge pull request 'feat(bootstrap): DR secrets from YubiKey/paper-key age files, not pass' (#1481) from dr-secrets-age into main
Reviewed-on: #1481
2026-10-27 14:25:33 +00:00
claude-botandClaude Opus 5.5 e4f14180ff feat(bootstrap): DR secrets from YubiKey/paper-key age files, not pass
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The cold-rebuild secrets (seal key, Hetzner restore keys, GHCR pull
token) came from the pass store: one gpg key file on the owner's laptop,
readable by any process of that user while gpg-agent caches the
passphrase, and with no second copy.

bootstrap.sh now decrypts ~/.config/homelab-secrets/<name>.age, files
encrypted to both YubiKeys (touch) and an offline paper key; a copy is
on nas-2. HOMELAB_AGE_IDENTITY switches to the paper key when both
sticks are gone. The in-cluster Job path is unchanged: the printed
streaming command is the age one. Runbook and the two OpenBao alert
texts point at the new files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:07:23 +01:00
k.renner a44b55b942 Merge pull request 'feat(ansible): remaining CA hosts accept only break-glass and CI keys (SSH CA phase 4)' (#1480) from ssh-keys-managed-rest into main
ci/woodpecker/push/deploy-ssh-ca Pipeline was successful
Reviewed-on: #1480
2026-10-27 13:31:34 +00:00
k.renner 0001f2f0c2 Merge pull request 'fix(crowdsec): never ban successful UI bursts on http-crawl-non_statics' (#1478) from worktree-crowdsec-ma-imageproxy-whitelist into main
Reviewed-on: #1478
2026-10-27 13:26:45 +00:00
claude-botandClaude Opus 5.5 f78e4bc713 feat(ansible): remaining CA hosts accept only break-glass and CI keys
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
proxy-1 is verified after #1477/#1479: authorized_keys holds exactly
breakglass-neo-6532348 and the CI key, and CI still reaches it.

Removed with this run, per the [ssh_ca] report and the sshd journals
(no root login with any of them except id_ecdsa, the owner's old key):
- proxy-2: id_ecdsa, ansible-runner@ci-new
- netbird: id_ecdsa, ansible-runner@ci-new, krenner@fortress,
  netbird-tunnel (SHA256:cNy8...), a second woodpecker-ci key
  (SHA256:hQxz...); none used for a root login since 2026-08-29
- edge-1: id_ecdsa, ansible-runner@ci-new, krenner@fortress
The CI key keeps from= the pod network on edge-1 (LAN, CI arrives from
10.244.2.x) and has none on proxy-2 and netbird (CI arrives NATed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:26:06 +01:00
claude-botandClaude Opus 5.5 e855921b39 fix(crowdsec): drop crawl-non_statics alerts for successful bursts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Replaces the per-service Music Assistant/Immich parser whitelists with
one postoverflow whitelist: http-crawl-non_statics overflows are dropped
unless at least half of the requests in the alert are 4xx.

Every self-ban in the alert history (MA 350/368/477, Immich 149/150) was
a burst of 200s from a signed-in UI. Every scanner ban on this scenario
was 404s (/.env, /.git/...). The per-service rules only covered apps
that had already failed and muted all scenarios on their paths; this
covers any image-heavy UI and touches only this scenario.

Verified in a crowdsecurity/crowdsec:v1.8.1 replay: a 60x200 burst is
whitelisted, a 60x404 burst with distinct paths still overflows and is
not whitelisted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:23:43 +01:00
k.renner ec5b7314d6 Merge pull request 'fix(ansible): no from= on the CI key for internet hosts' (#1479) from ssh-ci-key-from-per-host into main
ci/woodpecker/push/deploy-netbird-proxy Pipeline was successful
ci/woodpecker/push/deploy-ssh-ca Pipeline was successful
Reviewed-on: #1479
2026-10-27 13:20:28 +00:00
claude-botandClaude Opus 5.5 d46d6fde93 fix(ansible): no from= on the CI key for internet hosts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
#1477 locked CI out of proxy-1: the CI key got from= the pod network,
but CI reaches the internet hosts NATed from the home WAN address
(proxy-2 journal: root logins from 84.171.37.13). The from= came from
plays/configure-ssh-keys.yml, which no pipeline ever ran, so it was
never in effect on those hosts. LAN hosts do see the pod address
(edge-1 journal: 10.244.2.x).

ssh_ci_key_from is now per host: the pod CIDRs by default, empty for
[netbird_proxy] and netbird.renner.uno. The owner already removed the
from= on proxy-1 by hand; this keeps the next run from restoring it.
Restricting SSH on internet hosts to the NetBird mesh is the follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:15:01 +01:00
claude-botandClaude Opus 5.5 16ca2f69b7 fix(crowdsec): whitelist Immich timeline/asset loads
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Scrolling the Immich timeline fetches a /api/timeline/bucket and then
every /api/assets/<uuid> in it; 46-54 requests in 3-9s fired
crowdsecurity/http-crawl-non_statics against a residential Telekom IPv6
(alerts 149/150, 2026-10-23).

Same parser-stage pattern as the Music Assistant whitelist, scoped to
the Immich Traefik router and those two path prefixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:55:40 +01:00
claude-botandClaude Opus 5.5 cb4cabeca7 fix(crowdsec): whitelist Music Assistant imageproxy cover-art loads
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
The MA UI loads every cover through /imageproxy/<hash>?size=512 in one
burst; a library view (47 requests in 3s) fired
crowdsecurity/http-crawl-non_statics and banned a residential Telekom
IPv6 (alert 477, 2026-10-27).

Parser-stage whitelist scoped to the MA Traefik router and the
/imageproxy/ path. The Traefik parser sets no target_fqdn, so the
router name is used as host scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:54:34 +01:00
k.renner 2f63d2d426 Merge pull request 'feat(ansible): proxy-1 accepts only break-glass and CI keys (SSH CA phase 4)' (#1477) from ssh-keys-managed-proxy-1 into main
ci/woodpecker/push/deploy-ssh-ca Pipeline was successful
Reviewed-on: #1477
2026-10-27 12:14:08 +00:00
claude-botandClaude Opus 5.5 04b704fdbd feat(ansible): proxy-1 accepts only break-glass and CI keys
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
First host in [ssh_keys_managed]. root on proxy-1 currently has
id_ecdsa (krenner@krenner-XPS-13-9310), woodpecker-ci@homelab
(SHA256:DQWPS...) and ansible-runner@ci-new (dead since #184). After
this run: breakglass-neo-6532348 and the same CI key with its from=
pod-network restriction; people log in with CA certificates. proxy-2
is the twin, so the public edge survives a mistake here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:49:46 +01:00
k.renner 63df267a2f Merge pull request 'feat(ansible): one admin key list; report authorized_keys (SSH CA phase 4, step 1)' (#1476) from ssh-keys-managed into main
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/push/deploy-ssh-ca Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Reviewed-on: #1476
2026-10-27 11:38:59 +00:00
claude-botandClaude Opus 5.5 6c5bdd3e46 feat(ansible): one admin key list; report authorized_keys (phase 4)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Phase 4 of the SSH CA, step 1. Nothing is removed from any host yet.

- ssh_admin_authorized_keys (group_vars/all) is the single list of
  plain keys the admin logins may accept: the break-glass PIV key on
  the offline YubiKey NEO and the CI deploy key (from= pod network).
- ssh-ca reports, on every [ssh_ca] host, the fingerprints of the
  admin logins' current authorized_keys.
- configure-ssh-ca.yml gets a second play that sets the admin logins'
  authorized_keys exclusively to that list on [ssh_keys_managed]. The
  group is empty; hosts join one at a time after the report.
- Removed the three dead key lists: plays/configure-ssh-keys.yml (no
  pipeline ran it), common_ssh_public_keys (defined, never used) and
  opentofu/locals.tf common_ssh_keys (no reference). They carried
  krenner@fortress, ansible-runner@ci-new and the laptop id_ecdsa key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:28:48 +01:00
k.renner 8a13b241cb Merge pull request 'feat(gitea): trust the OpenBao SSH CA (SSH CA phase 3b)' (#1475) from gitea-ssh-ca-trust into main
Reviewed-on: #1475
2026-10-27 10:42:44 +00:00
claude-botandClaude Opus 5.5 1b63cb8970 feat(gitea): trust the OpenBao SSH CA (phase 3b)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Step A is verified: Secret gitea-ssh-ca holds exactly the CA key
(SHA256:xEhuqx..., one line, 81 bytes like OpenBao's content-length),
so the webhook provider passes a text/plain body through unchanged.

- StatefulSet: SSH_TRUSTED_USER_CA_KEYS from the Secret,
  SSH_AUTHORIZED_PRINCIPALS_ALLOW=username. The built-in SSH server
  looks a cert's principals up as principal keys, so each user
  registers their username once as a principal.
- ExternalSecret: trim the trailing newline before it reaches app.ini.
- Reloader annotation: Gitea reads env only at start, so a CA rotation
  restarts it. The step A comment claimed the refresh alone reaches
  Gitea; corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:40:05 +01:00
k.renner 4ba3f03b18 Merge pull request 'feat(gitea): sync the OpenBao SSH CA public key into a Secret (SSH CA phase 3a)' (#1474) from gitea-ssh-ca-secret into main
Reviewed-on: #1474
2026-10-27 10:35:31 +00:00
claude-botandClaude Opus 5.5 739b41b623 feat(gitea): sync the OpenBao SSH CA public key into a Secret
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Phase 3, step A of the SSH CA: Gitea's built-in SSH server needs the CA
key as SSH_TRUSTED_USER_CA_KEYS. A namespaced ESO webhook SecretStore
reads OpenBao's unauthenticated /v1/ssh-client-signer/public_key from
the in-cluster Service into Secret gitea-ssh-ca, so the key has one
source. Gitea is not wired to it yet; step B follows once the Secret
is verified to hold exactly the CA key (the ESO docs do not say how a
text/plain body without result.jsonPath is handled).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:22:47 +01:00
security-bot f8796b181f chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1793094262
Updates managed by image-automation-controller.
2026-10-27 09:47:02 +00:00
k.renner e8c72cd1f1 Merge pull request 'feat(ansible): SSH CA on edge-1' (#1473) from ssh-ca-edge-1 into main
ci/woodpecker/push/deploy-ssh-ca Pipeline was successful
Reviewed-on: #1473
2026-10-27 09:46:56 +00:00
claude-botandClaude Opus 5.5 2da554d40e feat(ansible): SSH CA on edge-1
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
netbird verified (Ubuntu, ssh.service): claude-bot cert login, sudo
journalctl allowed, systemctl restart refused. edge-1 is the last
Ansible-managed host (OpenSSH_10.6 banner like the proxies).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:44:45 +01:00
k.renner 35461f1f54 Merge pull request 'fix(ansible): ssh-ca reloads the sshd unit that is running' (#1472) from ssh-ca-running-unit into main
ci/woodpecker/push/deploy-ssh-ca Pipeline was successful
Reviewed-on: #1472
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-27 09:42:25 +00:00
claude-botandClaude Opus 5.5 adedc98b50 fix(ansible): ssh-ca reloads the sshd unit that is running
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The netbird run (pipeline 14795) failed at the reload: 'Could not find
the requested service sshd.service'. Ubuntu's service_facts list
sshd.service as a not-found alias next to the real ssh.service
(running; disabled because ssh.socket activates it), and the role only
checked that the name exists.

The unit is now the one of sshd.service/ssh.service whose state is
running, asserted to be exactly one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:41:06 +01:00
k.renner 1f228c94ef Merge pull request 'feat(ansible): SSH CA on netbird.renner.uno' (#1471) from ssh-ca-netbird into main
ci/woodpecker/push/deploy-ssh-ca Pipeline failed
Reviewed-on: #1471
2026-10-27 09:37:16 +00:00
claude-botandClaude Opus 5.5 9232937b85 feat(ansible): SSH CA on netbird.renner.uno
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
proxy-2 verified (claude-bot cert login, sudo allow/deny as on
proxy-1). netbird is the first Ubuntu host (OpenSSH_9.6p1
Ubuntu-3ubuntu13.20): unit ssh.service, which the role picks from
service_facts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:36:21 +01:00
k.renner 707f6ab840 Merge pull request 'feat(ansible): SSH CA on proxy-2' (#1470) from ssh-ca-proxy-2 into main
ci/woodpecker/push/deploy-ssh-ca Pipeline was successful
Reviewed-on: #1470
2026-10-27 09:33:35 +00:00
claude-botandClaude Opus 5.5 7600a9dbf9 feat(ansible): SSH CA on proxy-2
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
proxy-1 is verified: owner cert login as root (sk-ed25519 cert) and
claude-bot cert login with the expected sudo rules both work. proxy-2
is its twin (same OpenSSH_10.6 banner).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:31:42 +01:00
k.renner b12ef67002 Merge pull request 'fix(ansible): ssh-ca compares sshd -T output case-insensitively' (#1469) from ssh-ca-assert-case into main
ci/woodpecker/push/deploy-ssh-ca Pipeline was successful
Reviewed-on: #1469
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-27 09:25:47 +00:00
claude-botandClaude Opus 5.5 e6e0e44b6a fix(ansible): compare sshd -T output case-insensitively
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The second deploy-ssh-ca run on proxy-1 failed at the assert although
all three settings were active: OpenSSH 10.6 prints them as
TrustedUserCAKeys, AuthorizedPrincipalsFile and RevokedKeys, the check
expected lowercase keywords. The run stopped before the reload, so the
running sshd still has the old config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:24:41 +01:00
k.renner 39b7fc7fc3 Merge pull request 'fix(ansible): ssh-ca reloads sshd by its real unit name' (#1468) from ssh-ca-reload-fix into main
ci/woodpecker/push/deploy-ssh-ca Pipeline failed
Reviewed-on: #1468
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-27 09:09:51 +00:00
claude-botandClaude Opus 5.5 33dd968a25 fix(ansible): reload sshd by its real unit name on every run
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The first deploy to proxy-1 failed at the reload: 'Could not find the
requested service ssh'. proxy-1 is not Debian (banner OpenSSH_10.6
without distro suffix, root shell /usr/bin/bash), so the unit is
sshd.service; the handler only special-cased RedHat.

The unit is now picked from service_facts. The reload runs on every
run instead of via a handler: sshd -T only parses the files, so after
a failed reload a re-run with nothing changed would otherwise leave the
running daemon on the old config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:05:19 +01:00
k.renner d8813a68b7 Merge pull request 'feat(ansible): hosts trust the OpenBao SSH user CA (SSH CA phase 2)' (#1467) from ssh-ca-phase2 into main
ci/woodpecker/push/deploy-ssh-ca Pipeline failed
Reviewed-on: #1467
2026-10-27 08:53:33 +00:00
claude-botandClaude Opus 5.5 9dcaf42f96 fix(ansible): start the SSH CA rollout on proxy-1
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
claude-ws is decommissioned. proxy-1 resolves by name, answers on 22,
and has proxy-2 as its twin, so the public edge survives a bad run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:50:58 +01:00
security-bot d033503fc3 chore(image-automation): bump gotenberg/gotenberg:8.39.0
Updates managed by image-automation-controller.
2026-10-27 08:50:46 +00:00
claude-botandClaude Opus 5.5 66c56288a7 feat(ansible): hosts trust the OpenBao SSH user CA (phase 2)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
New role ssh-ca with play configure-ssh-ca.yml (serial: 1) and pipeline
deploy-ssh-ca. Hosts in the new [ssh_ca] group get:

- TrustedUserCAKeys from OpenBao's public_key endpoint, fetched on the
  controller at run time (no copy in git).
- AuthorizedPrincipalsFile: k.renner for root/ladmin (whichever
  exists), claude-bot for Claude's own claude-bot account.
- RevokedKeys, always deployed: sshd refuses all key logins when the
  file is set but missing.
- claude-bot sudo: read-only journalctl/systemctl status/ss/sshd -T,
  restart/reload only for ssh_ca_claude_sudo_units; NOEXEC against
  pager shell escapes; visudo-validated.

The drop-in is tested with sshd -t and removed again on failure; sshd -T
must show all three settings afterwards. authorized_keys is untouched,
so this only adds a login path. [ssh_ca] starts with claude-ws only.

ssh-sign-owner.hcl now also records the mount, CA and owner role
commands (review on #1466).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:47:48 +01:00
k.renner 2b309dec92 Merge pull request 'feat(openbao): SSH client CA roles (SSH CA phase 1)' (#1466) from ssh-ca-phase1 into main
Reviewed-on: #1466
2026-10-27 08:42:24 +00:00
k.renner 396f839567 Merge pull request 'chore(ci): bump ci-claude image to 2.1.311' (#1464) from ci-image/claude-2.1.311 into main
Reviewed-on: #1464
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-27 08:42:06 +00:00
claude-botandClaude Opus 5.5 2e412e6448 feat(openbao): SSH client CA roles for owner and claude-bot
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Phase 1 of the SSH CA. The ssh-client-signer mount and its ed25519 CA
were set up by hand (root token); this records the roles and the
claude-bot signing policy.

- role owner: principal k.renner, 6-month certs. The mount was enabled
  with -max-lease-ttl=4380h; the 768h system default would cap it.
- role claude: principal claude-bot, 1h certs, pty only.
- policy ssh-sign-claude for the claude-bot userpass user, next to its
  existing claude-bot-write.

Verified: a YubiKey sk-ssh-ed25519 key signed on role owner yields a
sk-ssh-ed25519-cert-v01 cert, principal k.renner, valid to 2027-04-27.
CA fingerprint SHA256:xEhuqxqFVZ/ig6KpAtHOhLbwsIhyWpzXhvDcjIfM/Js,
served unauthenticated at /v1/ssh-client-signer/public_key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:39:19 +01:00
security-bot 7f61ad0ce9 chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-10-27-0757 gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-27
Updates managed by image-automation-controller.
2026-10-27 08:23:50 +00:00
security-bot 6503161146 chore(image-automation): bump gitea.renner.uno/gitea_admin/kuma-sync:2026-10-27-0801
Updates managed by image-automation-controller.
2026-10-27 08:23:47 +00:00
security-bot 01133ca106 chore(image-automation): bump gitea.renner.uno/gitea_admin/secret-rotator:2026-10-27
Updates managed by image-automation-controller.
2026-10-27 08:23:46 +00:00
security-bot ce5be4d067 chore(image-automation): bump gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-27-0807
Updates managed by image-automation-controller.
2026-10-27 08:17:10 +00:00
security-bot 150e8229fd chore(image-automation): bump gitea.renner.uno/konairius/hermes:build.1793088058
Updates managed by image-automation-controller.
2026-10-27 08:07:03 +00:00
security-bot 0247b3dfd5 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-27-0801
Updates managed by image-automation-controller.
2026-10-27 08:06:48 +00:00
claude-bot 9b7a64e48c chore(ci): bump ci-claude image to 2.1.311
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
2026-10-27 07:53:48 +00:00
k.renner aee26df4b8 Merge pull request 'fix(wiki): wiki-sync retries while the wiki pod restarts' (#1462) from alert-fix/issue-1461 into main
Reviewed-on: #1462
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-27 07:53:21 +00:00
k.renner 0bf2cd309e Merge pull request 'feat(openbao): OIDC login via Authentik (SSH CA phase 0)' (#1463) from worktree-openbao-oidc-authentik into main
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
ci/woodpecker/push/deploy-claude-workstation Pipeline failed
ci/woodpecker/push/deploy-edge-mqtt-bridge Pipeline was successful
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/push/deploy-netbird-client Pipeline failed
ci/woodpecker/push/deploy-netbird-proxy Pipeline was successful
ci/woodpecker/push/deploy-netbird-server Pipeline was successful
ci/woodpecker/push/deploy-uptime-kuma Pipeline was successful
ci/woodpecker/push/hermes-image Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1463
2026-10-27 07:52:50 +00:00
claude-bot 73d85b4aa8 Merge remote-tracking branch 'origin/main' into worktree-openbao-oidc-authentik
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
# Conflicts:
#	kubernetes/apps/authentik/blueprints-configmap.yaml
2026-10-27 08:45:30 +01:00
claude-botandClaude Opus 5.5 b78cacb6c4 feat(openbao): OIDC login via Authentik for SSH cert signing
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
Phase 0 of the OpenBao SSH CA: humans log in to OpenBao with
Authentik instead of the root token.

- Authentik blueprint: group OpenBao Admins (k.renner), confidential
  OAuth2 provider openbao (CLI + UI callbacks), application, group
  binding. The client secret is generated by Authentik and only goes
  into auth/oidc/config.
- openbao-authentik-egress: auth.renner.uno resolves to the Traefik
  VIP in-cluster, which the existing world egress does not cover.
- Policy ssh-sign-owner with the manual bao commands for auth/oidc;
  its sign path is enabled in phase 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:44:15 +01:00
security-bot b753ba4776 chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1793086240
Updates managed by image-automation-controller.
2026-10-27 07:37:00 +00:00
k.renner 84c0561146 Merge pull request 'feat(authentik): bump to 2026.8.4 with samba-bridge 2026.8.4-3c8f413' (#1460) from claude/authentik-2026.8.4 into main
Reviewed-on: #1460
2026-10-27 07:28:39 +00:00
hermes-bot be4e605d65 fix(wiki): wiki-sync retry window covers the full 120 s
ci/woodpecker/pr/ci-scripts-test Pipeline failed
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Six retries at a fixed 10 s delay stopped after about 60 s, so
--retry-max-time 120 never bound. Twelve retries let the 120 s cap
be the limit, covering a Recreate rollout slowed by an image pull.

Retrying on 5xx is safe: POST /api/v1/sync is serialized by
SyncCoordinator and re-syncs the same state, the same as a webhook
landing mid-sync. --retry-max-time 120 keeps a long sync that fails
late from being repeated.
2026-10-27 07:27:46 +00:00
hermes-bot 10cb1c2152 fix(wiki): wiki-sync retries while the wiki pod restarts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
The wiki Deployment uses Recreate, so during an image rollout the Service
has no ready endpoint for ~10 s. wiki-sync-29884710 started 06:30:00Z, 4 s
before the new pod was Ready, failed with curl (7) and fired KubeJobFailed.
Retry up to 6 times, 10 s apart, on connection refused too, bounded to
120 s from the first attempt so a long sync that fails is not repeated.

Refs #1461
2026-10-27 07:26:13 +00:00
claude-botandClaude Opus 5.5 ae8ce26a01 feat(authentik): bump to 2026.8.4 with samba-bridge 2026.8.4-3c8f413
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Chart, server/worker image and LDAP outpost image move together, as
the chart pin requires. Images built from authentik-samba-bridge
3c8f413 (PR #8), pipeline 43.

Supersedes #1347.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 07:39:59 +01:00
security-bot 1f1125c49c chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1793081757
Updates managed by image-automation-controller.
2026-10-27 06:26:58 +00:00
security-bot 1467596309 chore(image-automation): bump valkey/valkey:9.1.3-alpine
Updates managed by image-automation-controller.
2026-10-27 02:50:38 +00:00
security-bot 8ef130da2e chore(image-automation): bump redis:8.10.3-alpine
Updates managed by image-automation-controller.
2026-10-26 17:50:32 +00:00
security-bot 2e677483db chore(image-automation): bump redis:7.4.12-alpine
Updates managed by image-automation-controller.
2026-10-26 17:46:58 +00:00
k.renner 532b853929 Merge pull request 'fix(music-assistant): bind streams to net1 so Sendspin mDNS queries the IoT VLAN' (#1459) from fix/ma-sendspin-mdns-net1 into main
Reviewed-on: #1459
2026-10-26 14:34:12 +00:00
claude-botandClaude Opus 5.5 af7103fd75 fix(music-assistant): bind streams to net1 so Sendspin mDNS queries the IoT VLAN
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
aiosendspin starts its zeroconf on [bind_ip], or on InterfaceChoice.Default
when bind_ip is 0.0.0.0. Default egresses via the pod's main-table default
route (eth0); net1 routes sit in the sbr table. MA therefore never sent
_sendspin._tcp queries onto v30iot and only rediscovered Voice PEs when they
announced on their own, leaving them disconnected for hours after every MA
restart (e.g. 2026-10-26 13:56: 3 of 4 still offline at 15:05).

The reconcile initContainer now writes bind_ip = the live net1 address it
already computes for publish_ip. The address is read from the interface on
every boot, so the Sendspin no-bind-fallback failure (stale literal IP)
cannot recur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:12:32 +01:00
k.renner 1984b01e5d Merge pull request 'fix(music-assistant): mark Up Next episodes played from the playlist' (#1458) from claude/ma-pocketcasts-up-next-mark-played into main
Reviewed-on: #1458
2026-10-26 12:53:42 +00:00
claude-botandClaude Opus 5.5 e02ad7639b fix(music-assistant): mark Up Next episodes played from the playlist
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The Up Next payload carries no episode duration. Marking an episode played
in the UI reports the duration as the position, so the provider received 0
and took it for an explicit "mark unplayed": the episode stayed in Up Next
and was flagged unplayed.

The Up Next playlist now takes duration and playback status from the
(cached, throttled) episode details, falling back to the bare entry when
the details cannot be fetched.

Tested against the 2.10.7 source: 21/21 pocketcasts tests, ruff, mypy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:51:27 +01:00
k.renner 80f5310653 Merge pull request 'feat(music-assistant): Pocket Casts Up Next as a library playlist' (#1457) from claude/ma-pocketcasts-up-next-playlist into main
Reviewed-on: #1457
2026-10-26 12:30:42 +00:00
claude-botandClaude Opus 5.5 9895b7f4f4 feat(music-assistant): Pocket Casts Up Next as a library playlist
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Overlays the Pocket Casts provider with a patched __init__.py that adds
ProviderFeature.LIBRARY_PLAYLISTS and a read-only "Up Next" playlist of
podcast episodes, fetched live from /up_next/list in queue order.

An initContainer copies the image's provider into an emptyDir and swaps
in the patch only when the stock file's sha256 matches the version it was
written against (identical in 2.10.7 and 2.10.8). A later image bump that
changes the provider keeps the stock provider instead of breaking it.

Temporary until the change is upstreamed to music-assistant/server.
Tested against the 2.10.7 source: 18/18 pocketcasts tests, ruff, mypy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:27:44 +01:00
k.renner c380c7c2b8 Merge pull request 'revert(music-assistant): remove PO token server' (#1456) from revert/ma-po-token-server into main
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Reviewed-on: #1456
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-26 10:18:28 +00:00
claude-botandClaude Opus 5.5 0f86537bb1 revert(music-assistant): remove PO token server
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Reverts #1451. The YouTube Music provider is not kept; the PO token
server and its image automation go with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:11:11 +01:00
security-bot 31459dac6d chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1793006975
Updates managed by image-automation-controller.
2026-10-26 09:36:48 +00:00
k.renner 57c96a9a62 Merge pull request 'fix(image-automation): unpin the gitea-mcp ImageRepository' (#1452) from claude/gitea-mcp-imagerepo into main
Reviewed-on: #1452
2026-10-26 09:28:43 +00:00
claude-botandClaude Opus 5.5 ffd24865ee fix(image-automation): unpin the gitea-mcp ImageRepository
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Renovate #1439 pinned a digest into the ImageRepository's spec.image,
which image-reflector rejects ("should not include a tag"), so Flux
stopped tracking gitea-mcp-server. The image was missing from the
renovate.json list of images Flux image automation already bumps; add
it there so Renovate leaves it alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:11:42 +01:00
k.renner 439e4b4b83 Merge pull request 'feat(music-assistant): PO token server for YouTube Music provider' (#1451) from feat/ma-youtube-music-po-token-v2 into main
Reviewed-on: #1451
2026-10-26 09:05:42 +00:00
k.renner 622a4cc593 Merge pull request 'chore(coredns): Talos alone owns the Corefile (2/2)' (#1450) from claude/coredns-talos-owned-2 into main
Reviewed-on: #1450
2026-10-26 09:04:47 +00:00
claude-botandClaude Opus 5.5 96ea510143 feat(music-assistant): add PO token server for YouTube Music provider
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
MA's YouTube Music provider needs a bgutil PO token server; MA installs
the matching yt-dlp plugin unpinned from PyPI (2.0.2 today), so the
server tracks 2.x via image automation. The MA docs' "1.2.1" predates
the 2.x plugin line.

Reachable only from the MA pod; egress limited to DNS and world:443.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:02:41 +01:00
claude-botandClaude Opus 5.5 60d3acc202 chore(coredns): Talos alone owns the Corefile
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Step 2 of 2. The coredns ConfigMap is a Talos bootstrap manifest; Flux
stops applying its own copy (prune is disabled since the previous
commit, so the live ConfigMap stays until talosctl upgrade-k8s writes
Talos's version). CoreDNS forwards to the node resolver, which asks
k8s-gateway first, so cluster DNS no longer depends on Flux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:59:37 +01:00
k.renner cb697e2812 Merge pull request 'chore(coredns): keep Flux from pruning the Talos-owned Corefile (1/2)' (#1449) from claude/coredns-talos-owned into main
Reviewed-on: #1449
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-26 08:58:29 +00:00
claude-botandClaude Opus 5.5 1e26d5cdb2 chore(coredns): keep Flux from pruning the Talos-owned Corefile
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Step 1 of 2 to hand the coredns ConfigMap back to Talos. It is a Talos
bootstrap manifest (talos-bootstrap-manifests-inventory) that Flux also
applies; talosctl upgrade-k8s reverts Flux's Corefile. Bootstrap already
runs on Talos's Corefile (forward to the node resolver, which asks
k8s-gateway first), and k8s-gateway carries serve_stale for every name,
so the Flux copy is redundant.

With prune disabled, step 2 can drop the file without Flux deleting the
live ConfigMap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:55:13 +01:00
k.renner fbd3fbd119 Merge pull request 'fix(dns): pods ask the UDM on the segment they leave on' (#1448) from claude/k8s-gateway-ingress-resolver into main
Reviewed-on: #1448
2026-10-26 07:39:52 +00:00
k.renner 3c05098780 Merge pull request 'fix(egress): syncthing STUN 3479, Loki usage reporting off' (#1447) from claude/egress-followups into main
Reviewed-on: #1447
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-26 07:39:25 +00:00
claude-botandClaude Opus 5.5 5ecd17d26b fix(dns): pods ask the UDM on the segment they leave on
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Since UniFi OS 6.0.15 / Network 11.0.97 (2026-10-25 18:42Z) the UDM's
DNS answers out of the interface a query arrived on. Pod traffic to
10.2.40.1 arrives on VLAN 40 (on-link route in table 47) while the pod
CIDRs are routed back over VLAN 47, so the UDM ARPs for the pod on
VLAN 40 and never answers (seen with talosctl pcap on k8s-3; TCP 443 to
the same address works, UDP and TCP 53 do not). fd00::1 was reached
over VLAN 47 and kept working.

k8s-gateway's upstream (k8s-gateway-site), the blackbox-dns probes and
the gatus UDM check now use the UDM's ingress-segment addresses,
fd02:47::1 and 10.2.47.1 (the egress-gw annotations); both answer from
a pod.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 08:29:04 +01:00
security-bot f29e9f965a chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1792997373
Updates managed by image-automation-controller.
2026-10-26 06:56:47 +00:00
security-bot 42a3a31460 chore(image-automation): bump gitea.renner.uno/k.renner/shelly-manager:build.1792997278 gitea.renner.uno/gitea_admin/review-bot:2026-10-26-0647
Updates managed by image-automation-controller.
2026-10-26 06:53:44 +00:00
security-bot 188784d406 chore(image-automation): bump gitea.renner.uno/gitea_admin/secret-rotator:2026-10-26
Updates managed by image-automation-controller.
2026-10-26 06:53:42 +00:00
security-bot 6f5c4f7b81 chore(image-automation): bump docker.gitea.com/gitea-mcp-server:1.11.0
Updates managed by image-automation-controller.
2026-10-26 06:52:51 +00:00
claude-botandClaude Opus 5.5 76f3d4b6b5 fix(egress): syncthing STUN 3479, Loki usage reporting off
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
syncthing's STUN NAT-type probe sends its change request to port 3479,
which syncthing-egress dropped (seen: 77.72.169.212:3479/UDP).

Loki tried to send usage reports to Grafana (34.49.170.206:443), denied
by its egress policy; switch reporting off instead of allowing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:51:31 +01:00
k.renner 67ff176875 Merge pull request 'chore(deps): update alpine:3.24 docker digest to be26560' (#1441) from renovate/multus into main
Reviewed-on: #1441
2026-10-26 06:43:16 +00:00
k.renner 2ddb9544e6 Merge pull request 'feat(wiki): sync and encyclopedia schedules as CronJobs' (#1446) from feat/wiki-cronjobs into main
Reviewed-on: #1446
2026-10-26 06:43:02 +00:00
k.renner 25e70dc555 Merge pull request 'chore(deps): update home-assistant non-major' (#1442) from renovate/home-assistant-minor-patch into main
Reviewed-on: #1442
2026-10-26 06:42:36 +00:00
k.renner a4b18f44c3 Merge pull request 'chore(deps): update all non-major dependencies' (#1440) from renovate/all-minor-patch into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1440
2026-10-26 06:42:16 +00:00
k.renner 49856fe577 Merge pull request 'chore(deps): update influxdb:2.9.1 docker digest to 8f106f7' (#1443) from renovate/influxdb into main
Reviewed-on: #1443
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-26 06:42:01 +00:00
k.renner 576611faae Merge pull request 'chore(deps): update ci non-major' (#1444) from renovate/ci-minor-patch into main
ci/woodpecker/push/build-ci-images Pipeline is pending
ci/woodpecker/push/ci-claude-refresh Pipeline is pending
ci/woodpecker/push/deploy-infrastructure Pipeline is pending
Reviewed-on: #1444
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-26 06:41:44 +00:00
k.renner cc898ec553 Merge pull request 'chore(deps): update hermes image' (#1445) from renovate/hermes-image into main
ci/woodpecker/push/build-ci-images Pipeline is pending
ci/woodpecker/push/hermes-image Pipeline is pending
Reviewed-on: #1445
2026-10-26 06:41:25 +00:00
k.renner 7d53c99062 Merge pull request 'chore(deps): pin docker.gitea.com/gitea-mcp-server docker tag to ccdb566' (#1439) from renovate/pin-image-digests into main
Reviewed-on: #1439
2026-10-26 06:40:47 +00:00
claude-botandClaude Opus 5.5 8ce5ebd8ac feat(wiki): sync and encyclopedia schedules as CronJobs
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
wiki-sync (every 30 min) and wiki-encyclopedia (06:00 Europe/Berlin) call
the service's API with a generated scheduler key; the service no longer
schedules anything itself (konairius/Wiki#18). Pods in ai already reach
the wiki through the namespace default policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:37:15 +01:00
renovate-bot 9258947fef chore(deps): update hermes image
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
2026-10-26 03:01:35 +00:00
renovate-bot 1955a11b81 chore(deps): update ci non-major
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
2026-10-26 03:01:34 +00:00
renovate-bot da614f83fe chore(deps): update influxdb:2.9.1 docker digest to 8f106f7
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-26 03:01:30 +00:00
renovate-bot e5c99655e5 chore(deps): update home-assistant non-major
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-26 03:01:27 +00:00
renovate-bot 392a010daa chore(deps): update alpine:3.24 docker digest to be26560
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-26 03:01:25 +00:00
renovate-bot f04f449163 chore(deps): update all non-major dependencies
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
2026-10-26 03:01:23 +00:00
renovate-bot 32d0f0cc7b chore(deps): pin docker.gitea.com/gitea-mcp-server docker tag to ccdb566
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-26 03:01:19 +00:00
security-bot c79ccfb3f3 chore(image-automation): bump lscr.io/linuxserver/lidarr:3.1.6.5079-ls46
ci/woodpecker/cron/update-audit Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
Updates managed by image-automation-controller.
2026-10-26 02:52:46 +00:00
k.renner 0904f73721 Merge pull request 'fix(netpol): allow shelly-manager mDNS queries' (#1436) from claude/shelly-manager-mdns into main
Reviewed-on: #1436
2026-10-25 22:16:39 +00:00
k.renner ef2024c565 Merge pull request 'feat(mqtt): admin login as a register account' (#1437) from claude/mqtt-admin-register into main
Reviewed-on: #1437
2026-10-25 22:16:23 +00:00
claude-botandClaude Opus 5.5 3c86f0970c feat(mqtt): admin login as a register account
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The controller (main-b1f294d, konairius/mqtt-account-controller#13)
delivers a password account to a namespace Secret. admin moves into the
register with its password in Secret mqtt-admin-password; Dynamic
Security is asked first, so it replaces the password_file login without
a broker restart. The controller gets get/create/patch/delete on that
Secret by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:06:03 +01:00
security-bot 4c5665aa75 chore(image-automation): bump gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-25 gitea.renner.uno/gitea_admin/review-bot:2026-10-25-1455 gitea.renner.uno/gitea_admin/secret-rotator:2026-10-25
Updates managed by image-automation-controller.
2026-10-25 15:23:41 +00:00
security-bot 38091cbe11 chore(image-automation): bump gitea.renner.uno/gitea_admin/kuma-sync:2026-10-25-1459
Updates managed by image-automation-controller.
2026-10-25 15:23:40 +00:00
security-bot 33c0266fc1 chore(image-automation): bump gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-25-1504
Updates managed by image-automation-controller.
2026-10-25 15:17:02 +00:00
claude-botandClaude Opus 5.5 9bcc1cb420 fix(netpol): allow shelly-manager mDNS queries
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
shelly-manager resolves device *.local names via mDNS; the queries leave on
eth0 to 224.0.0.251:5353 and have been dropped since the egress lockdown
(#1432), failing with 'Name or service not known' since 15:07Z.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:10:58 +01:00
security-bot b10b50fd2d chore(image-automation): bump gitea.renner.uno/konairius/hermes:build.1792940452
Updates managed by image-automation-controller.
2026-10-25 15:06:45 +00:00
security-bot f9c517bd37 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-25-1458
Updates managed by image-automation-controller.
2026-10-25 15:06:43 +00:00
security-bot 49cafb2f6d chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1792939775
Updates managed by image-automation-controller.
2026-10-25 14:56:41 +00:00
k.renner 6fa10a7744 Merge pull request 'feat(wiki): gate wiki sign-in to Wiki Readers and enable encyclopedia planning' (#1435) from feat/wiki-encyclopedia-pilot into main
Reviewed-on: #1435
2026-10-25 14:51:08 +00:00
k.renner b341b26673 Merge pull request 'feat(netpol): restrict pod egress to the outside world per workload' (#1432) from claude/egress-lockdown into main
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
ci/woodpecker/push/deploy-claude-workstation Pipeline failed
ci/woodpecker/push/deploy-edge-mqtt-bridge Pipeline was successful
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/push/deploy-netbird-client Pipeline failed
ci/woodpecker/push/deploy-netbird-proxy Pipeline was successful
ci/woodpecker/push/deploy-netbird-server Pipeline was successful
ci/woodpecker/push/deploy-uptime-kuma Pipeline was successful
ci/woodpecker/push/hermes-image Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1432
2026-10-25 14:50:32 +00:00
k.renner aad24d4186 Merge pull request 'feat(mosquitto): retire legacy password users (admin stays, generated Secret)' (#1433) from claude/mosquitto-legacy-auth-removal into main
Reviewed-on: #1433
2026-10-25 14:50:09 +00:00
k.renner 7ef4475952 Merge pull request 'chore(ci): bump ci-claude image to 2.1.310' (#1434) from ci-image/claude-2.1.310 into main
Reviewed-on: #1434
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-25 14:48:49 +00:00
claude-botandClaude Opus 5.5 aca39a5a8f feat(wiki): gate wiki sign-in to Wiki Readers and enable encyclopedia planning
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Only members of the new Authentik group Wiki Readers may sign in to the wiki
OIDC application; membership is managed in Authentik, so the owner must be
added before merge. Encyclopedia is enabled with MaxArticles=0 so the first
nightly run only plans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:52:51 +01:00
claude-bot d7ca8dda11 chore(ci): bump ci-claude image to 2.1.310
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-25 00:01:21 +00:00
security-bot c8483b3545 chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.10.4
ci/woodpecker/cron/ci-claude-refresh Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Updates managed by image-automation-controller.
2026-10-24 20:50:33 +00:00
claude-botandClaude Opus 5.5 bb090e6809 feat(mosquitto): retire legacy password users; admin stays on a generated Secret
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Since the broker restart on 2026-10-22 only register accounts connect; the
last legacy logins (homeassistant, sml2mqtt, open3e, zigbee2mqtt) were on
2026-10-08, and admin/evcc never logged in within the 30-day Loki window.

- Delete mosquitto-bootstrap.yaml (5 Jobs that apk add at runtime + RBAC).
- The broker's password_file/acl_file now hold only admin, the owner's
  workstation login over the LoadBalancer. The controller has no delivery
  mode that puts a password account's password into a Secret, and the cert
  listener :8884 is in-cluster only, so admin cannot be a register account.
  Its password comes from an ESO Password generator (CreatedOnce), the
  pattern of apps/hermes/api-key.yaml, in Secret mosquitto-admin-password.
- Delete the evcc and health ExternalSecrets and the health rotator
  CronJob/policy; nothing reads them.
- Delete PROPOSAL-mosquitto-credential-rotation.md; its subject is gone.
- MosquittoDown test masks with the dynsec-test pod instead of a
  bootstrap Job pod; comments and docs follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:46:00 +02:00
claude-botandClaude Opus 5.5 5aee6a54ad revert(netpol): no world egress for mosquitto-bootstrap
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Runtime package installs are not to be enabled by policy. The Jobs feed only
the unused legacy password_file and are removed in a separate PR, which must
merge before this one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:33:25 +02:00
claude-botandClaude Opus 5.5 8a53171ad0 fix(netpol): let mosquitto-bootstrap Jobs reach Alpine mirrors
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The Jobs run apk add curl jq on every execution; without world 443/80 they
fail and block the home-assistant Kustomization. Also correct the stale
renderer egress comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:26:49 +02:00
claude-botandClaude Opus 5.5 299194247e feat(netpol): allow mqtt-account-controller to deliver to edge-1
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
edge-1.iot.renner.uno:8443 is the deliver.esp32 target in mqtt-accounts.yaml
(https+mTLS credential push). Matched by FQDN, not the current 10.2.30.108.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:26:00 +02:00
claude-bot 6b6e1e7e04 Merge remote-tracking branch 'origin/main' into worktree-egress-lockdown
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
# Conflicts:
#	kubernetes/infrastructure/network-policies/gitea-ns.yaml
#	kubernetes/infrastructure/network-policies/matrix-ns.yaml
2026-10-24 12:45:03 +02:00
claude-botandClaude Opus 5.5 752fcd5d03 feat(netpol): restrict pod egress to the outside world per workload
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
Drop namespace-wide world egress and give each workload only the external
destinations it needs (FQDN where the host is in our config, port-scoped
world otherwise). Namespaces that had ingress-only policies get an
in-cluster + DNS egress baseline. CNPG instances get Hetzner S3 via one
clusterwide policy.

Out of scope: kube-system, flux-system, rook-ceph, woodpecker-ci,
music-assistant, evcc, openbao.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:43:38 +02:00
security-bot 55cd083484 chore(image-automation): bump ghcr.io/advplyr/audiobookshelf:2.37.2
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Updates managed by image-automation-controller.
2026-10-23 20:49:32 +00:00
security-bot ada3d3d0b4 chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.10.3
Updates managed by image-automation-controller.
2026-10-23 19:50:03 +00:00
security-bot 1bd3111912 chore(image-automation): bump ghcr.io/music-assistant/server:2.10.8
Updates managed by image-automation-controller.
2026-10-23 14:49:29 +00:00
170 changed files with 3038 additions and 1705 deletions
+1 -1
View File
@@ -319,7 +319,7 @@ steps:
# writes its stripped mirror under mktemp and never into the workspace.
- name: promtool
depends_on: [] # see note above `steps:`
image: prom/prometheus:v3.15.0@sha256:efd719c99d83b060d9daefdcf00360461adf279f45ef5391f8d111892118753e
image: prom/prometheus:v3.15.1@sha256:fa3302279710be2f21a8301ec17a8b4f2301fd829bd113839b3b6b02b6f191a2
commands:
- sh scripts/ci/promtool-rules.sh
+5 -5
View File
@@ -35,25 +35,25 @@ when:
steps:
- name: model-selection-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-model-selection.sh
- name: pr-review-mistral-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-pr-review-mistral.sh
- name: promtool-rules-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-promtool-rules.sh
- name: yaml-citations-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-yaml-citations.sh
@@ -63,7 +63,7 @@ steps:
# bridge, and the secret guard.
- name: hermes-skill-tests
depends_on: []
image: python:3.14-alpine@sha256:f6a589d43c42b9e7f7dc67a12d37132491f362859a5d750607710cc56da3bc72
image: python:3.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
environment:
PYTHONDONTWRITEBYTECODE: "1"
commands:
-33
View File
@@ -1,33 +0,0 @@
when:
# Deploy only on main (merge) + manual — never auto-deploy from a branch.
- event: push
branch: main
path:
- "ansible/roles/claude-workstation/**"
- "ansible/plays/install-claude-workstation.yml"
- ".woodpecker/deploy-claude-workstation.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-claude-workstation"'
timeout: 30
steps:
- name: deploy-claude-workstation
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
- ansible-playbook -i inventory/ plays/install-claude-workstation.yml -v
-36
View File
@@ -1,36 +0,0 @@
when:
# Deploy only on main (merge) + manual — never auto-deploy from a branch.
- event: push
branch: main
path:
- "ansible/roles/mqtt-bridge/**"
- "ansible/plays/edge-mqtt-bridge.yml"
- "ansible/inventory/group_vars/edge_host/**"
- ".woodpecker/deploy-edge-mqtt-bridge.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-edge-mqtt-bridge"'
timeout: 30
steps:
- name: deploy-edge-mqtt-bridge
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
# Local MQTT bridge on the edge hosts only (REN-1307); restarts only the
# local mosquitto on edge-1, never the cluster broker.
- ansible-playbook -i inventory/ plays/edge-mqtt-bridge.yml -v
+1 -8
View File
@@ -13,7 +13,7 @@ when:
steps:
- name: validate-secrets
image: alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
image: alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
environment:
TF_VAR_proxmox_api_url:
from_secret: proxmox_api_url
@@ -109,8 +109,6 @@ steps:
from_secret: hetzner_s3_access_key
TF_VAR_hetzner_s3_secret_key:
from_secret: hetzner_s3_secret_key
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
PROXMOX_TOKEN_ID:
from_secret: proxmox_token_id
PROXMOX_TOKEN_SECRET:
@@ -121,11 +119,6 @@ steps:
ANSIBLE_ROLES_PATH: "../roles:./roles"
ANSIBLE_FORCE_COLOR: "true"
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- export TF_VAR_ssh_private_key="$(cat ~/.ssh/id_ed25519)"
- printf "Host *\n\tStrictHostKeyChecking no\n" > ~/.ssh/config
- echo "$VAULT_PASS" > /tmp/.vault_pass && chmod 600 /tmp/.vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=/tmp/.vault_pass
- tofu init -upgrade -backend-config="access_key=$AWS_ACCESS_KEY_ID" -backend-config="secret_key=$AWS_SECRET_ACCESS_KEY"
-35
View File
@@ -1,35 +0,0 @@
when:
# Deploy only on main (merge) + manual — never auto-deploy from a branch.
- event: push
branch: main
path:
- "ansible/roles/netbird-client/**"
- "ansible/plays/install-netbird-client.yml"
- "ansible/inventory/group_vars/claude_workstation/**"
- ".woodpecker/deploy-netbird-client.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-netbird-client"'
timeout: 30
steps:
- name: deploy-netbird-client
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
# Enrol only the off-site Claude workstation into the Netbird mesh.
- ansible-playbook -i inventory/ plays/install-netbird-client.yml --limit claude_workstation -v
-54
View File
@@ -1,54 +0,0 @@
when:
# Deploy only on main (merge) + manual — never auto-deploy from a branch.
- event: push
branch: main
path:
- "ansible/roles/netbird-proxy/**"
# install-netbird-proxy.yml also applies the netbird-proxy-watchdog role,
# so changes to it must redeploy this host too.
- "ansible/roles/netbird-proxy-watchdog/**"
# install-netbird-proxy.yml also applies the edge bouncer.
- "ansible/roles/crowdsec-edge-bouncer/**"
- "ansible/plays/install-netbird-proxy.yml"
# The variables that steer those roles, not just the roles themselves.
# Without these two, a change that only touches inventory never deploys
# and nothing says so: arming the bouncer lives in host_vars, and its
# LAPI key lives in group_vars, so both would sit in git looking applied
# while the hosts kept running the previous configuration.
#
# pve_hosts.yml is deliberately NOT listed. It is the single inventory
# file for the whole fleet, so listing it would redeploy the proxies for
# any unrelated host edit. The cost is that a change to [netbird_proxy]
# group membership needs a manual deployment run.
- "ansible/inventory/host_vars/proxy-*.yml"
- "ansible/inventory/group_vars/netbird_proxy/**"
- ".woodpecker/deploy-netbird-proxy.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-netbird-proxy"'
timeout: 30
steps:
- name: deploy-netbird-proxy
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
PROXMOX_TOKEN_ID:
from_secret: proxmox_token_id
PROXMOX_TOKEN_SECRET:
from_secret: proxmox_token_secret
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
- ansible-playbook -i inventory/ plays/install-netbird-proxy.yml -v
-37
View File
@@ -1,37 +0,0 @@
when:
# Deploy only on main (merge) + manual — never auto-deploy from a branch.
- event: push
branch: main
path:
- "ansible/roles/netbird-server/**"
- "ansible/plays/install-netbird-server.yml"
- ".woodpecker/deploy-netbird-server.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-netbird-server"'
timeout: 30
steps:
- name: deploy-netbird-server
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
PROXMOX_TOKEN_ID:
from_secret: proxmox_token_id
PROXMOX_TOKEN_SECRET:
from_secret: proxmox_token_secret
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
- ansible-playbook -i inventory/ plays/install-netbird-server.yml -v
-33
View File
@@ -1,13 +1,4 @@
when:
# Deploy only on main (merge) + manual — never auto-deploy from a branch.
- event: push
branch: main
path:
- "ansible/roles/uptime-kuma/**"
- "ansible/plays/install-uptime-kuma.yml"
- ".woodpecker/deploy-uptime-kuma.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-uptime-kuma"'
- event: cron
cron: uptime-kuma-token-renew
@@ -21,27 +12,3 @@ steps:
from_secret: openbao_uptime_kuma_token
commands:
- 'curl -sSf -X POST -H "X-Vault-Token: $VAULT_TOKEN" https://openbao.renner.uno/v1/auth/token/renew-self -o /dev/null'
- name: deploy-uptime-kuma
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-10-18
when:
- event: [push, deployment]
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
VAULT_TOKEN:
from_secret: openbao_uptime_kuma_token
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
- ansible-playbook -i inventory/ plays/install-uptime-kuma.yml -v
+1 -1
View File
@@ -26,7 +26,7 @@ steps:
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
# test from rotting (it exercises the exact injection shapes).
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-model-selection.sh
+1 -1
View File
@@ -6,7 +6,7 @@ when:
steps:
- name: go-test
image: golang:1.27.2-bookworm@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61
image: golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5
commands:
- test -z "$(gofmt -l review-bot)"
- go -C review-bot vet ./...
+3 -3
View File
@@ -18,12 +18,12 @@ when:
steps:
- name: iac-scan
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
image: aquasec/trivy:latest@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
commands:
- trivy fs --format json -o trivy-iac.json --severity CRITICAL,HIGH --ignore-unfixed .
- name: image-scans
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
image: aquasec/trivy:latest@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
commands:
- trivy image --download-db-only
- |
@@ -70,7 +70,7 @@ steps:
# this step is skipped — otherwise we'd fork-bomb PRs.
when:
- branch: main
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -30,7 +30,7 @@ when:
steps:
- name: update-audit-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
-28
View File
@@ -1,28 +0,0 @@
when:
# Manual only: reboots edge-1 (Z-Wave, heating CAN, sole NTP server) and the
# NetBird control plane. Run inside the 02:00-05:00 Europe/Berlin window.
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "update-os"'
timeout: 120
steps:
- name: update-os
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
- ansible-playbook -i inventory/ plays/update-os.yml -v
+1 -1
View File
@@ -206,7 +206,7 @@ inventory=~/source/homelab/ansible/inventory
;poll_interval=15
# (path) Option for connections using a certificate or key file to authenticate, rather than an agent or passwords, you can set the default value here to avoid re-specifying --private-key with every invocation.
private_key_file=~/.ssh/id_ed25519
;private_key_file=
# (boolean) By default, imported roles publish their variables to the play and other roles, this setting can avoid that.
# This was introduced as a way to reset role variables to default values if a role is used more than once in a playbook.
+15 -16
View File
@@ -72,14 +72,14 @@ none of these commands.
## Secrets (before first play run)
Create `inventory/group_vars/edge_host/vault.yml` with ansible-vault; the
current live values come straight out of the cluster:
Create `inventory/group_vars/edge_host/vault.yml` with ansible-vault. The
cluster broker has no `open3e` user: edge-1 logs in only as the register
account `edge-1` (see "MQTT bridge" below), so
`vault_edge_open3e_mqtt_password` reaches only the loopback broker, which
allows anonymous clients; any random value works (`openssl rand -hex 24`).
The zwave-js value comes out of the cluster:
```
# sml2mqtt broker password
kubectl -n home-assistant get secret mosquitto-sml2mqtt-credentials -o jsonpath='{.data.password}' | base64 -d
# open3e broker password
kubectl -n home-assistant get secret mosquitto-open3e-credentials -o jsonpath='{.data.password}' | base64 -d
# zwave-js session secret
kubectl -n home-assistant get secret zwave-js-session -o jsonpath='{.data.session-secret}' | base64 -d
```
@@ -230,15 +230,14 @@ They connect to a **local mosquitto on loopback** (`127.0.0.1:1883`,
broker is briefly unreachable, and flushes on reconnect — edge-1 no longer
hard-depends on cluster/network uptime for every publish.
Two bridge `connection`s preserve the existing cluster identities, so **no
cluster-side change was needed**: one authenticates as the `open3e` user
(carries `open3e/#` out + `open3e/cmnd` back), one as `sml2mqtt` (`sml2mqtt/#`
out). The local broker is `allow_anonymous` on loopback — the trust boundary
is the host; nothing off-host can reach it.
The local broker is `allow_anonymous` on loopback — the trust boundary is the
host; nothing off-host can reach it.
**Bridge credential (REN-1307).** The two vault-password bridges are being
replaced by one bridge as the register account `edge-1`
(`kubernetes/apps/home-assistant/mqtt-accounts.yaml`). Its password is pushed
**Bridge credential (REN-1307).** One bridge `connection` logs in as the
register account `edge-1` (`kubernetes/apps/home-assistant/mqtt-accounts.yaml`)
and carries `open3e/#` out and `open3e/cmnd` back. The cluster broker does not
accept the vault-password bridges in `bridge.d/legacy.conf`, which exists only
until the first push. Its password is pushed
by mqtt-account-controller to `edge-mqtt-cred` (`:8443`, the controller's
esp32 API over HTTPS). That endpoint accepts only a client certificate from
the MQTT CA with CN `mqtt-account-controller`; edge-1 holds the public CA and
@@ -246,8 +245,8 @@ its own server key, nothing else. On a push it writes
`/etc/mosquitto/bridge.d/edge-1.conf`, deletes `bridge.d/legacy.conf` and
restarts the local mosquitto. The controller sets a new password whenever the
bridge reports "not connected" (also after a reinstall), so no play run is
needed for a rotation. `.woodpecker/deploy-edge-mqtt-bridge.yml` runs
`plays/edge-mqtt-bridge.yml` on merge. Diagnosis: `journalctl -u
needed for a rotation. `plays/edge-mqtt-bridge.yml` is run by hand from a
workstation. Diagnosis: `journalctl -u
edge-mqtt-cred`, and `mosquitto_sub -t '$SYS/broker/connection/edge-1/state'`
(retained `1` = connected).
+7 -24
View File
@@ -5,27 +5,10 @@ pve_token_id: "ansible"
ansible_user: "ladmin"
ansible_python_interpreter: /usr/bin/python3
# SSH Public Keys for Cloud-Init
# ansible-runner@ci-new removed (issue #184): dead key, no such host exists.
common_ssh_public_keys: |
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPzFNBP3bq2m/6JqDqod9w+FP/uxXxHMolqhq3Ojflr1 krenner@fortress
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICyNnX1I4W9jyDvCJ1DJauHSmbo1ZXSyBxVhSAwMWgen woodpecker-ci@homelab
# Network-of-origin restriction for the woodpecker-ci@homelab CI key
# (issue #184). Consumed by plays/configure-ssh-keys.yml as the authorized_key
# `from=` option so the CI key only authenticates from within the cluster's
# pod network — NOT from any host-pinned IP.
#
# The in-cluster Woodpecker step pods SSH to fleet hosts using their POD IP as
# source, not a node IP: Cilium runs `ipv4-native-routing-cidr=10.0.0.0/8` +
# `ipv6-native-routing-cidr=fd00::/8` with masquerade enabled, so pod egress to
# any 10.x / fd.. LAN destination is NOT SNAT'd, and each node's PodCIDR is
# advertised to the UDM over BGP (kubernetes/infrastructure/post-crds/
# cilium-bgp.yaml) so return traffic L3-routes back to the pod. Hosts therefore
# see the real pod source IP.
#
# The cluster pod subnets (kubernetes/talos/patches/cluster.yaml podSubnets)
# are the stable boundary — they survive node DHCP-lease churn and pod
# rescheduling across k8s-1/2/3, unlike pinning any node's current IP.
# v6 is included because some SSH targets (edge IoT hosts) are IPv6-only.
ci_ssh_from_cidrs: '10.244.0.0/16,fd10:244::/48'
# The only plain key the admin logins accept on [ssh_keys_managed] hosts
# (ssh-ca role, tasks/authorized_keys.yml). People log in with OpenBao SSH CA
# certificates; this one is for when that path is unavailable.
# breakglass-neo: PIV key (slot 9a, PIN) on the offline YubiKey NEO.
# ssh -I /usr/lib/opensc-pkcs11.so root@<host>
ssh_admin_authorized_keys:
- key: "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKvSl9LUP8u3S2Sq5YTWpW0Jxbue8cJhD7/LMFQbEsa3xo145HVR7pBzRxW71MUy+jgrgmpSlDhIb606Hswhvoo= breakglass-neo-6532348"
@@ -0,0 +1,4 @@
---
# The host NetBird client (wt0) must not take over DNS: resolv.conf is pinned to
# the systemd-resolved stub by the netbird-proxy role.
netbird_disable_dns: true
+21 -3
View File
@@ -13,11 +13,11 @@
# Their hosts no longer resolve, except the three that resolve to Talos nodes.
[netbird_server]
netbird.renner.uno ansible_user=root
netbird.renner.uno ansible_host=netbird.nb.renner.uno ansible_user=root
[netbird_proxy]
proxy-1.renner.uno ansible_user=root
proxy-2.renner.uno ansible_user=root
proxy-1.renner.uno ansible_host=proxy-1.nb.renner.uno ansible_user=root
proxy-2.renner.uno ansible_host=proxy-2.nb.renner.uno ansible_user=root
# Every bare-metal edge thin client. Gets the host-generic roles only:
# flash-wear handling, the log shipper, the MQTT bridge, the VLAN
@@ -69,3 +69,21 @@ edge_host
# home, so it must NOT be used as ansible_host.
[claude_workstation]
claude-ws ansible_host=10.3.40.78 ansible_user=root
# Hosts that trust the OpenBao SSH user CA (plays/configure-ssh-ca.yml).
# Rolled out one host at a time: add the next host after the previous one
# was verified.
[ssh_ca]
proxy-1.renner.uno
proxy-2.renner.uno
netbird.renner.uno
edge-1.iot.renner.uno
# Hosts whose admin logins accept only ssh_admin_authorized_keys (break-glass
# + CI); everything else needs a CA certificate. Members must be in [ssh_ca].
# Added one host at a time after the [ssh_ca] report was reviewed.
[ssh_keys_managed]
proxy-1.renner.uno
proxy-2.renner.uno
netbird.renner.uno
edge-1.iot.renner.uno
+27
View File
@@ -0,0 +1,27 @@
---
# Trust the OpenBao SSH user CA (ssh-client-signer) on the hosts in [ssh_ca]:
# owner certificates (principal k.renner) for root/ladmin, Claude's 1h
# certificates (principal claude-bot) for its own claude-bot account with
# limited sudo. The first play only reports the admin logins' authorized_keys;
# the second reduces them to ssh_admin_authorized_keys on [ssh_keys_managed].
#
# serial: 1 so a host whose sshd rejects the change stops the run before the
# next host is touched.
- name: Configure the SSH user CA
hosts: ssh_ca
become: true
gather_facts: true
serial: 1
roles:
- ssh-ca
- name: Reduce authorized_keys to the break-glass key
hosts: ssh_keys_managed
become: true
gather_facts: false
serial: 1
tasks:
- name: Set the managed authorized_keys
ansible.builtin.include_role:
name: ssh-ca
tasks_from: authorized_keys
-48
View File
@@ -1,48 +0,0 @@
---
- name: Deploy authorized SSH keys to all hosts
hosts: all:!localhost
become: true
gather_facts: false
# Authorized keys deployed to root and ladmin on every managed host.
#
# - krenner@fortress (human): unrestricted.
# - woodpecker-ci@homelab (CI): restricted with from= to the cluster's pod
# network (ci_ssh_from_cidrs in group_vars/all/vars.yml). The in-cluster
# Woodpecker step pods SSH to fleet hosts presenting their POD IP as source
# (Cilium native-routing-cidr covers the LAN, so no SNAT; PodCIDRs are
# BGP-advertised). This is a network-of-origin restriction, NOT host-pinned,
# so it survives node DHCP churn and pod rescheduling. See vars.yml for the
# full derivation.
# - ansible-runner@ci-new: removed — dead key, no such host exists.
vars:
authorized_keys:
- key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPzFNBP3bq2m/6JqDqod9w+FP/uxXxHMolqhq3Ojflr1 krenner@fortress"
- key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICyNnX1I4W9jyDvCJ1DJauHSmbo1ZXSyBxVhSAwMWgen woodpecker-ci@homelab"
options: 'from="{{ ci_ssh_from_cidrs }}"'
tasks:
- name: Check SSH connectivity
ansible.builtin.wait_for_connection:
timeout: 5
ignore_errors: true
register: ssh_check
- name: Skip unreachable hosts
ansible.builtin.meta: end_host
when: ssh_check is failed
- name: Deploy authorized keys to root
ansible.builtin.authorized_key:
user: root
key: "{{ item.key }}"
key_options: "{{ item.options | default(omit) }}"
state: present
loop: "{{ authorized_keys }}"
- name: Deploy authorized keys to ladmin
ansible.builtin.authorized_key:
user: ladmin
key: "{{ item.key }}"
key_options: "{{ item.options | default(omit) }}"
state: present
loop: "{{ authorized_keys }}"
ignore_errors: true
+2 -4
View File
@@ -1,8 +1,6 @@
---
# Local MQTT bridge and its credential receiver only (REN-1307). Run by
# .woodpecker/deploy-edge-mqtt-bridge.yml on merge, so a change to this role
# reaches edge-1 without a play run by hand. The full base play
# (install-edge-host.yml) carries the same role.
# Local MQTT bridge and its credential receiver only (REN-1307). Run from a
# workstation. The full base play (install-edge-host.yml) carries the same role.
- name: Converge the edge MQTT bridge
hosts: edge_host
become: true
+9 -6
View File
@@ -15,17 +15,20 @@
- name: Install NetBird client on hosts with setup key defined
hosts: all
become: true
gather_facts: true
# One host at a time: from a workstation every new SSH session asks for the
# YubiKey PIN and a touch, and parallel hosts would prompt at once.
serial: 1
# Facts only after the skip: gathering them first opens an SSH session (a
# YubiKey touch from a workstation) to every host, including skipped ones.
gather_facts: false
pre_tasks:
- name: Check if netbird_setup_key is defined
ansible.builtin.debug:
msg: "NetBird setup key is defined for {{ inventory_hostname }}"
when: netbird_setup_key is defined
- name: Skip hosts without netbird_setup_key
ansible.builtin.meta: end_host
when: netbird_setup_key is not defined
- name: Gather facts
ansible.builtin.setup:
roles:
- netbird-client
+2 -4
View File
@@ -51,10 +51,8 @@
# -------------------------------------------------------
# 4. SSH Configuration
# -------------------------------------------------------
# Authorized keys (human + woodpecker-ci, the latter from=-restricted) are
# managed by the dedicated ansible/plays/configure-ssh-keys.yml play. The old
# "Add Ansible Runner SSH Key" task deployed ansible-runner@ci-new, a dead key
# for a host that no longer exists — removed (issue #184).
# SSH access (CA trust, admin authorized_keys) is managed by the ssh-ca role
# via plays/configure-ssh-ca.yml.
# -------------------------------------------------------
# 5. Disable NTP on VMs/LXC (use host time)
@@ -6,3 +6,13 @@ netbird_management_url: "https://netbird.renner.uno"
# Setup key - should be defined in group_vars or host_vars
# netbird_setup_key: ""
# Release installed on hosts without a NetBird package repository (Arch).
# Debian/Ubuntu follow the apt repository instead.
netbird_client_version: "0.75.0"
netbird_release_url: "https://github.com/netbirdio/netbird/releases/download/v{{ netbird_client_version }}"
# On the controller; the archive is copied from there to the hosts.
netbird_download_dir: "{{ lookup('ansible.builtin.env', 'HOME') }}/.cache/ansible/netbird"
# Leave the host's DNS configuration alone (hosts with a pinned resolv.conf).
netbird_disable_dns: false
+76 -29
View File
@@ -2,32 +2,82 @@
# NetBird Client Installation Role
# Installs and configures NetBird client on hosts with netbird_setup_key defined
- name: Install required packages
ansible.builtin.apt:
name:
- curl
- ca-certificates
- gnupg
state: present
update_cache: true
- name: Install NetBird from the NetBird apt repository
when: ansible_facts['os_family'] == 'Debian'
block:
- name: Install required packages
ansible.builtin.apt:
name:
- curl
- ca-certificates
- gnupg
state: present
update_cache: true
- name: Add NetBird repository key
ansible.builtin.shell:
cmd: |
curl -sSL https://pkgs.netbird.io/debian/public.key | gpg --dearmor -o /usr/share/keyrings/netbird-archive-keyring.gpg
creates: /usr/share/keyrings/netbird-archive-keyring.gpg
- name: Add NetBird repository key
ansible.builtin.shell:
cmd: |
curl -sSL https://pkgs.netbird.io/debian/public.key | gpg --dearmor -o /usr/share/keyrings/netbird-archive-keyring.gpg
creates: /usr/share/keyrings/netbird-archive-keyring.gpg
- name: Add NetBird repository
ansible.builtin.apt_repository:
repo: "deb [signed-by=/usr/share/keyrings/netbird-archive-keyring.gpg] https://pkgs.netbird.io/debian stable main"
state: present
filename: netbird
- name: Add NetBird repository
ansible.builtin.apt_repository:
repo: "deb [signed-by=/usr/share/keyrings/netbird-archive-keyring.gpg] https://pkgs.netbird.io/debian stable main"
state: present
filename: netbird
- name: Install NetBird client
ansible.builtin.apt:
- name: Install NetBird client
ansible.builtin.apt:
name: netbird
state: present
update_cache: true
# Arch has no netbird package in its repositories; the release binary is
# verified against the release's checksums file. It is downloaded on the
# controller and copied over: github.com has no IPv6 address, and proxy-1 is
# IPv6-only.
- name: Install NetBird from the release tarball
when: ansible_facts['os_family'] == 'Archlinux'
block:
- name: Create the controller download directory
ansible.builtin.file:
path: "{{ netbird_download_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
become: false
run_once: true
- name: Download the NetBird release on the controller
ansible.builtin.get_url:
url: "{{ netbird_release_url }}/netbird_{{ netbird_client_version }}_linux_amd64.tar.gz"
dest: "{{ netbird_download_dir }}/netbird_{{ netbird_client_version }}_linux_amd64.tar.gz"
checksum: "sha256:{{ netbird_release_url }}/netbird_{{ netbird_client_version }}_checksums.txt"
mode: "0644"
delegate_to: localhost
become: false
run_once: true
- name: Install the NetBird binary
ansible.builtin.unarchive:
src: "{{ netbird_download_dir }}/netbird_{{ netbird_client_version }}_linux_amd64.tar.gz"
dest: /usr/local/bin
include:
- netbird
owner: root
group: root
mode: "0755"
- name: Install the NetBird service
ansible.builtin.command: netbird service install
args:
creates: /etc/systemd/system/netbird.service
- name: Enable NetBird service
ansible.builtin.service:
name: netbird
state: present
update_cache: true
state: started
enabled: true
- name: Check if NetBird is already connected
ansible.builtin.command: netbird status
@@ -35,20 +85,17 @@
changed_when: false
failed_when: false
# "Disconnected" contains "Connected", so match the management line.
- name: Connect NetBird to management server
ansible.builtin.command: >
netbird up
--management-url {{ netbird_management_url }}
--setup-key {{ netbird_setup_key }}
when: "'Connected' not in netbird_status.stdout"
{{ '--disable-dns' if netbird_disable_dns else '' }}
when: "'Management: Connected' not in netbird_status.stdout"
register: netbird_connect
changed_when: netbird_connect.rc == 0
- name: Enable NetBird service
ansible.builtin.service:
name: netbird
state: started
enabled: true
no_log: true
- name: Get NetBird status
ansible.builtin.command: netbird status
+2 -12
View File
@@ -136,23 +136,13 @@
- /opt/netbird/proxy
- /opt/netbird/proxy-state
# The management store has no cluster-side backup. Pulling one put a root
# key on this host that the cluster held, and this server's own state is
# its own business to protect rather than the cluster's. Dropping the two
# tasks that placed them would only stop ansible managing the key and the
# script — the pair below revokes them instead, and can go once the role
# has run against every host that ever carried them.
# The management store has no cluster-side backup; the cluster's backup
# key is gone with the managed authorized_keys (ssh-ca role).
- name: Remove the backup dump script
ansible.builtin.file:
path: /usr/local/bin/netbird-backup-dump
state: absent
- name: Revoke the cluster backup key
ansible.posix.authorized_key:
user: root
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFeW/L9bHMTSa5/Qb0ClppCf4Z0hL39JwzI6tBmKd2FG netbird-backup@homelab-cluster"
state: absent
- name: Wait for management container to be ready
ansible.builtin.pause:
seconds: 10
+18
View File
@@ -0,0 +1,18 @@
---
# SSH user CA (OpenBao ssh-client-signer). Hosts trust certificates signed by
# it; the public key is fetched from OpenBao on the controller at run time.
ssh_ca_public_key_url: "https://openbao.renner.uno/v1/ssh-client-signer/public_key"
# Owner certificates (role owner) carry this principal. It is accepted for
# whichever admin login the distro has; users that do not exist are skipped.
ssh_ca_owner_principal: k.renner
ssh_ca_owner_users:
- root
- ladmin
# Claude's own account. Certificates (role claude, 1h) carry the principal.
ssh_ca_claude_user: claude-bot
ssh_ca_claude_principal: claude-bot
# Units claude-bot may restart/reload via sudo on this host, e.g. [netbird].
ssh_ca_claude_sudo_units: []
+2
View File
@@ -0,0 +1,2 @@
# Public keys whose certificates sshd must reject (one per line, OpenSSH format).
# Revoking a key also revokes every certificate issued for it.
@@ -0,0 +1,23 @@
---
# Replaces the admin logins' authorized_keys with ssh_admin_authorized_keys
# (break-glass + CI). Everything else logs in with a CA certificate.
- name: Look up the admin logins
ansible.builtin.getent:
database: passwd
key: "{{ item }}"
fail_key: false
loop: "{{ ssh_ca_owner_users }}"
register: ssh_ca_admin_lookup
- name: Set the admin logins' authorized_keys to the managed list
ansible.posix.authorized_key:
user: "{{ item.item }}"
key: |
{% for k in ssh_admin_authorized_keys %}
{{ (k.options ~ ' ') if (k.options | default('')) else '' }}{{ k.key }}
{% endfor %}
exclusive: true
loop: "{{ ssh_ca_admin_lookup.results }}"
loop_control:
label: "{{ item.item }}"
when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none
+153
View File
@@ -0,0 +1,153 @@
---
- name: Fetch the SSH CA public key from OpenBao
ansible.builtin.uri:
url: "{{ ssh_ca_public_key_url }}"
return_content: true
delegate_to: localhost
become: false
run_once: true
register: ssh_ca_key
- name: Check the fetched CA key is an SSH public key
ansible.builtin.assert:
that:
- ssh_ca_key.content is match('^ssh-ed25519 AAAA')
fail_msg: "Unexpected CA key from {{ ssh_ca_public_key_url }}"
run_once: true
- name: Install the trusted user CA key
ansible.builtin.copy:
content: "{{ ssh_ca_key.content | trim }}\n"
dest: /etc/ssh/trusted_user_ca_keys
owner: root
group: root
mode: "0644"
# sshd refuses all public key logins when RevokedKeys is set but unreadable,
# so the file is always deployed, even when empty.
- name: Install the revoked keys list
ansible.builtin.copy:
src: revoked_keys
dest: /etc/ssh/revoked_keys
owner: root
group: root
mode: "0644"
- name: Create the Claude account
ansible.builtin.user:
name: "{{ ssh_ca_claude_user }}"
shell: /bin/bash
create_home: true
password: "!"
- name: Look up the owner admin logins
ansible.builtin.getent:
database: passwd
key: "{{ item }}"
fail_key: false
loop: "{{ ssh_ca_owner_users }}"
register: ssh_ca_owner_lookup
- name: Create the principals directory
ansible.builtin.file:
path: /etc/ssh/auth_principals
state: directory
owner: root
group: root
mode: "0755"
- name: Accept the owner principal for the existing admin logins
ansible.builtin.copy:
content: "{{ ssh_ca_owner_principal }}\n"
dest: "/etc/ssh/auth_principals/{{ item.item }}"
owner: root
group: root
mode: "0644"
loop: "{{ ssh_ca_owner_lookup.results }}"
loop_control:
label: "{{ item.item }}"
when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none
- name: Accept the Claude principal for the Claude account
ansible.builtin.copy:
content: "{{ ssh_ca_claude_principal }}\n"
dest: "/etc/ssh/auth_principals/{{ ssh_ca_claude_user }}"
owner: root
group: root
mode: "0644"
- name: Install the Claude sudo rules
ansible.builtin.template:
src: sudoers-claude.j2
dest: "/etc/sudoers.d/{{ ssh_ca_claude_user }}"
owner: root
group: root
mode: "0440"
validate: visudo -cf %s
# A broken drop-in only bites on the next sshd restart, so it is tested here
# and removed again if the full config does not parse.
- name: Configure sshd for the user CA
block:
- name: Install the sshd drop-in
ansible.builtin.template:
src: ssh-ca.conf.j2
dest: /etc/ssh/sshd_config.d/50-ssh-ca.conf
owner: root
group: root
mode: "0644"
- name: Test the full sshd config
ansible.builtin.command: sshd -t
changed_when: false
rescue:
- name: Remove the drop-in that broke sshd -t
ansible.builtin.file:
path: /etc/ssh/sshd_config.d/50-ssh-ca.conf
state: absent
- name: Fail the host
ansible.builtin.fail:
msg: "sshd -t failed with the SSH CA drop-in; it was removed again"
- name: Read the effective sshd settings
ansible.builtin.command: sshd -T
changed_when: false
register: ssh_ca_effective
# Keyword case in sshd -T output differs between OpenSSH versions (mixed case
# on 10.6), so the lines are compared lowercased.
- name: Check sshd uses the CA, principals and revocation list
vars:
ssh_ca_effective_lines: "{{ ssh_ca_effective.stdout_lines | map('lower') | list }}"
ansible.builtin.assert:
that:
- "'trustedusercakeys /etc/ssh/trusted_user_ca_keys' in ssh_ca_effective_lines"
- "'authorizedprincipalsfile /etc/ssh/auth_principals/%u' in ssh_ca_effective_lines"
- "'revokedkeys /etc/ssh/revoked_keys' in ssh_ca_effective_lines"
fail_msg: "sshd -T does not show the SSH CA settings; another config line takes precedence"
# The unit is sshd.service on Arch and RedHat, ssh.service on Debian/Ubuntu.
# Ubuntu also lists sshd.service as a not-found alias, so the running one is
# picked. sshd -T only parses the files, so the running daemon is reloaded on
# every run; a reload keeps open sessions.
- name: Read the service list
ansible.builtin.service_facts:
- name: Reload the running sshd unit
vars:
ssh_ca_sshd_units: "{{ ansible_facts.services | dict2items | selectattr('key', 'in', ['sshd.service', 'ssh.service']) | selectattr('value.state', 'equalto', 'running') | map(attribute='key') | list }}"
block:
- name: Check exactly one sshd unit is running
ansible.builtin.assert:
that:
- ssh_ca_sshd_units | length == 1
fail_msg: "Expected one running sshd.service or ssh.service, found {{ ssh_ca_sshd_units }}"
- name: Reload sshd so the running daemon uses the CA config
ansible.builtin.systemd:
name: "{{ ssh_ca_sshd_units | first }}"
state: reloaded
- name: Report the admin logins' authorized keys
ansible.builtin.include_tasks: report.yml
+26
View File
@@ -0,0 +1,26 @@
---
# Read-only: what the admin logins accept besides certificates. Hosts in
# [ssh_keys_managed] have this reduced to ssh_admin_authorized_keys.
- name: Find the admin logins' authorized_keys
ansible.builtin.stat:
path: "{{ item.ansible_facts.getent_passwd[item.item][4] }}/.ssh/authorized_keys"
loop: "{{ ssh_ca_owner_lookup.results }}"
loop_control:
label: "{{ item.item }}"
when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none
register: ssh_ca_keyfiles
- name: Fingerprint the admin logins' authorized keys
ansible.builtin.command: ssh-keygen -lf {{ item.stat.path }}
loop: "{{ ssh_ca_keyfiles.results | selectattr('stat', 'defined') | selectattr('stat.exists') | list }}"
loop_control:
label: "{{ item.stat.path }}"
changed_when: false
register: ssh_ca_fingerprints
- name: Report the admin logins' authorized keys
ansible.builtin.debug:
msg: "{{ item.item.stat.path }}: {{ item.stdout_lines }}"
loop: "{{ ssh_ca_fingerprints.results }}"
loop_control:
label: "{{ item.item.stat.path }}"
@@ -0,0 +1,4 @@
# {{ ansible_managed }}
TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
RevokedKeys /etc/ssh/revoked_keys
@@ -0,0 +1,6 @@
# {{ ansible_managed }}
# NOEXEC: journalctl/systemctl open a pager, which must not spawn a shell.
{{ ssh_ca_claude_user }} ALL=(root) NOPASSWD:NOEXEC: /usr/bin/journalctl, /usr/bin/journalctl *, /usr/bin/systemctl status, /usr/bin/systemctl status *, /usr/bin/systemctl list-units, /usr/bin/systemctl list-units *, /usr/bin/ss -tulpn, /usr/sbin/sshd -T
{% for unit in ssh_ca_claude_sudo_units %}
{{ ssh_ca_claude_user }} ALL=(root) NOPASSWD:NOEXEC: /usr/bin/systemctl restart {{ unit }}, /usr/bin/systemctl reload {{ unit }}
{% endfor %}
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
# Alpine plus jq. Nothing application-specific — a general-purpose base for any
# workload that needs to read JSON from a shell script.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
# 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
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
RUN apk add --no-cache \
ansible \
+2 -2
View File
@@ -5,12 +5,12 @@
# The unit tests gate the image: the final stage copies the controller out of
# this stage, so a failing test fails the build.
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce AS test
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS test
WORKDIR /src
COPY controller.py test_controller.py ./
RUN python3 -B -m unittest -v
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY --from=test /src/controller.py /opt/controller.py
+2 -2
View File
@@ -1,6 +1,6 @@
FROM hetznercloud/cli:v1.70.1@sha256:795ac09a5ecabd48cc13a8f1c3dc45983c8f75ae1277c70d621719e07f276ccb AS hcloud
FROM hetznercloud/cli:v1.70.2@sha256:3b3a8f66323a5754089df7939fd9d3f350127072deed13418f3d3052b4bbeeae AS hcloud
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
RUN apk add --no-cache \
bash \
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
# bird: BGP daemon for the pod-network mesh over pod0. Distro package, no build
# step — the image is the binary plus its runtime.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-alpine@sha256:44fce696f38ba8c987c4db89954051000bbd05ffd8d8659ba15ed4a8602e937f
FROM node:24-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53
# Default to the latest Claude Code. Pinning a fixed version is the wrong
# default here: old releases eventually stop working against the API, so a
+2 -2
View File
@@ -2,7 +2,7 @@
# Cloudflare. The unit tests (no tags, so no envtest) gate the image: the
# final stage copies the binary out of this stage, so a failing test fails the
# build.
FROM golang:1.27@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8 AS build
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
@@ -10,7 +10,7 @@ COPY . .
RUN go test ./...
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/cloudflare-lb-sync ./cmd/cloudflare-lb-sync
FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
COPY --from=build /out/cloudflare-lb-sync /cloudflare-lb-sync
USER 65532
ENTRYPOINT ["/cloudflare-lb-sync"]
+2 -2
View File
@@ -28,7 +28,7 @@
# amd64 only, matching the ARCH the installer already hardcoded and the cluster's
# node architecture. Add a second stage if a different arch is ever needed.
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 AS fetch
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a AS fetch
ARG CNI_PLUGINS_VERSION=v1.9.1
ARG CNI_PLUGINS_SHA256=b98f74a0f8522f0a83867178729c1aa70f2158f90c45a2ca8fa791db1c76b303
@@ -61,7 +61,7 @@ RUN set -eu; \
[ -x "/plugins/$p" ] || { echo "FATAL: /plugins/$p missing or not executable" >&2; exit 1; }; \
done
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
# Alpine rather than scratch: the installer needs a shell to copy the binaries
# onto the host. busybox covers cp/mv/chown/ls, so nothing is added.
+2 -2
View File
@@ -23,7 +23,7 @@
# namespace, so can0 is only reachable with network_mode: host. That is also
# how the local MQTT broker on 127.0.0.1:1883 is reached. CAN_RAW sockets need
# no capabilities, so both run unprivileged.
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce AS build
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
# Pins live here, in the artifact, rather than in Ansible variables.
#
@@ -56,7 +56,7 @@ RUN git clone --quiet https://github.com/MyHomeMyData/E3onCAN.git /opt/e3oncan \
&& rm -rf /opt/e3oncan/.git
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
+4 -4
View File
@@ -1,4 +1,4 @@
FROM nousresearch/hermes-agent:v0.22.2@sha256:36099926da316742423544e055d4734777105ffe6341873af5459b957d0c2919
FROM nousresearch/hermes-agent:v0.22.5@sha256:9c5561579f3ffdf333401797094ac3a743738d6c25d40f4331c7bf77b694238d
USER root
@@ -6,9 +6,9 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends jq \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/siderolabs/talosctl:v1.13.10@sha256:3116f64c97ddffb53bd3c5bf87c8614d99f04df4326177dbbb7702b5087f0e4d /talosctl /usr/local/bin/talosctl
COPY --from=alpine/kubectl:1.36.3@sha256:dae261106d5555008acede256b7102c8a6decde7209198434f5999f051460f57 /usr/local/bin/kubectl /usr/local/bin/kubectl
COPY --from=ghcr.io/fluxcd/flux-cli:v2.9.5@sha256:704d5529535570a495249b643159c93a9aa88ffca07813c974a54b7ba6edbd86 /usr/local/bin/flux /usr/local/bin/flux
COPY --from=ghcr.io/siderolabs/talosctl:v1.14.4@sha256:b3233234327350f20ed194f3fad83d90a64344c1940f839c7b0c0abef50c8c33 /talosctl /usr/local/bin/talosctl
COPY --from=alpine/kubectl:1.37.1@sha256:a5d0fbecab3f7b6c2ab7398f1daa73a3e0ed6ece18556d21698233e9e3759cc7 /usr/local/bin/kubectl /usr/local/bin/kubectl
COPY --from=ghcr.io/fluxcd/flux-cli:v2.9.6@sha256:b1ac18156f227af9a524b842a96f2c20986c7a779ef721df3ba4e4540f449d76 /usr/local/bin/flux /usr/local/bin/flux
COPY --from=quay.io/openbao/openbao:2.7.1@sha256:6d2b93856e3fcf7b18ad855a0b51eaba474dc8b79cf554379ea32034797d2acf /usr/bin/bao /usr/local/bin/bao
COPY --from=gitea/tea:0.16.0@sha256:af50a2060ce4d8fd76b7ebe75a3d6db4bb0c615c5bc8f2bdfd8ea83207c4752d /usr/bin/tea /usr/local/bin/tea
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
ARG TALOS_VERSION=v1.13.12
ARG VELERO_VERSION=v1.16.0
+2 -2
View File
@@ -1,7 +1,7 @@
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. The unit tests
# (no tags, so no Docker and no envtest) gate the image: the final stage copies
# the binary out of this stage, so a failing test fails the build.
FROM golang:1.27@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8 AS build
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
@@ -9,7 +9,7 @@ COPY . .
RUN go test ./...
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/kuma-sync ./cmd/kuma-sync
FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
COPY --from=build /out/kuma-sync /kuma-sync
USER 65532
ENTRYPOINT ["/kuma-sync"]
+1 -1
View File
@@ -2,7 +2,7 @@
# domains -> TLS passthrough -> Traefik ClusterIP) from Ingresses carrying the
# public label. Standard library only, so the image is the interpreter plus one
# file; runs unprivileged as its own user.
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY controller.py /opt/controller.py
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
ARG TOFU_VERSION=1.11.0
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
# Everything `bootstrap.sh vmtest` needs, so the rebuild test can run as a Job in
# the cluster instead of on a workstation. The workstation has 31G and the test
+7 -4
View File
@@ -12,7 +12,10 @@ The image is the official archiso **releng** profile plus:
- `edge-install` in the live environment — partitions the NVMe
(ESP + ext4 root, systemd-boot), pacstraps the base system incl.
docker/python, configures DHCP via systemd-networkd, enables sshd and
drops the common homelab SSH keys for root (key-only, no root password)
sets up root login (key-only, no root password) with owner certificates
from the OpenBao SSH CA, fetched at build time from the `ssh-ca` role's
URL, and the break-glass key (YubiKey NEO, PIV) as fallback:
`ssh -I /usr/lib/opensc-pkcs11.so root@<host>`
- the extra disk tools the installer needs
Division of labour: the ISO produces an **ansible-ready base system**;
@@ -30,7 +33,8 @@ sudo ./build.sh # ISO lands in ./out/
The profile is assembled at build time from `/usr/share/archiso/configs/releng`
plus the overlay in `airootfs/` — the repo carries only the delta, so
archiso updates flow in automatically.
archiso updates flow in automatically. The build needs openbao.renner.uno
for the CA key; rebuild the ISO after a CA rotation.
## Install
@@ -38,8 +42,7 @@ archiso updates flow in automatically.
2. Boot the target from it (UEFI, Secure Boot off; one-time F12/setup
visit if the box doesn't already boot USB first)
3. Either on the console (auto-login as root) or headless —
`ssh root@<dhcp-ip>` works out of the box, the live env carries the
homelab keys — run `edge-install`. It auto-detects the internal disk
`ssh root@<dhcp-ip>` with an owner certificate — run `edge-install`. It auto-detects the internal disk
(NVMe, else eMMC — covers the Wyse 3040 — else first non-USB disk),
shows the target and requires typing the hostname before wiping
anything. Override with `edge-install /dev/sdY`.
@@ -1,4 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPzFNBP3bq2m/6JqDqod9w+FP/uxXxHMolqhq3Ojflr1 krenner@fortress
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGfe3HJqFR+jIGrboweseAO7XjIVE24sje/b1HybgU9C ansible-runner@ci-new
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICyNnX1I4W9jyDvCJ1DJauHSmbo1ZXSyBxVhSAwMWgen woodpecker-ci@homelab
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBB1OFjh6Q5xeioI6Cwfuv8Xka27veomYy9tKBbpMo8mcrsB4yIN4FX8HovBLtogetBhwBUefBar8nhA9GbRh6HQ= krenner@krenner-XPS-13-9310
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKvSl9LUP8u3S2Sq5YTWpW0Jxbue8cJhD7/LMFQbEsa3xo145HVR7pBzRxW71MUy+jgrgmpSlDhIb606Hswhvoo= breakglass-neo-6532348
@@ -1,4 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPzFNBP3bq2m/6JqDqod9w+FP/uxXxHMolqhq3Ojflr1 krenner@fortress
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGfe3HJqFR+jIGrboweseAO7XjIVE24sje/b1HybgU9C ansible-runner@ci-new
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICyNnX1I4W9jyDvCJ1DJauHSmbo1ZXSyBxVhSAwMWgen woodpecker-ci@homelab
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBB1OFjh6Q5xeioI6Cwfuv8Xka27veomYy9tKBbpMo8mcrsB4yIN4FX8HovBLtogetBhwBUefBar8nhA9GbRh6HQ= krenner@krenner-XPS-13-9310
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKvSl9LUP8u3S2Sq5YTWpW0Jxbue8cJhD7/LMFQbEsa3xo145HVR7pBzRxW71MUy+jgrgmpSlDhIb606Hswhvoo= breakglass-neo-6532348
@@ -81,6 +81,17 @@ ln -sf /run/systemd/resolve/stub-resolv.conf /mnt/etc/resolv.conf
install -d -m700 /mnt/root/.ssh
install -m600 /root/edge-authorized_keys /mnt/root/.ssh/authorized_keys
# SSH user CA trust as baked into this ISO (owner certificates for root).
install -m644 -t /mnt/etc/ssh /etc/ssh/trusted_user_ca_keys /etc/ssh/revoked_keys
install -d -m755 /mnt/etc/ssh/auth_principals /mnt/etc/ssh/sshd_config.d
install -m644 /etc/ssh/auth_principals/root /mnt/etc/ssh/auth_principals/root
install -m644 /etc/ssh/sshd_config.d/50-ssh-ca.conf /mnt/etc/ssh/sshd_config.d/
# sshd -t/-T need host keys; sshdgenkeys would create them on first boot.
ssh-keygen -A -f /mnt
arch-chroot /mnt sshd -t
arch-chroot /mnt sshd -T | grep -qix 'trustedusercakeys /etc/ssh/trusted_user_ca_keys' \
|| { echo "installed sshd does not use the SSH CA"; exit 1; }
arch-chroot /mnt systemctl enable \
systemd-networkd systemd-resolved systemd-timesyncd sshd docker
+17
View File
@@ -10,6 +10,23 @@ profile=/tmp/profile
cp -r /usr/share/archiso/configs/releng/. "$profile"
cp -r /src/airootfs/. "$profile/airootfs/"
# SSH user CA trust from the ssh-ca role (mounted at /ssh-ca): the live system
# and the installed one accept owner certificates for root, like the managed
# hosts. The CA public key is fetched from OpenBao at build time.
ca_url=$(sed -n 's/^ssh_ca_public_key_url: "\(.*\)"$/\1/p' /ssh-ca/defaults/main.yml)
principal=$(sed -n 's/^ssh_ca_owner_principal: \(.*\)$/\1/p' /ssh-ca/defaults/main.yml)
[ -n "$ca_url" ] && [ -n "$principal" ] || { echo "ssh-ca defaults not readable"; exit 1; }
ca_key=$(curl -sSf "$ca_url")
[[ "$ca_key" == "ssh-ed25519 AAAA"* ]] || { echo "unexpected CA key from $ca_url"; exit 1; }
sshd_conf=$(grep -vx '# {{ ansible_managed }}' /ssh-ca/templates/ssh-ca.conf.j2)
! grep -q '{[{%]' <<<"$sshd_conf" || { echo "ssh-ca.conf.j2 has template logic"; exit 1; }
ssh_dir="$profile/airootfs/etc/ssh"
install -d "$ssh_dir/sshd_config.d" "$ssh_dir/auth_principals"
printf '%s\n' "$ca_key" > "$ssh_dir/trusted_user_ca_keys"
cp /ssh-ca/files/revoked_keys "$ssh_dir/revoked_keys"
printf '%s\n' "$principal" > "$ssh_dir/auth_principals/root"
printf '%s\n' "$sshd_conf" > "$ssh_dir/sshd_config.d/50-ssh-ca.conf"
# Tools the installer script needs in the live environment (idempotent —
# skip entries releng already ships).
for pkg in gptfdisk dosfstools e2fsprogs; do
+1
View File
@@ -13,6 +13,7 @@ mkdir -p "$out"
docker run --rm --privileged \
-v "$here:/src:ro" \
-v "$here/../../ansible/roles/ssh-ca:/ssh-ca:ro" \
-v "$out:/out" \
archlinux:latest /src/build-inner.sh
+11 -9
View File
@@ -52,9 +52,9 @@ kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste key
**Unseal is automatic since 2026-09-08** (static seal, PR #825, issue #805):
the seal key lives in the `openbao-seal-key` Secret (created by `bootstrap.sh`
from `pass show homelab/openbao/seal-key`) and every restarted replica unseals
itself. The shamir key above became the **recovery key** in the migration; it
is kept in `pass` under its old name, `homelab/openbao/unseal-key`. The server
from `~/.config/homelab-secrets/seal-key.age`) and every restarted replica
unseals itself. The shamir key above became the **recovery key** in the
migration; it is `~/.config/homelab-secrets/openbao-recovery-key.age`. The server
runs three raft peers (see `infrastructure/openbao/helmrelease.yaml`); a sealed
one is neither a voter nor a standby, so a replica that stays sealed after a
restart — wrong or missing seal-key Secret — costs redundancy, two cost
@@ -237,19 +237,21 @@ one full reconcile + one OpenBao pod restart/unseal cycle:
- **Backups:** ✅ nightly raft-snapshot CronJob (03:00, keeps 7 on a
dedicated PVC; see infrastructure/openbao/snapshot-cronjob.yaml) + velero
`openbao-daily` Schedule (06:15) ships that PVC offsite, 30 d retention.
A raft snapshot restore needs the matching seal key — which lives in
`pass`, outside the cluster (GPG key backed up externally 2026-07-14).
A raft snapshot restore needs the matching seal key — which lives outside
the cluster in `~/.config/homelab-secrets/seal-key.age` (age, encrypted to
both YubiKeys and the offline paper key; copy on nas-2).
A failed snapshot Job is the sealed-after-restart alarm. Still owed:
one tested restore before phase 4.
- **Cluster rebuild order:** seal-key Secret from `pass` → Flux brings up
- **Cluster rebuild order:** seal-key Secret from `seal-key.age` → Flux brings up
openbao (empty) → init → restore raft snapshot → replicas unseal themselves
→ ClusterSecretStore Ready → ESO materializes Secrets → apps start
(`bootstrap.sh flux` does all of it; RUNBOOK-cold-rebuild.md Step 4 by
hand). Until then, secret-consuming apps simply stay Pending on missing
Secrets — same as any missing dependency.
- **Break-glass:** `pass` holds seal key, recovery key + root token (GPG,
offline-capable). With those, everything is recoverable from a raft
snapshot alone.
- **Break-glass:** `~/.config/homelab-secrets` holds the seal key and
recovery key (YubiKeys + paper key, copy on nas-2);
`~/.config/openbao/root-token.age` holds the root token (YubiKeys). With
those, everything is recoverable from a raft snapshot alone.
- **Ansible Vault password:** `ansible/vault-password.sh` reads
`ANSIBLE_VAULT_PASSWORD` from the environment with no offline backup as of
2026-07-14. It gates decryption of several Ansible-Vault files (PBS
+24 -13
View File
@@ -67,17 +67,24 @@ These must exist outside the cluster or the rebuild cannot complete:
`flux` phase creates the `sops-age` Secret, and any not-yet-migrated sops
files decrypt with it. (Post-OpenBao-phase-4 this may be retired — see
`MIGRATION-sops-openbao.md`.)
- [ ] **OpenBao seal key, recovery key + root token** — in the GPG `pass`
store (`homelab/openbao/seal-key`, `homelab/openbao/unseal-key` — the
former shamir key, now the recovery key — and `homelab/openbao/root-token`),
and the GPG private key backed up off-machine. The seal key is the one
the snapshots are encrypted under: without it, no OpenBao snapshot can be
restored and no secret-consuming app can start.
- [ ] **OpenBao seal key, recovery key, restore + pull credentials** — age
files in `~/.config/homelab-secrets/` (`seal-key.age`,
`openbao-recovery-key.age` — the former shamir key —, `s3-access.age`,
`s3-secret.age`, `ghcr-pull.age`, `hetzner-cloud-api-secret.age`),
encrypted to both YubiKeys and the
offline paper key in the safe; a copy is on nas-2 in
`~ladmin/homelab-secrets-backup` (`~/.ssh/ssh-keys-rotate dr-copy`). The
root token is `~/.config/openbao/root-token.age` (YubiKeys only). Needed
to decrypt: one YubiKey with its PIV PIN, or the paper key. The seal key
is the one the snapshots are encrypted under: without it, no OpenBao
snapshot can be restored and no secret-consuming app can start.
- [ ] **OpenBao raft snapshot** reachable — nightly PVC snapshots
(`infrastructure/openbao/snapshot-cronjob.yaml`) plus the Velero
`openbao-daily` offsite copy. In a total-loss rebuild the offsite Velero
copy is the one that survives.
- [ ] **Tooling:** `talosctl`, `kubectl`, `flux`, `tofu`, `bao`, plus the
- [ ] **Tooling:** `talosctl`, `kubectl`, `flux`, `tofu`, `bao`, `age` +
`age-plugin-yubikey` (the plugin is only needed for the YubiKeys; the
paper key needs `age` alone), plus the
OpenTofu backend + UniFi credentials the `opentofu/` stack needs. No
Proxmox credentials — there is no Proxmox any more.
- [ ] **Install media** written and to hand: the Image Factory ISO named in
@@ -299,14 +306,16 @@ output** — a green step over a still-forming Ceph is a known trap.
## Step 4 — openbao: restore the snapshot
`bootstrap.sh flux` does this step itself (`restore_openbao`): it creates the
`openbao-seal-key` Secret from `pass` before Flux brings OpenBao up, initialises
`openbao-seal-key` Secret from `~/.config/homelab-secrets/seal-key.age` before Flux brings OpenBao up, initialises
the empty server, restores the newest raft snapshot straight out of the Velero
kopia repository, and waits until every replica has unsealed itself from the
static seal. What follows is the same procedure by hand, for when the script
is not an option.
OpenBao unseals itself from the static seal (`openbao-seal-key` Secret, key
`key`, the 32-byte base64 key from `pass show homelab/openbao/seal-key`). The
`key`, the 32-byte base64 key in `~/.config/homelab-secrets/seal-key.age`,
age-encrypted to the owner's YubiKeys and the offline paper key; a copy sits on
nas-2 in `~ladmin/homelab-secrets-backup`). The
snapshots are encrypted under that key, so it must be the same key the backed
up cluster used — a different one restores nothing. Until the snapshot is
restored, the `ClusterSecretStore` stays NotReady, ESO cannot materialize
@@ -316,7 +325,8 @@ Secrets, and every secret-consuming app stays Pending.
# 1. The seal key, before the pods start (a pod without it never starts).
kubectl create namespace openbao --dry-run=client -o yaml | kubectl apply -f -
kubectl -n openbao create secret generic openbao-seal-key \
--from-literal=key="$(pass show homelab/openbao/seal-key)"
--from-literal=key="$(age -d -i ~/.config/age/yubikeys.txt ~/.config/homelab-secrets/seal-key.age)"
# Both YubiKeys lost: -i <file holding the paper key> instead.
# 2. Wait for openbao-0 (uninitialised: 0/1 Ready is expected), then
# initialise it. With an auto-seal, init hands out recovery keys and the
@@ -337,7 +347,8 @@ kubectl -n openbao exec -it openbao-0 -- \
# 4. The server seals against the snapshot's keyring and unseals itself from
# the static seal; openbao-1 and openbao-2 follow (OrderedReady). Nothing
# to type. Afterwards the tokens and recovery key from pass apply again.
# to type. Afterwards the operator's root token
# (~/.config/openbao/root-token.age) and recovery key apply again.
kubectl -n openbao get pods -w
```
@@ -352,7 +363,7 @@ kubectl -n <ns> get externalsecret # want: SecretSynced
> A replica that stays **sealed** after a restart could not use the static
> seal — the Secret is missing or does not match the key the data was
> written under. Fix the Secret and restart the pod; `bao operator unseal`
> with the recovery key (`pass show homelab/openbao/unseal-key`) does not
> with the recovery key (`~/.config/homelab-secrets/openbao-recovery-key.age`) does not
> unseal an auto-sealed server and is only for recovery-mode operations.
> Details + rationale: `MIGRATION-sops-openbao.md`.
@@ -434,7 +445,7 @@ it: repo → **Settings → Repository → Mirror Settings** in Gitea, or
Step 2 no longer uses it. Its `flux` and `wait-apps` phases are still used in
Steps 3, 5 and 6 and work; do not read this as the whole script being gone.
Its `flux` phase carries the OpenBao restore (Step 4); the seal key and the
other secrets are read from `pass` or streamed in at run time and never live
other secrets are decrypted from `~/.config/homelab-secrets` or streamed in at run time and never live
in CI.
- **OpenBao unseals itself, but only under the seal key the snapshot was
written with.** Three raft replicas mean the service survives losing one; a
@@ -48,7 +48,7 @@ spec:
# cert when doing server-side OIDC token exchange. Bundle both
# roots into one PEM since NODE_EXTRA_CA_CERTS accepts one file.
- name: fetch-le-staging-roots
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
image: curlimages/curl:8.23.0@sha256:11e64e28c5f58c8d93ee95fcf22ac055f48b0bc440e0ce7d8013bbb5ad6aa8ac
command:
- sh
- -c
@@ -70,7 +70,7 @@ spec:
mountPath: /ca
containers:
- name: audiobookshelf
image: ghcr.io/advplyr/audiobookshelf:2.37.1 # {"$imagepolicy": "flux-system:audiobookshelf"}
image: ghcr.io/advplyr/audiobookshelf:2.37.2 # {"$imagepolicy": "flux-system:audiobookshelf"}
ports:
- name: http
containerPort: 80
@@ -706,7 +706,7 @@ data:
# return entries instead of short-circuiting. Built from
# the samba-bridge repo at
# gitea.renner.uno/konairius/authentik-samba-bridge .
container_image: gitea.renner.uno/konairius/authentik-samba-bridge-ldap-outpost:2026.8.3-9dd92dd
container_image: gitea.renner.uno/konairius/authentik-samba-bridge-ldap-outpost:2026.8.4-3c8f413
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
@@ -1034,9 +1034,19 @@ data:
negate: false
failure_result: false
# wiki.renner.uno signs in via OIDC application `wiki`; only this group may sign in.
# Members are managed in Authentik.
- id: wiki-readers-group
model: authentik_core.group
identifiers:
name: Wiki Readers
state: present
attrs:
is_superuser: false
# Wiki (apps/wiki): its own OIDC login. Public client + PKCE; RS256
# so the service verifies the ID token against the JWKS. No policy
# binding: every Authentik user may sign in.
# so the service verifies the ID token against the JWKS. Only the
# Wiki Readers group passes.
- id: wiki-provider
model: authentik_providers_oauth2.oauth2provider
identifiers:
@@ -1072,6 +1082,83 @@ data:
meta_launch_url: https://wiki.renner.uno
meta_description: "Homelab wiki"
- model: authentik_policies.policybinding
identifiers:
target: !Find [authentik_core.application, [slug, wiki]]
group: !KeyOf wiki-readers-group
order: 0
state: present
attrs:
enabled: true
negate: false
failure_result: false
# OpenBao human login (auth/oidc, role owner). Confidential client:
# OpenBao's OIDC method requires a secret. Authentik generates it; it
# lives only in OpenBao's auth/oidc/config, never in git.
- id: openbao-admins-group
model: authentik_core.group
identifiers:
name: OpenBao Admins
state: present
attrs:
is_superuser: false
# Members may log in to OpenBao via OIDC. Add/remove members HERE.
users:
- !Find [authentik_core.user, [username, k.renner]]
- id: openbao-provider
model: authentik_providers_oauth2.oauth2provider
identifiers:
name: OpenBao
state: present
attrs:
authentication_flow: null
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
client_type: confidential
client_id: openbao
# See the Matrix (MAS) provider: a blueprint-made provider starts
# with no grant types.
grant_types:
- authorization_code
- refresh_token
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
redirect_uris:
# bao login -method=oidc (CLI listener).
- url: http://localhost:8250/oidc/callback
matching_mode: strict
- url: https://openbao.renner.uno/ui/vault/auth/oidc/oidc/callback
matching_mode: strict
# The slug is the issuer path: https://auth.renner.uno/application/o/openbao/
- model: authentik_core.application
identifiers:
slug: openbao
state: present
attrs:
name: OpenBao
slug: openbao
provider: !KeyOf openbao-provider
meta_launch_url: https://openbao.renner.uno
meta_description: "OpenBao secrets"
# Only the OpenBao Admins group passes.
- model: authentik_policies.policybinding
identifiers:
target: !Find [authentik_core.application, [slug, openbao]]
group: !KeyOf openbao-admins-group
order: 0
state: present
attrs:
enabled: true
negate: false
failure_result: false
# OpenProject, decommissioned. Application first: Application.provider
# is on_delete=PROTECT. Its policy binding cascades with it.
- model: authentik_core.application
+1 -1
View File
@@ -105,7 +105,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-23-0757 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-27-0801 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
+2 -2
View File
@@ -20,7 +20,7 @@ spec:
# Pinned to match the samba-bridge fork's app image (server.Dockerfile
# AUTHENTIK_VERSION). Bump in lockstep with the bridge rebuild — a
# floating range drifts the chart ahead of the pinned app image.
version: "2026.8.3"
version: "2026.8.4"
sourceRef:
kind: HelmRepository
name: authentik
@@ -58,7 +58,7 @@ spec:
# 2. Pinning means the deployed binary is traceable to a
# specific repo commit. Bump the tag whenever a new build
# is published from gitea.renner.uno/konairius/authentik-samba-bridge .
tag: "2026.8.3-9dd92dd"
tag: "2026.8.4-3c8f413"
envFrom:
# SAMBA_BRIDGE_DOMAIN_SID / _ENABLED_GROUP / _UID_OFFSET
# consumed by the bridge module at Django app startup. Missing
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-22-1904 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-27-0807 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
env:
- name: CLOUDFLARE_API_TOKEN
valueFrom:
+1 -1
View File
@@ -28,7 +28,7 @@ spec:
enableServiceLinks: false
containers:
- name: evcc
image: evcc/evcc:0.317.1@sha256:c50cd4f83c22b39722a76a76b8d428b69bf8ab3334209ed9ed24ec0e3c48dde5
image: evcc/evcc:0.318.1@sha256:2f8163ab22f04863afd86cd511c09607ba71debd9eebd65511f3d654922a80d1
# evcc auto-discovers /etc/evcc.yaml on its default search path;
# the file is delivered (with the MQTT password) via the
# sops-encrypted evcc-config Secret mounted below.
+4 -2
View File
@@ -20,7 +20,7 @@ metadata:
# services group infrastructure group
# Web Service 1 = Authentik Infrastructure 1 = retired (was PBS 2)
# Web Service 2 = Gitea Infrastructure 2 = retired (was Proxmox VE)
# Web Service 3 = Immich Infrastructure 3 = UDM DNS (10.2.40.1)
# Web Service 3 = Immich Infrastructure 3 = UDM DNS (10.2.47.1)
# Web Service 4 = Woodpecker CI Infrastructure 4 = Mosquitto MQTT broker
# Web Service 5 = Grafana
# Web Service 6 = Home Assistant
@@ -290,9 +290,11 @@ data:
# a backup server that nothing backs up to reports on nothing. Re-add
# alongside the first host that is declared to back up to it.
# The UDM on the segment pod traffic leaves on (k8s-gateway-site in
# talos/patches/site-homelab.yaml).
- name: Infrastructure 3
group: infrastructure
url: 10.2.40.1
url: 10.2.47.1
interval: 30s
dns:
query-name: udm.renner.uno
+2
View File
@@ -16,6 +16,8 @@ resources:
- networkpolicy.yaml
# Nightly prune of old CI image builds (REN-1203).
- package-retention.yaml
# OpenBao SSH CA public key for certificate logins.
- ssh-ca.yaml
- kumamonitors.yaml
configMapGenerator:
- name: gitea-package-retention
+1 -1
View File
@@ -115,7 +115,7 @@ spec:
type: RuntimeDefault
containers:
- name: retention
image: docker.io/library/python:3.14-alpine@sha256:f6a589d43c42b9e7f7dc67a12d37132491f362859a5d750607710cc56da3bc72
image: docker.io/library/python:3.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
command: ["python3", "/app/package-retention.py"]
env:
# In-cluster Service; the public name hairpins off the ingress
+37
View File
@@ -0,0 +1,37 @@
# Public key of the OpenBao SSH user CA (ssh-client-signer), for Gitea's
# built-in SSH server to accept owner and claude-bot certificates. OpenBao
# serves it unauthenticated; ESO reads it from the in-cluster Service, so the
# key has one source. Gitea reads it from env at start; Reloader restarts the
# StatefulSet when the Secret changes.
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
name: openbao-ssh-ca
namespace: gitea
spec:
provider:
webhook:
url: http://openbao.openbao.svc.cluster.local:8200/v1/ssh-client-signer/public_key
method: GET
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: gitea-ssh-ca
namespace: gitea
spec:
refreshInterval: 1h
secretStoreRef:
kind: SecretStore
name: openbao-ssh-ca
target:
name: gitea-ssh-ca
# OpenBao ends the body with a newline; it must not reach app.ini.
template:
engineVersion: v2
data:
trusted-user-ca-keys: "{{ .ca | trim }}"
data:
- secretKey: ca
remoteRef:
key: public_key
+13
View File
@@ -5,6 +5,9 @@ metadata:
namespace: gitea
labels:
app.kubernetes.io/name: gitea
annotations:
# A CA rotation changes gitea-ssh-ca; Gitea reads it only at start.
secret.reloader.stakater.com/reload: gitea-ssh-ca
spec:
serviceName: gitea
replicas: 1 # SQLite => single writer. Do NOT scale up without moving to Postgres first.
@@ -85,6 +88,16 @@ spec:
# HAS CHANGED". Paths are relative to APP_DATA_PATH (/var/lib/gitea/data).
- name: GITEA__server__SSH_SERVER_HOST_KEYS
value: "ssh/ssh_host_ed25519_key,ssh/ssh_host_rsa_key"
# OpenBao SSH CA (ssh-ca.yaml): certificates log in as the user whose
# name is a principal of the cert. Each user registers that
# principal once (Settings > SSH / GPG Keys > Manage principals).
- name: GITEA__server__SSH_TRUSTED_USER_CA_KEYS
valueFrom:
secretKeyRef:
name: gitea-ssh-ca
key: trusted-user-ca-keys
- name: GITEA__server__SSH_AUTHORIZED_PRINCIPALS_ALLOW
value: username
# Gitea Actions was decommissioned — keep it off.
- name: GITEA__actions__ENABLED
value: "false"
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: sync
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/python
- /opt/hermes-skills/gitea-issues/scripts/sync_webhook.py
+10 -10
View File
@@ -51,7 +51,7 @@ spec:
# connects from the pod network. No env equivalent exists, so it is
# set in config.yaml on every start (idempotent).
- name: config
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -75,7 +75,7 @@ spec:
mountPath: /opt/data
# WebSocket keepalive ping interval in seconds (default 20).
- name: config-ws-ping
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -99,7 +99,7 @@ spec:
mountPath: /opt/data
# Skills from git (ConfigMap hermes-skills) beside the PVC's skills/.
- name: skills-config
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -130,7 +130,7 @@ spec:
# - alert-monitor: hourly; the script wakes the agent only for
# untriaged alerts.
- name: cron-jobs
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
# Non-fatal: a broken jobs.json or a changed cron CLI after an image
# bump logs a warning instead of keeping Hermes from starting.
command:
@@ -187,7 +187,7 @@ spec:
readOnly: true
# Home Assistant MCP server; token resolved from HA_TOKEN at connect, writes need approval.
- name: config-mcp-homeassistant
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -214,7 +214,7 @@ spec:
# skills, the Home Assistant MCP and the tea login are re-applied on
# every start. default gets the kanban toolset to hand cards to them.
- name: experts
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
# Non-fatal like cron-jobs: a broken expert logs a warning.
command:
- sh
@@ -305,7 +305,7 @@ spec:
# Gitea issues -> kanban (skills/gitea-issues): route script, webhook route on default,
# completion shell hook plus its allowlist entry on every profile.
- name: gitea-issues
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
# Non-fatal like cron-jobs: a failure logs a warning.
command:
- sh
@@ -368,7 +368,7 @@ spec:
readOnly: true
# Multiplexed default reads platform credentials only from .env (rationale: commit message).
- name: default-env
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
# Fatal on a writer failure: without these Matrix and the API server stay down.
command:
- /opt/hermes/.venv/bin/python
@@ -428,7 +428,7 @@ spec:
# tea login hermes-bot, written from the mounted token into an
# emptyDir at the agent's ~/.config/tea.
- name: tea-login
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
command:
- sh
- -ec
@@ -457,7 +457,7 @@ spec:
readOnly: true
containers:
- name: hermes
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
args: ["gateway", "run"]
env:
- name: HERMES_DASHBOARD
@@ -88,7 +88,7 @@ spec:
# deliver, and the ClusterIP has no backend while HA restarts. The
# trap forwards SIGTERM so the pod still terminates promptly.
- name: socat-ipv4
image: alpine/socat:1.8.1.3@sha256:fb3862c949af7c79996d0e514ee96128644d840835ed5368eeecb14a869de361
image: alpine/socat:1.8.1.3@sha256:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
imagePullPolicy: IfNotPresent
command:
- /bin/sh
@@ -130,7 +130,7 @@ spec:
# depends on the kernel's bindv6only default rather than on anything
# stated here, so the roles are made explicit instead of inherited.
- name: socat-ipv6
image: alpine/socat:1.8.1.3@sha256:fb3862c949af7c79996d0e514ee96128644d840835ed5368eeecb14a869de361
image: alpine/socat:1.8.1.3@sha256:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
imagePullPolicy: IfNotPresent
command:
- /bin/sh
@@ -61,7 +61,7 @@ spec:
type: RuntimeDefault
containers:
- name: shelly-manager
image: gitea.renner.uno/k.renner/shelly-manager:build.1792387459 # {"$imagepolicy": "flux-system:shelly-manager"}
image: gitea.renner.uno/k.renner/shelly-manager:build.1792997278 # {"$imagepolicy": "flux-system:shelly-manager"}
imagePullPolicy: IfNotPresent
ports:
- name: http
@@ -177,7 +177,7 @@ spec:
# No probes: it only serves the API-key path, and a probe failure would
# also pull the pod out of the shelly-manager Service.
- name: api-proxy
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b9241c6e7b8e9a862f129d8d4199ab64b10390949a78bdd5603379b32c844083
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b5047b9b76845eac124358373c6852377c52e4b05834a47647e955dd91cb8f1e
env:
- name: POD_IP
valueFrom:
@@ -1,179 +0,0 @@
# 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.
@@ -128,7 +128,7 @@ spec:
memory: 32Mi
containers:
- name: home-assistant
image: ghcr.io/home-assistant/home-assistant:2026.10.2 # {"$imagepolicy": "flux-system:home-assistant"}
image: ghcr.io/home-assistant/home-assistant:2026.10.4 # {"$imagepolicy": "flux-system:home-assistant"}
env:
- name: TZ
value: Europe/Berlin
@@ -192,7 +192,7 @@ spec:
# substitution to POD_IP so nginx-runtime variables like $host
# / $http_x_forwarded_for in the template stay intact.
- name: proxy
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:378d0c32f6562f7fd9629170797f57d2b953aa09be983b3daaf4809bb039ca00
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b5047b9b76845eac124358373c6852377c52e4b05834a47647e955dd91cb8f1e
env:
- name: POD_IP
valueFrom:
@@ -236,7 +236,7 @@ spec:
# readiness probe on purpose: a broker outage must not take HA out of
# its Service endpoints.
- name: mqtt-tls
image: ghostunnel/ghostunnel:v1.11.4@sha256:e5248233863aadd36383a11c92360f2981141881ef05e38360a6ada8630d36b1
image: ghostunnel/ghostunnel:v1.11.5@sha256:65c4e34ec0ee0f30ea1f72b7411ec70a91c32fc50f067a8466ad2adb1e7420f2
args:
- client
- --listen=127.0.0.1:11883
@@ -275,7 +275,7 @@ spec:
# any LAN client can edit, matching the user's trust-the-network
# model. Runs as root so its writes match HA's root-owned files.
- name: code-server
image: codercom/code-server:4.141.0@sha256:72ab24b5680ee597dd647b30d82625752f40109b5f61bef90ffbf1ea1f02dbb8
image: codercom/code-server:4.143.1@sha256:377b66bec5645510ed52549bb00e648c273edc604d230a3f90fd2b2433afcb97
args:
- "--auth=none"
- "--bind-addr=0.0.0.0:8080"
@@ -337,7 +337,7 @@ spec:
# dedicated long-lived token.
# Runs as root to match HA's root-owned /config files. See config-sync.sh.
- name: config-sync
image: alpine/git:2.54.0@sha256:03228d7ded483b0b3e55e1de7fa660d70d3e1bb432fa7021d29e192640de11aa
image: alpine/git:2.54.0@sha256:e8ceedc73bd80992ae8d58a7d24c58b8322146c354f1d2e81a97db4452068ec0
command: ["/bin/sh", "/scripts/sync.sh"]
env:
- name: SYNC_BRANCH
@@ -36,7 +36,7 @@ spec:
type: RuntimeDefault
containers:
- name: esphome
image: ghcr.io/esphome/esphome:2026.9.1@sha256:776a845d508909bf426d2ee7bdd81796361ebe6a7f091807ce1d6d0b48985413
image: ghcr.io/esphome/esphome:2026.10.0@sha256:1dff19a59070cdb523b76576cdc74d200f862d43c1407809088855920cf57145
imagePullPolicy: IfNotPresent
# Default entrypoint runs the dashboard (Device Builder) against the
# mounted /config — no command override needed.
@@ -12,26 +12,20 @@ resources:
- config-sync-token-externalsecret.yaml
- config-sync-gitea-externalsecret.yaml
- matrix-externalsecret.yaml
- mosquitto-bootstrap.yaml
# Former `health` probe credential. The broker no longer reads it (the
# probes use the cert account mosquitto-probe, REN-953); removed with its
# rotator in the REN-802 cleanup step.
- mosquitto-health-credentials-externalsecret.yaml
- mosquitto-admin-password.yaml
- mosquitto-config.yaml
- mosquitto-certificate.yaml
- mosquitto-service.yaml
- mosquitto-iot-service.yaml
- mosquitto-deployment.yaml
- mosquitto-evcc-credentials-externalsecret.yaml
# shelly-manager and the mDNS relay sit on the IoT VLAN: ../home-assistant-iot.
- esphome-pvc.yaml
- esphome-deployment.yaml
- esphome-service.yaml
- 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).
# runbook (zigbee2mqtt-migration-runbook.md) scales it up. It logs in to
# the broker as register account `zigbee2mqtt-bridge` (mqtt-accounts.yaml).
- zigbee2mqtt-coordinator-backup-externalsecret.yaml
- zigbee2mqtt-config.yaml
- zigbee2mqtt-pvc.yaml
@@ -0,0 +1,32 @@
# Password of the broker's `admin` workstation login (mosquitto-deployment.yaml).
# Generated once in-cluster, nothing in OpenBao. Read it with
# kubectl -n home-assistant get secret mosquitto-admin-password -o jsonpath='{.data.password}' | base64 -d
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
metadata:
name: mosquitto-admin-password
namespace: home-assistant
spec:
length: 32
digits: 8
symbols: 0
allowRepeat: true
noUpper: false
secretKeys:
- password
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mosquitto-admin-password
namespace: home-assistant
spec:
refreshPolicy: CreatedOnce
target:
name: mosquitto-admin-password
dataFrom:
- sourceRef:
generatorRef:
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
name: mosquitto-admin-password
@@ -1,394 +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
annotations:
# Job pod templates are immutable; force-recreate so image bumps
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
kustomize.toolkit.fluxcd.io/force: enabled
spec:
backoffLimit: 4
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-bootstrap
spec:
serviceAccountName: mosquitto-bootstrap
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
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
annotations:
# Job pod templates are immutable; force-recreate so image bumps
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
kustomize.toolkit.fluxcd.io/force: enabled
spec:
backoffLimit: 4
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-bootstrap
spec:
serviceAccountName: mosquitto-bootstrap
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
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
---
# 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
annotations:
# Job pod templates are immutable; force-recreate so image bumps
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
kustomize.toolkit.fluxcd.io/force: enabled
spec:
backoffLimit: 4
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-bootstrap
spec:
serviceAccountName: mosquitto-bootstrap
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
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
annotations:
# Job pod templates are immutable; force-recreate so image bumps
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
kustomize.toolkit.fluxcd.io/force: enabled
spec:
backoffLimit: 4
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-bootstrap
spec:
serviceAccountName: mosquitto-bootstrap
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
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 `zigbee2mqtt` — the Zigbee2MQTT bridge (ZHA replacement). Scoped
# to its own `zigbee2mqtt/#` subtree plus `write homeassistant/#` (so it can
# publish HA MQTT-discovery configs) and
# `read homeassistant/status` (HA's birth/will topic; on `online` Z2M
# republishes cached device states, NOT discovery configs — ACL and the full
# reasoning 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
annotations:
# Job pod templates are immutable; force-recreate so image bumps
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
kustomize.toolkit.fluxcd.io/force: enabled
spec:
backoffLimit: 4
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-bootstrap
spec:
serviceAccountName: mosquitto-bootstrap
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
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
@@ -77,18 +77,15 @@ data:
allow_anonymous false
# Two account sources side by side (REN-802 step 2, tested on 2.0.22 in
# REN-900, document `coexistence`):
# Dynamic Security: accounts from the register mqtt-accounts.yaml,
# managed live by mqtt-account-controller over $CONTROL.
# password_file/acl_file: the legacy users, unchanged until each client
# moves to the register (step 3).
# Accounts (tested on 2.0.22 in REN-900, document `coexistence`):
# Dynamic Security: every client account, from the register
# mqtt-accounts.yaml, managed live by mqtt-account-controller over
# $CONTROL. Applies to all listeners (per_listener_settings false).
# password_file/acl_file: the owner's workstation login `admin` only
# (mosquitto-deployment.yaml).
# Dynamic Security is asked first. For a username it does not know, and
# for a request none of its roles match, it defers to the files. A
# register entry with the same name as a legacy user therefore replaces
# that user's password, while its acl_file block still applies to
# whatever the entry's roles do not cover. Delete the legacy user when
# its client moves.
# register entry named `admin` therefore replaces the file login.
#
# The acl_file check of 2.0 allows every SUBSCRIBE and checks only on
# delivery, so the Dynamic Security default must deny publishClientReceive.
@@ -64,67 +64,15 @@ spec:
- -c
- |
set -eu
# `admin` is the owner's workstation login over the LoadBalancer
# (1883/8883), full readwrite. The only password_file user: the
# controller cannot deliver a password account to a Secret, and
# the cert listener :8884 is in-cluster only.
rm -f /mosquitto/auth/passwd
mosquitto_passwd -c -b /mosquitto/auth/passwd homeassistant "$HA_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd admin "$ADMIN_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd sml2mqtt "$SML2MQTT_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd open3e "$OPEN3E_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd evcc "$EVCC_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd zigbee2mqtt "$ZIGBEE2MQTT_PASSWORD"
# ACL: HA keeps full access (subscribes to everything). Shellies
# are not legacy users any more: each has its own Dynamic Security
# account from mqtt-accounts.yaml (REN-802, REN-1017).
# `admin` is a workstation/debugging credential — full
# readwrite, kept on its own credential so its password can
# be rotated independently of the HA session.
# `sml2mqtt` is the power-meter bridge — WRITE-only under its own
# `sml2mqtt/#` subtree (it only publishes; HA reads it via the
# full-access homeassistant user).
# `open3e` is the Viessmann E3 CAN bridge (Vitocal/VX3) —
# scoped to its own `open3e/#` subtree: publishes datapoints
# and later subscribes to `open3e/cmnd` in listener mode (HA
# reads via the full-access homeassistant user).
# `evcc` is the (cross-namespace) EV charge controller — reads
# `open3e/#` (VX3 grid/PV/battery/SoC datapoints), publishes its
# own state under `evcc/#`, and reads the grid price from
# `energy/#` (HA republishes input_number.strompreis to
# energy/tariff/grid; kept OUTSIDE evcc/# so evcc's own retained-
# topic management can't wipe it).
mosquitto_passwd -c -b /mosquitto/auth/passwd admin "$ADMIN_PASSWORD"
cat > /mosquitto/auth/acl <<'EOF'
user homeassistant
topic readwrite #
user admin
topic readwrite #
user sml2mqtt
topic write sml2mqtt/#
user open3e
topic readwrite open3e/#
user evcc
topic read open3e/#
topic readwrite evcc/#
topic read energy/#
# `zigbee2mqtt` is the Zigbee2MQTT bridge (the ZHA replacement).
# Owns its own `zigbee2mqtt/#` subtree; may WRITE `homeassistant/#`
# to publish HA MQTT-discovery configs (write-only there, to deny
# discovery-tree recon from a compromised bridge); and READs `homeassistant/status`, HA's birth/will topic.
#
# That read grant does NOT buy a discovery re-publish: on
# `online`, Z2M 2.12.1 republishes bridge/state and cached device
# states only (lib/extension/homeassistant.ts onMQTTMessage) —
# discover() runs solely from start(). The grant is still correct,
# because the cached-state republish is what makes HA show current
# values right after an HA restart. Restoring discovery after a
# BROKER restart is handled by the discovery-guard sidecar in
# zigbee2mqtt-deployment.yaml.
user zigbee2mqtt
topic readwrite zigbee2mqtt/#
topic write homeassistant/#
topic read homeassistant/status
EOF
# Dynamic Security start file (REN-802): the controller's seed
# if it exists, else the static base file. The seed carries
@@ -143,38 +91,11 @@ spec:
chmod 0600 /mosquitto/auth/passwd /mosquitto/auth/acl /mosquitto/auth/dynamic-security.json
exec mosquitto -c /mosquitto/config/mosquitto.conf
env:
- name: HA_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-credentials
key: homeassistant-password
# mosquitto-admin-password.yaml
- name: ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-admin-credentials
key: password
- name: SML2MQTT_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-sml2mqtt-credentials
key: password
- name: OPEN3E_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-open3e-credentials
key: password
- name: EVCC_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-evcc-credentials
key: password
# Zigbee2MQTT bridge credential (mosquitto-zigbee2mqtt-credentials-
# bootstrap). Hashed into the passwd file here; Z2M reads the same
# Secret via its init container to authenticate to the broker.
- name: ZIGBEE2MQTT_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-zigbee2mqtt-credentials
name: mosquitto-admin-password
key: password
ports:
- name: mqtt
@@ -231,12 +152,12 @@ spec:
# Service and the probe dials 127.0.0.1; the chain is still checked
# against mqtt-ca.
#
# mosquitto is a single-replica SPOF (HA, Shelly, sml2mqtt, open3e,
# evcc). Liveness is deliberately slow to fire — failureThreshold 6
# x 30s = 3 min of sustained failure before a restart — so a
# transient hiccup never bounces the broker every client depends
# on. The startupProbe (tcpSocket) suspends both while the
# container builds the passwd/acl files and mosquitto binds :1883.
# mosquitto is a single-replica SPOF (HA, Shelly, Zigbee2MQTT,
# edge-1, evcc). Liveness is deliberately slow to fire —
# failureThreshold 6 x 30s = 3 min of sustained failure before a
# restart — so a transient hiccup never bounces the broker every
# client depends on. The startupProbe (tcpSocket) suspends both while
# the container prepares /mosquitto/auth and mosquitto binds :1883.
startupProbe:
tcpSocket:
port: mqtt
@@ -1,15 +0,0 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mosquitto-evcc-credentials
namespace: home-assistant
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: mosquitto-evcc-credentials # keeps the old Secret's name — consumers unchanged
dataFrom:
- extract:
key: apps/home-assistant/mosquitto-evcc-credentials
@@ -1,36 +0,0 @@
# Projects the mosquitto `health` broker credential from OpenBao into the Secret
# the broker already consumes. FIRST of the eight broker users to move off the
# bootstrap Jobs and onto Class-A rotation (see
# PROPOSAL-mosquitto-credential-rotation.md (PR #453 — not yet merged; this
# file lands with it)); the rotator lives at
# kubernetes/infrastructure/openbao/rotation/mosquitto-health.yaml.
#
# Secret name and key are UNCHANGED (`password`), so mosquitto-deployment.yaml's
# secretKeyRef and the exec probes that read $HEALTH_PASSWORD need no edit.
#
# refreshInterval is 10m rather than the 1h used by mosquitto-evcc-credentials:
# a rotation only lands once ESO re-reads, so 1h would mean up to an hour between
# the rotator running and the broker actually using the new password, with the
# old one still valid in the meantime. 10m keeps that window short without
# polling OpenBao pointlessly hard.
#
# ⚠️ OWNERSHIP: creationPolicy is implicitly Owner, so ESO sets an ownerReference
# on the Secret. Deleting this ExternalSecret therefore DELETES the live Secret
# and with it the broker's probe credential — the bootstrap Job it replaces had
# no such coupling. To detach without destroying, set creationPolicy: Orphan
# before removing it.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mosquitto-health-credentials
namespace: home-assistant
spec:
refreshInterval: 10m
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: mosquitto-health-credentials
dataFrom:
- extract:
key: apps/home-assistant/mosquitto-health-credentials
@@ -5,9 +5,7 @@
# mqtt-accounts.yaml, every INTERVAL (2m).
#
# Manages the production broker `mosquitto` over its mTLS listener :8884
# (REN-802 step 2, REN-900). The legacy users in that broker's password_file
# are not Dynamic Security clients, so the controller never sees or touches
# them.
# (REN-802 step 2, REN-900).
apiVersion: v1
kind: ServiceAccount
metadata:
@@ -87,6 +85,9 @@ subjects:
# delivered to this namespace. Add the name with the register entry.
# mqtt-ha-cert-sync: sha256 of the client certificate last accepted by
# Home Assistant's MQTT config entry (HA cert sync, REN-953/REN-1284).
# mqtt-<name>-password: the login of a password account delivered to this
# namespace, written, rewritten and deleted on revocation. One entry per
# account.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
@@ -99,6 +100,11 @@ rules:
resources: ["secrets"]
resourceNames: ["mqtt-account-hashes", "mqtt-dynsec-seed", "mqtt-ha-cert-sync"]
verbs: ["get", "create", "patch"]
- apiGroups: [""]
resources: ["secrets"]
resourceNames:
- mqtt-admin-password
verbs: ["get", "create", "patch", "delete"]
- apiGroups: [""]
resources: ["secrets"]
resourceNames:
@@ -219,7 +225,7 @@ spec:
- name: controller
# Woodpecker builds main-<sha> on every merge to main; no sortable
# tag, so bumps are manual (same as ical-proxy).
image: gitea.renner.uno/konairius/mqtt-account-controller:main-cb96b1a@sha256:9d9b9e83a9c0f9f567e57edacab11cf4c58e8c0e49e38e8d7b51421cd1242911
image: gitea.renner.uno/konairius/mqtt-account-controller:main-b1f294d@sha256:8081adfee171c36cfa83734e5e7ccc3aad3debf1c0154488de70143674cde85f
env:
- name: BROKER_URL
value: ssl://mosquitto.home-assistant.svc.cluster.local:8884
@@ -7,51 +7,50 @@
# configMapGenerator in kustomization.yaml). An empty list is refused, so it
# never revokes everyone.
#
# The controller manages the production broker `mosquitto` (REN-900). Its
# legacy users (password_file in mosquitto-deployment.yaml) are not listed
# here; each moves here with its client (REN-802 step 3). `test-cert` is the
# certificate test account from REN-888.
# The controller manages the production broker `mosquitto` (REN-900).
# `test-cert` is the certificate test account from REN-888.
#
# A cert account needs its Secret `mqtt-<name>-tls` added to the
# A cert account needs its Secret `mqtt-<name>-tls`, a password account
# delivered to a namespace its Secret `mqtt-<name>-password`, added to the
# `mqtt-account-controller-secrets-<namespace>` Role in
# mqtt-account-controller.yaml, or revoking it fails with 403.
# mqtt-account-controller.yaml, or writing or revoking it fails with 403.
# The owner's workstation login over the LoadBalancer (1883/8883). Password in
# the Secret mqtt-admin-password (home-assistant); deleting the Secret makes a
# new one.
- name: admin
auth: password
deliver: {namespace: home-assistant}
acl: ["readwrite #"]
- name: test-cert
auth: cert
deliver: {namespace: home-assistant}
acl: ["readwrite test/cert/#"]
# The broker's own liveness/readiness probe (mosquitto-deployment.yaml).
# Not named `health`: a Dynamic Security client of that name would shadow the
# legacy password user while the old broker pod still probes with it.
- name: mosquitto-probe
auth: cert
deliver: {namespace: home-assistant}
acl: ["readwrite health/#"]
# Zigbee2MQTT and its two sidecars (discovery-guard, interview-watch), one
# certificate for the pod. Same ACL as the legacy user `zigbee2mqtt`; not
# named like it, so this entry cannot shadow the password login of a running
# Z2M (REN-953).
# certificate for the pod (REN-953).
- name: zigbee2mqtt-bridge
auth: cert
deliver: {namespace: home-assistant}
acl: ["readwrite zigbee2mqtt/#", "write homeassistant/#", "read homeassistant/status"]
# evcc (evcc namespace). Same ACL as the legacy user `evcc`, different name
# so it cannot shadow the running password login (REN-953). evcc takes the
# certificate PEMs by env (EVCC_MQTT_*), see apps/evcc/deployment.yaml.
# evcc (evcc namespace, REN-953). evcc takes the certificate PEMs by env
# (EVCC_MQTT_*), see apps/evcc/deployment.yaml.
- name: evcc-client
auth: cert
deliver: {namespace: evcc}
acl: ["read open3e/#", "readwrite evcc/#", "read energy/#"]
# Home Assistant, via the ghostunnel sidecar `mqtt-tls` in its pod (HA keeps
# client certificates only as uploaded PEMs in its config entry, so a renewal
# could not reach it without a reconfigure). Same ACL as the legacy user
# `homeassistant`; not named like it, so it cannot shadow that login (REN-953).
# could not reach it without a reconfigure, REN-953).
- name: home-assistant
auth: cert
deliver: {namespace: home-assistant}
acl: ["readwrite #"]
# edge-1's local mosquitto bridge (REN-1307): one bridge for open3e/e3oncan
# and sml2mqtt, replacing the legacy password users `open3e` and `sml2mqtt`
# (different name, so it cannot shadow their logins). The controller pushes
# edge-1's local mosquitto bridge (REN-1307): one bridge for open3e/e3oncan.
# The controller pushes
# the password to edge-mqtt-cred on edge-1 (ansible role mqtt-bridge) over
# HTTPS with its own client certificate, and pins edge-1's self-generated
# server certificate on first contact (key edge-1.pin in mqtt-account-hashes;
@@ -50,13 +50,13 @@ release it before Z2M connects (step 3).
Confirm the standing pieces are in place (nothing is running yet):
```
kubectl -n home-assistant get secret mosquitto-zigbee2mqtt-credentials # exists (bootstrap Job ran)
kubectl -n home-assistant get secret mqtt-zigbee2mqtt-bridge-tls # exists (register account zigbee2mqtt-bridge)
kubectl -n home-assistant get externalsecret zigbee2mqtt-coordinator-backup # SecretSyncedError / NotReady — EXPECTED
kubectl -n home-assistant get deploy zigbee2mqtt # 0/0 — EXPECTED
```
The `mosquitto` pod will have rolled to pick up the new `zigbee2mqtt` passwd/ACL
entry. Verify the broker still serves HA (it will — the change is additive).
Z2M logs in to the broker by certificate on `:8884` as the register account
`zigbee2mqtt-bridge` (`mqtt-accounts.yaml`).
## Step 1 — export the Open Coordinator Backup (non-disruptive)
+1 -1
View File
@@ -27,7 +27,7 @@ spec:
# Pinned to 7.4-alpine to match the LXC 225 compose exactly.
# Any redis major-version bump is a separate migration.
- name: redis
image: redis:7.4.11-alpine # {"$imagepolicy": "flux-system:redis-immich"}
image: redis:7.4.12-alpine # {"$imagepolicy": "flux-system:redis-immich"}
args:
- redis-server
- --save
+1 -1
View File
@@ -66,7 +66,7 @@ spec:
# The library PVC is populated by the seed phase before this
# Deployment reconciles, so no rsync init is needed here.
- name: fetch-le-staging-roots
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
image: curlimages/curl:8.23.0@sha256:11e64e28c5f58c8d93ee95fcf22ac055f48b0bc440e0ce7d8013bbb5ad6aa8ac
command:
- sh
- -c
@@ -57,7 +57,7 @@ spec:
runAsNonRoot: false
initContainers:
- name: wait-for-influxd
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
env:
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
- { name: INFLUX_ORG, value: renner }
@@ -80,7 +80,7 @@ spec:
echo "[$(date -u +%H:%M:%S)] $INFLUX_HOST ready and authenticated"
containers:
- name: create
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
env:
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
- { name: INFLUX_ORG, value: renner }
+2 -2
View File
@@ -88,7 +88,7 @@ spec:
# state). Two-stage so we don't try to create an auth before
# the chart's `influx setup` has finished.
- name: wait-for-influxd
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
env:
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
- { name: INFLUX_ORG, value: renner }
@@ -111,7 +111,7 @@ spec:
echo "[$(date -u +%H:%M:%S)] $INFLUX_HOST ready and authenticated"
containers:
- name: create
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
env:
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
- { name: INFLUX_ORG, value: renner }
+1 -1
View File
@@ -55,7 +55,7 @@ spec:
repository: influxdb
# Was pinned to VM 108's 2.7.4 for the one-time `influx restore`; that
# migration is done. No downgrade below 2.9 without `influxd downgrade`.
tag: "2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac"
tag: "2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2"
# Single replica — InfluxDB OSS doesn't cluster. RWO Ceph-block + Velero
# backups (pending) is the durability story.
persistence:
+1 -1
View File
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-22-1858 # {"$imagepolicy": "flux-system:kuma-sync"}
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-27-0801 # {"$imagepolicy": "flux-system:kuma-sync"}
env:
- name: KUMA_URL
value: https://kuma.renner.uno
+1 -1
View File
@@ -72,7 +72,7 @@ spec:
type: RuntimeDefault
containers:
- name: element-web
image: ghcr.io/element-hq/element-web:v1.12.30@sha256:3e7dbd4424de9e11c812bc740acf2725b2abe66ea0d0802023b56fd85d3fa09a
image: ghcr.io/element-hq/element-web:v1.12.32@sha256:f8700633d4336258af7a05b53865a4f479e0d628cf2d9ca83d28b5939314d4ce
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
+1 -1
View File
@@ -137,7 +137,7 @@ spec:
type: RuntimeDefault
containers:
- name: mas
image: ghcr.io/element-hq/matrix-authentication-service:1.26.0@sha256:e089f1048a1d4a9a492ed17b9fe759100f1bd619407b001f5927928d88b780c4
image: ghcr.io/element-hq/matrix-authentication-service:1.27.1@sha256:02712e68c90f3743fedbd5e3343517481b2b4f246a9fd9e2f050dc2553033847
# The server applies database migrations and syncs the upstream
# providers from the config on start.
args: ["server", "--config", "/config/config.yaml"]
+1 -1
View File
@@ -124,7 +124,7 @@ spec:
type: RuntimeDefault
containers:
- name: livekit
image: livekit/livekit-server:v1.13.10@sha256:89ca9ce0791e18d6e03bf6e2c49ceb27e1c754eae8bd50c989386c1a5963791a
image: livekit/livekit-server:v1.13.11@sha256:25cd902de7cacbd5f1f779f6a6ac1eae20ba8f56b0935934ce6b0ab80d1e3d20
args:
- --config
- /config/livekit.yaml
+2 -2
View File
@@ -179,7 +179,7 @@ spec:
# first. The marker on the data PVC makes later starts skip it.
# Debug image: the plain one has no shell for the marker check.
- name: migrate-to-mas
image: ghcr.io/element-hq/matrix-authentication-service:1.26.0-debug@sha256:46f7480f5ea4243ccb6fd2c447ea859e0230c8b3c0e676ee285d4ae6ca344ee3
image: ghcr.io/element-hq/matrix-authentication-service:1.27.1-debug@sha256:75f369b206785f1480f4a9f8dd81d7f9f3c6793c40fd184358ecd5d7acae17d4
command:
- /busybox/sh
- -ec
@@ -234,7 +234,7 @@ spec:
mountPath: /data
containers:
- name: synapse
image: ghcr.io/element-hq/synapse:v1.162.0@sha256:6b84a7bbac36f080b2d2e51e0289cf1b08b349598ea44a558df38d558f2c2311
image: ghcr.io/element-hq/synapse:v1.163.2@sha256:c3c21919a322f74d177a43bce8031adc81ef839ddd117a8dd03cfec350e76e02
# Bypass /start.py: it expects to run as root and template a config
# from env. Default mode is generate-missing-and-run, which creates
# the signing key on first start.
@@ -5,7 +5,9 @@
#
# The UDM is queried directly rather than through the cluster resolver:
# k8s-gateway caches these with `serve_stale 1h immediate`, which would keep
# a deleted record green for an hour.
# a deleted record green for an hour. The addresses are the UDM on the segment
# pod traffic leaves on, the same upstream k8s-gateway uses (k8s-gateway-site
# in talos/patches/site-homelab.yaml).
#
# `release` is load-bearing — probeSelector matches on it.
apiVersion: monitoring.coreos.com/v1
@@ -25,8 +27,8 @@ spec:
targets:
staticConfig:
static:
- "[fd00::1]:53"
- "10.2.40.1:53"
- "[fd02:47::1]:53"
- "10.2.47.1:53"
# instance is the resolver and identical across these Probes, so the
# queried name has to come from a label.
labels:
@@ -49,8 +51,8 @@ spec:
targets:
staticConfig:
static:
- "[fd00::1]:53"
- "10.2.40.1:53"
- "[fd02:47::1]:53"
- "10.2.47.1:53"
labels:
dns_name: mqtt.renner.uno
---
@@ -71,8 +73,8 @@ spec:
targets:
staticConfig:
static:
- "[fd00::1]:53"
- "10.2.40.1:53"
- "[fd02:47::1]:53"
- "10.2.47.1:53"
labels:
dns_name: ldap.renner.uno
---
@@ -93,8 +95,8 @@ spec:
targets:
staticConfig:
static:
- "[fd00::1]:53"
- "10.2.40.1:53"
- "[fd02:47::1]:53"
- "10.2.47.1:53"
labels:
dns_name: syslog.renner.uno
---
@@ -115,8 +117,8 @@ spec:
targets:
staticConfig:
static:
- "[fd00::1]:53"
- "10.2.40.1:53"
- "[fd02:47::1]:53"
- "10.2.47.1:53"
labels:
dns_name: ftp.paperless.renner.uno
---
@@ -137,7 +139,7 @@ spec:
targets:
staticConfig:
static:
- "[fd00::1]:53"
- "10.2.40.1:53"
- "[fd02:47::1]:53"
- "10.2.47.1:53"
labels:
dns_name: syncthing.paperless.renner.uno
@@ -82,9 +82,9 @@ spec:
#
# The pod regex is the Deployment's name shape
# (mosquitto-<replicaset hash>-<suffix>) and not `mosquitto-.*` on
# purpose: mosquitto-bootstrap.yaml runs six Jobs named
# mosquitto-*-credentials-bootstrap, and a bootstrap pod that is
# briefly Ready must not hide a broker that is down.
# purpose: other pods in the namespace start with `mosquitto-`
# (mosquitto-dynsec-test), and a Ready one must not hide a broker
# that is down.
expr: |
absent(kube_pod_status_ready{namespace="home-assistant", pod=~"mosquitto-[a-z0-9]{6,10}-[a-z0-9]{5}", condition="true"} == 1)
for: 5m
@@ -49,7 +49,7 @@ spec:
failure would cost quorum. Check `kubectl -n openbao get pods`,
the pod log and `bao status` in the not-ready pod. If it is
sealed, the static seal failed: verify the openbao-seal-key
Secret matches `pass show homelab/openbao/seal-key`.
Secret matches ~/.config/homelab-secrets/seal-key.age (age, YubiKey).
- alert: OpenBaoQuorumLost
# Raft quorum of a three-node cluster is two. At one ready replica
@@ -76,7 +76,7 @@ spec:
surface only when something rotates or a new secret is needed.
Replicas unseal themselves from the static seal; one that stays
sealed could not. Check its log and the openbao-seal-key Secret
against `pass show homelab/openbao/seal-key`, fix the Secret and
against ~/.config/homelab-secrets/seal-key.age, fix the Secret and
restart the pod. The recovery key cannot unseal an auto-sealed
server. Procedure: kubernetes/RUNBOOK-cold-rebuild.md, Step 4.
@@ -137,13 +137,13 @@ tests:
exp_alerts: []
# --- MosquittoDown ------------------------------------------------------
# The broker goes not-Ready at t=10m while a credentials-bootstrap Job pod is
# Ready. The Job pod must not mask the broker.
# The broker goes not-Ready at t=10m while the mosquitto-dynsec-test pod is
# Ready. That pod must not mask the broker.
- interval: 1m
input_series:
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="mosquitto-7f8b9c6d5-x2k9q",condition="true"}'
values: '1x9 0x20'
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="mosquitto-credentials-bootstrap-q7w2e",condition="true"}'
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="mosquitto-dynsec-test-6d9f8b7c5-r4t8w",condition="true"}'
values: '1x30'
alert_rule_test:
- eval_time: 14m

Some files were not shown because too many files have changed in this diff Show More