Compare commits

...
Author SHA1 Message Date
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
security-bot 0e12e90509 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-23-0757
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Updates managed by image-automation-controller.
2026-10-23 08:02:02 +00:00
k.renner 613e1da011 Merge pull request 'fix(authentik-gate-sync): embedded outpost redirects browsers to the public URL' (#1430) from fix/gate-embedded-browser-host into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1430
2026-10-23 07:57:11 +00:00
claude-botandClaude Opus 5.5 6998845854 fix(authentik-gate-sync): embedded outpost redirects browsers to the public URL
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The embedded outpost builds the sign-in redirect from authentik_host, not authentik_host_browser. Setting it to the in-cluster Service sent every gate's login to http://authentik-server.authentik.svc.cluster.local/. authentik_host is now https://auth.renner.uno/, Authentik's default for the embedded outpost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:55:33 +02:00
security-bot 430c710fd0 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-23-0748
Updates managed by image-automation-controller.
2026-10-23 07:51:29 +00:00
k.renner 6accd1d3cd Merge pull request 'feat(authentik-gate): Traefik ForwardAuth, gate config on the Ingress' (#1429) from feat/authentik-gate-forwardauth into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1429
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-23 07:48:22 +00:00
claude-botandClaude Opus 5.5 6178063299 fix(authentik-gate-sync): verify the embedded outpost match, refuse shared hosts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The embedded outpost is matched on the client too, so a filter the server ignored cannot select the LDAP outpost. A host named by two gated Ingresses is a GateError: the outpost finds providers by host, so it would serve one gate for both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:44:34 +02:00
claude-botandClaude Opus 5.5 0b7a2b6c46 feat(authentik-gate): gate config lives on the Ingress itself
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
The gate is now declared on the app's own traefik Ingress: naming the
static Middleware authentik/gate (ForwardAuth against the embedded
outpost) in router.middlewares opts in, and its auth.renner.uno/*
annotations configure it. No generated route Ingress, ExternalName
Service or per-gate Middleware; the IngressClass authentik-gate is gone.

authentik-gate-sync keeps building the Authentik side (forward_single
provider on the embedded outpost, application, group policy) and the
backend CNP admitting Traefik, and sweeps the objects it generated
before. Traefik publishes the Ingress status itself.

The outpost's own paths (/outpost.goauthentik.io/) are one static
IngressRoute above the app routes. The embedded outpost answers 404 for
a host without a provider (checked live: auth/traefik with
X-Forwarded-Host no-such-gate.renner.uno), so a gate without its
provider stays closed.

docs.renner.uno points at paperless:80 behind redirect + gate (Paperless
Users) instead of the removed paperless-gate Service. Drops __pycache__
committed by mistake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:40:27 +02:00
claude-botandClaude Opus 5.5 772311c574 feat(authentik-gate): Traefik ForwardAuth on the embedded outpost
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Each gate was a single-replica proxy outpost in series with Traefik, so
any outpost restart, drain or node loss took its app down. Gates now
run as Traefik ForwardAuth against Authentik's embedded outpost inside
authentik-server: the request path is Traefik -> app, and the only auth
dependency is authentik-server, which every login already needs.

Per gated Ingress the controller now creates a forward_single provider
attached to the embedded outpost, a ForwardAuth Middleware first in the
route's chain, a /outpost.goauthentik.io route to authentik-server, and
a CNP admitting Traefik to the backend pods. It removes the per-gate
outposts and their egress CNPs.

Identity headers: authResponseHeadersRegex makes Traefik delete every
client-sent X-authentik-* header before copying the outpost's answer
(traefik pkg/middlewares/auth/forward.go), so a gated backend only ever
sees identity Authentik issued. Traefik answers 500 if the outpost is
unreachable and drops a router whose Middleware is missing: fail closed.

The blueprint no longer declares the embedded outpost: an entry must
carry providers, and re-applying [] would detach every gate. The
controller owns its providers and authentik_host/_browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:16:30 +02:00
security-bot 7bf1681c2d chore(image-automation): bump gitea.renner.uno/gitea_admin/secret-rotator:2026-10-23
Updates managed by image-automation-controller.
2026-10-23 05:53:33 +00:00
k.renner c9cd0783df Merge pull request 'fix(hermes): gitea-webhook-sync covers every konairius repo via team membership' (#1413) from fix/gitea-webhook-sync-org-scope into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1413
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-23 05:34:08 +00:00
k.renner 7e57b42316 Merge pull request 'docs: drop references to the deleted *.renner.uno wildcard' (#1428) from docs/drop-wildcard-references into main
ci/woodpecker/push/deploy-claude-workstation Pipeline failed
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/push/deploy-netbird-proxy Pipeline was canceled
Reviewed-on: #1428
2026-10-23 05:33:31 +00:00
security-bot 9afb56536c chore(image-automation): bump docker.gitea.com/gitea-mcp-server:1.11.0
Updates managed by image-automation-controller.
2026-10-23 00:49:33 +00:00
security-bot 23c089c8b7 chore(image-automation): bump ghcr.io/paperless-ngx/paperless-ngx:3.3.4
Updates managed by image-automation-controller.
2026-10-22 22:49:23 +00:00
claude-botandClaude Opus 5.5 a3eb69ee20 docs: drop references to the deleted *.renner.uno wildcard
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The public Cloudflare wildcard CNAME *.renner.uno -> proxy.renner.uno and
its carve-out TXTs were removed on 2026-10-22. Public hosts now carry an
explicit CNAME to proxy.renner.uno created by external-dns from
dns.renner.uno/public; UDM records are opt-in via dns.renner.uno/internal.

Comment/doc text only; no config value changes:
- KumaMonitor, Ingress, HelmRelease and tofu comments that said hosts
  resolve via the wildcard or that external-dns-unifi "skips" public hosts.
- PrometheusRule descriptions (k8s-gateway, dns-resolution) and the
  matching promtool test strings.
- k8s-gateway cache comment: the cache tuning stays (dnsmasq refusal
  bursts, 5s lease TTLs); the wildcard-trap rationale is gone.
- hermes skill references, claude-workstation runbook, crowdsec bouncer
  defaults.

Not changed: kubernetes/apps/home-assistant/* (README rule 1 window) and
the wildcard sentence inside the CoreDNS Corefile data
(infrastructure/coredns-config), where an edit changes the ConfigMap.

Verified live before the edit: all 30 Kuma DNS monitors and all blackbox
DNS/HTTP probes green after the wildcard removal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:08:07 +02:00
k.renner c6cb6b3aec Merge pull request 'fix(netbird-server): keep the host mesh client out of DNS' (#1427) from fix/netbird-controller-disable-dns into main
ci/woodpecker/push/deploy-netbird-server Pipeline was successful
Reviewed-on: #1427
2026-10-22 19:56:51 +00:00
claude-botandClaude Opus 5.5 29c299c0fe fix(netbird-server): keep the host mesh client out of DNS
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The controller's own NetBird client registered ~renner.uno in
systemd-resolved, pointing at the kube-dns-lb nameserver group, which is
unreachable from this peer. Every *.renner.uno lookup on the host returned
SERVFAIL, and Uptime Kuma, resolving through Docker's embedded DNS and the
host stub, marked all HTTP monitors DOWN after the 2026-10-22 update-os
reboot.

The role now reconnects the client with --disable-dns when its config has
DNS management enabled. The flag is only honoured on a fresh up, so the
task runs netbird down + up; it is a no-op once disableDns is true.
Applied live and verified 2026-10-22 before this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:55:11 +02:00
k.renner a3efc511c8 Merge pull request 'feat(dns): one consumer per label; public DNS as CNAME to proxy.renner.uno (step 2/2)' (#1426) from feat/dns-labels-split-2 into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1426
2026-10-22 19:52:53 +00:00
k.renner 924b6e9e88 Merge pull request 'feat(dns): add dns.renner.uno/internal and netbird.renner.uno/public labels (step 1/2)' (#1425) from feat/dns-labels-split-1 into main
Reviewed-on: #1425
2026-10-22 19:46:56 +00:00
claude-botandClaude Opus 5.5 5bfa957f29 feat(dns): one consumer per label; public DNS as CNAME to proxy.renner.uno
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Step 2 of 2 of the label split (owner decision 2026-10-22). Requires
#1425 (step 1) to be applied first.

- external-dns-unifi: labelFilter dns.renner.uno/internal=true (opt-in).
  Every object it publishes today carries the label since step 1, so the
  UDM record set is unchanged.
- external-dns-cloudflare: labelFilter dns.renner.uno/public=true, default
  target proxy.renner.uno (the cloudflare-lb-sync load balancer) instead of
  the retired tunnel, no --cloudflare-proxied, policy sync. Each public
  host gets a DNS-only CNAME to proxy.renner.uno plus its TXT registry
  record; this is the prerequisite for removing the *.renner.uno wildcard.
- netbird-service-sync: reads netbird.renner.uno/public only. Every
  dns-public Ingress carries it since step 1, so the 21 proxy services are
  unchanged.
- git/gitea/gitea-mcp: add dns.renner.uno/public (public today via the
  wildcard; keep their LAN record via dns.renner.uno/internal).
- Drop comments describing the old public!=true filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:45:20 +02:00
hermes-bot 08004a508a Merge remote-tracking branch 'origin/main' into fix/gitea-webhook-sync-org-scope
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
# Conflicts:
#	kubernetes/apps/hermes/skills/gitea-issues/SKILL.md
2026-10-22 06:01:33 +00:00
hermes-bot 3d3510b7bd fix(hermes): list hermes-bot's orgs via the admin route the PAT's scopes cover
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot: /users/{u}/orgs requires the user and organization scope
categories (Gitea 1.27.3 api.go, HasScope needs all), and the sync's PAT
has write:admin,write:organization,write:issue,read:repository, no
read:user. /admin/users/{u}/orgs needs only the admin category and lists
private orgs for a site admin.

Also: drop paged()'s dead /repos/search unwrapping, test the team-mode
fallback of team_writes_issues, and say in SKILL.md that direct-collaborator
and user repos still get cards via the system hook.
2026-10-21 07:47:13 +00:00
hermes-bot bd9f0cb18d fix(hermes): gitea-webhook-sync covers every konairius repo via team membership
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
The sync reached only konairius/homelab ("repos: 1 with issues writable by
hermes-bot") although hermes-bot has write on all 10 org repos through the
org team Bots (includes_all_repositories, repo.issues write).

Cause: GET /repos/{o}/{r}/collaborators/{user}/permission (Gitea 1.27.3
GetRepoPermissions) returns permission.AccessMode, the repo-wide mode from
the collaborator/access level. Rights from a unit-based team land only in
the per-unit modes and are not part of that field, so it said "none" for
the 9 repos where hermes-bot is not a direct collaborator (checked live:
homelab "write", shelly-manager and ical-proxy "none", while
/orgs/konairius/repos reports push: true for all 10).

Scope is now derived from org membership: orgs of hermes-bot
(/users/hermes-bot/orgs) -> teams granting repo.issues write that it is a
member of -> all org repos (includes_all_repositories) or the team's repos,
non-archived with issues on. User-owned repos are out of scope, so labels
are org labels only and the repo-label branch is gone.

Tests: team scope, read-only team skipped, selected-repo team, user repos
out of scope, failed team lookups, org-labels-only. Live read-only probe
with hermes-bot's token: 10 repos, label target konairius.
2026-10-21 07:40:33 +00:00
200 changed files with 3307 additions and 1813 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:
+1 -1
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
+34
View File
@@ -0,0 +1,34 @@
when:
# Deploy only on main (merge) + manual — never auto-deploy from a branch.
- event: push
branch: main
path:
- "ansible/roles/ssh-ca/**"
- "ansible/plays/configure-ssh-ca.yml"
- "ansible/inventory/pve_hosts.yml"
- ".woodpecker/deploy-ssh-ca.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-ssh-ca"'
timeout: 30
steps:
- name: deploy-ssh-ca
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/configure-ssh-ca.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
+13 -14
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
+6
View File
@@ -69,3 +69,9 @@ 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
+15
View File
@@ -0,0 +1,15 @@
---
# 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. Existing authorized_keys stay untouched.
#
# 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
+2 -3
View File
@@ -95,9 +95,8 @@ the VM enrolled as `claude-ws-190-112.nb.renner.uno` / `100.96.190.112` (+ IPv6)
> inventory targets the tunnel DHCP IP.
> **DNS trap.** `claude-ws.obg.renner.uno` resolves to the VM's lease **only at
> the off-site DNS** (`@10.3.40.1`). The home resolver catch-alls `*.renner.uno`
> to the public proxy (`162.55.50.97`, the Netbird VPS) — so from home the FQDN
> lands on `root@netbird`, **not** the VM. Never use it as `ansible_host`.
> the off-site DNS** (`@10.3.40.1`). From home this FQDN does not resolve (it
> exists only at the off-site DNS @10.3.40.1). Never use it as `ansible_host`.
**Optional — mesh route to the k8s API (redundant read path):** to reach the API
over the mesh too, add a Netbird dashboard **network route** advertising the
@@ -3,7 +3,7 @@
#
# Enforces the decisions the in-cluster CrowdSec already detects, at the point
# where public traffic actually enters: these hosts terminate TLS for every
# *.renner.uno name. A drop here never reaches the mesh, the relay or the
# public renner.uno name. A drop here never reaches the mesh, the relay or the
# cluster.
#
# Detection stays in the cluster. The reverse-proxy produces no parseable
@@ -157,6 +157,29 @@
ansible.builtin.pause:
seconds: 10
# The host's mesh client must not manage DNS: containers here resolve through
# the host stub, and the renner.uno nameserver group is unreachable from it.
- name: Check for the host NetBird client
ansible.builtin.stat:
path: /usr/bin/netbird
register: _netbird_client
- name: Read the host NetBird client config
ansible.builtin.command: netbird debug config
register: _netbird_client_config
changed_when: false
when: _netbird_client.stat.exists
# disable-dns is only applied on a fresh `up`, not to a connected client.
- name: Reconnect the host NetBird client with DNS management disabled
ansible.builtin.shell: |
set -eu
netbird down
netbird up --disable-dns
when:
- _netbird_client.stat.exists
- not (_netbird_client_config.stdout | from_json).disableDns
- name: Display deployment status
ansible.builtin.debug:
msg: |
+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.
+139
View File
@@ -0,0 +1,139 @@
---
- 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. 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 sshd so the running daemon uses the CA config
ansible.builtin.systemd:
name: "{{ 'sshd.service' if 'sshd.service' in ansible_facts.services else 'ssh.service' }}"
state: reloaded
@@ -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
+102 -193
View File
@@ -1,34 +1,26 @@
#!/usr/bin/env python3
"""authentik-gate-sync: an ingress class that puts an Authentik proxy gate in front of an app.
"""authentik-gate-sync: the Authentik side of the gate Middleware.
Source of truth: every Ingress with spec.ingressClassName "authentik-gate". Traefik
ignores that class (its IngressClass names another controller), so a source
Ingress is never served directly. For each one this controller builds:
An Ingress is gated when its traefik.ingress.kubernetes.io/router.middlewares
annotation names the gate Middleware (authentik/gate, a ForwardAuth against the
embedded outpost in authentik-server; kubernetes/apps/authentik/gate.yaml). The
gate's configuration lives on that Ingress; for each one this controller builds:
Authentik proxy provider + application + outpost (+ group-membership
expression policy bound to the application).
Authentik's own Kubernetes integration then runs the outpost as
ak-outpost-<gate> in the authentik namespace.
app ns ExternalName Service <ingress>-gate -> the outpost Service,
Traefik Ingress <ingress>-gate: the source's hosts, paths, TLS,
labels and annotations, backend swapped for the gate,
CNP <ingress>-gate: the backend pods admit this outpost.
authentik CNP gate-<gate>: the outpost may reach only those backend pods.
Authentik forward_single proxy provider for the Ingress's host +
application (+ group-membership expression policy bound to it),
the provider attached to the embedded outpost.
app ns CNP <ingress>-gate: the backend pods admit Traefik.
Like any ingress controller it publishes the source's status.loadBalancer: the
address Traefik reports for the route, or none while the gate has no route.
The source Ingress's backend is the real upstream; it becomes the provider's
internal host and its host the external host. Annotations (all optional):
Annotations (all optional):
auth.renner.uno/group group whose members may pass; none = any user
auth.renner.uno/login-flow authentication flow slug; none = the brand's
auth.renner.uno/authorization-flow authorization flow slug (default below)
auth.renner.uno/skip-path provider skip_path_regex
Fail closed: the Traefik Ingress is applied last and only after the Authentik
side reconciled, and is removed when that fails (e.g. the named group is gone),
so there is never a route to an unguarded backend.
Fail closed: until the provider exists, or after it is removed (e.g. the named
group is gone), the embedded outpost has no provider for the host and rejects
the request; Traefik answers 500 when the outpost is unreachable.
Ownership: Kubernetes objects carry app.kubernetes.io/managed-by; Authentik
applications carry meta_publisher. Anything without the marker is never touched.
@@ -48,10 +40,8 @@ LOG = logging.getLogger("authentik-gate-sync")
AK_URL = os.environ.get("AK_URL", "http://authentik-server.authentik.svc.cluster.local").rstrip("/")
AK_TOKEN_FILE = os.environ.get("AK_TOKEN_FILE", "/var/run/secrets/authentik-gate-sync/token")
INGRESS_CLASS = os.environ.get("INGRESS_CLASS", "authentik-gate")
TARGET_CLASS = os.environ.get("TARGET_INGRESS_CLASS", "traefik")
OUTPOST_NS = os.environ.get("OUTPOST_NAMESPACE", "authentik")
SERVICE_CONNECTION = os.environ.get("SERVICE_CONNECTION", "Local Kubernetes Cluster")
GATE_MIDDLEWARE = os.environ.get("GATE_MIDDLEWARE", "authentik-gate@kubernetescrd")
INGRESS_NS = os.environ.get("INGRESS_NAMESPACE", "ingress")
DEFAULT_AUTHZ_FLOW = os.environ.get("DEFAULT_AUTHORIZATION_FLOW", "default-provider-authorization-implicit-consent")
INVALIDATION_FLOW = os.environ.get("INVALIDATION_FLOW", "default-provider-invalidation-flow")
COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", "renner.uno")
@@ -63,14 +53,9 @@ ANN = "auth.renner.uno/"
MANAGER = "authentik-gate-sync"
MANAGED_BY = {"app.kubernetes.io/managed-by": MANAGER}
GATE_LABEL = "auth.renner.uno/gate"
OUTPOST_PORT = 9000
# Never copied from the source onto the generated Ingress: cert-manager would
# issue a second Certificate for the same secret, Flux would treat it as its own,
# and the gate annotations mean nothing to Traefik.
DROP_LABEL_PREFIXES = ("kustomize.toolkit.fluxcd.io/", "app.kubernetes.io/managed-by")
DROP_ANNOTATION_PREFIXES = (ANN, "cert-manager.io/", "acme.cert-manager.io/",
"kubectl.kubernetes.io/last-applied-configuration")
EMBEDDED_OUTPOST = "goauthentik.io/outposts/embedded"
TRAEFIK = {"k8s:io.kubernetes.pod.namespace": INGRESS_NS, "app.kubernetes.io/name": "traefik"}
MIDDLEWARES_ANN = "traefik.ingress.kubernetes.io/router.middlewares"
K8S_API = "https://kubernetes.default.svc"
K8S_TOKEN_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/token"
@@ -114,7 +99,7 @@ def http(method, url, body=None, headers=None, cafile=None, content_type="applic
def gate_name(ing):
"""<ns>-<ingress>, truncated, plus a hash of both: unambiguous (a-b/c vs a/b-c)
and short enough for label values and ak-outpost-<name> (63)."""
and short enough for label values (63)."""
ns, name = ing["metadata"]["namespace"], ing["metadata"]["name"]
digest = hashlib.sha256(f"{ns}/{name}".encode()).hexdigest()[:6]
return f"{ns}-{name}"[:45].rstrip("-") + "-" + digest
@@ -126,6 +111,24 @@ def group_expression(group):
return f"return ak_is_group_member(request.user, name={group!r})"
def is_gated(ing):
chain = ((ing.get("metadata") or {}).get("annotations") or {}).get(MIDDLEWARES_ANN, "")
return GATE_MIDDLEWARE in (m.strip() for m in chain.split(","))
def shared_hosts(sources):
"""Hosts named by more than one gated Ingress. The outpost finds the provider
by host, so such a host would get one gate for several Ingresses."""
seen, shared = set(), set()
for ing in sources:
for r in ing.get("spec", {}).get("rules") or []:
host = r.get("host")
if host in seen:
shared.add(host)
seen.add(host)
return shared
def gate_spec(ing):
"""Translate a source Ingress into the gate it asks for. Raises GateError."""
meta = ing["metadata"]
@@ -174,63 +177,15 @@ def gate_labels(gate):
return {**MANAGED_BY, GATE_LABEL: gate["name"]}
def outpost_service(gate):
return f"ak-outpost-{gate['name']}"
def external_name_service(gate):
return {"apiVersion": "v1", "kind": "Service",
"metadata": {"name": f"{gate['ingress']}-gate", "namespace": gate["namespace"],
"labels": gate_labels(gate), "ownerReferences": owner_ref(gate)},
"spec": {"type": "ExternalName",
"externalName": f"{outpost_service(gate)}.{OUTPOST_NS}.svc.cluster.local",
"ports": [{"name": "http", "port": OUTPOST_PORT, "targetPort": OUTPOST_PORT}]}}
def route_ingress(ing, gate):
"""The Traefik Ingress: the source with its backend swapped for the gate."""
meta = ing["metadata"]
labels = {k: v for k, v in (meta.get("labels") or {}).items() if not k.startswith(DROP_LABEL_PREFIXES)}
annotations = {k: v for k, v in (meta.get("annotations") or {}).items()
if not k.startswith(DROP_ANNOTATION_PREFIXES)}
backend = {"service": {"name": f"{gate['ingress']}-gate", "port": {"number": OUTPOST_PORT}}}
rules = []
for r in ing["spec"]["rules"]:
paths = [{"path": p.get("path", "/"), "pathType": p.get("pathType", "Prefix"), "backend": backend}
for p in r["http"]["paths"]]
rules.append({"host": r["host"], "http": {"paths": paths}})
spec = {"ingressClassName": TARGET_CLASS, "rules": rules}
if ing["spec"].get("tls"):
spec["tls"] = ing["spec"]["tls"]
return {"apiVersion": "networking.k8s.io/v1", "kind": "Ingress",
"metadata": {"name": f"{gate['ingress']}-gate", "namespace": gate["namespace"],
"labels": {**labels, **gate_labels(gate)}, "annotations": annotations,
"ownerReferences": owner_ref(gate)},
"spec": spec}
def outpost_selector(gate):
return {"k8s:io.kubernetes.pod.namespace": OUTPOST_NS, "goauthentik.io/outpost-name": gate["name"]}
def backend_ingress_cnp(gate, pod_selector, target_port):
return {"apiVersion": "cilium.io/v2", "kind": "CiliumNetworkPolicy",
"metadata": {"name": f"{gate['ingress']}-gate", "namespace": gate["namespace"],
"labels": gate_labels(gate), "ownerReferences": owner_ref(gate)},
"spec": {"endpointSelector": {"matchLabels": pod_selector},
"ingress": [{"fromEndpoints": [{"matchLabels": outpost_selector(gate)}],
"ingress": [{"fromEndpoints": [{"matchLabels": TRAEFIK}],
"toPorts": [{"ports": [{"port": str(target_port), "protocol": "TCP"}]}]}]}}
def outpost_egress_cnp(gate, pod_selector, target_port):
peer = {"k8s:io.kubernetes.pod.namespace": gate["namespace"], **pod_selector}
return {"apiVersion": "cilium.io/v2", "kind": "CiliumNetworkPolicy",
"metadata": {"name": f"gate-{gate['name']}", "namespace": OUTPOST_NS, "labels": gate_labels(gate)},
"spec": {"endpointSelector": {"matchLabels": {"goauthentik.io/outpost-name": gate["name"]}},
"egress": [{"toEndpoints": [{"matchLabels": peer}],
"toPorts": [{"ports": [{"port": str(target_port), "protocol": "TCP"}]}]}]}}
def resolve_backend(gate, service):
"""(service port number, pod selector, target port) for the gate's backend.
A named targetPort stays a name: Cilium resolves named ports against the
@@ -254,34 +209,20 @@ def resolve_backend(gate, service):
return sp["port"], selector, target
def outpost_config():
return {
"log_level": "info",
"authentik_host": AK_URL,
"authentik_host_insecure": False,
"authentik_host_browser": AK_BROWSER_URL,
"refresh_interval": "minutes=5",
"container_image": None,
"object_naming_template": "ak-outpost-%(name)s",
"kubernetes_namespace": OUTPOST_NS,
"kubernetes_replicas": 1,
"kubernetes_service_type": "ClusterIP",
"kubernetes_disabled_components": ["ingress"],
"kubernetes_image_pull_secrets": [],
"kubernetes_ingress_annotations": {},
"kubernetes_ingress_class_name": None,
"kubernetes_ingress_secret_name": "authentik-outpost-tls",
"kubernetes_json_patches": None,
}
def embedded_config(config):
"""The embedded outpost's config with the host the gates depend on. The
embedded outpost builds the sign-in redirect from authentik_host, so it is
the public URL, which is also Authentik's default for it."""
return {**(config or {}), "authentik_host": AK_BROWSER_URL, "authentik_host_insecure": False,
"authentik_host_browser": AK_BROWSER_URL}
def provider_body(gate, svc_port, flows):
def provider_body(gate, flows):
return {
"name": gate["name"],
"mode": "proxy",
"mode": "forward_single",
"external_host": f"{gate['scheme']}://{gate['host']}",
"internal_host": f"http://{gate['service']}.{gate['namespace']}.svc.cluster.local:{svc_port}",
"internal_host_ssl_validation": False,
"internal_host": "",
"cookie_domain": COOKIE_DOMAIN,
"access_token_validity": "hours=24",
"refresh_token_validity": "days=30",
@@ -293,18 +234,6 @@ def provider_body(gate, svc_port, flows):
}
def lb_ingress(obj):
return ((obj or {}).get("status") or {}).get("loadBalancer", {}).get("ingress") or []
def status_patch(source, addresses):
"""Merge patch publishing `addresses` as the source Ingress's load-balancer
status, as an ingress controller does; None when it already matches."""
if lb_ingress(source) == addresses:
return None
return {"status": {"loadBalancer": {"ingress": addresses or None}}}
def drift(current, desired):
return {k: v for k, v in desired.items() if current.get(k) != v}
@@ -342,23 +271,9 @@ class K8s:
if e.code != 404:
raise
def patch_status(self, ns, name, patch):
path = f"/apis/networking.k8s.io/v1/namespaces/{ns}/ingresses/{name}/status"
return http("PATCH", K8S_API + path, patch, headers=self._h(), cafile=K8S_CA_FILE,
content_type="application/merge-patch+json")
def delete_managed(self, obj):
try:
live = self.get(object_path(obj))
except ApiError as e:
if e.code == 404:
return
raise
if (live["metadata"].get("labels") or {}).get("app.kubernetes.io/managed-by") == MANAGER:
self.delete(obj)
LOG.info("deleted %s %s/%s", obj["kind"], obj["metadata"]["namespace"], obj["metadata"]["name"])
# Swept by label: any object of these kinds marked as this controller's that no
# gate wants is deleted. It creates only CNPs; Ingresses and Services carrying
# the label are routes a gate must not have besides its own Ingress.
COLLECTIONS = {
"Ingress": "/apis/networking.k8s.io/v1/namespaces/{ns}/ingresses",
"Service": "/api/v1/namespaces/{ns}/services",
@@ -425,7 +340,24 @@ def get_application(ak, slug):
return ak.get(f"core/applications/{slug}/")
def ensure_gate(ak, gate, svc_port, flows, connection):
def embedded_outpost(ak):
# Matched again here: a filter the server ignored must not select another outpost.
found = [o for o in ak.list("outposts/instances/", managed__iexact=EMBEDDED_OUTPOST)
if o.get("managed") == EMBEDDED_OUTPOST]
if len(found) != 1:
raise ApiError(f"expected one embedded outpost, found {len(found)}")
return found[0]
def ensure_embedded_config(ak):
outpost = embedded_outpost(ak)
want = embedded_config(outpost.get("config"))
if outpost.get("config") != want:
ak.req("PATCH", f"outposts/instances/{outpost['pk']}/", {"config": want})
LOG.info("embedded outpost: updated config")
def ensure_gate(ak, gate, flows):
name = gate["name"]
if gate["group"] and ak.one("core/groups/", name=gate["group"]) is None:
raise GateError(f"group {gate['group']!r} not found in Authentik")
@@ -434,7 +366,7 @@ def ensure_gate(ak, gate, svc_port, flows, connection):
if app is not None and app.get("meta_publisher") != MANAGER:
raise GateError(f"application {name} exists and is not managed by {MANAGER}")
want = provider_body(gate, svc_port, flows)
want = provider_body(gate, flows)
provider = ak.one("providers/proxy/", name__iexact=name)
if provider is None:
provider = ak.req("POST", "providers/proxy/", want)
@@ -461,17 +393,22 @@ def ensure_gate(ak, gate, svc_port, flows, connection):
ensure_access(ak, gate, app["pbm_uuid"])
want_outpost = {"name": name, "type": "proxy", "service_connection": connection,
"providers": [provider["pk"]], "config": outpost_config()}
outpost = ak.one("outposts/instances/", name__iexact=name)
if outpost is None:
ak.req("POST", "outposts/instances/", want_outpost)
LOG.info("%s: created outpost", name)
elif set(outpost.get("providers") or []) - {provider["pk"]}:
raise GateError(f"outpost {name} serves providers this controller does not own")
elif drift(outpost, want_outpost):
ak.req("PATCH", f"outposts/instances/{outpost['pk']}/", drift(outpost, want_outpost))
LOG.info("%s: updated outpost %s", name, sorted(drift(outpost, want_outpost)))
# Re-read per gate: the providers list is replaced whole.
embedded = embedded_outpost(ak)
if provider["pk"] not in (embedded.get("providers") or []):
ak.req("PATCH", f"outposts/instances/{embedded['pk']}/",
{"providers": [*(embedded.get("providers") or []), provider["pk"]]})
LOG.info("%s: attached provider to the embedded outpost", name)
remove_dedicated_outposts(ak, name, provider["pk"])
def remove_dedicated_outposts(ak, name, provider):
"""Outposts named after the gate that serve only its provider: the embedded
outpost serves every gate, so Authentik tears these and their pods down."""
for o in ak.list("outposts/instances/", name__iexact=name):
if o.get("providers") == [provider] and not o.get("managed"):
ak.req("DELETE", f"outposts/instances/{o['pk']}/")
LOG.info("%s: removed dedicated outpost", name)
def ensure_access(ak, gate, target):
@@ -511,15 +448,12 @@ def ensure_access(ak, gate, target):
def remove_gate(ak, app):
"""Delete an owned gate. The outpost serving only its provider goes first
(Authentik tears its pods down), then the application (bindings cascade; its
provider is PROTECTed while referenced), the provider, the policy. Ownership
is found through the application, so until it is gone a failed step is
retried on the next pass."""
"""Delete an owned gate: the application (bindings cascade; its provider is
PROTECTed while referenced), then the provider (which leaves the embedded
outpost with it), the policy. Ownership is found through the application,
so until it is gone a failed step is retried on the next pass."""
name, provider = app["slug"], app.get("provider")
for o in ak.list("outposts/instances/", name__iexact=name):
if o.get("providers") == [provider]:
ak.req("DELETE", f"outposts/instances/{o['pk']}/")
remove_dedicated_outposts(ak, name, provider)
ak.req("DELETE", f"core/applications/{name}/")
if provider:
ak.req("DELETE", f"providers/proxy/{provider}/")
@@ -543,32 +477,15 @@ def owned_applications(ak):
# --- reconcile --------------------------------------------------------------
def publish_status(k8s, source, addresses):
"""Status is cosmetic for the gate itself: a failure is logged, never fatal."""
patch = status_patch(source, addresses)
if patch is None:
return
m = source["metadata"]
try:
k8s.patch_status(m["namespace"], m["name"], patch)
LOG.info("%s/%s: status %s", m["namespace"], m["name"],
[a.get("ip") or a.get("hostname") for a in addresses])
except Exception as e:
LOG.warning("%s/%s: publishing status: %s", m["namespace"], m["name"], e)
def reconcile():
k8s = K8s()
with open(AK_TOKEN_FILE) as f:
ak = Authentik(f.read().strip())
sources = [i for i in k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", [])
if i.get("spec", {}).get("ingressClassName") == INGRESS_CLASS]
sources = [i for i in k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", []) if is_gated(i)]
shared = shared_hosts(sources)
flows = {f["slug"]: f["pk"] for f in ak.list("flows/instances/")}
connection = ak.one("outposts/service_connections/kubernetes/", name=SERVICE_CONNECTION)
if connection is None:
raise ApiError(f"Kubernetes service connection {SERVICE_CONNECTION!r} not found")
connection = connection["pk"]
ensure_embedded_config(ak)
desired = set() # (kind, ns, name) of every Kubernetes object some gate wants
gates = set() # gate names whose Authentik side should exist
@@ -578,6 +495,8 @@ def reconcile():
ref = f'{ing["metadata"]["namespace"]}/{ing["metadata"]["name"]}'
try:
gate = gate_spec(ing)
if gate["host"] in shared:
raise GateError(f"host {gate['host']} is gated by more than one Ingress")
for slug in filter(None, (gate["authorization_flow"], gate["login_flow"], INVALIDATION_FLOW)):
if slug not in flows:
raise GateError(f"flow {slug!r} not found in Authentik")
@@ -588,28 +507,18 @@ def reconcile():
if e.code == 404:
raise GateError(f"backend Service {gate['service']} does not exist") from None
raise
svc_port, pods, target_port = resolve_backend(gate, service)
_, pods, target_port = resolve_backend(gate, service)
gates.add(gate["name"])
ensure_gate(ak, gate, svc_port, flows, connection)
objs = [outpost_egress_cnp(gate, pods, target_port), backend_ingress_cnp(gate, pods, target_port),
external_name_service(gate), route_ingress(ing, gate)] # the route goes last
for obj in objs:
live = k8s.apply(obj)
desired.add((obj["kind"], obj["metadata"]["namespace"], obj["metadata"]["name"]))
publish_status(k8s, ing, lb_ingress(live)) # the route's address, from Traefik
ensure_gate(ak, gate, flows)
cnp = backend_ingress_cnp(gate, pods, target_port)
k8s.apply(cnp)
desired.add((cnp["kind"], cnp["metadata"]["namespace"], cnp["metadata"]["name"]))
except GateError as e:
# The Ingress cannot be gated as written: its route goes now, the
# rest with the sweep below.
# The Ingress cannot be gated as written: its provider goes with the
# sweep below, after which the outpost rejects the host.
LOG.error("%s: %s", ref, e)
failed += 1
gates.discard(gate_name(ing))
route = {"kind": "Ingress", "metadata": {"namespace": ing["metadata"]["namespace"],
"name": f'{ing["metadata"]["name"]}-gate'}}
try:
k8s.delete_managed(route)
except Exception as e: # the sweep retries
LOG.error("%s: removing route: %s", ref, e)
publish_status(k8s, ing, [])
except Exception as e:
# Transient: leave this gate exactly as the last good pass left it.
LOG.error("%s: %s", ref, e)
@@ -11,11 +11,12 @@ LLAMA = {
"cert-manager.io/cluster-issuer": "letsencrypt-cluster",
"auth.renner.uno/login-flow": "sso-authentication",
"auth.renner.uno/skip-path": r"^/(manifest\.webmanifest|sw\.js)$",
"traefik.ingress.kubernetes.io/router.middlewares": "ingress-foo@kubernetescrd",
"traefik.ingress.kubernetes.io/router.middlewares":
"authentik-gate@kubernetescrd, ingress-foo@kubernetescrd",
},
},
"spec": {
"ingressClassName": "authentik-gate",
"ingressClassName": "traefik",
"tls": [{"hosts": ["llm.renner.uno"], "secretName": "llama-server-tls"}],
"rules": [{"host": "llm.renner.uno", "http": {"paths": [
{"path": "/", "pathType": "Prefix",
@@ -41,7 +42,7 @@ class Names(unittest.TestCase):
def test_bounded(self):
n = c.gate_name(ing("n" * 63, "i" * 253))
self.assertLessEqual(len(c.outpost_service({"name": n})), 63)
self.assertLessEqual(len(n), 63)
self.assertNotIn("--", n)
def test_group_expression_is_a_literal(self):
@@ -90,6 +91,22 @@ class GateSpec(unittest.TestCase):
with self.assertRaises(c.GateError):
c.gate_spec(ing)
def test_shared_hosts(self):
other = copy.deepcopy(LLAMA)
other["metadata"]["name"] = "llama-admin"
self.assertEqual(c.shared_hosts([LLAMA, other]), {"llm.renner.uno"})
other["spec"]["rules"][0]["host"] = "admin.renner.uno"
self.assertEqual(c.shared_hosts([LLAMA, other]), set())
def test_gated_by_middleware_reference(self):
self.assertTrue(c.is_gated(LLAMA))
ing = copy.deepcopy(LLAMA)
ing["metadata"]["annotations"]["traefik.ingress.kubernetes.io/router.middlewares"] = \
"ingress-foo@kubernetescrd,authentik-gate-other@kubernetescrd"
self.assertFalse(c.is_gated(ing))
del ing["metadata"]["annotations"]
self.assertFalse(c.is_gated(ing))
class Backend(unittest.TestCase):
def test_named_target_port_stays_named(self):
@@ -126,65 +143,34 @@ class Objects(unittest.TestCase):
def setUp(self):
self.g = c.gate_spec(LLAMA)
def test_route_ingress(self):
r = c.route_ingress(LLAMA, self.g)
self.assertEqual(r["metadata"]["name"], "llama-server-gate")
self.assertEqual(r["spec"]["ingressClassName"], "traefik")
self.assertEqual(r["spec"]["tls"], LLAMA["spec"]["tls"])
backend = r["spec"]["rules"][0]["http"]["paths"][0]["backend"]["service"]
self.assertEqual(backend, {"name": "llama-server-gate", "port": {"number": 9000}})
labels, ann = r["metadata"]["labels"], r["metadata"]["annotations"]
self.assertEqual(labels["dns.renner.uno/public"], "true")
self.assertNotIn("kustomize.toolkit.fluxcd.io/name", labels)
self.assertEqual(labels["app.kubernetes.io/managed-by"], c.MANAGER)
self.assertEqual(ann, {"traefik.ingress.kubernetes.io/router.middlewares": "ingress-foo@kubernetescrd"})
self.assertEqual(r["metadata"]["ownerReferences"][0]["uid"], "u-1")
def test_external_name(self):
s = c.external_name_service(self.g)
self.assertEqual(s["spec"]["externalName"], f"ak-outpost-{self.g['name']}.authentik.svc.cluster.local")
def test_cnps(self):
def test_backend_admits_traefik_only(self):
pods = {"app.kubernetes.io/name": "llama-server"}
e = c.outpost_egress_cnp(self.g, pods, 8080)
self.assertEqual(e["metadata"]["namespace"], "authentik")
self.assertEqual(e["spec"]["endpointSelector"]["matchLabels"],
{"goauthentik.io/outpost-name": self.g["name"]})
self.assertEqual(e["spec"]["egress"][0]["toEndpoints"][0]["matchLabels"],
{"k8s:io.kubernetes.pod.namespace": "ai", "app.kubernetes.io/name": "llama-server"})
i = c.backend_ingress_cnp(self.g, pods, 8080)
self.assertEqual(i["metadata"]["namespace"], "ai")
self.assertEqual(i["spec"]["ingress"][0]["fromEndpoints"][0]["matchLabels"],
{"k8s:io.kubernetes.pod.namespace": "authentik",
"goauthentik.io/outpost-name": self.g["name"]})
self.assertEqual(i["spec"]["ingress"][0]["fromEndpoints"],
[{"matchLabels": {"k8s:io.kubernetes.pod.namespace": "ingress",
"app.kubernetes.io/name": "traefik"}}])
self.assertEqual(i["spec"]["ingress"][0]["toPorts"][0]["ports"][0]["port"], "8080")
def test_provider_body(self):
flows = {c.DEFAULT_AUTHZ_FLOW: "az", "sso-authentication": "au",
c.INVALIDATION_FLOW: "inv"}
p = c.provider_body(self.g, 8080, flows)
p = c.provider_body(self.g, flows)
self.assertEqual(p["mode"], "forward_single")
self.assertEqual(p["external_host"], "https://llm.renner.uno")
self.assertEqual(p["internal_host"], "http://llama-server.ai.svc.cluster.local:8080")
self.assertEqual(p["internal_host"], "")
self.assertEqual((p["authorization_flow"], p["authentication_flow"], p["invalidation_flow"]),
("az", "au", "inv"))
self.assertEqual(p["skip_path_regex"], r"^/(manifest\.webmanifest|sw\.js)$")
def test_status_mirrors_route_address(self):
addrs = [{"ip": "10.2.99.86"}, {"ip": "fd99::86"}]
route = {"status": {"loadBalancer": {"ingress": addrs}}}
self.assertEqual(c.status_patch(LLAMA, c.lb_ingress(route)),
{"status": {"loadBalancer": {"ingress": addrs}}})
synced = dict(LLAMA, status={"loadBalancer": {"ingress": addrs}})
self.assertIsNone(c.status_patch(synced, addrs))
def test_status_cleared_without_route(self):
stale = dict(LLAMA, status={"loadBalancer": {"ingress": [{"ip": "10.2.99.86"}]}})
self.assertEqual(c.status_patch(stale, []), {"status": {"loadBalancer": {"ingress": None}}})
self.assertIsNone(c.status_patch(LLAMA, []))
def test_drift(self):
self.assertEqual(c.drift({"a": 1, "b": 2}, {"a": 1, "b": 3}), {"b": 3})
def test_embedded_config_keeps_other_keys(self):
cfg = c.embedded_config({"log_level": "debug", "authentik_host": c.AK_URL})
self.assertEqual(cfg, {"log_level": "debug", "authentik_host": c.AK_BROWSER_URL,
"authentik_host_insecure": False, "authentik_host_browser": c.AK_BROWSER_URL})
class FakeAuthentik:
"""In-memory stand-in for the endpoints the controller uses; records calls."""
@@ -267,20 +253,69 @@ class Reconcile(unittest.TestCase):
def setUp(self):
self.ak = FakeAuthentik()
self.g = c.gate_spec(LLAMA)
self.embedded = self.ak.add("outposts/instances/", {
"name": "authentik Embedded Outpost", "managed": c.EMBEDDED_OUTPOST,
"providers": [], "config": {"log_level": "info"}})
def gate(self, group=None):
g = dict(self.g, group=group)
c.ensure_gate(self.ak, g, 8080, FLOWS, "conn")
c.ensure_gate(self.ak, g, FLOWS)
return g
def embedded_providers(self):
return self.ak.get(f"outposts/instances/{self.embedded['pk']}/")["providers"]
def test_creates_owned_gate(self):
self.gate()
app = self.ak.get(f"core/applications/{self.g['name']}/")
self.assertEqual(app["meta_publisher"], c.MANAGER)
outpost = self.ak.one("outposts/instances/", name__iexact=self.g["name"])
self.assertEqual(outpost["providers"], [app["provider"]])
self.assertEqual(self.embedded_providers(), [app["provider"]])
self.assertEqual(self.ak.list("outposts/instances/", name__iexact=self.g["name"]), [])
self.assertEqual(self.ak.list("policies/bindings/"), [])
def test_keeps_other_providers_on_embedded_outpost(self):
self.ak.db["outposts/instances/"][self.embedded["pk"]]["providers"] = [999]
self.gate()
app = self.ak.get(f"core/applications/{self.g['name']}/")
self.assertEqual(self.embedded_providers(), [999, app["provider"]])
def test_removes_dedicated_outpost(self):
self.gate()
app = self.ak.get(f"core/applications/{self.g['name']}/")
self.ak.add("outposts/instances/", {"name": self.g["name"], "providers": [app["provider"]]})
shared = self.ak.add("outposts/instances/", {"name": self.g["name"].upper(),
"providers": [app["provider"], 999]})
self.gate()
self.assertEqual([o["pk"] for o in self.ak.list("outposts/instances/", name__iexact=self.g["name"])],
[shared["pk"]])
def test_embedded_lookup_ignores_unfiltered_results(self):
ldap = self.ak.add("outposts/instances/", {"name": "LDAP Outpost", "managed": None, "providers": [7]})
unfiltered = self.ak.list
self.ak.list = lambda path, **q: unfiltered(path) # a server that ignores the filter
self.assertEqual(c.embedded_outpost(self.ak)["pk"], self.embedded["pk"])
del self.ak.db["outposts/instances/"][self.embedded["pk"]]
with self.assertRaises(c.ApiError):
c.embedded_outpost(self.ak)
self.assertEqual(self.ak.get(f"outposts/instances/{ldap['pk']}/")["providers"], [7])
def test_embedded_config(self):
c.ensure_embedded_config(self.ak)
cfg = self.ak.get(f"outposts/instances/{self.embedded['pk']}/")["config"]
self.assertEqual((cfg["log_level"], cfg["authentik_host"], cfg["authentik_host_browser"]),
("info", c.AK_BROWSER_URL, c.AK_BROWSER_URL))
before = len(self.ak.calls)
c.ensure_embedded_config(self.ak)
self.assertEqual(self.ak.calls[before:], [])
def test_provider_moves_from_proxy_mode(self):
self.ak.add("providers/proxy/", {"name": self.g["name"], "mode": "proxy",
"internal_host": "http://llama-server.ai.svc.cluster.local:8080",
"assigned_application_slug": None})
self.gate()
provider = self.ak.one("providers/proxy/", name__iexact=self.g["name"])
self.assertEqual((provider["mode"], provider["internal_host"]), ("forward_single", ""))
def test_second_pass_is_a_noop(self):
self.gate()
before = len(self.ak.calls)
@@ -329,15 +364,15 @@ class Reconcile(unittest.TestCase):
self.assertIsNone(self.ak.one("policies/expression/", name=self.g["name"]))
def test_remove_gate_and_ownership_scan(self):
self.ak.add("providers/proxy/", {"name": "foreign", "assigned_application_slug": "foreign"})
self.ak.add("core/applications/", {"slug": "foreign", "meta_publisher": "", "provider": 1})
foreign = self.ak.add("providers/proxy/", {"name": "foreign", "assigned_application_slug": "foreign"})
self.ak.add("core/applications/", {"slug": "foreign", "meta_publisher": "", "provider": foreign["pk"]})
self.gate()
owned = list(c.owned_applications(self.ak))
self.assertEqual([a["slug"] for a in owned], [self.g["name"]])
c.remove_gate(self.ak, owned[0])
self.assertIsNone(self.ak.get(f"core/applications/{self.g['name']}/"))
self.assertEqual([p["name"] for p in self.ak.list("providers/proxy/")], ["foreign"])
self.assertEqual(self.ak.list("outposts/instances/"), [])
self.assertEqual([o["pk"] for o in self.ak.list("outposts/instances/")], [self.embedded["pk"]])
def test_adopts_provider_orphaned_by_interrupted_teardown(self):
self.gate()
+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
+4 -5
View File
@@ -1,10 +1,9 @@
#!/usr/bin/env python3
"""netbird-service-sync: reconcile NetBird reverse-proxy services from Ingresses.
Source of truth: every Ingress carrying one of the public labels.
dns.renner.uno/public also takes the host out of the LAN DNS (external-dns-unifi
skips it); netbird.renner.uno/public publishes on the proxy only and leaves the
LAN record in place.
Source of truth: every Ingress labeled netbird.renner.uno/public=true. DNS is
independent of it: dns.renner.uno/internal (UDM) and dns.renner.uno/public
(Cloudflare) are read by the two external-dns instances.
For each host on such an Ingress the NetBird management gets one reverse-proxy
service in TLS-passthrough mode on :443, targeting Traefik's ClusterIP through a
subnet resource that covers the cluster's service CIDR. SNI and Host reach
@@ -45,7 +44,7 @@ NETWORK_NAME = os.environ.get("NETWORK_NAME", "k8s public ingress")
ROUTER_GROUP = os.environ.get("ROUTER_GROUP_NAME", "k8s-ingress-router")
SOURCE_GROUP = os.environ.get("SOURCE_GROUP_NAME", "All")
TRAEFIK_NS, TRAEFIK_SVC = os.environ.get("TRAEFIK_SERVICE", "ingress/traefik").split("/", 1)
PUBLIC_LABELS = os.environ.get("PUBLIC_LABELS", "dns.renner.uno/public,netbird.renner.uno/public").split(",")
PUBLIC_LABELS = os.environ.get("PUBLIC_LABELS", "netbird.renner.uno/public").split(",")
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "443"))
INTERVAL = int(os.environ.get("INTERVAL_SECONDS", "60"))
HTTP_TIMEOUT = 20
+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
@@ -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
+2 -2
View File
@@ -4,8 +4,8 @@ metadata:
name: audiobookshelf
namespace: audiobookshelf
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: audiobooks.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -374,16 +374,10 @@ data:
name: lidarr-gate
state: absent
- model: authentik_outposts.outpost
identifiers:
name: authentik Embedded Outpost
state: present
attrs:
type: proxy
service_connection: !KeyOf k8s-conn
# No providers attached — each gate outpost serves its own
# /outpost.goauthentik.io/* path on the app host.
providers: []
# The embedded outpost is not declared here: authentik-gate-sync owns its
# providers list (every gate) and the hosts in its config, and a
# blueprint entry must carry `providers`, which would detach the gates
# on every apply.
# Shared LDAPS cert for the LDAP Outpost. Authentik's data model
# owns the cert per-LDAPProvider (no outpost-level cert field), so
@@ -712,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
@@ -1040,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:
@@ -1078,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
+12 -23
View File
@@ -1,15 +1,9 @@
# The authentik-gate ingress class: an Ingress of this class becomes an
# Authentik proxy gate (provider, application, outpost, group binding) plus the
# Traefik Ingress, ExternalName Service and CiliumNetworkPolicies in front of
# its backend. Traefik ignores the class (it only serves classes naming
# traefik.io/ingress-controller). Controller: ci/images/authentik-gate-sync.
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: authentik-gate
spec:
controller: renner.uno/authentik-gate
---
# authentik-gate-sync: for every Ingress that names the gate Middleware
# (gate.yaml) it builds the Authentik provider (on the embedded outpost),
# application and group binding from the Ingress's auth.renner.uno/*
# annotations, and a CiliumNetworkPolicy admitting Traefik to its backend.
# Controller: ci/images/authentik-gate-sync.
#
# API token of the authentik-gate-sync service account. Generated once; the
# blueprint (gate-sync.yaml key in blueprints-configmap.yaml) sets it as the
# token's key via AUTHENTIK_GATE_SYNC_TOKEN, the controller reads the same Secret.
@@ -51,8 +45,9 @@ metadata:
labels:
app.kubernetes.io/name: authentik-gate-sync
---
# Cluster-wide: gated Ingresses live in any namespace, and so do the route
# Ingress, ExternalName Service and backend CNP generated next to them.
# Cluster-wide: gated Ingresses live in any namespace, and so do their backend
# Services and the CNP generated next to them. Delete on Ingresses and Services
# covers objects labelled as this controller's that no gate wants.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
@@ -60,14 +55,10 @@ metadata:
rules:
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# The source Ingress's load-balancer status mirrors its generated route.
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses/status"]
verbs: ["get", "patch", "update"]
verbs: ["get", "list", "watch", "delete"]
- apiGroups: [""]
resources: ["services"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
verbs: ["get", "list", "watch", "delete"]
- apiGroups: ["cilium.io"]
resources: ["ciliumnetworkpolicies"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
@@ -114,7 +105,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-22-1858 # {"$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
@@ -125,8 +116,6 @@ spec:
value: http://authentik-server.authentik.svc.cluster.local
- name: AUTHENTIK_HOST_BROWSER
value: https://auth.renner.uno/
- name: SERVICE_CONNECTION
value: Local Kubernetes Cluster
- name: DEFAULT_AUTHORIZATION_FLOW
value: default-provider-authorization-implicit-consent
- name: INTERVAL_SECONDS
+38
View File
@@ -0,0 +1,38 @@
# The Authentik gate. An Ingress opts in by naming this Middleware in
# traefik.ingress.kubernetes.io/router.middlewares (authentik-gate@kubernetescrd);
# its auth.renner.uno/* annotations configure the gate, and authentik-gate-sync
# (gate-sync.yaml) builds the Authentik provider and the backend CNP from them.
# The embedded outpost picks the provider by Host and rejects hosts it has no
# provider for.
#
# authResponseHeadersRegex makes Traefik delete every client-sent X-authentik-*
# header before copying the outpost's, so a gated backend only sees identity
# Authentik issued.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: gate
namespace: authentik
spec:
forwardAuth:
address: http://authentik-server.authentik.svc.cluster.local/outpost.goauthentik.io/auth/traefik
authResponseHeadersRegex: "(?i)^x-authentik-"
---
# The outpost's own paths (sign-in callback, sign-out) on every host. Above the
# app routes by priority; on hosts without a gate provider the outpost rejects.
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: gate-outpost
namespace: authentik
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: PathPrefix(`/outpost.goauthentik.io/`)
priority: 100000
services:
- name: authentik-server
port: 80
tls: {}
+4 -4
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
@@ -161,8 +161,8 @@ spec:
enabled: true
ingressClassName: traefik
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the
# UDM external-dns instance skips it, LAN uses the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: auth.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -6,6 +6,7 @@ resources:
- blueprints-configmap.yaml
- helmrelease.yaml
- gate-sync.yaml
- gate.yaml
- ldap-outpost-cert.yaml
- ldap-outpost-lb.yaml
- ldap-outpost-cnp.yaml
@@ -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.
@@ -43,16 +43,16 @@ spec:
name: cloudflare-api-token
key: api-token
extraArgs:
- --cloudflare-proxied
- --cloudflare-dns-records-per-page=5000
- --default-targets=d2386ccd-a665-41e7-8631-603fdafcb006.cfargotunnel.com
# Every public host is a DNS-only CNAME to the proxy load balancer (kubernetes/apps/cloudflare-lb-sync).
- --default-targets=proxy.renner.uno
- --force-default-targets
domainFilters:
- renner.uno
sources:
- ingress
txtOwnerId: k8s-homelab
policy: upsert-only
policy: sync
# Filter by label, not annotation. external-dns translates annotations
# in its prefix namespace into ProviderSpecific properties on desired
# endpoints; the UniFi webhook does not implement AdjustEndpoints, so
@@ -60,4 +60,4 @@ spec:
# records and cause every reconcile to delete-and-recreate the same
# values. Labels are not translated, so they are safe to use as a
# selector without leaking provider-specific state to the UniFi side.
labelFilter: external-dns/cloudflare=true
labelFilter: dns.renner.uno/public=true
+5 -3
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
@@ -211,7 +211,7 @@ data:
# Paperless sits entirely behind the Authentik gate — every path,
# including /api/, answers 302 to unauthenticated callers, so there is no
# endpoint that proves the app rather than the gate. This check is
# therefore reachability only: DNS, TLS, Traefik and the outpost. It
# therefore reachability only: DNS, TLS, Traefik and forward auth. It
# stays green if paperless itself is dead behind a live gate.
#
# Closing that blind spot needs an in-cluster probe of the paperless
@@ -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 -2
View File
@@ -4,8 +4,8 @@ metadata:
name: gatus
namespace: apps
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: status.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
type: RuntimeDefault
containers:
- name: gitea-mcp
image: docker.gitea.com/gitea-mcp-server:1.10.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
image: docker.gitea.com/gitea-mcp-server:1.11.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
# Image has no ENTRYPOINT (binary is in CMD), so args alone would replace it.
command:
- /app/gitea-mcp
+1
View File
@@ -8,6 +8,7 @@ metadata:
# Published on the NetBird proxy; LAN DNS stays on the Traefik VIP.
netbird.renner.uno/public: "true"
dns.renner.uno/internal: "true"
dns.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: gitea-mcp.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
+1
View File
@@ -9,6 +9,7 @@ metadata:
# SSH (gitea-ssh) and node image pulls stay local.
netbird.renner.uno/public: "true"
dns.renner.uno/internal: "true"
dns.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
# Long-running git fetch/push and container-registry blob uploads:
+2 -2
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: git.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -90,7 +90,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: gitea.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
+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
@@ -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
@@ -19,8 +19,11 @@ expert's PR goes back to the card that opened it, without a manual step.
labels, PR reviews, PR comments, PR open/close) posts to
`http://hermes.hermes.svc.cluster.local:8644/webhooks/gitea-issues`.
The CronJob `gitea-webhook-sync` (namespace hermes, every 15 min) keeps that hook in
place, creates the domain labels and backfills, all only in repos where hermes-bot
has issue write (org labels for their orgs, repo labels for user-owned ones): open
place, creates the domain labels and backfills, all in every non-archived repo with
issues of the orgs hermes-bot belongs to, where an org team grants it issue write
(today: all repos of `konairius` through team `Bots`). Labels are org labels only.
User-owned repos and repos where hermes-bot is only a direct collaborator get neither
labels nor backfill; their new issues still become cards through the system hook. Backfill: open
issues without a card comment are replayed to the route as signed `opened`
deliveries, at most 10 per run, and the reviews of open hermes-bot PRs are replayed
(step 8). Its site-admin PAT lives at OpenBao `apps/gitea-webhook-sync/admin`,
@@ -7,10 +7,10 @@
active system hook carries the desired name, a new one is created first and every
other hook with the prefix is deleted afterwards.
2. Domain labels. hestia/atlas/apollo/clio/daedalus are created only where Hermes
works issues: repos with an issue tracker in which BOT_LOGIN (hermes-bot) has
issue write. They become org labels in each org owning such a repo and repo labels
in such user-owned repos (org labels do not reach those). Existing labels are left
as they are; only missing ones are created.
works issues: the non-archived org repos with an issue tracker in which BOT_LOGIN
(hermes-bot) may write issues through an org team (it labels and comments). They
are org labels of those orgs, never repo labels; user-owned repos are out of scope.
Existing labels are left as they are; only missing ones are created.
3. Backfill. Open issues of the same repos without the bridge's card comment are
replayed to the Hermes webhook route as signed `opened` deliveries, at most
BACKFILL_LIMIT per run and none started after RUN_BUDGET seconds, in random order
@@ -60,9 +60,10 @@ BACKFILL_SPACING = float(os.environ.get("BACKFILL_SPACING_SECONDS", "3"))
RUN_BUDGET = float(os.environ.get("RUN_BUDGET_SECONDS", "900"))
DELIVER_TIMEOUT = 60
CARD_MARKER = "<!-- hermes-kanban-card:"
# Labels and backfill only where this account may write issues (it labels and comments).
# Labels and backfill only in org repos this account may write issues in (it labels and comments).
BOT_LOGIN = os.environ.get("BOT_LOGIN", "hermes-bot")
BOT_WRITE = ("write", "admin", "owner")
ISSUES_UNIT = "repo.issues"
STARTED = time.monotonic()
# Review replay (step 4): reviews by these accounts on open BOT_LOGIN PRs.
REVIEW_BOT_LOGIN = os.environ.get("REVIEW_BOT_LOGIN", "review-bot")
@@ -110,13 +111,11 @@ def api(method: str, path: str, token: str, body: dict | None = None):
def paged(path: str, token: str) -> list[dict]:
"""All pages of a list endpoint; /repos/search wraps its list in {"data": [...]}."""
"""All pages of a list endpoint."""
out, page = [], 1
sep = "&" if "?" in path else "?"
while True:
batch = api("GET", f"{path}{sep}page={page}&limit=50", token) or []
if isinstance(batch, dict):
batch = batch.get("data") or []
out.extend(batch)
if len(batch) < 50:
return out
@@ -160,25 +159,56 @@ def reconcile(token: str, secret: str) -> str:
# --- 2. domain labels --------------------------------------------------------
def team_writes_issues(team: dict) -> bool:
"""Issue write from the team's unit map (Gitea >= 1.20), else its team-wide mode."""
if team.get("permission") in ("admin", "owner"):
return True
units = team.get("units_map") or {}
return (units.get(ISSUES_UNIT) if units else team.get("permission")) in BOT_WRITE
def issue_repos(token: str, errors: list[str] | None = None) -> list[dict]:
"""Repos whose issues Hermes works: issue tracker on, not archived, and hermes-bot may
write issues there (it sets the labels and comments). A site admin sees every repo.
A repo whose permission lookup fails is skipped and named in `errors`."""
out = []
for r in paged("/repos/search", token):
if not r.get("has_issues", True) or r.get("archived"):
"""Repos whose issues Hermes works: in each org BOT_LOGIN belongs to, the repos of its
teams that grant issue write (every org repo for an includes_all_repositories team),
issue tracker on, not archived. Not /collaborators/{user}/permission: that reports
the repo-wide access mode, which stays "none" when the write comes from a unit-based
team. The admin route lists private orgs too and needs only the PAT's admin scope
(/users/{u}/orgs also requires read:user). An org or team whose lookup fails is
skipped and named in `errors`."""
def failed(what: str, exc: RuntimeError) -> None:
if errors is None:
raise exc
errors.append(f"{what}: {exc}")
found: dict[str, dict] = {}
for org in paged(f"/admin/users/{q(BOT_LOGIN)}/orgs", token):
name = org.get("username") or org.get("name") or ""
if not name:
continue
owner, _, name = r["full_name"].partition("/")
try:
perm = api("GET", f"/repos/{q(owner)}/{q(name)}/collaborators/{q(BOT_LOGIN)}/permission", token)
teams = paged(f"/orgs/{q(name)}/teams", token)
except RuntimeError as exc:
if errors is None:
raise
errors.append(f"{r['full_name']}: {exc}")
failed(name, exc)
continue
if (perm or {}).get("permission") in BOT_WRITE:
out.append(r)
return out
for team in teams:
if not team_writes_issues(team):
continue
try:
try:
api("GET", f"/teams/{team['id']}/members/{q(BOT_LOGIN)}", token)
except RuntimeError as exc:
if str(exc).endswith("HTTP 404"):
continue # not a member of this team
raise
repos = paged(f"/orgs/{q(name)}/repos" if team.get("includes_all_repositories")
else f"/teams/{team['id']}/repos", token)
except RuntimeError as exc:
failed(f"{name} team {team.get('name')}", exc)
continue
for r in repos:
if r.get("has_issues", True) and not r.get("archived"):
found.setdefault(r["full_name"], r)
return [found[k] for k in sorted(found)]
def ensure_labels_at(path: str, token: str) -> list[str]:
@@ -192,18 +222,14 @@ def ensure_labels_at(path: str, token: str) -> list[str]:
def ensure_labels(token: str, repos: list[dict]) -> str:
"""Org labels in each org that owns one of `repos`, repo labels in the user-owned ones."""
orgs = {n for o in paged("/admin/orgs", token) if (n := o.get("username") or o.get("name"))}
owners = {(r.get("owner") or {}).get("login") for r in repos}
targets = [f"/orgs/{q(org)}/labels" for org in sorted(orgs & owners)]
targets += [f"/repos/{q(r['owner']['login'])}/{q(r['name'])}/labels" for r in repos
if (r.get("owner") or {}).get("login") not in orgs]
"""Org labels in each org that owns one of `repos` (all org repos); never repo labels."""
orgs = sorted({o for r in repos if (o := (r.get("owner") or {}).get("login"))})
actions = []
for path in targets:
created = ensure_labels_at(path, token)
for org in orgs:
created = ensure_labels_at(f"/orgs/{q(org)}/labels", token)
if created:
actions.append(f"{path.rsplit('/labels', 1)[0]}: created {','.join(created)}")
return "; ".join(actions) or f"labels present in {len(targets)} orgs/repos"
actions.append(f"/orgs/{org}: created {','.join(created)}")
return "; ".join(actions) or f"labels present in {len(orgs)} orgs"
# --- 3. backfill -------------------------------------------------------------
@@ -372,9 +398,9 @@ def main() -> int:
def load_repos() -> str:
errors: list[str] = []
repos.extend(issue_repos(token, errors))
msg = f"{len(repos)} with issues writable by {BOT_LOGIN}"
msg = f"{len(repos)} org repos with issues writable by {BOT_LOGIN}"
if errors: # the other repos still get labels and backfill; the Job fails
raise RuntimeError(f"{msg}; permission lookup failed, skipped: {'; '.join(errors)}")
raise RuntimeError(f"{msg}; team lookup failed, skipped: {'; '.join(errors)}")
return msg
failures = 0
@@ -5,8 +5,12 @@
is a TLS-passthrough service on :443 with a PROXY v2 header, going to Traefik `websecure`.
There are no UDM port forwards and no other public L4 path. LB VIPs (`bgp-pool`,
`fd99::/64`, `10.2.99.0/24`) are LAN/mesh-only.
- DNS labels on an Ingress: `dns.renner.uno/internal` writes the UDM (LAN) record
(opt-in), `dns.renner.uno/public` writes the Cloudflare CNAME to `proxy.renner.uno`, and
`netbird.renner.uno/public` makes `netbird-service-sync` publish the proxy service. A
public host needs both public labels.
- `netbird-service-sync` publishes **only hosts on Ingresses** labelled
`dns.renner.uno/public` (or `netbird.renner.uno/public`), and it reads no other kind.
`netbird.renner.uno/public`, and it reads no other kind.
To publish a TLS host served by an `IngressRouteTCP` (TLS terminated by Traefik), put the
host on its **own** labelled Ingress annotated
`traefik.ingress.kubernetes.io/router.entrypoints: web` with **no** `tls:` section, and
@@ -16,15 +16,15 @@ monitor, and its history starts over.
the KumaMonitor `healthCheckExprs` entry with `current: "true"` (see SKILL.md pitfalls).
## Which hosts are public
The public set is exactly the hosts on Ingresses labelled `dns.renner.uno/public` or
`netbird.renner.uno/public`. To confirm it, do a TLS handshake against the proxy-1 and
A public host needs both labels on its Ingress: `netbird.renner.uno/public` for the proxy
service and `dns.renner.uno/public` for the Cloudflare CNAME. To confirm it, do a TLS handshake against the proxy-1 and
proxy-2 addresses (resolved via DoH) with each candidate hostname as the SNI. Only public hosts
return a CA-verified certificate. The proxies do not answer for their own names, so a
handshake with `proxy-N.renner.uno` as the SNI fails.
## Checks per host (owner's spec: DNS + HTTP reachability + response validation)
- **DNS.** A cluster host has no record of its own and resolves through the `*.renner.uno`
wildcard. Check `resolveType: CNAME` with `conditions: [{operator: equals, value:
- **DNS.** A cluster host's public record is a CNAME to `proxy.renner.uno` created by
external-dns from `dns.renner.uno/public`. Check `resolveType: CNAME` with `conditions: [{operator: equals, value:
proxy.renner.uno}]`. An external host checks its own AAAA record, or A when it has no
AAAA. Use `resolveServer: "2606:4700:4700::1111,1.1.1.1"` so IPv6 comes first; Kuma
fails over through the list in order.
@@ -71,7 +71,7 @@ reached Kuma.
parent exists and is a group.
- Emulate each http/keyword/json-query check the way Kuma does it: axios, Kuma's
`evaluateJsonQuery` logic via `jsonata`, and a custom `lookup` that resolves through DoH,
because this pod's DNS is split-horizon. Run each wildcard host pinned to proxy-1 and to
because this pod's DNS is split-horizon. Run each public host pinned to proxy-1 and to
proxy-2.
- DNS monitors can't be checked from the cluster, because outbound :53 to external
resolvers is blocked. After merge, read `monitor_status` from Prometheus instead
@@ -701,62 +701,120 @@ def repo(full, has_issues=True, archived=False):
"has_issues": has_issues, "archived": archived}
def team(tid, name, issues="write", all_repos=True, permission="write"):
return {"id": tid, "name": name, "includes_all_repositories": all_repos,
"permission": permission, "units_map": {"repo.issues": issues, "repo.code": "write"}}
def fake_gitea(orgs, teams, members, repos, fail=()):
"""api() stand-in: orgs of the bot, teams per org, bot's team ids, repos per org or team."""
seen = []
def fake(method, path, token, body=None):
p = path.split("?")[0]
seen.append(p)
if p in fail:
raise RuntimeError(f"GET {p}: HTTP 500")
if p == "/admin/users/hermes-bot/orgs":
return [{"username": o} for o in orgs]
if p.startswith("/orgs/") and p.endswith("/teams"):
return teams.get(p.split("/")[2], [])
if p.startswith("/teams/") and "/members/" in p:
if int(p.split("/")[2]) in members:
return {"login": "hermes-bot"}
raise RuntimeError(f"GET {p}: HTTP 404")
if p.endswith("/repos"):
return repos.get(p, [])
raise AssertionError(f"unexpected {method} {p}")
return fake, seen
class DomainLabels(unittest.TestCase):
def test_labels_only_for_orgs_and_user_repos_hermes_works(self):
def test_org_labels_only_never_repo_labels(self):
calls = []
def fake(method, path, token, body=None):
calls.append((method, path, body))
if path.startswith("/admin/orgs"):
return [{"username": "konairius"}, {"username": "unrelated-org"}]
if path.startswith("/orgs/konairius/labels"):
return [{"name": "Atlas"}, {"name": "bug"}]
return []
repos = [repo("konairius/homelab"), repo("k.renner/turm1388")]
repos = [repo("konairius/homelab"), repo("konairius/shelly-manager")]
with mock.patch.object(sync, "api", side_effect=fake):
msg = sync.ensure_labels("tok", repos)
posts = [(p, b["name"]) for m, p, b in calls if m == "POST"]
self.assertEqual(sorted(n for p, n in posts if p == "/orgs/konairius/labels"),
["apollo", "clio", "daedalus", "hestia"]) # atlas exists (any case)
self.assertEqual(len([p for p, _ in posts if p == "/repos/k.renner/turm1388/labels"]), 5)
self.assertFalse([p for p, _ in posts if p.startswith("/repos/konairius/")]) # org labels reach it
self.assertFalse([c for c in calls if "unrelated-org" in c[1]]) # no repo of it is worked
self.assertFalse([c for c in calls if c[1].startswith("/repos/")]) # org labels reach them
self.assertFalse([c for c in calls if c[1].startswith("/admin/")])
self.assertIn("created", msg)
def test_repos_without_issues_archived_or_bot_write_are_skipped(self):
perms = {"a/x": "write", "a/w": "admin", "gitea_admin/_cargo-index": "read", "a/n": "none"}
seen = []
def fake(method, path, token, body=None):
if path.startswith("/repos/search"):
return {"ok": True, "data": [
repo("a/x"), repo("a/w"), repo("gitea_admin/_cargo-index"), repo("a/n"),
repo("a/y", has_issues=False), repo("a/z", archived=True)]}
seen.append(path)
full = path.split("/repos/")[1].split("/collaborators/")[0]
return {"permission": perms[full]}
def test_team_write_covers_every_org_repo_without_collaborator(self):
# Live 2026-10-21: hermes-bot is only in team Bots (all repos, issues write);
# /collaborators/hermes-bot/permission says "none" for 9 of the 10 repos.
org_repos = [repo("konairius/homelab"), repo("konairius/shelly-manager"),
repo("konairius/ical-proxy"), repo("konairius/off", has_issues=False),
repo("konairius/old", archived=True)]
fake, seen = fake_gitea(
orgs=["konairius"],
teams={"konairius": [team(1, "Owners", "owner", permission="owner"), team(2, "Bots"),
team(5, "Reviewers", "read", permission="none")]},
members={2},
repos={"/orgs/konairius/repos": org_repos})
with mock.patch.object(sync, "api", side_effect=fake):
got = [r["full_name"] for r in sync.issue_repos("tok")]
self.assertEqual(got, ["a/x", "a/w"])
self.assertIn("/repos/a/x/collaborators/hermes-bot/permission", seen)
self.assertFalse([p for p in seen if p.startswith(("/repos/a/y/", "/repos/a/z/"))])
self.assertEqual(got, ["konairius/homelab", "konairius/ical-proxy", "konairius/shelly-manager"])
self.assertFalse([p for p in seen if "/collaborators/" in p or p.startswith("/repos/")])
self.assertNotIn("/teams/5/members/hermes-bot", seen) # read-only team is not even checked
def test_failed_permission_lookup_skips_only_that_repo(self):
def fake(method, path, token, body=None):
if path.startswith("/repos/search"):
return {"data": [repo("a/x"), repo("a/broken"), repo("a/w")]}
if path.startswith("/repos/a/broken/"):
raise RuntimeError("GET /repos/a/broken/...: HTTP 500")
return {"permission": "write"}
def test_team_with_selected_repos_and_no_issue_write(self):
fake, seen = fake_gitea(
orgs=["a", "b"],
teams={"a": [team(3, "Some", all_repos=False)],
"b": [team(4, "CodeOnly", issues="read")]},
members={3, 4},
repos={"/teams/3/repos": [repo("a/x"), repo("a/y")],
"/orgs/a/repos": [repo("a/x"), repo("a/y"), repo("a/not-in-team")],
"/orgs/b/repos": [repo("b/z")]})
with mock.patch.object(sync, "api", side_effect=fake):
got = [r["full_name"] for r in sync.issue_repos("tok")]
self.assertEqual(got, ["a/x", "a/y"])
self.assertNotIn("/orgs/b/repos", seen)
def test_team_issue_write_from_units_map_or_team_mode(self):
self.assertTrue(sync.team_writes_issues(team(2, "Bots")))
self.assertFalse(sync.team_writes_issues(team(5, "Reviewers", "read", permission="none")))
self.assertTrue(sync.team_writes_issues(team(1, "Owners", "read", permission="owner")))
self.assertTrue(sync.team_writes_issues({"permission": "write"})) # no units_map
self.assertFalse(sync.team_writes_issues({"permission": "read", "units_map": {}}))
def test_user_repos_are_out_of_scope(self):
fake, seen = fake_gitea(orgs=[], teams={}, members=set(), repos={})
with mock.patch.object(sync, "api", side_effect=fake):
self.assertEqual(sync.issue_repos("tok"), [])
self.assertEqual(seen, ["/admin/users/hermes-bot/orgs"]) # no /repos/search over user repos
def test_failed_team_lookup_skips_only_that_team(self):
fake, _ = fake_gitea(
orgs=["a", "broken"],
teams={"a": [team(3, "Bots", all_repos=False), team(6, "More", all_repos=False)]},
members={3, 6},
repos={"/teams/3/repos": [repo("a/x")], "/teams/6/repos": [repo("a/w"), repo("a/x")]},
fail={"/orgs/broken/teams", "/teams/6/members/hermes-bot"})
errors = []
with mock.patch.object(sync, "api", side_effect=fake):
got = [r["full_name"] for r in sync.issue_repos("tok", errors)]
self.assertEqual(got, ["a/x", "a/w"])
self.assertEqual(len(errors), 1)
self.assertIn("a/broken", errors[0])
self.assertEqual(got, ["a/x"])
self.assertEqual(len(errors), 2)
self.assertIn("a team More", errors[0])
self.assertTrue(errors[1].startswith("broken:"))
def test_failed_lookup_raises_without_error_list(self):
fake, _ = fake_gitea(orgs=["broken"], teams={}, members=set(), repos={},
fail={"/orgs/broken/teams"})
with mock.patch.object(sync, "api", side_effect=fake), self.assertRaises(RuntimeError):
sync.issue_repos("tok")
class Backfill(unittest.TestCase):
@@ -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
@@ -3,8 +3,8 @@
# /etc/nginx/conf.d/ at start.
#
# shelly-manager signs in whoever X-authentik-username names when the peer is
# in Auth__ReverseProxy__TrustedProxies (the pod CIDR). Only the Authentik
# outpost may reach :8080 directly. Other clients reach this :8081 instead,
# in Auth__ReverseProxy__TrustedProxies (the pod CIDR). Only Traefik may
# reach :8080 directly. Other clients reach this :8081 instead,
# which drops the identity header, so every request here falls through
# to the API key (or the login cookie) and a forged header gets 401.
#
@@ -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
@@ -75,7 +75,7 @@ spec:
# macvlan net1 interface on the IoT VLAN — exposing the
# unauthenticated UI to that untrusted segment past the
# Authentik gate. POD_IP is the eth0 IPv4 (status.podIP); the
# Service, the Authentik outpost, and the kubelet httpGet
# Service, Traefik, and the kubelet httpGet
# probes all reach the app on that address. mDNS discovery is
# unaffected: ZeroconfResolver binds its own multicast sockets
# on net1 independently of Kestrel.
@@ -90,8 +90,7 @@ spec:
# BLE radios: ESP32 esphome bluetooth_proxy nodes (e.g. the M5 Atom
# Echo) are AUTO-DISCOVERED on the IoT VLAN via mDNS
# (_esphomelib._tcp) over the same net1 macvlan route as the Shellys —
# no static hostname/DNS (which the *.renner.uno wildcard would poison
# over IPv6). The shared fleet Noise key (= the dashboard's
# no static hostname/DNS. The shared fleet Noise key (= the dashboard's
# shelly_ble_proxy_api_key) lets shelly-manager complete the encrypted
# native-API handshake to each discovered proxy: DeviceInfo on
# /ble-radios today, the BLE scan/GATT provisioning path next.
@@ -114,11 +113,11 @@ spec:
secretKeyRef:
name: shelly-manager-auth
key: api-key
# SSO: the Authentik proxy outpost (authentik-gate class,
# which the ingress routes through) injects X-authentik-username;
# SSO: Authentik forward auth (authentik-gate class,
# via Traefik ForwardAuth) sets X-authentik-username;
# the app signs that identity in directly, so the gate login is the
# only login. The header is only honored from TrustedProxies — the
# outpost pod's address is dynamic, so this trusts the cluster pod
# Traefik pod's address is dynamic, so this trusts the cluster pod
# CIDR and leans on the namespace CNP to keep other pods off the
# app port. ADMIN_PASSWORD (above) stays as break-glass, e.g. for
# port-forward access when authentik is down. Never open :8080 to
@@ -170,15 +169,15 @@ spec:
memory: 128Mi
limits:
memory: 512Mi
# API-key-only listener for clients other than the Authentik outpost
# API-key-only listener for clients other than Traefik
# (REN-931). nginx on ${POD_IP}:8081 proxies to
# :8080 and drops X-authentik-username, so the ProxyHeader sign-in
# cannot be reached through it. Config and reasoning:
# shelly-manager-api-proxy.conf. Service: shelly-manager-api.
# No probes: it only serves the API-key path, and a probe failure would
# also pull the outpost's endpoint out of the shelly-manager Service.
# 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,6 +1,6 @@
# shelly-manager has no authentication of its own: anything reaching it can
# rewrite Shelly config, push scripts and read stored device AdminPasswords, so
# the Authentik gate (ingress class authentik-gate) is the only thing in front
# the Authentik gate (the authentik-gate Middleware) is the only thing in front
# of it: Authentik SSO login, Shelly Managers only.
#
# SignalR/WebSocket: the websecure entrypoint idleTimeout (180s) is kept alive
@@ -15,10 +15,11 @@ metadata:
labels:
dns.renner.uno/internal: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: Shelly Managers
spec:
ingressClassName: authentik-gate
ingressClassName: traefik
rules:
- host: shelly-manager.home.renner.uno
http:
@@ -15,7 +15,7 @@ spec:
# API-key-only entry for in-cluster clients (REN-931, REN-935): the api-proxy
# sidecar, which drops the X-authentik-username header. Clients call
# http://shelly-manager-api.home-assistant.svc/api/v1/... with the API key.
# :8080 stays outpost-only.
# :8080 stays Traefik-only.
apiVersion: v1
kind: Service
metadata:
@@ -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
@@ -23,7 +23,7 @@ spec:
# flashing is done in the browser over USB (ESP Web Tools / Web Serial),
# and building/compiling is offline. So the 0.0.0.0:6052 bind stays on the
# Cilium-governed pod network only, where the home-assistant CNP admits
# ONLY the esphome gate outpost (generated CNP esphome-gate; see
# ONLY Traefik (generated CNP esphome-gate; see
# esphome-ingress.yaml) — no unauthenticated IoT-VLAN exposure like the
# macvlan-on-0.0.0.0 hole shelly-manager had to pin Kestrel around.
#
@@ -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.
@@ -6,19 +6,20 @@ metadata:
labels:
dns.renner.uno/internal: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: ESPHome Managers
spec:
# ESPHome Device Builder has no auth of its own: anyone reaching it can edit
# device YAML (incl. wifi/api secrets), compile, and flash/OTA firmware, so the
# Authentik gate (ingress class authentik-gate) is the only thing in front of
# Authentik gate (the authentik-gate Middleware) is the only thing in front of
# it: Authentik SSO login, ESPHome Managers only.
#
# WebSocket: the dashboard streams build/OTA logs over a websocket; Traefik's
# websecure entrypoint passes it through. Browser USB flashing uses Web
# Serial, which needs a secure context; the serial link never traverses the
# proxy.
ingressClassName: authentik-gate
ingressClassName: traefik
rules:
- host: esphome.home.renner.uno
http:
@@ -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;
@@ -3,7 +3,7 @@
#
# code-server runs with --auth=none as root and mounts the entire /config
# (plaintext secrets.yaml, .storage, the git PAT in the PVC remote URL), so the
# Authentik gate (ingress class authentik-gate) is the only thing in front of
# Authentik gate (the authentik-gate Middleware) is the only thing in front of
# it: Authentik SSO login, Home Assistant Config Editors only.
apiVersion: networking.k8s.io/v1
kind: Ingress
@@ -13,13 +13,14 @@ metadata:
labels:
dns.renner.uno/internal: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: Home Assistant Config Editors
# code-server's manifest and service worker are fetched without
# credentials. Version-specific: re-check after a code-server bump.
auth.renner.uno/skip-path: ^/(_static/|manifest\.json|.*\.webmanifest|.*serviceWorker\.js).*
spec:
ingressClassName: authentik-gate
ingressClassName: traefik
rules:
- host: vscode.home.renner.uno
http:
@@ -6,17 +6,18 @@ metadata:
labels:
dns.renner.uno/internal: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: Zigbee2MQTT Managers
spec:
# The Z2M frontend has no auth of its own: anyone reaching it can rename
# devices, toggle permit_join, trigger OTA, and control the whole Zigbee
# network, so the Authentik gate (ingress class authentik-gate) is the only
# network, so the Authentik gate (the authentik-gate Middleware) is the only
# thing in front of it: Authentik SSO login, Zigbee2MQTT Managers only.
#
# WebSocket: the frontend streams live device state / logs over a websocket;
# Traefik's websecure entrypoint passes it through.
ingressClassName: authentik-gate
ingressClassName: traefik
rules:
- host: zigbee2mqtt.home.renner.uno
http:
@@ -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)
+4 -5
View File
@@ -4,8 +4,8 @@ metadata:
name: immich
namespace: immich
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
@@ -28,9 +28,8 @@ spec:
hosts:
- immich.renner.uno
---
# photos.renner.uno is LAN-registered (no proxy tunnel), so it lives on its
# own unlabeled Ingress: the dns.renner.uno/public label-filter is
# per-resource, and this name must keep its UDM record. It was the LXC alias
# photos.renner.uno is LAN-only (UDM record, no proxy), so it lives on its
# own Ingress: DNS label filters are per-resource. It was the LXC alias
# served by Caddy; preserved so external-dns-unifi can take over once the
# tofu CNAME on lxc_225 is removed.
apiVersion: networking.k8s.io/v1
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: immich.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
+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
+6 -6
View File
@@ -1,6 +1,6 @@
---
# llm.renner.uno — the chat WebUI, public and behind an Authentik gate
# (ingress class authentik-gate, kubernetes/apps/authentik/gate-sync.yaml).
# (the authentik-gate Middleware, kubernetes/apps/authentik/gate.yaml).
#
# The gate is the entire access control here: llama-server has no
# authentication of its own and serves a full OpenAI-compatible API on the same
@@ -8,25 +8,25 @@
#
# In-cluster consumers — Home Assistant, Paperless, CI — address
# llama-server.ai.svc.cluster.local:8080 directly and never pass through
# Traefik or the outpost.
# Traefik or Authentik.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: llama-server
namespace: ai
labels:
# Public: Cloudflare wildcard → tunnel → NetBird proxy → Traefik. The UDM
# external-dns instance skips anything carrying this, and LAN clients reach
# the same hostname through the hairpin NAT.
# Public: Cloudflare CNAME → NetBird proxy → Traefik. No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
cert-manager.io/cluster-issuer: letsencrypt-cluster
# Manifest and service worker are fetched without credentials; neither
# serves authenticated content.
auth.renner.uno/skip-path: ^/(manifest\.webmanifest|sw\.js)$
spec:
ingressClassName: authentik-gate
ingressClassName: traefik
tls:
- hosts:
- llm.renner.uno
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: llm.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
+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
+10 -9
View File
@@ -1,8 +1,9 @@
# Public (REN-663): each Ingress carries dns.renner.uno/public, so
# netbird-service-sync publishes its host through the NetBird proxy
# (Cloudflare wildcard -> proxy.renner.uno -> TLS passthrough to Traefik) and
# external-dns-unifi skips it; LAN clients follow the public answer and the UDM
# hairpins back in. Same path as audiobooks/immich. Federation stays off.
# (explicit Cloudflare CNAME -> proxy.renner.uno -> TLS passthrough to Traefik)
# and there is no dns.renner.uno/internal label, so external-dns-unifi writes
# no UDM record; LAN clients follow the public answer and the UDM hairpins
# back in. Same path as audiobooks/immich. Federation stays off.
#
# Synapse: only the client-facing paths. /_synapse/admin stays off the
# Ingress (in-cluster only); /_matrix/federation and /_matrix/key are not
@@ -13,8 +14,8 @@ metadata:
name: synapse
namespace: matrix
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
@@ -123,8 +124,8 @@ metadata:
name: element-web
namespace: matrix
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
@@ -154,8 +155,8 @@ metadata:
name: mas
namespace: matrix
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:

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