Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5.5 bc698ea22d feat(radius): forward non-anonymous EAP logins to the UDM RADIUS
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Outer identity anonymous[@realm] (any case) keeps the local EAP-TTLS ->
authentik path. Any other outer identity is a UniFi Endpoint account: the
outer server proxies every EAP round to new home server udm (10.2.47.1:1812,
UDM_RADIUS_SECRET) without local EAP processing; post-proxy/post-auth leave
UDM replies untouched. Endpoint account names look like ordinary usernames,
so routing keys on the anonymous identity, not a name pattern.

home_server udm: response_window 5 < proxy_dedup_window 10,
require_message_authenticator yes (RFC 3579, EAP replies), status_check
request with a user name that cannot exist.

CNP freeradius-udm-egress allows only 10.2.47.1/32 UDP 1812.

Harness: mock_udm.py on a second internal bridge at the udm address from
proxy.conf. Asserts both EAP rounds (State included) reach it, its Accept
reaches the NAS with Tunnel-* VLAN 200 and MS-MPPE keys intact, its Reject
passes, and anonymous logins and accounting never reach it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:13:07 +01:00
k.renner 73dd6f6362 Merge pull request 'perf(authentik): disable Postgres JIT, persistent DB connections' (#1557) from fix/authentik-pg-latency into main
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Reviewed-on: #1557
2026-11-04 08:56:56 +00:00
claude-botandClaude Opus 5.5 7434894f14 perf(authentik): disable Postgres JIT, persistent DB connections
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:52:41 +01:00
k.renner 7798a17ba5 Merge pull request 'fix(radius): run FreeRADIUS from a non-world-writable config dir' (#1556) from fix/freeradius-confdir-mode into main
Reviewed-on: #1556
2026-11-04 08:08:25 +00:00
claude-botandClaude Opus 5.5 61fc16ff67 fix(radius): idempotent raddb copy in the init container
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:05:51 +01:00
claude-botandClaude Opus 5.5 1e785e028e fix(radius): run FreeRADIUS from a copied, non-world-writable raddb
FreeRADIUS refuses a config dir that is globally writable; the kubelet
projected volume root is. Mount it at /etc/raddb-src, copy it into an
emptyDir with an initContainer and run radiusd from the copy. The test
harness now makes the mount world-writable and runs the initContainer
from the manifest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:04:23 +01:00
security-bot 265b745429 chore(image-automation): bump ghcr.io/linuxserver/lidarr:3.1.6.5079-ls47
Updates managed by image-automation-controller.
2026-11-04 08:04:09 +00:00
k.renner 014fe5efc0 Merge pull request 'fix(radius): skip Flux substitution on the FreeRADIUS config' (#1555) from fix/freeradius-flux-substitute into main
Reviewed-on: #1555
2026-11-04 07:52:46 +00:00
claude-botandClaude Opus 5.5 22fd0f8e54 fix(radius): skip Flux substitution on the FreeRADIUS config
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 08:51:56 +01:00
security-bot c382831464 chore(image-automation): bump docker.io/freeradius/freeradius-server:3.2.10-alpine
Updates managed by image-automation-controller.
2026-11-04 07:39:51 +00:00
k.renner 49017ac5d7 Merge pull request 'feat(radius): in-cluster 802.1X RADIUS via authentik (test SSID)' (#1554) from dot1x-radius-authentik into main
Reviewed-on: #1554
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-04 07:39:20 +00:00
claude-botandClaude Opus 5.5 64e19b4087 fix(radius): proxy_dedup_window above response_window
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
radiusd 3.2.10 segfaulted (exit 139) after the outpost came back from a
stall. A NAS retransmit forwarded to the outpost leaves the request in the
proxy hash while post-proxy runs; eapttls_postproxy() then sets
request->proxy->dst_port = 0 (ttls.c:880), which is part of the rbtree key,
so the later removal misses, the freed request stays in proxy_list and the
next reply lookup dereferences it in fr_packet_cmp(). Still present on the
v3.2.x head; 3.2.10 is the newest image.

With proxy_dedup_window (10) > response_window (5) a retransmit is never
forwarded while a request waits, num_proxied_requests stays 1 and the
request leaves the hash before post-proxy. Lost in-cluster packets are no
longer resent; the request fails after response_window.

Verified: 12/12 consecutive tests/radius/run.sh runs green incl. recovery
(10-15 s), radiusd -XC OK; before the change 2/2 runs crashed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 08:18:33 +01:00
claude-botandClaude Opus 5.5 f9eb38e2da fix(radius): outpost status checks, review doc corrections
Home server: response_window 5, status_check = request with a user that
does not exist in authentik (always rejected), check_interval 6,
check_timeout 4, num_answers_to_alive 3; revive_interval dropped. With
status_check = none the blind revive gave 7-8 s in some runs and no
recovery within 120 s in others.

tests/radius/run.sh: recovery case stalls the mock outpost until the
home server is marked dead, resumes it and asserts a login succeeds
within 20 s. It fails intermittently: radiusd stops answering
(liveness probe fails) after late replies arrive for requests it already
failed. Diagnosis pending.

Docs: secret is the only guard on the FreeRADIUS -> outpost hop; outpost
Service name resolved at startup. Spec: VLAN 10 is dual-stack
(fd02:10::/64). helmrelease comment: missing !Env resolves to the
default, no KeyError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 07:55:08 +01:00
claude-botandClaude Opus 5.5 3ed987dd74 docs(radius): anonymous outer identity on Android
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 07:15:19 +01:00
claude-botandClaude Opus 5.5 da58096453 feat(radius): IPv6 clients and listeners, client cert validation docs
FreeRADIUS listens on :: for 1812/1813 and accepts aps6 fd02:10::/64 and
test6 fd02:50::/64 with the same secrets as their IPv4 entries; the NAS
CNP admits both prefixes. The harness adds IPv6 cases on an isolated
dual-stack bridge.

Windows 11 setup requires server validation for radius.renner.uno and
never Windows sign-in credentials. The Apple profile uses EncryptionType
Any and OuterIdentity anonymous. dot1x.md marks the test/test6 clients
and their CNP CIDRs as test-phase only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 07:14:23 +01:00
claude-botandClaude Opus 5.5 06a916ddba fix(radius): Cisco-AVPair attribute name, absolute radclient path in probes
FreeRADIUS's dictionary names the Cisco VSA Cisco-AVPair; Cisco-AV-Pair
fails the config load. The image puts /opt/bin on PATH only in its
entrypoint, so exec probes need the absolute path. The AV-pair regex
now checks for the attribute first to avoid a condition-evaluation
failure message on Accepts without it.

tests/radius/run.sh now also runs the Deployment's readiness probe
command inside the test container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 07:00:51 +01:00
security-bot 7e95f4cc7d chore(image-automation): bump ghcr.io/paperless-ngx/paperless-ngx:3.4.2
Updates managed by image-automation-controller.
2026-11-03 21:51:36 +00:00
claude-botandClaude Opus 5.5 c6467a54ef docs(radius): Loki metric queries for 802.1X latency, Apple trust prompt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:40:29 +01:00
claude-botandClaude Opus 5.5 eb7949b0f0 docs(radius): client setup and measurement for the 802.1X test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:38:53 +01:00
claude-botandClaude Opus 5.5 f1d0dde23b feat(radius): FreeRADIUS EAP-TTLS frontend for authentik
FreeRADIUS 3.2 (2 replicas, non-root, read-only root) terminates EAP-TTLS
for radius.renner.uno and proxies inner PAP to ak-outpost-8021x-radius.
Inner post-auth passes standard Tunnel-* through, translates
Cisco-AV-Pair dot1x-vlan=<id> into Tunnel-*, strips Cisco-AV-Pair and
rejects an Accept without VLAN; doing this in the inner tunnel makes
TTLS answer with EAP-Failure. Home server does not require
Message-Authenticator (outpost rejects lack it). Accounting is answered
locally.

Adds radius-lb (10.2.99.91/fd99::91), CNPs, image automation, replica
alerts and tests/radius/run.sh (Docker + eapol_test against a mock
outpost).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:36:24 +01:00
claude-botandClaude Opus 5.5 e33abd320f fix(authentik): DOT1X_OUTPOST_SECRET on worker only, optional
Server pods (SSO) no longer depend on radius-secrets. Worker reloads on
radius-secrets changes. Docs: worker-only secret, unquoted integer
dot1x_vlan, FreeRADIUS VLAN contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:18:40 +01:00
claude-botandClaude Opus 5.5 a8affe6955 feat(authentik): 802.1X RADIUS provider, VLAN mapping and outpost
Blueprint dot1x-radius.yaml: non-interactive flow dot1x-authentication-flow,
policy dot1x-has-vlan, RADIUS mapping dot1x-vlan (lowest dot1x_vlan group
attribute; RFC 3580 Tunnel-* by raw code plus Cisco-AV-Pair fallback),
provider and outpost "802.1X Radius" (2 replicas, ClusterIP), client
networks = cluster pod CIDRs. Shared secret from radius-secrets via env
DOT1X_OUTPOST_SECRET.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:13:54 +01:00
claude-botandClaude Opus 5.5 6757eaf361 feat(radius): server certificate and secrets for radius.renner.uno
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:07:12 +01:00
claude-botandClaude Opus 5.5 da1dc5b00e docs: implementation plan for 802.1X RADIUS via authentik
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:59:10 +01:00
claude-botandClaude Opus 5.5 b05c84ac20 docs: rename VLAN group attribute to dot1x_vlan (WiFi and wired)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:54:11 +01:00
claude-botandClaude Opus 5.5 378496541c docs: record FreeRADIUS-as-own-service decision and outpost Message-Authenticator risk
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:50:00 +01:00
claude-botandClaude Opus 5.5 50d862d214 docs: switch 802.1X design to EAP-TTLS/PAP via authentik RADIUS outpost
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:27:38 +01:00
claude-botandClaude Opus 5.5 fae0276045 docs: design for in-cluster 802.1X RADIUS backed by authentik
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:50:32 +01:00
security-bot d8000634a9 chore(image-automation): bump ghcr.io/usetrmnl/trmnl-ha-amd64:0.12.0
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Updates managed by image-automation-controller.
2026-11-03 11:51:38 +00:00
security-bot 1d4bc326d8 chore(image-automation): bump quay.io/ceph/ceph:v20.2.5
Updates managed by image-automation-controller.
2026-11-02 15:55:28 +00:00
security-bot 6a62bf54e4 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-11-02-1412
Updates managed by image-automation-controller.
2026-11-02 14:21:19 +00:00
k.renner 27a34fa5f5 Merge pull request 'fix(lidarr): unpin ImageRepository, keep Renovate out of image-automation' (#1552) from alert-fix/issue-1551 into main
Reviewed-on: #1552
2026-11-02 14:01:53 +00:00
hermes-bot e633b1a0c5 fix(lidarr): unpin the ImageRepository and keep Renovate out of image-automation
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Renovate (141b11a9, 2026-11-02 03:01Z) pinned a digest into
ImageRepository/lidarr spec.image. image-reflector rejects that
(ImageURLInvalid: "should not include a tag"), so Flux stopped scanning
lidarr and FluxImageAutomationNotReady fires.

Renovate touched it because dab7c10e moved the ImageRepository and the
workload refs from lscr.io to ghcr.io/linuxserver/lidarr, while the
renovate.json list of Flux-managed images kept lscr.io/linuxserver/lidarr.

- lidarr.yaml: spec.image back to ghcr.io/linuxserver/lidarr.
- renovate.json: the list names ghcr.io/linuxserver/lidarr instead of
  lscr.io, so Renovate also leaves the workload refs in
  apps/navidrome/lidarr.yaml to Flux.
- renovate.json: ignorePaths gains kubernetes/infrastructure/image-automation/**.
  That directory holds only ImageRepository/ImagePolicy/ImageUpdateAutomation
  objects; no image ref in it is a deployable pin, and an ImageRepository
  spec.image must never carry a tag or digest. This is the second time
  in a week (gitea-mcp, ffd24865) a list gap let Renovate pin one.

Closes #1551
2026-11-02 13:24:54 +00:00
security-bot da60eba710 chore(image-automation): bump gitea.renner.uno/gitea_admin/secret-rotator:2026-11-02
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Updates managed by image-automation-controller.
2026-11-02 11:24:03 +00:00
security-bot ed59b8a32a chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-11-02-1051
Updates managed by image-automation-controller.
2026-11-02 11:21:19 +00:00
security-bot 46d91c8fad chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-11-02-1054
Updates managed by image-automation-controller.
2026-11-02 11:12:43 +00:00
security-bot c7e018e7b6 chore(image-automation): bump gitea.renner.uno/konairius/mqtt-account-controller:2026-11-02-1058
Updates managed by image-automation-controller.
2026-11-02 11:05:13 +00:00
security-bot cd5e947baf chore(image-automation): bump gitea.renner.uno/konairius/ical-proxy:2026-11-02-1055
Updates managed by image-automation-controller.
2026-11-02 11:04:56 +00:00
k.renner ce292d629b Merge pull request 'chore(deps): update all non-major dependencies' (#1539) from renovate/all-minor-patch into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1539
2026-11-02 10:53:05 +00:00
k.renner e5d0ffc3e5 Merge pull request 'chore(deps): update hermes image' (#1544) from renovate/hermes-image into main
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/push/hermes-image Pipeline was canceled
Reviewed-on: #1544
2026-11-02 10:52:51 +00:00
k.renner dae7550e6f Merge pull request 'chore(deps): update openbao' (#1545) from renovate/openbao into main
Reviewed-on: #1545
2026-11-02 10:52:39 +00:00
k.renner 1177bb839d Merge pull request 'chore(deps): update helm release authentik to v2026.8.5' (#1543) from renovate/authentik into main
Reviewed-on: #1543
2026-11-02 10:52:18 +00:00
k.renner 3c9c001685 Merge pull request 'chore(deps): update home-assistant non-major' (#1541) from renovate/home-assistant-minor-patch into main
Reviewed-on: #1541
2026-11-02 10:51:59 +00:00
k.renner c68befa4a4 Merge pull request 'chore(deps): update ci non-major' (#1540) from renovate/ci-minor-patch into main
ci/woodpecker/push/ci-claude-refresh Pipeline is pending
ci/woodpecker/push/hermes-image Pipeline is pending
ci/woodpecker/push/build-ci-images Pipeline was canceled
Reviewed-on: #1540
2026-11-02 10:51:41 +00:00
k.renner 68e4d42f77 Merge pull request 'chore(deps): update influxdb:2.9.1 docker digest to a450f6f' (#1542) from renovate/influxdb into main
Reviewed-on: #1542
2026-11-02 10:51:30 +00:00
k.renner eb035613ab Merge pull request 'chore(deps): pin ghcr.io/linuxserver/lidarr docker tag to 9fddcf5' (#1538) from renovate/pin-image-digests into main
Reviewed-on: #1538
2026-11-02 10:47:13 +00:00
security-bot e8645d11ed chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-11-02-1004
Updates managed by image-automation-controller.
2026-11-02 10:21:19 +00:00
k.renner f3c9188807 Merge pull request 'fix(shelly-manager): disable TCP black-hole detection in the pod netns' (#1550) from shelly-manager-no-mtu-probing into main
Reviewed-on: #1550
2026-11-02 09:37:08 +00:00
claude-botandClaude Opus 5.5 c68b9ad7c2 fix(shelly-manager): disable TCP black-hole detection in the pod netns
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Cilium sets net.ipv4.tcp_mtu_probing=1 in every pod netns
(packetization-layer-pmtud-mode: blackhole). Shelly devices stop ACKing
for seconds while they write flash during an OTA; after tcp_retries1
timeouts the kernel treats that as a PMTU black hole and halves the MSS
on every stall, from 1440 down to the 48-byte floor. Upward probes need
an open congestion state with cwnd >= 11, which the stalling device
never allows, so the download stays at 48-byte segments and fills the
device's receive buffer.

The tuning plugin of the IoT attachment sets the sysctl to 0 for the pod
netns; the annotation bump rolls the pod so the attachment is re-run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185Qb5YG28FfnVi1ujMEV5C
2026-11-02 10:36:01 +01:00
k.renner 59c9cc29fe Merge pull request 'fix(talos): route LoadBalancer replies through table 47' (#1549) from lb-replies-table-47 into main
Reviewed-on: #1549
2026-11-02 07:45:38 +00:00
claude-botandClaude Opus 5.5 3501586841 fix(talos): route LoadBalancer replies through table 47
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
A reply from a Cilium LoadBalancer VIP reaches the routing decision with
the VIP as its source, so the pod-source rules 1000/1001 missed it and
main sent it out mgmt0 while the request came in over ingress0, the BGP
next hop (k8s-3, 2026-11-02: replies from 10.2.99.90 to an IoT device
seen only on mgmt0). Rules 1002/1003 send the pool to table 47.

The pool becomes a site fact: each site patch carries lb-pool-site
(flux-system), and bgp-pool takes its blocks from it by Flux
substitution in infrastructure-crds. The CIDR blocks cover the same
addresses as the start/stop ranges did (allowFirstLastIPs defaults to
Yes). The qemu and hcloud sites carry the production pool, since
Services pin their addresses from it.

Refs #1547

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185Qb5YG28FfnVi1ujMEV5C
2026-11-02 08:42:49 +01:00
security-bot 9e1452cfe7 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-11-02-0659
Updates managed by image-automation-controller.
2026-11-02 07:21:19 +00:00
renovate-bot fe397f906d chore(deps): update openbao
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-11-02 03:02:10 +00:00
renovate-bot 48a28d1c8f chore(deps): update hermes image
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
2026-11-02 03:02:06 +00:00
renovate-bot a05dcfdc94 chore(deps): update helm release authentik to v2026.8.5
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-11-02 03:02:04 +00:00
renovate-bot 9616ee2ec6 chore(deps): update influxdb:2.9.1 docker digest to a450f6f
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-11-02 03:02:02 +00:00
renovate-bot e0e07c0327 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-11-02 03:01:59 +00:00
renovate-bot 50307fe748 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-11-02 03:01:57 +00:00
renovate-bot fd0d2ef301 chore(deps): update all non-major dependencies
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
2026-11-02 03:01:52 +00:00
renovate-bot 141b11a9ad chore(deps): pin ghcr.io/linuxserver/lidarr docker tag to 9fddcf5
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-11-02 03:01:48 +00:00
security-bot 03cf6a0e45 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-1059
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/update-audit Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
Updates managed by image-automation-controller.
2026-11-01 11:07:04 +00:00
k.renner 88576f7022 Merge pull request 'revert(authentik-gate-sync): roll back OIDC clients (#1535, #1536), gates are being deleted' (#1537) from revert/gate-sync-oidc into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1537
2026-11-01 10:59:29 +00:00
hermes-bot 8c85a49ae5 Revert "Merge pull request 'feat(authentik-gate-sync): OIDC clients from OIDCClient resources' (#1535) from feat/gate-sync-oidc into main"
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
This reverts commit 09a03dfd76, reversing
changes made to e518b46045.
2026-11-01 10:44:22 +00:00
hermes-bot 8fa68bc9cd Revert "Merge pull request 'fix(authentik-gate-sync): stop sweeping every gate as an OIDC client' (#1536) from fix/gate-sync-proxy-sweep into main"
This reverts commit 60dfcf5382, reversing
changes made to 1a99c02996.
2026-11-01 10:44:22 +00:00
security-bot 20d928aeba chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-1014
Updates managed by image-automation-controller.
2026-11-01 10:37:04 +00:00
k.renner 60dfcf5382 Merge pull request 'fix(authentik-gate-sync): stop sweeping every gate as an OIDC client' (#1536) from fix/gate-sync-proxy-sweep into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1536
2026-11-01 10:13:55 +00:00
hermes-bot 5e814c525a fix(authentik-gate-sync): stop sweeping every gate as an OIDC client
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Outage since 10:11Z: the first pass of image 2026-11-01-0959 (#1535)
removed all 14 gates ("removed OIDC client from Authentik", "14 objects
removed"), and every pass since recreates and removes them again.

ProxyProvider subclasses OAuth2Provider (authentik 2026.8.4
providers/proxy/models.py: class ProxyProvider(OutpostModel,
OAuth2Provider)), so GET providers/oauth2/ also lists every proxy
provider. owned_applications() yielded each gate a second time as an
OAuth2 application; it was not in the OIDC keep set, so the sweep
deleted its application and provider.

Both the sweep and ensure_oidc's provider lookup now take from
providers/oauth2/ only providers whose meta_model_name is
authentik_providers_oauth2.oauth2provider. The test fake now lists proxy
providers under providers/oauth2/ like the real API; the new tests
(gate-only and gate + OIDC client stable over two passes) fail against
the previous controller.
2026-11-01 10:12:58 +00:00
security-bot 1a99c02996 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-0959
Updates managed by image-automation-controller.
2026-11-01 10:07:04 +00:00
k.renner 09a03dfd76 Merge pull request 'feat(authentik-gate-sync): OIDC clients from OIDCClient resources' (#1535) from feat/gate-sync-oidc into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1535
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-01 09:59:01 +00:00
hermes-bot f76defb1e1 fix(authentik-gate-sync): review of the OIDC clients
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
- Desired redirect URIs carry redirect_uri_type: authorization, which the
  API returns on every entry (RedirectURISerializer default in 2026.8.4);
  without it every pass would re-PATCH the provider.
- OIDC slugs and gate names are kept apart: a slug that is a gate name is
  refused, an existing application without this controller's OAuth2
  provider is refused, and the sweep keeps proxy and OAuth2 applications
  by their own sets, so an OIDCClient can neither take over nor delete a
  gate.
- Status is written only when it changed.
- Adoption of an unassigned oidc-<slug> provider is documented; removal
  logs say OIDC client.
- reconcile() takes k8s/ak for tests; new loop tests: create then quiet,
  duplicate slug, transient error keeps the client, sweep after the
  resource is gone, slug equal to a gate name.
2026-11-01 09:49:33 +00:00
hermes-bot 3e0557cfe8 feat(authentik-gate-sync): OIDC clients as OIDCClient resources, not Ingress annotations
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
OIDC is not part of the Ingress. The client is now declared by an
OIDCClient (auth.renner.uno/v1alpha1, CRD in
infrastructure/authentik-gate-sync-crds, like kuma-sync-crds) next to the
app: launchURL, redirectURIs, optional slug, group, authorizationFlow.
The controller writes a Ready condition and the issuer to its status.

The auth.renner.uno/oidc-* Ingress annotations are gone; RBAC gains
get/list/watch on oidcclients and patch on their status.
2026-11-01 09:31:43 +00:00
hermes-bot 8739efe026 feat(authentik-gate-sync): OIDC clients from auth.renner.uno/oidc-client
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
An Ingress annotated auth.renner.uno/oidc-client: <slug> gets a
confidential OAuth2/OpenID provider (client ID = slug, secret generated by
Authentik), an application with that slug and, with auth.renner.uno/group,
the same group expression policy as gates. auth.renner.uno/oidc-redirect-uri
pins strict redirect URIs on the Ingress host; default is any path on it.

Same fail-closed and ownership rules as gates: owned clients are found via
the OAuth2 provider list and meta_publisher and swept when their Ingress
goes. The service-account role gains the OAuth2 provider permissions plus
view on scope mappings and certificate key pairs.

So apps that log in through Authentik via OIDC (first: YouTrack, #1534)
need no blueprint entries; the groups stay managed in Authentik.
2026-11-01 09:22:25 +00:00
k.renner e518b46045 Merge pull request 'chore(ci): bump ci-claude image to 2.1.316' (#1533) from ci-image/claude-2.1.316 into main
Reviewed-on: #1533
2026-11-01 09:14:15 +00:00
claude-bot d7a6c2bdc7 chore(ci): bump ci-claude image to 2.1.316
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-11-01 00:01:25 +00:00
k.renner 53d7e716f8 Merge pull request 'fix(loki): enforce retention via the compactor, keep 365 days' (#1532) from worktree-loki-retention-365d into main
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/ci-claude-refresh Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
Reviewed-on: #1532
2026-10-31 06:10:42 +00:00
security-bot 61e4ee2ba8 chore(image-automation): bump ghcr.io/paperless-ngx/paperless-ngx:3.4.1
Updates managed by image-automation-controller.
2026-10-31 05:50:56 +00:00
security-bot b447c57e78 chore(image-automation): bump ghcr.io/linuxserver/lidarr:3.1.6.5079-ls46
Updates managed by image-automation-controller.
2026-10-31 00:23:17 +00:00
security-bot 4f3cf541a4 chore(image-automation): bump docker.gitea.com/gitea-mcp-server:1.12.0
Updates managed by image-automation-controller.
2026-10-30 19:30:16 +00:00
security-bot 6463c38f68 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-30-1343
Updates managed by image-automation-controller.
2026-10-30 13:51:12 +00:00
security-bot b86aaf3f88 chore(image-automation): bump ghcr.io/music-assistant/server:2.10.9
Updates managed by image-automation-controller.
2026-10-30 13:50:57 +00:00
claude-botandClaude Opus 5.5 c9944cfce8 fix(loki): enforce retention via the compactor, keep 365 days
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
limits_config.retention_period was set (2160h) but never enforced: the
live config has no compactor block, so retention_enabled is false and
Loki keeps everything (logs from 2026-05-15 are still queryable).

Enable compactor retention (working_directory under the existing
/var/loki PVC, delete_request_store filesystem) and raise the period to
365 days.

Sizing: storage-loki-0 holds 21.3 GiB of 80 GiB after 5.5 months
(~0.13 GiB/day average, ~0.23 GiB/day over 22.-25.10). 365 days at the
recent rate is ~84 GiB, so the PVC may need expanding later
(ceph-block allows volume expansion).

Rendered with chart loki 7.5.0 and checked with
`loki:3.6.11 -verify-config`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:38:29 +01:00
k.renner 717d1ad619 Merge pull request 'fix(paperless): drop the off-site monitor of the internal Syncthing UI' (#1531) from paperless-drop-syncthing-monitor into main
Reviewed-on: #1531
2026-10-30 13:32:02 +00:00
claude-botandClaude Opus 5.5 40f30412aa fix(paperless): drop the off-site monitor of the internal Syncthing UI
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
syncthing.paperless.renner.uno is internal only (no public labels, no public
DNS record), so Uptime Kuma on the VPS cannot reach it and kept the Documents
group down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:29:42 +01:00
security-bot 3f73e7cd3a chore(image-automation): bump gitea.renner.uno/gitea_admin/kuma-sync:2026-10-30-1314
Updates managed by image-automation-controller.
2026-10-30 13:23:56 +00:00
k.renner a7543cb78b Merge pull request 'feat(alloy-syslog): label UniFi plain syslog on udp-5514 separately from CEF' (#1530) from worktree-alloy-unifi-syslog-label into main
Reviewed-on: #1530
2026-10-30 13:23:48 +00:00
claude-botandClaude Opus 5.5 229e0cf5d8 feat(alloy-syslog): label UniFi plain syslog on udp-5514 separately from CEF
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
UniFi activity logging has a single target, so the UDM SIEM CEF events,
the UDM system log (log_all_contents) and the AP/switch device logs
(debug) all arrive on udp-5514. Split them in loki.process.udm_cef:

- CEF lines keep source="udm-siem" and the existing CEF labels.
- Everything else gets source="unifi-syslog" plus host and app parsed
  from the line header (UDM: "<host> <host> <app>[pid]:", devices:
  "<host> <mac>,<model>-<fw>: [: ]<app>[pid]:").

The listener no longer sets source statically; each stage.match sets it,
so no label is overwritten. Regex checked against 10 min of live lines
(all non-CEF lines match); config validated with alloy v1.21.3
`validate --stability.level=experimental`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:22:28 +01:00
security-bot b7cf7632c6 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-30-1257
Updates managed by image-automation-controller.
2026-10-30 13:21:12 +00:00
k.renner b77b0a4d0d Merge pull request 'feat(kuma-sync): one mail per outage via a root group' (#1525) from kuma-root-group into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1525
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-30 13:14:40 +00:00
security-bot 2485b3ad38 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-30-1158
Updates managed by image-automation-controller.
2026-10-30 12:24:28 +00:00
k.renner a49c852666 Merge pull request 'feat(paperless): Syncthing UI behind the Authentik gate' (#1529) from worktree-syncthing-ui-gate into main
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Reviewed-on: #1529
2026-10-30 10:45:08 +00:00
claude-botandClaude Opus 5.5 811178e321 feat(kuma-sync): one mail per outage via a root group
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Monitors without a parentRef are placed under the root group set by
KUMA_ROOT_GROUP (kuma-sync/renner-uno). Only the root references the
email notification, so a fleet-wide outage sends one DOWN and one UP mail
listing the affected groups. resendInterval (consecutive DOWN heartbeats)
is now a managed KumaMonitor field; the root resends hourly while down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:33:16 +01:00
109 changed files with 3054 additions and 193 deletions
+16 -16
View File
@@ -47,7 +47,7 @@ when:
steps:
- name: build-ansible
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/ansible
# Only rebuild when THIS image's source (or the pipeline file) changed, or
# on a manual run. Without this every step ran on any matching push, so a
@@ -74,7 +74,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-ansible:latest
- name: build-k8s
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/k8s
when:
- event: deployment
@@ -97,7 +97,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-k8s:latest
- name: build-vmtest
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/vmtest
when:
- event: deployment
@@ -120,7 +120,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-vmtest:latest
- name: build-tofu
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/tofu
when:
- event: deployment
@@ -150,7 +150,7 @@ steps:
# Runtime image, not CI tooling: a static musl binary in a FROM-scratch image,
# used as the base for secret-rotation CronJobs. Replaces per-job inline Python.
- name: build-secret-rotator
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: secret-rotator
when:
- event: deployment
@@ -175,7 +175,7 @@ steps:
# review-bot: the Go service that reviews pull requests with a Mistral agent (webhook plus sweep).
# Runs after Gitea is up, so no ghcr mirror.
- name: build-review-bot
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: review-bot
when:
- event: deployment
@@ -225,7 +225,7 @@ steps:
#
# The gitea_admin/ copy stays until nothing references it any more.
- name: build-alpine-jq
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/alpine-jq
when:
- event: deployment
@@ -276,7 +276,7 @@ steps:
# note on build-alpine-jq. Anything an app needs while the cluster is still
# being built cannot live only in that cluster's own registry.
- name: build-cni-plugins
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/cni-plugins
when:
- event: deployment
@@ -316,7 +316,7 @@ steps:
# gitea.renner.uno is not there to serve it. A cold-rebuild rehearsal left
# paperless in Init:ErrImagePull on exactly this image.
- name: build-alpine-openssl
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/alpine-openssl
when:
- event: deployment
@@ -351,7 +351,7 @@ steps:
# Ansible converge. Not a ci-* image -- it is a runtime artifact -- but it
# builds the same way and lands in the same registry so the audit sees it.
- name: build-heating-can
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/heating-can
when:
- event: deployment
@@ -377,7 +377,7 @@ steps:
# services (public Ingress hosts -> TLS passthrough -> Traefik ClusterIP).
# Runtime artifact like heating-can; same registry so the audit tracks it.
- name: build-netbird-service-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/netbird-service-sync
when:
- event: deployment
@@ -402,7 +402,7 @@ steps:
# authentik-gate-sync: the authentik-gate ingress class (Ingress -> Authentik
# proxy gate + Traefik route). Its unit tests run as a stage of the image build.
- name: build-authentik-gate-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/authentik-gate-sync
when:
- event: deployment
@@ -428,7 +428,7 @@ steps:
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. Its unit
# tests run as a stage of the image build.
- name: build-kuma-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/kuma-sync
when:
- event: deployment
@@ -454,7 +454,7 @@ steps:
# cloudflare-lb-sync: reconciles CloudflareLoadBalancer resources into
# Cloudflare. Its unit tests run as a stage of the image build.
- name: build-cloudflare-lb-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/cloudflare-lb-sync
when:
- event: deployment
@@ -480,7 +480,7 @@ steps:
# bird: BGP daemon for the pod-network mesh over pod0 (#576). Runtime
# artifact like heating-can; same registry so the audit tracks it.
- name: build-bird
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/bird
when:
- event: deployment
@@ -504,7 +504,7 @@ steps:
# Orchestrator for the monthly backup-verify CronJob (velero ns).
- name: build-backup-verify
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/backup-verify
when:
- event: deployment
+1 -1
View File
@@ -131,7 +131,7 @@ steps:
# --build-arg to the resolved version (not "latest") guarantees the tag and
# the image content agree even if a new release lands mid-run.
- name: build
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/claude
depends_on: [decide]
environment:
+1 -1
View File
@@ -172,7 +172,7 @@ steps:
# whenever it trails the deployed server. If you bump the server, bump here.
- name: woodpecker-lint
depends_on: [] # see note above `steps:`
image: woodpeckerci/woodpecker-cli:v3.19.0-alpine@sha256:27a77117e38d7bbdc380f0e90ff3ee4871ab1eefa78b5d9136a2c969fbecd9f4
image: woodpeckerci/woodpecker-cli:v3.20.1-alpine@sha256:74c576faf49cce699825d11c7d7bd38ab5f007dd4f57cdbdd55099d2c0f2621a
commands:
- |
set -eu
+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.312
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
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.312
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
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.312
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
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.312
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
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.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
image: python:3.15-alpine@sha256:20a82dbc2783112ddb91d1534603f802d222a3e63e593cd002eb2ac534e70211
environment:
PYTHONDONTWRITEBYTECODE: "1"
commands:
+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.312
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
commands:
- sh scripts/ci/test-model-selection.sh
+1 -1
View File
@@ -10,7 +10,7 @@ when:
steps:
- name: build
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/hermes
environment:
# registry-bot write:package PAT (konairius org secret).
+1 -1
View File
@@ -6,7 +6,7 @@ when:
steps:
- name: go-test
image: golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5
image: golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9
commands:
- test -z "$(gofmt -l review-bot)"
- go -C review-bot vet ./...
+1 -1
View File
@@ -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.312
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
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.312
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+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:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS test
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913 AS test
WORKDIR /src
COPY controller.py test_controller.py ./
RUN python3 -B -m unittest -v
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY --from=test /src/controller.py /opt/controller.py
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53
FROM node:24-alpine@sha256:8a32c8cf649a49915a66e81e7a609223c708d0adabf288ee3f4077d765542f85
# 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:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
FROM golang:1.27@sha256:2920bb124f1a2e12a1d39f0cab756ae0068441ee1da7655dec7f5e374944095b 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:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
FROM gcr.io/distroless/static:nonroot@sha256:25ac8f0c3b2334a9eb05452266bd4697864a8c8ef0ef71e5e6480d7f92868329
COPY --from=build /out/cloudflare-lb-sync /cloudflare-lb-sync
USER 65532
ENTRYPOINT ["/cloudflare-lb-sync"]
+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:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913 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:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
+4 -4
View File
@@ -6,10 +6,10 @@ 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.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=ghcr.io/siderolabs/talosctl:v1.14.5@sha256:0b852e35c1f513ee5eacc22183d6193486012420569fde39fcd5260c51938443 /talosctl /usr/local/bin/talosctl
COPY --from=alpine/kubectl:1.37.2@sha256:5e2de75d37e811ba6eb92e06890b1b3263eae4f0a1c17b49269edec589b1352a /usr/local/bin/kubectl /usr/local/bin/kubectl
COPY --from=ghcr.io/fluxcd/flux-cli:v2.10.0@sha256:1a5178b1770052d6e88ab1c7c8434c5ac9bca8a8ab984dcaf6b9df31814ce9f2 /usr/local/bin/flux /usr/local/bin/flux
COPY --from=quay.io/openbao/openbao:2.7.2@sha256:5046911075e137e154ce49ab051502f4ec7cf367d484ae4a4dce665d9b90561f /usr/bin/bao /usr/local/bin/bao
COPY --from=gitea/tea:0.16.0@sha256:af50a2060ce4d8fd76b7ebe75a3d6db4bb0c615c5bc8f2bdfd8ea83207c4752d /usr/bin/tea /usr/local/bin/tea
RUN talosctl version --client \
+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:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
FROM golang:1.27@sha256:2920bb124f1a2e12a1d39f0cab756ae0068441ee1da7655dec7f5e374944095b 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:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
FROM gcr.io/distroless/static:nonroot@sha256:25ac8f0c3b2334a9eb05452266bd4697864a8c8ef0ef71e5e6480d7f92868329
COPY --from=build /out/kuma-sync /kuma-sync
USER 65532
ENTRYPOINT ["/kuma-sync"]
@@ -97,6 +97,10 @@ type KumaMonitorSpec struct {
// +kubebuilder:validation:Minimum=0
// +kubebuilder:default=0
MaxRetries int32 `json:"maxRetries,omitempty"`
// Consecutive DOWN heartbeats after which Kuma sends the DOWN notification
// again, not minutes; 0 sends it once per outage.
// +kubebuilder:validation:Minimum=0
ResendInterval int32 `json:"resendInterval,omitempty"`
// Seconds.
// +kubebuilder:validation:Minimum=1
Timeout *int32 `json:"timeout,omitempty"`
@@ -131,6 +131,15 @@ func TestNegativeMaxRetriesRejected(t *testing.T) {
expectInvalid(t, m, "spec.maxRetries")
}
func TestNegativeResendIntervalRejected(t *testing.T) {
m := monitor("neg-resend", KumaMonitorSpec{
Type: "group",
DisplayName: "g",
ResendInterval: -1,
})
expectInvalid(t, m, "spec.resendInterval")
}
func TestJSONQueryBadOperator(t *testing.T) {
m := monitor("jq-bad-op", KumaMonitorSpec{
Type: "json-query",
+32 -4
View File
@@ -3,19 +3,25 @@ package main
import (
"errors"
"fmt"
"strings"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/validation"
)
type config struct {
URL string
Username string
Password string
// RootGroup is the zero value when KUMA_ROOT_GROUP is unset.
RootGroup types.NamespacedName
}
// loadConfig reads the Kuma connection settings from the environment. It
// names every missing variable.
// names every missing or invalid variable.
func loadConfig(getenv func(string) string) (config, error) {
var cfg config
var missing []error
var errs []error
for _, v := range []struct {
name string
dst *string
@@ -25,8 +31,30 @@ func loadConfig(getenv func(string) string) (config, error) {
{"KUMA_PASSWORD", &cfg.Password},
} {
if *v.dst = getenv(v.name); *v.dst == "" {
missing = append(missing, fmt.Errorf("missing required environment variable %s", v.name))
errs = append(errs, fmt.Errorf("missing required environment variable %s", v.name))
}
}
return cfg, errors.Join(missing...)
if raw := getenv("KUMA_ROOT_GROUP"); raw != "" {
root, err := parseRootGroup(raw)
if err != nil {
errs = append(errs, fmt.Errorf("KUMA_ROOT_GROUP: %w", err))
}
cfg.RootGroup = root
}
return cfg, errors.Join(errs...)
}
// parseRootGroup parses namespace/name of a KumaMonitor.
func parseRootGroup(raw string) (types.NamespacedName, error) {
ns, name, ok := strings.Cut(raw, "/")
if !ok || strings.Contains(name, "/") {
return types.NamespacedName{}, fmt.Errorf("%q is not namespace/name", raw)
}
if msgs := validation.IsDNS1123Label(ns); len(msgs) > 0 {
return types.NamespacedName{}, fmt.Errorf("namespace %q: %s", ns, strings.Join(msgs, "; "))
}
if msgs := validation.IsDNS1123Subdomain(name); len(msgs) > 0 {
return types.NamespacedName{}, fmt.Errorf("name %q: %s", name, strings.Join(msgs, "; "))
}
return types.NamespacedName{Namespace: ns, Name: name}, nil
}
@@ -3,6 +3,8 @@ package main
import (
"strings"
"testing"
"k8s.io/apimachinery/pkg/types"
)
func TestLoadConfig(t *testing.T) {
@@ -36,3 +38,21 @@ func TestLoadConfig(t *testing.T) {
}
}
}
func TestLoadConfigRootGroup(t *testing.T) {
env := func(root string) func(string) string {
m := map[string]string{"KUMA_URL": "http://kuma", "KUMA_USERNAME": "u", "KUMA_PASSWORD": "p", "KUMA_ROOT_GROUP": root}
return func(k string) string { return m[k] }
}
cfg, err := loadConfig(env("kuma-sync/renner-uno"))
if err != nil || cfg.RootGroup != (types.NamespacedName{Namespace: "kuma-sync", Name: "renner-uno"}) {
t.Fatalf("cfg=%+v err=%v", cfg, err)
}
for _, bad := range []string{"renner-uno", "/renner-uno", "kuma-sync/", "a/b/c", "Kuma/x", "ns/UPPER", " ns/x"} {
if _, err := loadConfig(env(bad)); err == nil || !strings.Contains(err.Error(), "KUMA_ROOT_GROUP") {
t.Errorf("%q: err = %v, want KUMA_ROOT_GROUP rejected", bad, err)
}
}
}
@@ -48,6 +48,7 @@ func run() error {
GCInterval: 5 * time.Minute,
OrphanGrace: time.Hour,
Resync: 5 * time.Minute,
RootGroup: cfg.RootGroup,
})
if err != nil {
return err
@@ -222,6 +222,13 @@ spec:
- hostname
- port
type: object
resendInterval:
description: |-
Consecutive DOWN heartbeats after which Kuma sends the DOWN notification
again, not minutes; 0 sends it once per outage.
format: int32
minimum: 0
type: integer
retryInterval:
default: 60
description: Seconds.
+7 -1
View File
@@ -10,6 +10,7 @@ import (
"time"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/utils/ptr"
@@ -41,6 +42,9 @@ type Options struct {
// Resync is how often a healthy resource is reconciled again, which is
// what repairs drift made in Kuma; 0 means 5 minutes.
Resync time.Duration
// RootGroup is the group KumaMonitor that monitors without a parentRef are
// placed under; the zero value leaves them at the top level.
RootGroup types.NamespacedName
// SkipNameValidation allows several managers in one process, as in tests
// that restart the manager.
@@ -72,7 +76,9 @@ func NewManager(o Options) (manager.Manager, error) {
return nil, fmt.Errorf("create manager: %w", err)
}
if err := (&controller.Reconciler{Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync}).SetupWithManager(mgr); err != nil {
if err := (&controller.Reconciler{
Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync, RootGroup: o.RootGroup,
}).SetupWithManager(mgr); err != nil {
return nil, fmt.Errorf("set up reconciler: %w", err)
}
if err := (&controller.StatusPageReconciler{Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync}).SetupWithManager(mgr); err != nil {
@@ -58,6 +58,15 @@ type Reconciler struct {
// Resync is how often a healthy resource is reconciled again, which repairs
// drift made in Kuma. 0 means 5 minutes.
Resync time.Duration
// RootGroup is the group KumaMonitor every monitor without a parentRef is
// placed under in Kuma; the zero value places them at the top level.
RootGroup types.NamespacedName
}
func (r *Reconciler) hasRoot() bool { return r.RootGroup.Name != "" }
func (r *Reconciler) isRoot(o client.Object) bool {
return r.hasRoot() && o.GetNamespace() == r.RootGroup.Namespace && o.GetName() == r.RootGroup.Name
}
func (r *Reconciler) resync() time.Duration {
@@ -70,14 +79,18 @@ func (r *Reconciler) resync() time.Duration {
// SetupWithManager ignores status-only updates, which keeps failure backoff
// intact. Deletion still reconciles: setting deletionTimestamp bumps
// metadata.generation. A KumaNotification whose ID changes enqueues the
// monitors referencing it.
// monitors referencing it; a change to the root group enqueues the monitors
// without a parentRef.
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
b := ctrl.NewControllerManagedBy(mgr).
For(&v1.KumaMonitor{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Watches(&v1.KumaNotification{}, handler.EnqueueRequestsFromMapFunc(r.referencingMonitors),
builder.WithPredicates(predicate.Funcs{UpdateFunc: notificationIDChanged})).
Named("kumamonitor").
Complete(r)
builder.WithPredicates(predicate.Funcs{UpdateFunc: notificationIDChanged}))
if r.hasRoot() {
b = b.Watches(&v1.KumaMonitor{}, handler.EnqueueRequestsFromMapFunc(r.rootChildren),
builder.WithPredicates(r.rootPredicate()))
}
return b.Named("kumamonitor").Complete(r)
}
// failure is a Ready=False outcome. The message is fixed per reason; the cause
@@ -99,6 +112,9 @@ const (
msgParentNotGroup = "parent must be of type group"
msgParentCycle = "parentRef forms a cycle"
msgParentTooDeep = "parentRef chain is too deep"
msgRootPending = "root group has no monitor ID yet"
msgRootNotGroup = "root group must be of type group"
msgRootHasParent = "root group must not have a parentRef"
msgNotificationPending = "a referenced notification has no notification ID yet"
msgSynced = "monitor in sync"
msgNotOwned = "previous monitor exists without our ownership tag; restore the tag or delete it in Kuma"
@@ -119,17 +135,24 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
}
desired := Desired(km)
if km.Spec.ParentRef != nil {
p, err := r.resolveParent(ctx, km)
if err != nil {
var p parentOutcome
var err error
switch {
case km.Spec.ParentRef != nil:
p, err = r.resolveParent(ctx, km)
case r.hasRoot() && !r.isRoot(km):
p, err = r.resolveRoot(ctx)
}
if err != nil {
return ctrl.Result{}, err
}
if p.reason != "" {
if err := r.setReady(ctx, km, metav1.ConditionFalse, p.reason, p.message, nil); err != nil {
return ctrl.Result{}, err
}
if p.reason != "" {
if err := r.setReady(ctx, km, metav1.ConditionFalse, p.reason, p.message, nil); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: p.requeue}, nil
}
return ctrl.Result{RequeueAfter: p.requeue}, nil
}
if p.id != 0 {
desired.Parent = &p.id
}
@@ -323,8 +346,75 @@ type parentOutcome struct {
requeue time.Duration
}
// rootChanged reports changes to the root group that alter what the monitors
// without a parentRef resolve to.
func rootChanged(e event.UpdateEvent) bool {
o, ok1 := e.ObjectOld.(*v1.KumaMonitor)
n, ok2 := e.ObjectNew.(*v1.KumaMonitor)
if !ok1 || !ok2 {
return true
}
return o.Status.MonitorID != n.Status.MonitorID ||
o.Spec.Type != n.Spec.Type ||
(o.Spec.ParentRef == nil) != (n.Spec.ParentRef == nil) ||
o.DeletionTimestamp.IsZero() != n.DeletionTimestamp.IsZero()
}
// rootPredicate passes events of the root group only.
func (r *Reconciler) rootPredicate() predicate.Funcs {
return predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool { return r.isRoot(e.Object) },
DeleteFunc: func(e event.DeleteEvent) bool { return r.isRoot(e.Object) },
GenericFunc: func(e event.GenericEvent) bool { return r.isRoot(e.Object) },
UpdateFunc: func(e event.UpdateEvent) bool { return r.isRoot(e.ObjectNew) && rootChanged(e) },
}
}
// rootChildren enqueues the KumaMonitors of all namespaces that have no
// parentRef, except the root group itself.
func (r *Reconciler) rootChildren(ctx context.Context, _ client.Object) []reconcile.Request {
var list v1.KumaMonitorList
if err := r.Client.List(ctx, &list); err != nil {
log.FromContext(ctx).Error(err, "list KumaMonitors to enqueue")
return nil
}
var reqs []reconcile.Request
for i := range list.Items {
km := &list.Items[i]
if km.Spec.ParentRef == nil && !r.isRoot(km) {
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: km.Namespace, Name: km.Name}})
}
}
return reqs
}
// resolveRoot returns the Kuma ID of the root group for a monitor without a
// parentRef. The root must be a group without a parentRef: under one of its
// own ancestors it would close a cycle in Kuma.
func (r *Reconciler) resolveRoot(ctx context.Context) (parentOutcome, error) {
pending := parentOutcome{reason: ReasonParentPending, message: msgRootPending, requeue: parentRetry}
root := &v1.KumaMonitor{}
err := r.Client.Get(ctx, r.RootGroup, root)
if apierrors.IsNotFound(err) {
return pending, nil
}
if err != nil {
return parentOutcome{}, err
}
switch {
case root.Spec.Type != "group":
return parentOutcome{reason: ReasonInvalidParent, message: msgRootNotGroup, requeue: r.resync()}, nil
case root.Spec.ParentRef != nil:
return parentOutcome{reason: ReasonInvalidParent, message: msgRootHasParent, requeue: r.resync()}, nil
case root.Status.MonitorID == 0 || !root.DeletionTimestamp.IsZero():
return pending, nil
}
return parentOutcome{id: root.Status.MonitorID}, nil
}
// resolveParent checks the whole parentRef chain: every ancestor must exist
// and be a group, without cycles or more than maxParentDepth levels.
// and be a group, without cycles or more than maxParentDepth levels. The root
// group, which the topmost ancestor is placed under, counts as a level.
func (r *Reconciler) resolveParent(ctx context.Context, km *v1.KumaMonitor) (parentOutcome, error) {
invalid := func(msg string) (parentOutcome, error) {
return parentOutcome{reason: ReasonInvalidParent, message: msg, requeue: r.resync()}, nil
@@ -334,7 +424,8 @@ func (r *Reconciler) resolveParent(ctx context.Context, km *v1.KumaMonitor) (par
visited := map[string]bool{km.Name: true}
cur := km
var directID int64
for depth := 0; cur.Spec.ParentRef != nil; depth++ {
depth := 0
for ; cur.Spec.ParentRef != nil; depth++ {
if depth >= maxParentDepth {
return invalid(msgParentTooDeep)
}
@@ -359,6 +450,9 @@ func (r *Reconciler) resolveParent(ctx context.Context, km *v1.KumaMonitor) (par
}
cur = parent
}
if r.hasRoot() && !r.isRoot(cur) && depth >= maxParentDepth {
return invalid(msgParentTooDeep)
}
if directID == 0 {
return pending, nil
}
@@ -24,14 +24,15 @@ func OwnerKey(km *v1.KumaMonitor) string {
func Desired(km *v1.KumaMonitor) kumaclient.Monitor {
s := km.Spec
m := kumaclient.Monitor{
Type: s.Type,
Name: s.DisplayName,
Description: s.Description,
Active: !s.Paused,
Interval: s.Interval,
RetryInterval: s.RetryInterval,
MaxRetries: s.MaxRetries,
OwnerKey: OwnerKey(km),
Type: s.Type,
Name: s.DisplayName,
Description: s.Description,
Active: !s.Paused,
Interval: s.Interval,
RetryInterval: s.RetryInterval,
MaxRetries: s.MaxRetries,
ResendInterval: s.ResendInterval,
OwnerKey: OwnerKey(km),
}
if s.Timeout != nil {
m.Timeout = *s.Timeout
@@ -57,6 +57,8 @@ func TestMappingAllTypes(t *testing.T) {
port.Port = &v1.PortSpec{Hostname: "p.com", Port: 443}
paused := base("group")
paused.Paused = true
resend := base("group")
resend.ResendInterval = 60
wantKW := withHTTP(common("keyword"))
wantKW.Keyword, wantKW.InvertKeyword = "ok", true
@@ -74,6 +76,8 @@ func TestMappingAllTypes(t *testing.T) {
wantPort.Hostname, wantPort.Port = "p.com", 443
wantPaused := common("group")
wantPaused.Active = false
wantResend := common("group")
wantResend.ResendInterval = 60
cases := []struct {
name string
@@ -88,6 +92,7 @@ func TestMappingAllTypes(t *testing.T) {
{"dns with conditions", dnsCond, wantDNSCond},
{"port", port, wantPort},
{"paused maps to inactive", paused, wantPaused},
{"resend interval", resend, wantResend},
}
for _, c := range cases {
got := Desired(mk(c.spec))
@@ -0,0 +1,209 @@
package controller
import (
"fmt"
"slices"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/event"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
)
const rootID = 7
var rootName = types.NamespacedName{Namespace: "default", Name: "root"}
func rootGroup() *v1.KumaMonitor {
g := group("root", "")
g.Status.MonitorID = rootID
return g
}
func newRootEnv(t *testing.T, objs ...client.Object) *env {
t.Helper()
e := newEnv(t, objs...)
e.r.RootGroup = rootName
return e
}
func (e *env) parentOf(t *testing.T, name string) *int64 {
t.Helper()
got := e.kuma.Get(e.get(t, name).Status.MonitorID)
if got == nil {
t.Fatalf("%s: no Kuma monitor", name)
}
return got.Parent
}
func TestRootGroupParentsParentless(t *testing.T) {
e := newRootEnv(t, rootGroup(), httpMonitor("a"))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "a"); p == nil || *p != rootID {
t.Errorf("parent = %v, want %d", p, rootID)
}
}
func TestRootGroupKeepsParentRef(t *testing.T) {
grp := group("grp", "")
grp.Status.MonitorID = 42
child := httpMonitor("child")
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
e := newRootEnv(t, rootGroup(), grp, child)
if _, err := e.reconcile(t, "child"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "child"); p == nil || *p != 42 {
t.Errorf("parent = %v, want 42", p)
}
}
func TestRootGroupItselfHasNoParent(t *testing.T) {
root := rootGroup()
root.Status.MonitorID = 0
e := newRootEnv(t, root)
if _, err := e.reconcile(t, "root"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "root"); p != nil {
t.Errorf("parent = %d, want none", *p)
}
}
func TestRootGroupUnsetLeavesTopLevel(t *testing.T) {
e := newEnv(t, rootGroup(), httpMonitor("a"))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "a"); p != nil {
t.Errorf("parent = %d, want none", *p)
}
}
func TestRootGroupPending(t *testing.T) {
noID := rootGroup()
noID.Status.MonitorID = 0
deletingRoot := deleting(rootGroup())
cases := map[string][]client.Object{
"missing": nil,
"no ID": {noID},
"deleting": {deletingRoot},
"not group": {func() *v1.KumaMonitor { m := httpMonitor("root"); m.Status.MonitorID = rootID; return m }()},
"has parent": {func() *v1.KumaMonitor {
m := rootGroup()
m.Spec.ParentRef = &v1.LocalRef{Name: "x"}
return m
}()},
}
want := map[string]struct {
reason string
requeue time.Duration
}{
"missing": {ReasonParentPending, 10 * time.Second},
"no ID": {ReasonParentPending, 10 * time.Second},
"deleting": {ReasonParentPending, 10 * time.Second},
"not group": {ReasonInvalidParent, 5 * time.Minute},
"has parent": {ReasonInvalidParent, 5 * time.Minute},
}
for name, objs := range cases {
e := newRootEnv(t, append(objs, httpMonitor("a"))...)
res, err := e.reconcile(t, "a")
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if res.RequeueAfter != want[name].requeue {
t.Errorf("%s: RequeueAfter = %v, want %v", name, res.RequeueAfter, want[name].requeue)
}
if c := ready(t, e.get(t, "a")); c.Status != metav1.ConditionFalse || c.Reason != want[name].reason {
t.Errorf("%s: Ready = %+v", name, c)
}
if e.cnt.creates != 0 {
t.Errorf("%s: creates = %d", name, e.cnt.creates)
}
}
}
// A chain of maxParentDepth groups is allowed on its own, but the root group
// adds one more level above it.
func TestRootGroupCountsTowardsDepth(t *testing.T) {
var objs []client.Object
for i := range maxParentDepth {
parent := ""
if i < maxParentDepth-1 {
parent = fmt.Sprintf("c%d", i+1)
}
objs = append(objs, group(fmt.Sprintf("c%d", i), parent))
}
deep := httpMonitor("deep")
deep.Spec.ParentRef = &v1.LocalRef{Name: "c0"}
objs = append(objs, deep)
if _, err := newEnv(t, objs...).reconcile(t, "deep"); err != nil {
t.Fatal(err)
}
e := newRootEnv(t, append(objs, rootGroup())...)
if _, err := e.reconcile(t, "deep"); err != nil {
t.Fatal(err)
}
if c := ready(t, e.get(t, "deep")); c.Reason != ReasonInvalidParent || c.Message != msgParentTooDeep {
t.Errorf("Ready = %+v", c)
}
// A chain ending in the root itself gets no extra level.
objs[maxParentDepth-1] = func() *v1.KumaMonitor { g := rootGroup(); g.Name = "c9"; return g }()
e = newEnv(t, objs...)
e.r.RootGroup = types.NamespacedName{Namespace: "default", Name: "c9"}
if _, err := e.reconcile(t, "deep"); err != nil {
t.Fatal(err)
}
if c := ready(t, e.get(t, "deep")); c.Status != metav1.ConditionTrue {
t.Errorf("chain ending in root: Ready = %+v", c)
}
}
func TestRootChildrenEnqueuesParentless(t *testing.T) {
child := httpMonitor("child")
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
other := httpMonitor("other")
other.Namespace = "elsewhere"
e := newRootEnv(t, rootGroup(), group("grp", ""), child, httpMonitor("a"), other)
var got []string
for _, req := range e.r.rootChildren(t.Context(), rootGroup()) {
got = append(got, req.String())
}
slices.Sort(got)
want := []string{"default/a", "default/grp", "elsewhere/other"}
if !slices.Equal(got, want) {
t.Errorf("enqueued %v, want %v", got, want)
}
}
func TestRootPredicate(t *testing.T) {
r := &Reconciler{RootGroup: rootName}
p := r.rootPredicate()
old := rootGroup()
old.Status.MonitorID = 0
withID := rootGroup()
other := group("other", "")
if !p.Update(event.UpdateEvent{ObjectOld: old, ObjectNew: withID}) {
t.Error("root ID change must pass")
}
if p.Update(event.UpdateEvent{ObjectOld: withID, ObjectNew: withID.DeepCopy()}) {
t.Error("unchanged root must not pass")
}
if p.Update(event.UpdateEvent{ObjectOld: other, ObjectNew: other.DeepCopy()}) || p.Create(event.CreateEvent{Object: other}) {
t.Error("other monitors must not pass")
}
if !p.Create(event.CreateEvent{Object: withID}) || !p.Delete(event.DeleteEvent{Object: withID}) {
t.Error("root create and delete must pass")
}
}
@@ -378,7 +378,7 @@ func (b *Breml) update(ctx context.Context, m Monitor) error {
return fmt.Errorf("update %d: monitor carries owner key %q, not %q: %w", m.ID, key, m.OwnerKey, ErrNotOwned)
}
// Start from the full current object, also across a type change, so
// fields we do not manage (proxy, resend interval, upside down, auth)
// fields we do not manage (proxy, upside down, auth)
// survive the overwrite.
wire, err := toWire(m, &cur)
if err != nil {
@@ -420,6 +420,7 @@ func toDomain(base monitor.Base, ownerKey string) (Monitor, error) {
Interval: int32(base.Interval),
RetryInterval: int32(base.RetryInterval),
MaxRetries: int32(base.MaxRetries),
ResendInterval: int32(base.ResendInterval),
NotificationIDs: slices.Clone(base.NotificationIDs),
OwnerKey: ownerKey,
}
@@ -581,6 +582,7 @@ func writeBase(b *monitor.Base, m Monitor) {
b.Interval = int64(m.Interval)
b.RetryInterval = int64(m.RetryInterval)
b.MaxRetries = int64(m.MaxRetries)
b.ResendInterval = int64(m.ResendInterval)
b.IsActive = m.Active
b.NotificationIDs = slices.Clone(m.NotificationIDs)
}
@@ -127,7 +127,7 @@ func TestCreateFieldsIntact(t *testing.T) {
group := Monitor{
Type: "group", Name: "grp", Description: "a group", Active: true,
Interval: 60, RetryInterval: 60, MaxRetries: 0, OwnerKey: "default/grp",
Interval: 60, RetryInterval: 60, MaxRetries: 0, ResendInterval: 60, OwnerKey: "default/grp",
}
gid, err := b.Create(ctx, group)
if err != nil {
@@ -313,7 +313,6 @@ func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
t.Fatal(err)
}
kw.UpsideDown = true
kw.ResendInterval = 7
kw.NotificationIDs = []int64{nid}
if err := b.c.UpdateMonitor(ctx, &kw); err != nil {
t.Fatal(err)
@@ -328,8 +327,8 @@ func assertUnmanaged(t *testing.T, c *Breml, id int64, when string) {
if err != nil {
t.Fatal(err)
}
if !base.UpsideDown || base.ResendInterval != 7 {
t.Errorf("%s: unmanaged state lost: upsideDown=%v resend=%d", when, base.UpsideDown, base.ResendInterval)
if !base.UpsideDown {
t.Errorf("%s: unmanaged state lost: upsideDown=%v", when, base.UpsideDown)
}
}
@@ -344,7 +343,7 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
}
orig := Monitor{
Type: "keyword", Name: "kw", Active: true, Parent: &gid,
Interval: 60, RetryInterval: 60, URL: "https://example.com",
Interval: 60, RetryInterval: 60, ResendInterval: 7, URL: "https://example.com",
Headers: map[string]string{"A": "1"}, Keyword: "up", OwnerKey: "default/kw",
}
id, err := b.Create(ctx, orig)
@@ -360,6 +359,7 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
cleared.Headers = nil
cleared.Keyword = ""
cleared.Interval = 90
cleared.ResendInterval = 0
if err := b.Update(ctx, cleared); err != nil {
t.Fatal(err)
}
@@ -369,7 +369,7 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
t.Fatal(err)
}
m := byID(got)[id]
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 || m.NotificationIDs != nil {
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 || m.ResendInterval != 0 || m.NotificationIDs != nil {
t.Errorf("managed fields not cleared: %+v", m)
}
assertUnmanaged(t, fresh, id, "after plain update")
@@ -141,6 +141,9 @@ type Monitor struct {
Interval int32
RetryInterval int32
MaxRetries int32
// ResendInterval counts consecutive DOWN heartbeats between repeated DOWN
// notifications; 0 disables resending.
ResendInterval int32
// Timeout is in seconds and only applies to http, keyword and json-query.
// 0 is written as round(0.8*Interval), because Kuma treats a stored 0 as a
// huge request timeout. For other types it is ignored and reads back as 0.
@@ -0,0 +1,61 @@
package kumaclient
import (
"encoding/json"
"testing"
"github.com/breml/go-uptime-kuma-client/monitor"
)
// resendOnWire writes m over existing (nil for a create) and returns the wire
// resendInterval and the value read back from it.
func resendOnWire(t *testing.T, m Monitor, existing *monitor.Base) (wire, back int64) {
t.Helper()
w, err := toWire(m, existing)
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(w)
if err != nil {
t.Fatal(err)
}
var f struct {
ResendInterval int64 `json:"resendInterval"`
}
if err := json.Unmarshal(raw, &f); err != nil {
t.Fatal(err)
}
var base monitor.Base
if err := json.Unmarshal(raw, &base); err != nil {
t.Fatal(err)
}
got, err := toDomain(base, m.OwnerKey)
if err != nil {
t.Fatal(err)
}
return f.ResendInterval, int64(got.ResendInterval)
}
func TestResendIntervalRoundTrip(t *testing.T) {
for _, m := range []Monitor{
{ID: 1, Type: "group", Name: "g", Active: true, Interval: 60, RetryInterval: 60, ResendInterval: 60, OwnerKey: "ns/g"},
{ID: 2, Type: "http", Name: "h", Active: true, Interval: 60, RetryInterval: 60, ResendInterval: 3, URL: "https://e.com", OwnerKey: "ns/h"},
} {
wire, back := resendOnWire(t, m, nil)
if wire != int64(m.ResendInterval) || back != int64(m.ResendInterval) {
t.Errorf("%s: wire=%d read back=%d, want %d", m.Type, wire, back, m.ResendInterval)
}
}
}
// 0 must overwrite a resend interval stored in Kuma, not keep it.
func TestResendIntervalClearedOnUpdate(t *testing.T) {
var existing monitor.Base
if err := json.Unmarshal([]byte(`{"id":1,"type":"group","name":"g","resendInterval":7}`), &existing); err != nil {
t.Fatal(err)
}
m := Monitor{ID: 1, Type: "group", Name: "g", Active: true, Interval: 60, RetryInterval: 60, OwnerKey: "ns/g"}
if wire, back := resendOnWire(t, m, &existing); wire != 0 || back != 0 {
t.Errorf("wire=%d read back=%d, want 0", wire, back)
}
}
+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:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY controller.py /opt/controller.py
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>com.apple.wifi.managed</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>uno.renner.wifi.renner-test.network</string>
<key>PayloadUUID</key>
<string>c7814579-fc2b-4f38-bcc8-77e1123ab710</string>
<key>PayloadDisplayName</key>
<string>Renner Test Wi-Fi</string>
<key>PayloadOrganization</key>
<string>Renner</string>
<key>SSID_STR</key>
<string>Renner Test</string>
<key>HIDDEN_NETWORK</key>
<false/>
<key>AutoJoin</key>
<true/>
<key>EncryptionType</key>
<string>Any</string>
<key>EAPClientConfiguration</key>
<dict>
<key>OuterIdentity</key>
<string>anonymous</string>
<key>AcceptEAPTypes</key>
<array>
<integer>21</integer>
</array>
<key>TTLSInnerAuthentication</key>
<string>PAP</string>
<key>TLSTrustedServerNames</key>
<array>
<string>radius.renner.uno</string>
</array>
</dict>
</dict>
</array>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>uno.renner.wifi.renner-test</string>
<key>PayloadUUID</key>
<string>b35513d4-97e5-4804-a77b-5ca1f723a408</string>
<key>PayloadDisplayName</key>
<string>Renner Test Wi-Fi (802.1X)</string>
<key>PayloadOrganization</key>
<string>Renner</string>
</dict>
</plist>
+178
View File
@@ -0,0 +1,178 @@
# 802.1X via authentik
FreeRADIUS terminates EAP-TTLS and proxies the inner PAP request to the
authentik RADIUS outpost `802.1X Radius`. authentik checks the password and
the access policy and returns the VLAN.
## Routing by outer identity
FreeRADIUS decides on the outer EAP identity (`User-Name`) of each request,
before any local EAP processing:
| Outer identity | Path |
|---|---|
| `anonymous` or `anonymous@<anything>` (any case) | EAP-TTLS terminated in FreeRADIUS, inner PAP to authentik |
| anything else | whole EAP conversation (all rounds, `State` included) proxied to the UDM RADIUS for UniFi Endpoint accounts |
UniFi Endpoint clients send their real account name as outer identity; their
names look like ordinary usernames, so the rule cannot match on name patterns.
The UDM's Access-Accept/Reject/Challenge goes to the NAS unchanged, including
its Tunnel-* VLAN and MS-MPPE keys. The "Accept without VLAN is rejected" rule
applies to the authentik path only. Accounting is answered locally on both
paths.
The anonymous outer identity is mandatory for authentik clients: without it
the login goes to the UDM and fails.
UDM target: home server `udm`, `10.2.47.1` UDP 1812 (the UDM on VLAN 47, same
address as the BGP peer in `kubernetes/infrastructure/post-crds/cilium-bgp.yaml`),
shared secret `UDM_RADIUS_SECRET` from OpenBao `apps/radius`. Egress is
allowed by CNP `freeradius-udm-egress` in `freeradius-cnp.yaml`.
## authentik objects
Defined in `kubernetes/apps/authentik/blueprints-configmap.yaml`, key
`dot1x-radius.yaml`:
| Object | Name | Purpose |
|---|---|---|
| Flow | `dot1x-authentication-flow` | identification, password, login; no MFA, no enrollment |
| Expression policy | `dot1x-has-vlan` | admits users with a valid `dot1x_vlan` group |
| RADIUS property mapping | `dot1x-vlan` | puts the VLAN into the Access-Accept |
| RADIUS provider | `802.1X Radius` | shared secret `DOT1X_OUTPOST_SECRET`, clients = cluster pod CIDRs |
| Application | `dot1x` | bound to `dot1x-has-vlan` |
| Outpost | `802.1X Radius` | radius, 2 replicas, ClusterIP, namespace `authentik` |
The outpost's Kubernetes objects are named from the slugified outpost name:
Service/Deployment `ak-outpost-8021x-radius`, pod label
`goauthentik.io/outpost-name: 8021x-radius`.
The shared secret is key `DOT1X_OUTPOST_SECRET` of Secret `radius-secrets`
(ExternalSecret from OpenBao `apps/radius`), passed to the authentik worker
only (it applies the blueprints) as env `DOT1X_OUTPOST_SECRET`, with
`optional: true`: a missing Secret fails only the dot1x blueprint, not
authentik. Reloader restarts the worker when `radius-secrets` changes.
On the FreeRADIUS → outpost hop the shared secret is the only guard: the
authentik namespace baseline admits all same-namespace pods, and the
provider's client_networks is the pod CIDR.
FreeRADIUS resolves the outpost Service name at startup; a re-created
outpost Service needs the FreeRADIUS pods restarted.
## VLAN convention
- A group carries the VLAN as attribute `dot1x_vlan`, an integer VLAN id
(1–4094), e.g. `dot1x_vlan: 50`. It must be an unquoted integer: a string
such as `"50"` is ignored and the user is denied.
- Membership counts through group inheritance (`User.all_groups()`).
- A user without any such group is rejected (policy `dot1x-has-vlan`).
- A user in several such groups gets the lowest `dot1x_vlan`.
- The same attribute applies to WiFi and wired 802.1X.
Groups and memberships are managed by the owner in authentik, not in
blueprints.
## Reply format
The mapping sets both:
- RFC 3580 `Tunnel-Type = VLAN`, `Tunnel-Medium-Type = IEEE-802`,
`Tunnel-Private-Group-Id = "<id>"` (raw attribute codes 64, 65, 81; the
dictionary bundled with authentik does not define them).
- `Cisco-AV-Pair = "dot1x-vlan=<id>"`.
Contract for FreeRADIUS: if the outpost reply carries the standard Tunnel-*
attributes, use them; otherwise translate `Cisco-AV-Pair = "dot1x-vlan=<id>"`
into `Tunnel-Type = VLAN`, `Tunnel-Medium-Type = IEEE-802`,
`Tunnel-Private-Group-Id = "<id>"`. An Access-Accept without a VLAN is
rejected.
## Test-phase entries
The FreeRADIUS clients `test` (10.2.50.0/24) and `test6` (fd02:50::/64) and
their CIDRs in `freeradius-cnp.yaml` are for the test phase only and must be
removed after the test.
## Client setup (SSID "Renner Test")
All clients use EAP-TTLS with inner PAP. The server certificate is a public
Let's Encrypt certificate for `radius.renner.uno`, so no CA needs to be
installed. Username and password are the authentik credentials. PAP runs
inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air.
### Android
- EAP method: TTLS
- Phase 2 authentication: PAP
- CA certificate: Use system certificates
- Domain: `radius.renner.uno`
- Identity: authentik username
- Anonymous identity: `anonymous` (mandatory; empty sends the login to the
UDM, where it fails)
- Password: authentik password
### Linux (NetworkManager)
- Security: WPA & WPA2/3 Enterprise
- Authentication: Tunneled TLS (TTLS)
- Inner authentication: PAP
- CA certificate: system bundle (e.g. `/etc/ssl/certs/ca-certificates.crt`)
- Domain: `radius.renner.uno`
- Anonymous identity: `anonymous` (mandatory)
- Username / password: authentik credentials
### Windows 11
Settings > Network & Internet > Wi-Fi > Manage known networks > Add network:
- Network name: `Renner Test`
- Security type: WPA2-Enterprise AES
- EAP method: EAP-TTLS
- Authentication method: Unencrypted password (PAP)
- Server validation: where offered ("Verify server identity" / trusted
server name), set `radius.renner.uno`
- Do not use Windows sign-in credentials; enter the authentik credentials
On the first join, check the certificate details before connecting: it must
be issued to `radius.renner.uno` by Let's Encrypt. Otherwise cancel.
If EAP-TTLS is not offered, use Control Panel > "Manually connect to a
wireless network" instead.
### Apple (iOS, iPadOS, macOS)
Apple devices need the configuration profile
`docs/radius/clients/renner-test.mobileconfig`. Install it via AirDrop, Files
or Settings (Profile Downloaded). The device asks for username and password on
the first join. The profile sets security type Any (WPA2/WPA3), outer identity
`anonymous` and trusted server name `radius.renner.uno`; it contains no
password and no certificate. Apple
devices may show a one-time "trust certificate" prompt for `radius.renner.uno`
on the first join.
## Measurement: authentication latency per SSID
The UniFi access points log station events (`stahtd`) to Loki. The field
`radius_auth_delta` (microseconds) is in a JSON payload after
`stahtd_dump_event(): `.
VAP names per SSID are looked up through the UniFi API: `stat/device`,
`vap_table`, map `essid` to `name`.
The JSON mixes `"key":"v"` and `"key": "v"`, so the filters tolerate an
optional space. Values are microseconds. Run once per VAP of "Renner Test" and
of "Renner UniFi" and compare avg and max:
```
logcli instant-query --quiet \
'avg_over_time({source="unifi-syslog",app="stahtd"} |~ "\"vap\": ?\"<vap>\"" | regexp "radius_auth_delta\": ?\"?(?P<d>[0-9]+)" | unwrap d [24h])'
logcli instant-query --quiet \
'max_over_time({source="unifi-syslog",app="stahtd"} |~ "\"vap\": ?\"<vap>\"" | regexp "radius_auth_delta\": ?\"?(?P<d>[0-9]+)" | unwrap d [24h])'
```
| SSID | VAP(s) | Events | avg radius_auth_delta (us) | max radius_auth_delta (us) |
|---|---|---|---|---|
| Renner Test | | | | |
| Renner UniFi | | | | |
@@ -0,0 +1,123 @@
# 802.1X RADIUS via authentik — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** In-cluster FreeRADIUS that terminates EAP-TTLS/PAP for 802.1X and checks the password against a dedicated authentik RADIUS outpost, live behind a test SSID "Renner Test".
**Architecture:** FreeRADIUS (own Deployment, 2 replicas, LB `10.2.99.91`/`fd99::91`, Let's Encrypt cert `radius.renner.uno`) terminates EAP-TTLS, proxies the inner PAP request to a new blueprint-managed authentik RADIUS outpost "802.1X Radius" (2 replicas) whose provider returns the VLAN from the group attribute `dot1x_vlan`. UniFi gets a RADIUS profile, a test WLAN and a firewall policy via the UDM API.
**Tech Stack:** Flux/Kustomize manifests, authentik 2026.8 blueprints, `freeradius/freeradius-server:3.2.10-alpine`, cert-manager, External Secrets (OpenBao), Cilium LB-IPAM/BGP + CNP, UniFi Network API, `eapol_test` (workstation `/usr/bin/eapol_test`).
**Spec:** `docs/superpowers/specs/2026-11-03-wifi-radius-authentik-design.md`
## Global Constraints
- Work only in this worktree; commits as `git -c user.name='Claude Bot' -c user.email='claude-bot@renner.uno' commit …`; never push to `main`, open a PR, never self-merge.
- Never read Kubernetes Secret data; never print secret values; credentials only via env var on the local command (`VAR="$SECRET" tool`), never inside a string another program interprets.
- authentik users/groups are created by the owner, not in blueprints.
- Group attribute name: `dot1x_vlan` (integer). Test VLAN: `50`.
- LB: `lbipam.cilium.io/ips: "10.2.99.91,fd99::91"`, label `lb-pool: bgp`, `externalTrafficPolicy: Local`, `ipFamilyPolicy: PreferDualStack`.
- Hostname/cert: `radius.renner.uno`, ClusterIssuer `letsencrypt-cluster`.
- RADIUS clients: `10.2.10.0/24` (APs/switches). Test client: workstation network `10.2.50.0/24` with its own secret (test phase only).
- Existing "Unifi Radius" provider and "Local Radius" outpost: untouched.
- Main WLANs "Renner UniFi", "Renner IoT", "Renner Gast": untouched.
- Resource convention: requests set, memory limit only, no CPU limit; `allowPrivilegeEscalation: false`, `capabilities.drop: [ALL]`.
- New manifests must be listed in `kubernetes/apps/authentik/kustomization.yaml`.
## Review Focus
- User not in any `dot1x_vlan` group → must get Access-Reject, never an Accept without VLAN (would land in the Guest fallback).
- Wrong password → Access-Reject within < 1 s, no retry storm between FreeRADIUS and outpost.
- Outpost reject packets lack `Message-Authenticator` (upstream behaviour) → FreeRADIUS must still forward a Reject to the AP, not time out.
- One FreeRADIUS replica or one outpost replica down → logins keep working (Service routes to the other).
- Accounting packets from APs (UDP 1813) → answered locally, never forwarded to the outpost (which has no 1813).
---
### Task 1: Secrets and server certificate
**Files:**
- Create: `kubernetes/apps/authentik/radius-cert.yaml`
- Modify: `kubernetes/apps/authentik/kustomization.yaml`
**Interfaces:**
- Produces: Secret `radius-tls` (keys `tls.crt`, `tls.key`); OpenBao `apps/authentik` keys `DOT1X_OUTPOST_SECRET`, `DOT1X_NAS_SECRET`, `DOT1X_TEST_SECRET` (reach the cluster as keys of the existing Secret `authentik-secrets` via `externalsecret.yaml`).
- [ ] **Step 1:** Generate three secrets (`openssl rand -base64 48`, piped straight into `bao kv patch apps/authentik DOT1X_OUTPOST_SECRET=- …` without echoing) — if claude-bot's OpenBao policy cannot write `apps/authentik`, stop and hand the exact `bao kv patch` command to the owner.
- [ ] **Step 2:** Verify key names only: `bao kv get -format=json apps/authentik | jq -r '.data.data | keys[]' | grep DOT1X` → three names.
- [ ] **Step 3:** Write `radius-cert.yaml` (Certificate `radius-tls`, secretName `radius-tls`, dnsNames `[radius.renner.uno]`, issuerRef `letsencrypt-cluster`/ClusterIssuer), add to kustomization.
- [ ] **Step 4:** `kubectl kustomize kubernetes/apps/authentik >/dev/null` → exit 0; `kubectl apply --dry-run=server -f kubernetes/apps/authentik/radius-cert.yaml` → `created (server dry run)`.
- [ ] **Step 5:** Commit `feat(radius): server certificate for radius.renner.uno`.
### Task 2: authentik provider, VLAN mapping and outpost
**Files:**
- Modify: `kubernetes/apps/authentik/blueprints-configmap.yaml` (new key `dot1x-radius.yaml`)
- Modify: `kubernetes/apps/authentik/helmrelease.yaml` (worker env `DOT1X_OUTPOST_SECRET` from `authentik-secrets`, only if blueprints cannot already read it via `!Env`)
- Create: `docs/radius/dot1x.md` (owner steps + conventions)
**Interfaces:**
- Consumes: `DOT1X_OUTPOST_SECRET` (Task 1).
- Produces: outpost "802.1X Radius" → operator Service `ak-outpost-802-1x-radius` (ClusterIP, UDP 1812), pod labels `goauthentik.io/outpost-name: 802-1x-radius`; provider "802.1X Radius" with `client_networks` = cluster pod CIDRs (read from `kubectl get nodes -o jsonpath` podCIDRs / Cilium config, not guessed); reply carries the VLAN either as standard `Tunnel-Type`/`Tunnel-Medium-Type`/`Tunnel-Private-Group-Id` or, fallback, as `Cisco-AV-Pair = "dot1x-vlan=<id>"`.
- [ ] **Step 1: Blueprint objects** — expression policy `dot1x-has-vlan` (`return any(g.attributes.get("dot1x_vlan") for g in request.user.ak_groups.all())`), RADIUS property mapping `dot1x-vlan` (lowest numeric `dot1x_vlan` across the user's groups wins), provider "802.1X Radius" (`authorization_flow: ldap-authentication-flow` after Step 2 confirms it is non-interactive, `invalidation_flow: default-invalidation-flow`, `mfa_support: false`, `shared_secret: !Env DOT1X_OUTPOST_SECRET`, `property_mappings: [dot1x-vlan]`), application slug `dot1x` bound to `dot1x-has-vlan`, outpost "802.1X Radius" (type radius, `k8s-conn`, namespace `authentik`, `kubernetes_replicas: 2`, `kubernetes_service_type: ClusterIP`, providers: only "802.1X Radius").
- [ ] **Step 2:** Confirm `ldap-authentication-flow` contains only identification + password stages (authentik API/UI read); if not, add a blueprint flow `dot1x-authentication-flow` with exactly those two stages and use it.
- [ ] **Step 3:** Owner step (documented in `docs/radius/dot1x.md`, asked of the owner during execution): create group `802.1X Privat` with attribute `dot1x_vlan: 50`, add the owner and a test user `dot1x-test`.
- [ ] **Step 4:** After merge + reconcile: `kubectl -n authentik get deploy ak-outpost-802-1x-radius -o json | jq .status.readyReplicas` → `2`.
- [ ] **Step 5: VLAN attribute spike** — from a throwaway pod in `authentik` (`kubectl run radtest --rm -i --image=freeradius/freeradius-server:3.2.10-alpine --command -- radtest dot1x-test "$PW" ak-outpost-802-1x-radius 0 "$SECRET"` with values passed via `--env`, not in the string) expect `Access-Accept` containing `Tunnel-Private-Group-Id = "50"`. If the outpost cannot emit standard Tunnel attributes, switch the mapping to the `Cisco-AV-Pair` fallback and expect `Cisco-AV-Pair = "dot1x-vlan=50"`. Record which one in `docs/radius/dot1x.md`.
- [ ] **Step 6:** Same with a user outside any `dot1x_vlan` group and with a wrong password → `Access-Reject` (< 1 s).
- [ ] **Step 7:** Commit `feat(authentik): 802.1X RADIUS provider, VLAN mapping and outpost`.
### Task 3: FreeRADIUS service
**Files:**
- Create: `kubernetes/apps/authentik/freeradius-config.yaml` (ConfigMap: `radiusd.conf`, `clients.conf`, `proxy.conf`, `mods-enabled/eap`, `sites-enabled/default`, `sites-enabled/inner-tunnel`)
- Create: `kubernetes/apps/authentik/freeradius.yaml` (Deployment + Service `radius-lb`)
- Create: `kubernetes/apps/authentik/freeradius-cnp.yaml`
- Create: `kubernetes/infrastructure/image-automation/freeradius.yaml`; modify its `kustomization.yaml`
- Modify: `kubernetes/apps/authentik/kustomization.yaml`
**Interfaces:**
- Consumes: `radius-tls`, `authentik-secrets` keys `DOT1X_NAS_SECRET`, `DOT1X_TEST_SECRET`, `DOT1X_OUTPOST_SECRET`; outpost Service `ak-outpost-802-1x-radius:1812/UDP`; reply VLAN format from Task 2.
- Produces: `radius.renner.uno` → `10.2.99.91`/`fd99::91`, UDP 1812/1813.
- [ ] **Step 1: Config** — `clients.conf`: client `aps` `10.2.10.0/24` secret `$ENV{DOT1X_NAS_SECRET}`, client `test` `10.2.50.0/24` secret `$ENV{DOT1X_TEST_SECRET}`, both `require_message_authenticator = yes`. `eap`: `default_eap_type = ttls`, TLS from `/etc/raddb/certs/radius-tls/{tls.crt,tls.key}`, `ttls { virtual_server = inner-tunnel, use_tunneled_reply = yes }`; no PEAP/MSCHAP. `inner-tunnel`: PAP proxied to home server `authentik` (`ak-outpost-802-1x-radius.authentik.svc.cluster.local:1812`, secret `$ENV{DOT1X_OUTPOST_SECRET}`, `response_window = 3`, do not require Message-Authenticator on its rejects). `default` post-auth: if the Cisco-AV-Pair fallback is in use, translate `dot1x-vlan=<id>` into `Tunnel-Type := VLAN`, `Tunnel-Medium-Type := IEEE-802`, `Tunnel-Private-Group-Id := <id>`; reject an Accept that carries no VLAN. Accounting: answer locally, `detail`-free.
- [ ] **Step 2: Deployment** — image `freeradius/freeradius-server:3.2.10-alpine # {"$imagepolicy": "flux-system:freeradius"}`, args `-f -l stdout`, 2 replicas, `podAntiAffinity` required on `kubernetes.io/hostname`, env from the three secret keys (`secretKeyRef`, not `envFrom`), mounts ConfigMap + `radius-tls`, readiness/liveness via `radclient` Status-Server on 127.0.0.1 or a TCP-less exec check, requests `cpu 20m / memory 64Mi`, limit `memory 256Mi`, `stakater.com/auto: "true"` like other apps. Service `radius-lb` per Global Constraints, ports 1812/1813 UDP, annotation `external-dns.kubernetes.io/hostname: radius.renner.uno`.
- [ ] **Step 3: CNPs** — ingress to FreeRADIUS pods from `10.2.10.0/24` and `10.2.50.0/24` UDP 1812/1813; ingress to pods `goauthentik.io/outpost-name: 802-1x-radius` UDP 1812 only from FreeRADIUS pods (model on `ldap-outpost-cnp.yaml`, use container ports).
- [ ] **Step 4: Image automation** — `ImageRepository freeradius` (`docker.io/freeradius/freeradius-server`), `ImagePolicy freeradius` with `filterTags.pattern: '^(?P<v>3\.2\.\d+)-alpine$'`, `extract: '$v'`, `semver.range: "3.2.x"`.
- [ ] **Step 5:** `kubectl kustomize kubernetes/apps/authentik >/dev/null` and `kubectl apply --dry-run=server` on the new files → no errors.
- [ ] **Step 6:** After merge + reconcile: `kubectl -n authentik get deploy freeradius -o json | jq .status.readyReplicas` → `2`; `kubectl -n authentik get svc radius-lb -o json | jq -r '.status.loadBalancer.ingress[].ip'` → `10.2.99.91` and `fd99::91`.
- [ ] **Step 7: End-to-end from the workstation** — `eapol_test` config (TTLS, phase2 `auth=PAP`, `ca_cert` system bundle, `domain_suffix_match="radius.renner.uno"`, identity `dot1x-test`) run as `DOT1X_TEST_SECRET=… eapol_test -c ttls.conf -a 10.2.99.91 -s "$DOT1X_TEST_SECRET"` with the password supplied from an env var into the config file in the scratchpad (file deleted afterwards) → `SUCCESS`, `Tunnel-Private-Group-Id` `50` in the output; same with wrong password → `FAILURE` in < 1 s; user without group → `FAILURE`.
- [ ] **Step 8: Redundancy check without touching live pods** — confirm both FreeRADIUS replicas and both outpost replicas serve: `kubectl -n authentik get pods -l app.kubernetes.io/name=freeradius -o wide` shows two different nodes; loop `eapol_test` 20× → 20× `SUCCESS` and both pods log requests (`kubectl logs`). No rollout restart or pod deletion (house rule).
- [ ] **Step 9: Alert** — add rules to `kubernetes/apps/monitoring-rules/` (follow the existing rule files there): `kube_deployment_status_replicas_available{namespace="authentik",deployment=~"freeradius|ak-outpost-802-1x-radius"} < 1` for 2m → critical; `< 2` for 15m → warning. Verify `kubectl kustomize kubernetes/apps/monitoring-rules >/dev/null`.
- [ ] **Step 10:** Commit `feat(radius): FreeRADIUS EAP-TTLS frontend for authentik`.
### Task 4: UniFi RADIUS profile, test WLAN, firewall policy
**Files:**
- Modify: `docs/radius/dot1x.md` (record created objects, IDs, settings)
**Interfaces:**
- Consumes: `radius.renner.uno`/`10.2.99.91`, `DOT1X_NAS_SECRET`.
- Produces: UniFi RADIUS profile "Cluster 802.1X" (auth `10.2.99.91:1812`, acct `10.2.99.91:1813`, VLAN assignment enabled), WLAN "Renner Test", firewall policy "Allow APs RADIUS".
- [ ] **Step 1:** POST firewall policy (clone of "Allow APs syslog to Loki": source zone Isolated IP `10.2.10.0/24`, destination zone External IP `10.2.99.91`, UDP, ports `1812-1813`, ALLOW) via `/proxy/network/v2/api/site/default/firewall-policies`; verify it is listed.
- [ ] **Step 2:** Create RADIUS profile via `/proxy/network/api/s/default/rest/radiusprofile` (secret passed from env into a JSON file in the scratchpad, never on the command line); verify listed.
- [ ] **Step 3:** Create WLAN "Renner Test" via `/rest/wlanconf` cloned from "Renner UniFi" settings (security `wpaeap`, `wpa3_support`/`wpa3_transition` as main, `fast_roaming_enabled: true`, `bss_transition: true`, same `ap_group_ids`, `networkconf_id` (90) Guest as fallback) with the new `radiusprofile_id`; verify all APs broadcast it (`stat/device` vap_table essid).
- [ ] **Step 4:** Owner joins one Android device; verify in Loki stahtd `success` with `radius_auth_delta` and `vlan 50` in `stat/sta`.
- [ ] **Step 5:** Commit `docs(radius): document UniFi objects for the 802.1X test`.
### Task 5: Client configuration aids and measurement
**Files:**
- Create: `docs/radius/clients/renner-test.mobileconfig` (no password; EAP-TTLS, `TTLSInnerAuthentication: PAP`, `TLSTrustedServerNames: [radius.renner.uno]`)
- Modify: `docs/radius/dot1x.md` (Android/Linux/Windows steps; LogQL for `radius_auth_delta` per SSID; result table)
- [ ] **Step 1:** Write the profile; validate `plutil -lint`-equivalent: `xmllint --noout docs/radius/clients/renner-test.mobileconfig` → no output.
- [ ] **Step 2:** Document the comparison query (stahtd events, `radius_auth_delta`, grouped by VAP of "Renner Test" vs. "Renner UniFi") and run it once the owner's devices have joined; record avg/max.
- [ ] **Step 3:** Commit `docs(radius): client setup and measurement for the 802.1X test`.
### Task 6: PR
- [ ] **Step 1:** Push branch with claude-bot credentials; open PR via `tea pr create --login claude-bot` (description = the change; no session status).
- [ ] **Step 2:** Verify with `tea pr list --login claude-bot --state open`.
@@ -0,0 +1,228 @@
# 802.1X via authentik: in-cluster RADIUS (first test on a test SSID)
Date: 2026-11-03
## Intent
Full 802.1X logins on the main WLAN "Renner UniFi" spend 1.3–6 s in RADIUS
(avg ~2 s, every login >1 s; 0.23–0.43 s per RADIUS round trip). The RADIUS
server is the UDM's built-in FreeRADIUS, which runs Ubiquiti's
`/usr/sbin/pyauth_log.py` in authorize/authenticate/post-auth and spawns
`mca-ctrl` synchronously for every RADIUS packet. Every failed fast roam,
every arrival at home and every band change pays that cost. The owner has
been unhappy with the UDM RADIUS performance for a while.
This project stands up an in-cluster RADIUS whose password check and group
membership come from authentik, and puts it behind a **separate test SSID**.
The owner moves some of his own devices to it. If RADIUS time per login is
significantly better, a solution for the rest of the household follows in a
later project.
## Goals
- In-cluster RADIUS for **802.1X in general** (WiFi and wired switch ports;
owner: "802.1x ist das was relevant ist") that authenticates users with
their **authentik username and password** (central credentials; a password
change in authentik applies immediately).
- Password verification **by authentik itself** via the authentik RADIUS
outpost (owner decision).
- **VLAN per user from authentik groups.**
- Must work with **every client OS** (Android, iOS/iPadOS, macOS, Windows,
Linux).
- Client setup as automatic as possible; no client certificates, no private
CA. A device configured once should carry the credentials to new devices
through platform sync where the platform does so.
- A test SSID that points only at the new RADIUS; measurable result.
## Non-goals
- Migrating "Renner UniFi" or the UniFi Endpoint-provisioned accounts. The main
WLAN keeps using the UDM RADIUS unchanged.
- VPN authentication (OpenVPN "Renner SSL VPN" also uses the UDM RADIUS
profile; out of scope).
- Enabling 802.1X on switch ports in this project (no port has it today); the
server must be ready for it (switches as RADIUS clients); the first test is
the test SSID.
- An onboarding page that hands out Apple/Windows profiles — owner: leave it
out for the test (likely follow-up).
- RADIUS accounting (authentik supports authentication requests only).
- MFA for 802.1X.
## Anti-goals (a failure even if it technically works)
- Anything that changes behaviour of the existing "Renner UniFi", "Renner IoT"
or "Renner Gast" WLANs or breaks the UniFi Endpoint app.
- Client certificates (EAP-TLS) — owner: without central device management
unacceptable fiddling.
- NT hashes (PEAP-MSCHAPv2 via `sambaNTPassword` from authentik-samba-bridge)
— rejected by the owner: weak password-equivalent MD4 hash stored and
exposed, and dependency on the samba-bridge fork.
- A private CA that every client has to trust.
- Copying Ubiquiti's RADIUS certificate/private key off the UDM.
## Constraints and facts
- authentik RADIUS provider (outpost image `ghcr.io/goauthentik/radius:2026.8.4`)
supports **PAP** and **EAP-TLS** only (no PEAP/MSCHAPv2, no EAP-TTLS);
custom reply attributes via RADIUS property mappings (since 2024.8);
authentication requests only (docs.goauthentik.io, RADIUS provider). It
speaks plain RADIUS (no RadSec).
- APs/switches speak EAP, so an EAP server must terminate the outer method:
FreeRADIUS terminates **EAP-TTLS** and forwards the **inner PAP** request to
the authentik RADIUS outpost.
- Client OS support for EAP-TTLS/PAP (vendor docs: IronWiFi, JumpCloud,
Fortinet, Meraki; no Apple statement found):
- Android, Linux: normal join dialog (EAP method TTLS, phase 2 PAP,
identity/password, CA "system certificates", domain).
- Windows 11: no file needed, but via "add network manually": EAP-TTLS,
"Unencrypted password (PAP)" (Control Panel route if Settings lacks it).
- iOS/iPadOS/macOS: requires a configuration profile (inner auth PAP is
only selectable in a profile). For the test the profile is built and
installed by hand.
- authentik does not support EAP (maintainer, goauthentik/authentik#15498);
MSCHAPv2/PEAP will not be implemented (reversible password storage);
EAP-TLS needs the MTLS stage (enterprise-licensed per community project
wus-technik/authentik-freeradius). FreeRADIUS in front of the outpost is
the established pattern. Owner decision (2026-11-03): **FreeRADIUS as its
own service** — the alternatives (FreeRADIUS as sidecar in the outpost pod,
keeping the UDM RADIUS, another IdP) were rejected.
- Known outpost issues with `Message-Authenticator` (goauthentik/authentik
#17901, #22931: packets dropped as "invalid message authenticator"). The
builder must test the FreeRADIUS → outpost hop with Message-Authenticator
early and document the working combination.
- **The FreeRADIUS → authentik outpost hop is plain RADIUS**: the password is
only obfuscated with the shared secret (RFC 2865 MD5 hiding). Cilium has no
transparent encryption enabled (no WireGuard/IPsec in `cilium-config`).
The owner chose this path over LDAPS bind (2026-11-03). Mitigations below.
- Existing objects: RADIUS provider "Unifi Radius" (exists in the authentik
DB, only referenced by `!Find` in
`kubernetes/apps/authentik/blueprints-configmap.yaml`) and outpost
"Local Radius" (`ak-outpost-local-radius`, ClusterIP, 1 replica, namespace
`authentik`). What currently uses "Unifi Radius" is unknown; it must not
break.
- authentik users and groups are created by the owner in authentik, not in
blueprints. Designs read a convention.
- APs (and switches) send RADIUS from the management network VLAN 10
(`10.2.10.0/24`). VLAN 10 is dual-stack, `10.2.10.0/24` and
`fd02:10::/64` (verified live: all APs/switches have fd02:10:: addresses).
- LB services: pool `bgp-pool` (10.2.99.0/24, fd99::/64, label `lb-pool: bgp`,
`kubernetes/infrastructure/post-crds/cilium-bgp.yaml`), pinned IPs with the
same last byte in both families (`lbipam.cilium.io/ips`),
`externalTrafficPolicy: Local` (preserves the AP source IP),
`ipFamilyPolicy: PreferDualStack`. Live in use: .81–.90.
- UDM firewall: the LB range 10.2.99.0/24 is in the UDM "External" zone; the
existing policy "Allow APs syslog to Loki" (Isolated 10.2.10.0/24 → External
10.2.99.84 UDP 5514) is the template. Firewall policies and WLANs are not in
OpenTofu today (UI/API only; the UDM API key is available to the builder).
- House patterns: cert-manager `Certificate` with ClusterIssuer
`letsencrypt-cluster` (Cloudflare DNS-01), as `ldap-outpost-cert.yaml`;
secrets via External Secrets Operator from OpenBao (`ClusterSecretStore
openbao`, key `apps/<app>`, Reloader restarts pods); DNS via
`external-dns.kubernetes.io/hostname` on the Service (UDM split-horizon);
additive CNP for LAN ingress like `ldap-outpost-cnp.yaml`; namespace
baseline `infrastructure/network-policies/authentik-ns.yaml`.
## Design (decided)
### Authentication path
```
client --EAP-TTLS (outer TLS to FreeRADIUS, cert radius.renner.uno), inner PAP--> AP / switch
AP/switch --RADIUS UDP 1812--> FreeRADIUS (in cluster, LB 10.2.99.91)
FreeRADIUS (inner tunnel) --RADIUS PAP--> authentik RADIUS outpost (in cluster)
<-- Access-Accept + VLAN attributes (authentik property mapping) --
FreeRADIUS --Access-Accept + Tunnel attrs (copied from inner reply)--> AP/switch
```
- Owner decision: username + **authentik password** (central credentials).
- Owner decision: password check **directly by the authentik RADIUS outpost**.
- Owner decision: **separate test SSID**; test devices are the owner's own,
across all OSes, and must keep working normally.
- Owner decision: client certificates and NT hashes are out.
- Success threshold (proposed by me, accepted by the owner): RADIUS time per
full login **< 0.5 s** vs. ~2 s today — a data point for the follow-up
decision, not a hard pass/fail gate.
### Protecting the FreeRADIUS → outpost hop (my calls)
- CNP so the RADIUS outpost accepts RADIUS only from the FreeRADIUS pods.
- Long random shared secret for this hop (OpenBao).
- Require and send `Message-Authenticator` on both hops (BlastRADIUS).
### Server certificate
Public Let's Encrypt certificate for `radius.renner.uno` via cert-manager
(renewed automatically). Android/Linux validate with system certificates +
domain `radius.renner.uno`; Apple via the profile (CA/server name in the
profile); Windows shows the certificate on first join. No private CA.
(My call on the hostname.) The builder verifies and documents what clients do
after a certificate renewal (Windows may prompt again).
### VLAN from groups (convention, my call)
A group that grants 802.1X access (WiFi and wired) carries the group
attribute `dot1x_vlan` (integer VLAN ID, e.g. `50`). An authentik RADIUS
property mapping returns `Tunnel-Type = VLAN (13)`,
`Tunnel-Medium-Type = IEEE-802 (6)`, `Tunnel-Private-Group-Id = <dot1x_vlan>`.
A user without any group carrying `dot1x_vlan` is rejected. If a user has
several, the builder picks a deterministic rule and documents it. The owner
creates the groups and memberships in authentik; for the test the owner's
group maps to VLAN 50 ((50) Privat), like his current UDM account.
### authentik side (my calls)
- A dedicated RADIUS provider for 802.1X (blueprint-managed), with the VLAN
property mapping and a non-interactive authentication flow, attached to an
outpost with **2 replicas**. Whether that is the existing "Local Radius"
outpost or a new one is the builder's call, after finding out what uses
"Unifi Radius" today; nothing that already uses it may break.
- The provider's allowed client network: the FreeRADIUS pods only.
### Test SSID (my calls)
- Name: "Renner Test".
- WPA2/WPA3-Enterprise like "Renner UniFi", Fast Roaming on, BSS transition on,
same bands. Fallback network for unassigned clients: same as "Renner UniFi"
((90) Guest).
- RADIUS profile with **only** the cluster RADIUS (no UDM fallback), so the
measurement shows the real performance.
### Placement and exposure (my calls)
- FreeRADIUS in namespace `authentik`, files under `kubernetes/apps/authentik/`
(no new Flux Kustomization; egress to the outpost is already allowed by the
namespace baseline). **2 replicas on different nodes.**
- LB Service `radius-lb`, IPs `10.2.99.91,fd99::91`, UDP 1812 (+1813 so APs
don't wait on accounting), `external-dns` hostname `radius.renner.uno`.
- Additive CNP: ingress from `10.2.10.0/24` to the FreeRADIUS pods, UDP
1812/1813.
- RADIUS clients in FreeRADIUS: APs and switches (`10.2.10.0/24`).
- Secrets (AP/switch↔FreeRADIUS, FreeRADIUS↔outpost) in OpenBao `apps/radius`.
- UDM firewall policy "Allow APs RADIUS": Isolated `10.2.10.0/24` → External
`10.2.99.91` UDP 1812/1813, modelled on "Allow APs syslog to Loki".
- Accounting: FreeRADIUS accepts and discards.
### Measurement and test
- stahtd `STA_ASSOC_TRACKER` events in Loki (`{source="unifi-syslog",
app="stahtd"}`) carry `radius_auth_delta` (µs) per full login, end to end as
seen by the AP (including the authentik check). Compare the test SSID's VAPs
against "Renner UniFi" over the test period.
- Test matrix: the owner's devices — Android, iOS/iPadOS, macOS (with
hand-made profile), Windows, Linux — join "Renner Test"; each must connect,
land in VLAN 50, survive fast roaming between APs, and keep working
normally.
## Left to the builder
- FreeRADIUS image/version and configuration details (EAP-TTLS module,
inner-tunnel proxying of PAP to the outpost, copying inner reply
attributes / `use_tunneled_reply`, Message-Authenticator settings, TLS
settings), resource requests/limits.
- The authentik flow used for RADIUS password checks (must not require
interactive stages) and the exact property-mapping code.
- How the UniFi RADIUS profile, test WLAN and firewall policy are created
(UniFi API vs. OpenTofu); if not IaC, document them.
- The Apple profile for the test (`.mobileconfig` with TTLS/PAP, server name
`radius.renner.uno`), and the Windows manual steps.
- Verification steps and monitoring (metrics, alert on RADIUS unreachable).
+7
View File
@@ -22,6 +22,13 @@ Two rules, one per family, send every packet with a pod source to table 47
(`talos/patches/site-homelab.yaml`, priorities 1000/1001, below main). The
table decides by longest prefix; no rule carves out exceptions.
A reply from a LoadBalancer VIP is pod traffic as well, but it reaches the
routing decision with the VIP as its source. Two more rules (1002/1003) send
the LoadBalancer pool to table 47, so the reply leaves over `ingress0`, where
the request arrived as the BGP next hop. Without them main sent it out
`mgmt0`. The pool is a site fact: the site patch's `lb-pool-site` ConfigMap,
which `bgp-pool` takes by Flux substitution, and the rules beside it.
bird (`kubernetes/infrastructure/bird/`) owns table 47 and fills it with:
- the node's attached networks — `mgmt0`, `ingress0`, `pod0`, `node0` —
@@ -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.23.0@sha256:11e64e28c5f58c8d93ee95fcf22ac055f48b0bc440e0ce7d8013bbb5ad6aa8ac
image: curlimages/curl:8.23.0@sha256:3fb7e13e5d179a845c97d78204f3f1064d1907037f26a3ebf91710571151fd93
command:
- sh
- -c
@@ -1447,3 +1447,180 @@ data:
stage: !KeyOf sso-passkey-validation
order: 30
state: present
# 802.1X: FreeRADIUS terminates EAP-TTLS and proxies inner PAP to the
# "802.1X Radius" outpost. VLAN from group attribute dot1x_vlan; see
# docs/radius/dot1x.md.
dot1x-radius.yaml: |
version: 1
metadata:
name: 802.1X RADIUS (homelab)
entries:
# Non-interactive: identification, password, login. No MFA.
- id: dot1x-flow
model: authentik_flows.flow
identifiers:
slug: dot1x-authentication-flow
state: present
attrs:
name: 802.1X authentication
title: "802.1X"
designation: authentication
- id: dot1x-identification
model: authentik_stages_identification.identificationstage
identifiers:
name: dot1x-identification
state: present
attrs:
user_fields:
- username
- email
- id: dot1x-password
model: authentik_stages_password.passwordstage
identifiers:
name: dot1x-password
state: present
attrs:
backends:
- authentik.core.auth.InbuiltBackend
- id: dot1x-login
model: authentik_stages_user_login.userloginstage
identifiers:
name: dot1x-login
state: present
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf dot1x-flow
stage: !KeyOf dot1x-identification
order: 10
state: present
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf dot1x-flow
stage: !KeyOf dot1x-password
order: 20
state: present
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf dot1x-flow
stage: !KeyOf dot1x-login
order: 100
state: present
- id: dot1x-has-vlan
model: authentik_policies_expression.expressionpolicy
identifiers:
name: dot1x-has-vlan
state: present
attrs:
expression: |
for g in request.user.all_groups():
v = g.attributes.get("dot1x_vlan")
if type(v) is int and 1 <= v <= 4094:
return True
return False
# Lowest dot1x_vlan wins. Tunnel-* (RFC 3580) set by raw code: the
# bundled dictionary lacks them. Cisco-AV-Pair is the fallback.
- id: dot1x-vlan-mapping
model: authentik_providers_radius.radiusproviderpropertymapping
identifiers:
name: dot1x-vlan
state: present
attrs:
expression: |
vlans = [g.attributes.get("dot1x_vlan") for g in request.user.all_groups()]
vlans = [v for v in vlans if type(v) is int and 1 <= v <= 4094]
if not vlans:
return packet
vlan = str(min(vlans))
try:
packet[64] = [b"\x00\x00\x00\x0d"] # Tunnel-Type = VLAN
packet[65] = [b"\x00\x00\x00\x06"] # Tunnel-Medium-Type = IEEE-802
packet[81] = [vlan.encode()] # Tunnel-Private-Group-Id
except Exception:
pass
define_attribute(
vendor_code=9,
vendor_name="Cisco",
attribute_name="AV-Pair",
attribute_code=1,
attribute_type="string",
)
packet["Cisco-AV-Pair"] = f"dot1x-vlan={vlan}"
return packet
# client_networks: cluster pod CIDRs (kube-controller-manager
# --cluster-cidr, talos/patches/cluster.yaml podSubnets).
- id: dot1x-provider
model: authentik_providers_radius.radiusprovider
identifiers:
name: 802.1X Radius
state: present
attrs:
authorization_flow: !KeyOf dot1x-flow
invalidation_flow: !Find [authentik_flows.flow, [slug, default-invalidation-flow]]
shared_secret: !Env DOT1X_OUTPOST_SECRET
client_networks: "10.244.0.0/16,fd10:244::/48"
mfa_support: false
certificate: null
property_mappings:
- !KeyOf dot1x-vlan-mapping
- id: dot1x-application
model: authentik_core.application
identifiers:
slug: dot1x
state: present
attrs:
name: 802.1X network access
slug: dot1x
provider: !KeyOf dot1x-provider
meta_description: "WiFi and wired 802.1X via FreeRADIUS"
- model: authentik_policies.policybinding
identifiers:
target: !KeyOf dot1x-application
policy: !KeyOf dot1x-has-vlan
order: 0
state: present
attrs:
enabled: true
negate: false
failure_result: false
- model: authentik_outposts.outpost
identifiers:
name: 802.1X Radius
state: present
attrs:
type: radius
service_connection: !Find [authentik_outposts.kubernetesserviceconnection, [name, "Local Kubernetes Cluster"]]
providers:
- !KeyOf dot1x-provider
config:
log_level: info
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
authentik_host_insecure: false
authentik_host_browser: "https://auth.renner.uno/"
refresh_interval: "minutes=5"
container_image: null
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 2
kubernetes_service_type: ClusterIP
kubernetes_disabled_components: []
kubernetes_image_pull_secrets: []
kubernetes_ingress_annotations: {}
kubernetes_ingress_class_name: null
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_ingress_path_type: null
kubernetes_httproute_annotations: {}
kubernetes_httproute_parent_refs: []
kubernetes_json_patches: null
+4
View File
@@ -73,6 +73,10 @@ spec:
# on an unclean shutdown is not an acceptable trade.
checkpoint_timeout: "15min"
max_wal_size: "2GB"
# authentik's subclass joins over empty tables get planner costs far above
# jit_above_cost; LLVM compilation then takes ~420 ms for a query that
# executes in 0.1 ms (every flow start, every RADIUS/LDAP login).
jit: "off"
# zstd, not "on": the boolean spelling resolves to pglz (2016-era, weaker
# and slower than the lz4/zstd this build also accepts). Compresses the
# full-page images that dominate WAL volume after each checkpoint.
@@ -0,0 +1,65 @@
# RADIUS clients reach FreeRADIUS through radius-lb (externalTrafficPolicy
# Local keeps their source IP): APs 10.2.10.0/24 + fd02:10::/64, test
# clients 10.2.50.0/24 + fd02:50::/64 (test phase only).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: freeradius-nas-ingress
namespace: authentik
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: freeradius
ingress:
- fromCIDR:
- 10.2.10.0/24
- fd02:10::/64
- 10.2.50.0/24
- fd02:50::/64
toPorts:
- ports:
- port: "1812"
protocol: UDP
- port: "1813"
protocol: UDP
---
# FreeRADIUS proxies UniFi Endpoint logins to the UDM RADIUS on VLAN 47.
# 10.2.47.1 is also the UDM BGP peer in
# kubernetes/infrastructure/post-crds/cilium-bgp.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: freeradius-udm-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: freeradius
egress:
- toCIDR:
- 10.2.47.1/32
toPorts:
- ports:
- port: "1812"
protocol: UDP
---
# FreeRADIUS proxies inner PAP to the 802.1X outpost on its container port.
# Additive: default-deny-ingress already admits the whole namespace.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: dot1x-outpost-freeradius-ingress
namespace: authentik
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: authentik-outpost-radius
goauthentik.io/outpost-name: 8021x-radius
ingress:
- fromEndpoints:
- matchLabels:
app.kubernetes.io/name: freeradius
toPorts:
- ports:
- port: "1812"
protocol: UDP
@@ -0,0 +1,397 @@
# FreeRADIUS 3.2 config for 802.1X: outer identity anonymous[@...] -> EAP-TTLS
# terminated here, inner PAP proxied to the authentik outpost
# ak-outpost-8021x-radius; any other outer identity -> whole EAP conversation
# proxied to the UDM RADIUS (UniFi Endpoint). Replaces the image's raddb
# completely. Functional test: tests/radius/run.sh.
# VLAN contract: docs/radius/dot1x.md.
apiVersion: v1
kind: ConfigMap
metadata:
name: freeradius-config
namespace: authentik
annotations:
# FreeRADIUS uses ${...} itself; Flux postBuild substitution must skip it.
kustomize.toolkit.fluxcd.io/substitute: disabled
data:
radiusd.conf: |
prefix = /opt
exec_prefix = ${prefix}
sbindir = ${exec_prefix}/sbin
libdir = ${exec_prefix}/lib
raddbdir = /run/raddb/raddb
confdir = ${raddbdir}
modconfdir = ${confdir}/mods-config
certdir = ${confdir}/certs
cadir = ${confdir}/certs
db_dir = ${raddbdir}
# only writable path in the pod
localstatedir = /tmp
logdir = /tmp
radacctdir = /tmp
run_dir = /tmp
name = radiusd
pidfile = ${run_dir}/${name}.pid
max_request_time = 30
cleanup_delay = 5
# Greater than every home server's response_window (proxy.conf): never
# forward a NAS retransmit to a home server. A TTLS inner request sent
# twice to the home server leaves a freed request in the proxy hash;
# radiusd segfaults.
proxy_dedup_window = 10
max_requests = 16384
hostname_lookups = no
log {
destination = stdout
colourise = no
stripped_names = no
auth = yes
auth_badpass = no
auth_goodpass = no
}
checkrad = ${sbindir}/checkrad
security {
allow_core_dumps = no
max_attributes = 200
# Rejects decided here wait 0.5 s; proxied rejects are not delayed.
reject_delay = 0.5
status_server = yes
require_message_authenticator = yes
limit_proxy_state = yes
}
proxy_requests = yes
$INCLUDE proxy.conf
$INCLUDE clients.conf
thread pool {
start_servers = 2
max_servers = 16
min_spare_servers = 2
max_spare_servers = 4
max_requests_per_server = 0
}
modules {
$INCLUDE mods-enabled/
}
instantiate {
}
policy {
}
$INCLUDE sites-enabled/
# Site-local dictionary; the image's dictionaries under /opt/share/freeradius
# are loaded first.
dictionary: |
# no local attributes
clients.conf: |
client aps {
ipaddr = 10.2.10.0/24
secret = $ENV{DOT1X_NAS_SECRET}
require_message_authenticator = yes
}
client aps6 {
ipv6addr = fd02:10::/64
secret = $ENV{DOT1X_NAS_SECRET}
require_message_authenticator = yes
}
# test, test6: test phase only (docs/radius/dot1x.md).
client test {
ipaddr = 10.2.50.0/24
secret = $ENV{DOT1X_TEST_SECRET}
require_message_authenticator = yes
}
client test6 {
ipv6addr = fd02:50::/64
secret = $ENV{DOT1X_TEST_SECRET}
require_message_authenticator = yes
}
proxy.conf: |
proxy server {
default_fallback = no
}
home_server authentik {
type = auth
ipaddr = ak-outpost-8021x-radius.authentik.svc.cluster.local
port = 1812
proto = udp
secret = $ENV{DOT1X_OUTPOST_SECRET}
# The outpost sends Message-Authenticator on Access-Accept only.
require_message_authenticator = no
# Less than proxy_dedup_window (radiusd.conf).
response_window = 5
zombie_period = 20
# The outpost does not answer Status-Server. Probe with an
# Access-Request for a user that does not exist in authentik; the
# reject counts as an answer. Sent only while zombie or dead.
status_check = request
username = "dot1x-status-probe"
password = "not-a-secret"
check_interval = 6
check_timeout = 4
num_answers_to_alive = 3
}
home_server_pool authentik {
type = fail-over
home_server = authentik
}
realm authentik {
auth_pool = authentik
nostrip
}
# UDM RADIUS (FreeRADIUS) for UniFi Endpoint accounts; gets the whole
# outer EAP conversation. Address: UDM on VLAN 47, also the BGP peer in
# kubernetes/infrastructure/post-crds/cilium-bgp.yaml.
home_server udm {
type = auth
ipaddr = 10.2.47.1
port = 1812
proto = udp
secret = $ENV{UDM_RADIUS_SECRET}
# RFC 3579: every reply to an EAP request carries Message-Authenticator.
require_message_authenticator = yes
# Less than proxy_dedup_window (radiusd.conf).
response_window = 5
zombie_period = 20
# Probe with an Access-Request for a user that cannot exist (no
# EAP-Message, so no Endpoint login); the reject counts as an answer.
# Sent only while zombie or dead.
status_check = request
username = "udm-status-probe.invalid"
password = "not-a-secret"
check_interval = 6
check_timeout = 4
num_answers_to_alive = 3
}
home_server_pool udm {
type = fail-over
home_server = udm
}
realm udm {
auth_pool = udm
nostrip
}
mods-enabled_always: |
always ok {
rcode = ok
}
always reject {
rcode = reject
}
always noop {
rcode = noop
}
mods-enabled_eap: |
eap {
default_eap_type = ttls
timer_expire = 60
ignore_unknown_eap_types = no
max_sessions = ${max_requests}
tls-config tls-common {
certificate_file = ${certdir}/radius-tls/tls.crt
private_key_file = ${certdir}/radius-tls/tls.key
# tls.crt from cert-manager carries leaf + intermediate.
auto_chain = yes
tls_min_version = "1.2"
tls_max_version = "1.2"
cipher_list = "DEFAULT"
ecdh_curve = ""
cache {
enable = no
}
}
ttls {
tls = tls-common
copy_request_to_tunnel = no
use_tunneled_reply = yes
virtual_server = inner-tunnel
}
}
sites-enabled_default: |
server default {
listen {
type = auth
ipaddr = *
port = 1812
}
listen {
type = acct
ipaddr = *
port = 1813
}
listen {
type = auth
ipv6addr = ::
port = 1812
}
listen {
type = acct
ipv6addr = ::
port = 1813
}
authorize {
# EAP only.
if (!&EAP-Message) {
reject
}
# Outer identity anonymous[@realm]: authentik clients, EAP-TTLS here.
# Any other: UniFi Endpoint account (real name as outer identity);
# every round goes to the UDM unprocessed.
if (&User-Name && &User-Name !~ /^anonymous(@.*)?$/i) {
update control {
&Proxy-To-Realm := udm
}
return
}
eap {
ok = return
updated = return
}
Autz-Type Status-Server {
ok
}
}
authenticate {
Auth-Type EAP {
eap
}
}
# Answered here; the outpost has no accounting port.
preacct {
}
accounting {
ok
}
# UDM replies pass unchanged.
post-auth {
if (&control:Proxy-To-Realm == "udm") {
noop
}
else {
update reply {
&Cisco-AVPair !* ANY
}
}
Post-Auth-Type REJECT {
if (&control:Proxy-To-Realm == "udm") {
noop
}
else {
eap
}
}
}
pre-proxy {
}
# Runs the TTLS callback that hands the outpost reply back into the tunnel.
post-proxy {
if (&control:Proxy-To-Realm == "udm") {
noop
}
else {
eap
}
}
}
sites-enabled_inner-tunnel: |
server inner-tunnel {
authorize {
# PAP only.
if (!&User-Password) {
reject
}
update control {
&Proxy-To-Realm := authentik
}
}
authenticate {
}
# Runs on the outpost's Access-Accept. use_tunneled_reply copies this
# reply into the outer Access-Accept.
post-auth {
update reply {
&Message-Authenticator !* ANY
&Proxy-State !* ANY
}
if (!&reply:Tunnel-Private-Group-Id && &reply:Cisco-AVPair && &reply:Cisco-AVPair =~ /^dot1x-vlan=([0-9]+)$/) {
update reply {
&Tunnel-Type := VLAN
&Tunnel-Medium-Type := IEEE-802
&Tunnel-Private-Group-Id := "%{1}"
}
}
update reply {
&Cisco-AVPair !* ANY
}
# No VLAN would put the client in the WLAN's fallback network.
if (!&reply:Tunnel-Private-Group-Id) {
reject
}
}
pre-proxy {
}
post-proxy {
}
}
# Status-Server only, loopback only; used by the probes.
sites-enabled_status: |
server status {
listen {
type = status
ipaddr = 127.0.0.1
port = 18121
}
client probe {
ipaddr = 127.0.0.1
secret = status-probe
}
authorize {
ok
Autz-Type Status-Server {
ok
}
}
}
probe_status: |
Message-Authenticator = 0x00
+232
View File
@@ -0,0 +1,232 @@
# 802.1X RADIUS front end (radius.renner.uno). Config: freeradius-config.yaml.
apiVersion: apps/v1
kind: Deployment
metadata:
name: freeradius
namespace: authentik
labels:
app.kubernetes.io/name: freeradius
annotations:
configmap.reloader.stakater.com/reload: freeradius-config
secret.reloader.stakater.com/reload: radius-secrets,radius-tls
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: freeradius
template:
metadata:
labels:
app.kubernetes.io/name: freeradius
spec:
enableServiceLinks: false
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app.kubernetes.io/name: freeradius
topologyKey: kubernetes.io/hostname
# The image has no radius user and its own raddb is root-only; the
# mounted raddb replaces it.
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
fsGroup: 65534
seccompProfile:
type: RuntimeDefault
# FreeRADIUS refuses a config dir that is world-writable; kubelet's
# volume root is, so the config is copied to a dir the pod owns.
initContainers:
- name: raddb-copy
image: docker.io/freeradius/freeradius-server:3.2.10-alpine # {"$imagepolicy": "flux-system:freeradius"}
command:
- /bin/sh
- -ec
args:
- mkdir -p /run/raddb/raddb && cp -rL /etc/raddb-src/. /run/raddb/raddb/ && chmod 0750 /run/raddb/raddb
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 64Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: raddb
mountPath: /etc/raddb-src
readOnly: true
- name: raddb-run
mountPath: /run/raddb
containers:
- name: freeradius
image: docker.io/freeradius/freeradius-server:3.2.10-alpine # {"$imagepolicy": "flux-system:freeradius"}
args:
- -f
- -l
- stdout
- -d
- /run/raddb/raddb
env:
- name: DOT1X_NAS_SECRET
valueFrom:
secretKeyRef:
name: radius-secrets
key: DOT1X_NAS_SECRET
- name: DOT1X_TEST_SECRET
valueFrom:
secretKeyRef:
name: radius-secrets
key: DOT1X_TEST_SECRET
- name: DOT1X_OUTPOST_SECRET
valueFrom:
secretKeyRef:
name: radius-secrets
key: DOT1X_OUTPOST_SECRET
- name: UDM_RADIUS_SECRET
valueFrom:
secretKeyRef:
name: radius-secrets
key: UDM_RADIUS_SECRET
ports:
- name: radius
containerPort: 1812
protocol: UDP
- name: radius-acct
containerPort: 1813
protocol: UDP
readinessProbe:
exec:
command:
- /opt/bin/radclient
- -d
- /run/raddb/raddb
- -q
- -r
- "1"
- -t
- "2"
- -f
- /run/raddb/raddb/probe/status
- 127.0.0.1:18121
- status
- status-probe
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
exec:
command:
- /opt/bin/radclient
- -d
- /run/raddb/raddb
- -q
- -r
- "1"
- -t
- "2"
- -f
- /run/raddb/raddb/probe/status
- 127.0.0.1:18121
- status
- status-probe
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
resources:
requests:
cpu: 20m
memory: 64Mi
limits:
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: raddb-run
mountPath: /run/raddb
readOnly: true
- name: tmp
mountPath: /tmp
volumes:
- name: raddb
projected:
defaultMode: 0440
sources:
- configMap:
name: freeradius-config
items:
- key: radiusd.conf
path: radiusd.conf
- key: dictionary
path: dictionary
- key: clients.conf
path: clients.conf
- key: proxy.conf
path: proxy.conf
- key: mods-enabled_always
path: mods-enabled/always
- key: mods-enabled_eap
path: mods-enabled/eap
- key: sites-enabled_default
path: sites-enabled/default
- key: sites-enabled_inner-tunnel
path: sites-enabled/inner-tunnel
- key: sites-enabled_status
path: sites-enabled/status
- key: probe_status
path: probe/status
- secret:
name: radius-tls
items:
- key: tls.crt
path: certs/radius-tls/tls.crt
- key: tls.key
path: certs/radius-tls/tls.key
- name: raddb-run
emptyDir:
medium: Memory
sizeLimit: 4Mi
# pidfile
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 16Mi
---
apiVersion: v1
kind: Service
metadata:
name: radius-lb
namespace: authentik
labels:
lb-pool: bgp
annotations:
lbipam.cilium.io/ips: "10.2.99.91,fd99::91"
# LAN-side DNS only (external-dns-unifi); see ldap-outpost-lb.yaml.
external-dns.kubernetes.io/hostname: radius.renner.uno
spec:
type: LoadBalancer
externalTrafficPolicy: Local
ipFamilyPolicy: PreferDualStack
ipFamilies:
- IPv4
- IPv6
selector:
app.kubernetes.io/name: freeradius
ports:
- name: radius
port: 1812
targetPort: radius
protocol: UDP
- name: radius-acct
port: 1813
targetPort: radius-acct
protocol: UDP
+1 -1
View File
@@ -105,7 +105,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-29-1349 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-1059 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
+21 -3
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.4"
version: "2026.8.5"
sourceRef:
kind: HelmRepository
name: authentik
@@ -69,6 +69,11 @@ spec:
secretKeyRef:
name: ak-pg-app
key: password
# Default 0 opens a new Postgres connection per request.
- name: AUTHENTIK_POSTGRESQL__CONN_MAX_AGE
value: "60"
- name: AUTHENTIK_POSTGRESQL__CONN_HEALTH_CHECKS
value: "true"
- name: AUTHENTIK_EMAIL__PASSWORD
valueFrom:
secretKeyRef:
@@ -80,8 +85,8 @@ spec:
# authentik_crypto.certificatekeypair the LDAP providers attach
# via attrs.certificate. Issued by cert-manager — see
# ldap-outpost-cert.yaml. Both pods get it because the chart's
# global.env propagates to server AND worker; if only the worker
# had it, blueprint apply would crash on startup with KeyError.
# global.env propagates to server AND worker; a pod without the
# variable resolves `!Env` to None/the default (no KeyError).
- name: LDAP_OUTPOST_TLS_CRT
valueFrom:
secretKeyRef:
@@ -164,6 +169,19 @@ spec:
hosts:
- auth.renner.uno
worker:
# Overrides the global key; keep authentik-secrets in the list.
podAnnotations:
secret.reloader.stakater.com/reload: authentik-secrets,radius-secrets
# Shared secret of the 802.1X Radius provider, set via `!Env` in
# dot1x-radius.yaml. Worker only (blueprint applier); optional so a
# missing Secret fails only the dot1x blueprint entry.
env:
- name: DOT1X_OUTPOST_SECRET
valueFrom:
secretKeyRef:
name: radius-secrets
key: DOT1X_OUTPOST_SECRET
optional: true
resources:
requests:
# Same reasoning as server above, and the worker is burstier still:
@@ -10,6 +10,11 @@ resources:
- ldap-outpost-cert.yaml
- ldap-outpost-lb.yaml
- ldap-outpost-cnp.yaml
- radius-cert.yaml
- radius-externalsecret.yaml
- freeradius-config.yaml
- freeradius.yaml
- freeradius-cnp.yaml
# ak-pg CNPG Postgres backend (moved here from the retired seed-authentik KS;
# each carries prune: disabled so ownership transferred without a teardown).
# authentik has no data PVCs — all state lives in ak-pg. This KS depends on
@@ -0,0 +1,15 @@
---
# EAP-TTLS server certificate for the FreeRADIUS service (radius.renner.uno).
# Issued via the cluster-wide Cloudflare DNS-01 ClusterIssuer.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: radius-tls
namespace: authentik
spec:
secretName: radius-tls
issuerRef:
name: letsencrypt-cluster
kind: ClusterIssuer
dnsNames:
- radius.renner.uno
@@ -0,0 +1,15 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: radius-secrets
namespace: authentik
spec:
refreshInterval: 10m
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: radius-secrets
dataFrom:
- extract:
key: apps/radius
+1 -1
View File
@@ -28,7 +28,7 @@ spec:
enableServiceLinks: false
containers:
- name: evcc
image: evcc/evcc:0.318.1@sha256:2f8163ab22f04863afd86cd511c09607ba71debd9eebd65511f3d654922a80d1
image: evcc/evcc:0.318.2@sha256:7ef9bcb15a4fdbfe3bf75d7a0857c40b6272b8783ea833fcbb22f8f134b336c7
# 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.
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
type: RuntimeDefault
containers:
- name: gitea-mcp
image: docker.gitea.com/gitea-mcp-server:1.11.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
image: docker.gitea.com/gitea-mcp-server:1.12.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
# Image has no ENTRYPOINT (binary is in CMD), so args alone would replace it.
command:
- /app/gitea-mcp
@@ -14,6 +14,9 @@ monitor, and its history starts over.
proxies, the Cloudflare-served apex `renner.uno` and third-party sites.
- A monitor alerts only through its `notificationRefs` to cluster-scoped `KumaNotification`
objects (`kubernetes/apps/kuma-sync/notification-*.yaml`). Without refs it sends nothing.
- Parentless monitors hang under the root group `kuma-sync/renner-uno` (controller env
`KUMA_ROOT_GROUP`). Only the root carries `notificationRefs`; don't add `notificationRefs`
to service monitors.
- The CRD lives in `infrastructure/kuma-sync-crds`. Every consuming Flux Kustomization needs
the KumaMonitor `healthCheckExprs` entry with `current: "true"` (see SKILL.md pitfalls).
@@ -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:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
image: alpine/socat:1.8.1.3@sha256:466c8a0383e16463fabdb7df01008292fb78d07da60224a46cb5d3142ce122ed
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:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
image: alpine/socat:1.8.1.3@sha256:466c8a0383e16463fabdb7df01008292fb78d07da60224a46cb5d3142ce122ed
imagePullPolicy: IfNotPresent
command:
- /bin/sh
@@ -46,7 +46,7 @@ spec:
k8s.v1.cni.cncf.io/networks: shelly-manager-iot
# Changing this rolls the pod, which is the only way a change to the
# shelly-manager-iot attachment takes effect.
net.renner.uno/iot-attachment-config: "tuning-no-ra"
net.renner.uno/iot-attachment-config: "tuning-no-ra-no-mtu-probing"
spec:
# eth0 (Cilium-managed pod network) is still the primary — it
# carries the ingress, mosquitto LB return path, etc. The macvlan
@@ -64,7 +64,7 @@ spec:
type: RuntimeDefault
containers:
- name: shelly-manager
image: gitea.renner.uno/konairius/shelly-manager:2026-10-30-0913 # {"$imagepolicy": "flux-system:shelly-manager"}
image: gitea.renner.uno/konairius/shelly-manager:2026-11-02-1412 # {"$imagepolicy": "flux-system:shelly-manager"}
imagePullPolicy: IfNotPresent
ports:
- name: http
@@ -50,6 +50,9 @@ spec:
# to IPv6 clients on v30iot leave via net1, bypassing Cilium's LoadBalancer
# reverse NAT. tuning turns RA processing off on net1; the link-local
# address mDNS uses stays.
#
# tcp_mtu_probing (pod-wide; Cilium sets 1): Shelly flash-write stalls
# trip black-hole detection, which cuts firmware downloads to 48-byte MSS.
config: |
{
"cniVersion": "0.3.1",
@@ -67,7 +70,8 @@ spec:
"type": "tuning",
"sysctl": {
"net.ipv6.conf.IFNAME.accept_ra": "0",
"net.ipv6.conf.IFNAME.autoconf": "0"
"net.ipv6.conf.IFNAME.autoconf": "0",
"net.ipv4.tcp_mtu_probing": "0"
}
},
{
@@ -60,7 +60,7 @@ spec:
# repo's configuration.yaml reads this key, so HA does not start
# without it. Removing this init container takes the recorder down.
- name: render-db-url
image: busybox:1.38@sha256:fd7dc98638c8e305f4dc34e979f1c0fdfdcaeb0fbf8fcff77ae834b6da3d7e6e
image: busybox:1.38@sha256:a46257e06c82ad85242d0d38077d0a52d9896389ca60a1a59d3463db586e5fa4
command:
- sh
- -c
@@ -97,7 +97,7 @@ spec:
# (homeassistant repo packages/matrix_assist.yaml), same filter-then-append
# render as recorder_db_url above. Value is hex, nothing to escape.
- name: render-matrix-password
image: busybox:1.38@sha256:fd7dc98638c8e305f4dc34e979f1c0fdfdcaeb0fbf8fcff77ae834b6da3d7e6e
image: busybox:1.38@sha256:a46257e06c82ad85242d0d38077d0a52d9896389ca60a1a59d3463db586e5fa4
command:
- sh
- -c
@@ -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.143.1@sha256:377b66bec5645510ed52549bb00e648c273edc604d230a3f90fd2b2433afcb97
image: codercom/code-server:4.144.0@sha256:b9e8056bb74bd0c4f90284c7559eddeccd38400cfedc28e64c9d3ca50fef1f83
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:e8ceedc73bd80992ae8d58a7d24c58b8322146c354f1d2e81a97db4452068ec0
image: alpine/git:2.54.0@sha256:ac6c828f15d82775d204d3d75112e1d34af8945da80ccf922713c9891ba68e7a
command: ["/bin/sh", "/scripts/sync.sh"]
env:
- name: SYNC_BRANCH
@@ -36,7 +36,7 @@ spec:
type: RuntimeDefault
containers:
- name: esphome
image: ghcr.io/esphome/esphome:2026.10.0@sha256:1dff19a59070cdb523b76576cdc74d200f862d43c1407809088855920cf57145
image: ghcr.io/esphome/esphome:2026.10.1@sha256:eab8cd6b4f51077c3883af4cedc058280ddc34a94f3b2ffee23e9d2cd5f1db64
imagePullPolicy: IfNotPresent
# Default entrypoint runs the dashboard (Device Builder) against the
# mounted /config — no command override needed.
@@ -223,7 +223,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/konairius/mqtt-account-controller:2026-10-29-1634 # {"$imagepolicy": "flux-system:mqtt-account-controller"}
image: gitea.renner.uno/konairius/mqtt-account-controller:2026-11-02-1058 # {"$imagepolicy": "flux-system:mqtt-account-controller"}
env:
- name: BROKER_URL
value: ssl://mosquitto.home-assistant.svc.cluster.local:8884
@@ -163,7 +163,7 @@ spec:
# Pinned tag. No Flux image-automation marker yet (unlike mosquitto) —
# add an infrastructure/image-automation/zigbee2mqtt.yaml policy as a
# follow-up if desired; pinned-and-manual is fine for the initial cutover.
image: ghcr.io/koenkk/zigbee2mqtt:2.14.2@sha256:addbfb08fcef2e4477de1535636118241e10d13e898fbe4d9faba8a4d7396311
image: ghcr.io/koenkk/zigbee2mqtt:2.14.3@sha256:fb4a6b0f6868ad1cd6a9996479802b2420e599c3463b62bbff8c8cd9a908f43a
imagePullPolicy: IfNotPresent
env:
- name: TZ
+1 -1
View File
@@ -17,7 +17,7 @@ spec:
spec:
containers:
- name: ical-proxy
image: gitea.renner.uno/konairius/ical-proxy:2026-10-28-0651 # {"$imagepolicy": "flux-system:ical-proxy"}
image: gitea.renner.uno/konairius/ical-proxy:2026-11-02-1055 # {"$imagepolicy": "flux-system:ical-proxy"}
ports:
- containerPort: 8080
name: http
+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.23.0@sha256:11e64e28c5f58c8d93ee95fcf22ac055f48b0bc440e0ce7d8013bbb5ad6aa8ac
image: curlimages/curl:8.23.0@sha256:3fb7e13e5d179a845c97d78204f3f1064d1907037f26a3ebf91710571151fd93
command:
- sh
- -c
@@ -57,7 +57,7 @@ spec:
runAsNonRoot: false
initContainers:
- name: wait-for-influxd
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
image: influxdb:2.9.1@sha256:a450f6faf9573e6c2e96a0f575885ae8b7387cc87e4de94599e247ebfa3ac417
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:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
image: influxdb:2.9.1@sha256:a450f6faf9573e6c2e96a0f575885ae8b7387cc87e4de94599e247ebfa3ac417
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:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
image: influxdb:2.9.1@sha256:a450f6faf9573e6c2e96a0f575885ae8b7387cc87e4de94599e247ebfa3ac417
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:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
image: influxdb:2.9.1@sha256:a450f6faf9573e6c2e96a0f575885ae8b7387cc87e4de94599e247ebfa3ac417
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:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2"
tag: "2.9.1@sha256:a450f6faf9573e6c2e96a0f575885ae8b7387cc87e4de94599e247ebfa3ac417"
# Single replica — InfluxDB OSS doesn't cluster. RWO Ceph-block + Velero
# backups (pending) is the durability story.
persistence:
+3 -1
View File
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-29-1349 # {"$imagepolicy": "flux-system:kuma-sync"}
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-30-1314 # {"$imagepolicy": "flux-system:kuma-sync"}
env:
- name: KUMA_URL
value: https://kuma.renner.uno
@@ -44,6 +44,8 @@ spec:
secretKeyRef:
name: kuma-sync-kuma
key: password
- name: KUMA_ROOT_GROUP
value: kuma-sync/renner-uno
ports:
- name: metrics
containerPort: 8080
@@ -0,0 +1,14 @@
# Root group (KUMA_ROOT_GROUP): parent of every monitor without a parentRef; the only one with notificationRefs.
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: renner-uno
namespace: kuma-sync
spec:
type: group
displayName: renner.uno
interval: 60
# heartbeats, not minutes: 60 x 60s = hourly reminder while down
resendInterval: 60
notificationRefs:
- name: email
@@ -6,3 +6,4 @@ resources:
- deployment.yaml
- statuspage-renner.yaml
- notification-email.yaml
- kumamonitor-root.yaml
+2 -2
View File
@@ -48,7 +48,7 @@ spec:
# this sentinel: the entrypoint copies AccountsSettings.json
# into ephemeral /config-internal rather than symlinking it.
- name: seed-config
image: busybox:1.38.0@sha256:fd7dc98638c8e305f4dc34e979f1c0fdfdcaeb0fbf8fcff77ae834b6da3d7e6e
image: busybox:1.38.0@sha256:a46257e06c82ad85242d0d38077d0a52d9896389ca60a1a59d3463db586e5fa4
command: ["/bin/sh", "-eu", "-c"]
args:
- |
@@ -94,7 +94,7 @@ spec:
- name: libation
# Pinned by digest: Spegel caches mutable tag resolutions, so
# :latest stayed on 13.7.11 despite imagePullPolicy Always.
image: docker.io/rmcrackan/libation:14.2.2@sha256:fe188ac3c4ce63cc305971c76e648748341427e16bcf884011da8a384fd3908c
image: docker.io/rmcrackan/libation:14.2.5@sha256:7bb47a417311d154c6691747320d0b62ce15527280ad99f0e2a85ac9a6b07e40
# The entrypoint stages config into ephemeral /config-internal
# by copy (only the DB is symlinked to the PVC), so Audible
# tokens refreshed during a run are lost at container exit.
+1 -1
View File
@@ -72,7 +72,7 @@ spec:
type: RuntimeDefault
containers:
- name: element-web
image: ghcr.io/element-hq/element-web:v1.12.32@sha256:f8700633d4336258af7a05b53865a4f479e0d628cf2d9ca83d28b5939314d4ce
image: ghcr.io/element-hq/element-web:v1.12.33@sha256:b0aa4b5a4fc1afaaff62e2dbd0c92d57e2b20724f3ccb2fef53aae949088c3d2
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
+1 -1
View File
@@ -137,7 +137,7 @@ spec:
type: RuntimeDefault
containers:
- name: mas
image: ghcr.io/element-hq/matrix-authentication-service:1.27.1@sha256:02712e68c90f3743fedbd5e3343517481b2b4f246a9fd9e2f050dc2553033847
image: ghcr.io/element-hq/matrix-authentication-service:1.28.0@sha256:494c67f08ee8344368f4ed336611dea02b26c4e058bf37a212d090d50b612659
# The server applies database migrations and syncs the upstream
# providers from the config on start.
args: ["server", "--config", "/config/config.yaml"]
+2 -2
View File
@@ -179,7 +179,7 @@ spec:
# first. The marker on the data PVC makes later starts skip it.
# Debug image: the plain one has no shell for the marker check.
- name: migrate-to-mas
image: ghcr.io/element-hq/matrix-authentication-service:1.27.1-debug@sha256:75f369b206785f1480f4a9f8dd81d7f9f3c6793c40fd184358ecd5d7acae17d4
image: ghcr.io/element-hq/matrix-authentication-service:1.28.0-debug@sha256:362b8cc6b1176aec059f3539da2d47c8b47587d1584431bf1aca285146d42b08
command:
- /busybox/sh
- -ec
@@ -234,7 +234,7 @@ spec:
mountPath: /data
containers:
- name: synapse
image: ghcr.io/element-hq/synapse:v1.163.2@sha256:c3c21919a322f74d177a43bce8031adc81ef839ddd117a8dd03cfec350e76e02
image: ghcr.io/element-hq/synapse:v1.164.1@sha256:fa4aa980f279bcf26d4e7ecc2a2c9a701c73f2ebe8841da9bdcf3b2fdf693d75
# Bypass /start.py: it expects to run as root and template a config
# from env. Default mode is generate-missing-and-run, which creates
# the signing key on first start.
@@ -91,4 +91,6 @@ resources:
# Gitea's data volume. It filled twice (#336, REN-580) and the chart's
# KubePersistentVolumeFillingUp warned 33 minutes before the second (REN-583).
- prometheusrule-gitea.yaml
# 802.1X: FreeRADIUS and the authentik outpost behind it.
- prometheusrule-radius.yaml
- probes-blackbox.yaml
@@ -0,0 +1,40 @@
# 802.1X RADIUS chain: FreeRADIUS (TTLS front end) and the authentik outpost
# behind it, 2 replicas each. Either at 0 means no 802.1X logins.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: radius
namespace: monitoring
labels:
release: kube-prometheus-stack
spec:
groups:
- name: radius.rules
rules:
- alert: RadiusDeploymentDown
expr: >-
kube_deployment_status_replicas_available{namespace="authentik",deployment=~"freeradius|ak-outpost-8021x-radius"}
< 1
for: 2m
labels:
severity: critical
annotations:
summary: "{{ $labels.deployment }} has no available replica"
description: >-
Deployment {{ $labels.deployment }} (ns authentik) has had no
available replica for 2m. 802.1X logins (radius.renner.uno) fail.
Check `kubectl -n authentik describe deploy {{ $labels.deployment }}`
and its pod logs.
- alert: RadiusDeploymentDegraded
expr: >-
kube_deployment_status_replicas_available{namespace="authentik",deployment=~"freeradius|ak-outpost-8021x-radius"}
< 2
for: 15m
labels:
severity: warning
annotations:
summary: "{{ $labels.deployment }} has {{ $value }} of 2 replicas available"
description: >-
Deployment {{ $labels.deployment }} (ns authentik) has run with
fewer than 2 available replicas for 15m. Logins still work but one
more failure stops them.
@@ -158,43 +158,69 @@ spec:
relabel_rules = loki.relabel.syslog.rules
}
// UniFi UDM SIEM Server (Network app) — UDP/5514. UDM emits CEF
// (Common Event Format), not RFC syslog: payload starts with
// "<bsd-ts> <iso-ts> <device-name> CEF:0|Vendor|Product|...".
// raw listener captures verbatim; loki.process.udm_cef extracts
// CEF header + UNIFI* extension fields and promotes a curated
// low-cardinality set to labels.
// UniFi activity logging — UDP/5514, one target for two payloads:
// the UDM SIEM events in CEF ("<bsd-ts> <device-name> CEF:0|...")
// and plain syslog from the UDM (log_all_contents) and from every
// adopted AP/switch (debug). raw listener captures verbatim;
// loki.process.udm_cef sets `source` per payload type.
loki.source.syslog "udp_5514" {
listener {
address = "0.0.0.0:5514"
protocol = "udp"
syslog_format = "raw"
labels = { job = "syslog", listener = "udp-5514", source = "udm-siem" }
labels = { job = "syslog", listener = "udp-5514" }
}
forward_to = [loki.process.udm_cef.receiver]
}
// CEF parser for UDM SIEM payload. The listener also receives the
// UDM's plain syslog (log_all_contents), which does not match and
// passes through unlabelled. CEF extension keys differ per event
// type, so the regex anchors only on the header
// (vendor|product|product_version|sig_id|name|sev), UNIFIcategory,
// UNIFIhost and the trailing msg. The full raw line stays the log
// content.
loki.process "udm_cef" {
forward_to = [loki.write.default.receiver]
stage.regex {
expression = "CEF:\\d+\\|(?P<vendor>[^|]*)\\|(?P<product>[^|]*)\\|(?P<product_version>[^|]*)\\|(?P<signature_id>[^|]*)\\|(?P<event_name>[^|]*)\\|(?P<cef_severity>[^|]*)\\|UNIFIcategory=(?P<category>.+?) UNIFIhost=(?P<unifi_host>.+?) \\S+=.* msg=(?P<msg>.+)$"
// CEF: extension keys differ per event type, so the regex
// anchors only on the header
// (vendor|product|product_version|sig_id|name|sev),
// UNIFIcategory, UNIFIhost and the trailing msg. The full raw
// line stays the log content.
stage.match {
selector = "{listener=\"udp-5514\"} |= \"CEF:\""
stage.static_labels {
values = { source = "udm-siem" }
}
stage.regex {
expression = "CEF:\\d+\\|(?P<vendor>[^|]*)\\|(?P<product>[^|]*)\\|(?P<product_version>[^|]*)\\|(?P<signature_id>[^|]*)\\|(?P<event_name>[^|]*)\\|(?P<cef_severity>[^|]*)\\|UNIFIcategory=(?P<category>.+?) UNIFIhost=(?P<unifi_host>.+?) \\S+=.* msg=(?P<msg>.+)$"
}
stage.labels {
values = {
vendor = "vendor",
product = "product",
signature_id = "signature_id",
event_name = "event_name",
category = "category",
}
}
}
stage.labels {
values = {
vendor = "vendor",
product = "product",
signature_id = "signature_id",
event_name = "event_name",
category = "category",
// Plain syslog. UDM: "<ts> <host> <host> <app>[pid]: ...";
// AP/switch: "<ts> <host> <mac>,<model>-<fw>: [: ]<app>[pid]: ...".
stage.match {
selector = "{listener=\"udp-5514\"} != \"CEF:\""
stage.static_labels {
values = { source = "unifi-syslog" }
}
stage.regex {
expression = "^\\S+ +\\d+ \\S+ (?P<host>\\S+) (?:[0-9a-f]{12},\\S+:(?: :)? |\\S+ )(?P<app>[A-Za-z0-9_./+-]+)"
}
stage.labels {
values = {
host = "host",
app = "app",
}
}
}
}
@@ -39,8 +39,14 @@ spec:
replication_factor: 1
storage:
type: filesystem
# retention_period is only enforced by the compactor with
# retention_enabled; Loki keeps logs forever otherwise.
compactor:
working_directory: /var/loki/compactor
retention_enabled: true
delete_request_store: filesystem
limits_config:
retention_period: 2160h
retention_period: 8760h
ingestion_rate_mb: 10
ingestion_burst_size_mb: 20
max_streams_per_user: 10000
@@ -17,7 +17,7 @@ spec:
chart:
spec:
chart: kube-prometheus-stack
version: "92.13.1"
version: "92.17.0"
sourceRef:
kind: HelmRepository
name: prometheus-community
@@ -54,7 +54,7 @@ spec:
# stdlib only; Flux keeps the tag in lockstep via the shared image
# policy) — no extra dependency.
- name: reconcile-publish-ip
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.9 # {"$imagepolicy": "flux-system:music-assistant"}
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
volumeMounts:
- name: data
@@ -67,7 +67,7 @@ spec:
# the stock file is the one it was written against (2.10.7 = 2.10.8);
# any other version keeps the stock provider.
- name: pocketcasts-overlay
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.9 # {"$imagepolicy": "flux-system:music-assistant"}
command:
- /bin/sh
- -euc
@@ -88,7 +88,7 @@ spec:
readOnly: true
containers:
- name: music-assistant
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.9 # {"$imagepolicy": "flux-system:music-assistant"}
env:
- name: LOG_LEVEL
value: info
+2 -2
View File
@@ -93,7 +93,7 @@ spec:
type: RuntimeDefault
containers:
- name: lidarr
image: lscr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
image: ghcr.io/linuxserver/lidarr:3.1.6.5079-ls47 # {"$imagepolicy": "flux-system:lidarr"}
env:
- name: PUID
value: "1000"
@@ -140,7 +140,7 @@ spec:
drop: ["ALL"]
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
- name: reconcile
image: lscr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
image: ghcr.io/linuxserver/lidarr:3.1.6.5079-ls47 # {"$imagepolicy": "flux-system:lidarr"}
command: ["sh", "/scripts/reconcile.sh"]
volumeMounts:
- name: config
+1 -1
View File
@@ -74,7 +74,7 @@ spec:
# alpine/kubectl is the maintained replacement. Pin the tag
# to the cluster's server version so kubectl is in lock-step
# (mismatched skew can break exec streaming).
image: docker.io/alpine/kubectl:1.37.1@sha256:a5d0fbecab3f7b6c2ab7398f1daa73a3e0ed6ece18556d21698233e9e3759cc7
image: docker.io/alpine/kubectl:1.37.2@sha256:5e2de75d37e811ba6eb92e06890b1b3263eae4f0a1c17b49269edec589b1352a
env:
- name: DATASET_TLV
valueFrom:
+1 -1
View File
@@ -47,7 +47,7 @@ spec:
#
# Hence socat is installed at startup and run inside this container.
- name: otbr
image: openthread/otbr:latest@sha256:f3e43ccd86d6da87323873efa0c9b6f92a2a8cc75a804ea50e34c96680363e2e
image: openthread/otbr:latest@sha256:51f9af250252f4faae85d0197574a7c17ada873c78799854461a93e3e42cf324
command:
- bash
- -c
@@ -138,25 +138,3 @@ spec:
expiryNotification: true
keyword:
keyword: "<title>authentik</title>"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: syncthing-ui-gate
namespace: paperless
spec:
type: keyword
displayName: Syncthing Login
parentRef:
name: docs
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 20
http:
# behind the Authentik gate; asserts the login page the redirect chain ends on
url: https://syncthing.paperless.renner.uno/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
keyword:
keyword: "<title>authentik</title>"
+2 -2
View File
@@ -182,7 +182,7 @@ spec:
drop: ["ALL"]
containers:
- name: paperless
image: ghcr.io/paperless-ngx/paperless-ngx:3.4.0 # {"$imagepolicy": "flux-system:paperless-ngx"}
image: ghcr.io/paperless-ngx/paperless-ngx:3.4.2 # {"$imagepolicy": "flux-system:paperless-ngx"}
ports:
- name: http
containerPort: 8000
@@ -287,7 +287,7 @@ spec:
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
# anchor: bridge-tls-proxy
- name: bridge-tls-proxy
image: alpine/socat:1.8.1.3@sha256:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
image: alpine/socat:1.8.1.3@sha256:466c8a0383e16463fabdb7df01008292fb78d07da60224a46cb5d3142ce122ed
args:
- "-d"
- "TCP-LISTEN:11143,fork,reuseaddr,bind=127.0.0.1"
+1 -1
View File
@@ -59,7 +59,7 @@ spec:
- name: renovate
# Pinned by tag and digest. Renovate updates itself through
# homelab's renovate.json, like any other image here.
image: docker.io/renovate/renovate:44.181.8@sha256:b28b2de1dfd35de7e8198756d7f7cdd84ebc28c3f25e80f5b09e598d3faefb2a
image: docker.io/renovate/renovate:44.199.2@sha256:0538f498be1b074e2434f8449cab7b2be30f8c0d0baf3b72814010f19db5bcfb
env:
- name: RENOVATE_CONFIG_FILE
value: /config/config.json
+1 -1
View File
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: review-bot
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-29-1346 # {"$imagepolicy": "flux-system:review-bot"}
image: gitea.renner.uno/gitea_admin/review-bot:2026-11-02-1054 # {"$imagepolicy": "flux-system:review-bot"}
env:
- name: GITEA_URL
value: https://gitea.renner.uno
+1 -1
View File
@@ -239,7 +239,7 @@ spec:
# the panel after one lands. ARCH-SUFFIXED image: upstream publishes
# no multi-arch manifest.
- name: renderer
image: ghcr.io/usetrmnl/trmnl-ha-amd64:0.11.1 # {"$imagepolicy": "flux-system:trmnl-renderer"}
image: ghcr.io/usetrmnl/trmnl-ha-amd64:0.12.0 # {"$imagepolicy": "flux-system:trmnl-renderer"}
ports:
- name: http
containerPort: 10000
@@ -57,7 +57,7 @@ spec:
# Pinned, and deliberately NOT under Flux image automation. rbac.yaml
# is derived from what this exact version calls; an automatic bump
# could add a call the Role does not cover, silently.
image: ghcr.io/stakater/ingressmonitorcontroller:v2.2.13@sha256:3301afb61c10abc4e334deab9cd8601cef069e352b4d7f4dd0897787fca32eac
image: ghcr.io/stakater/ingressmonitorcontroller:v2.2.14@sha256:6d7b7fcb39d72296b6b6c0301b443fb31e12d86c55e706adcc7e4f92a887a3ba
command: ["/manager"]
args:
- --health-probe-bind-address=:8081
+2 -2
View File
@@ -68,7 +68,7 @@ spec:
type: RuntimeDefault
containers:
- name: sync
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
image: curlimages/curl:8.23.0@sha256:3fb7e13e5d179a845c97d78204f3f1064d1907037f26a3ebf91710571151fd93
command:
- sh
- -c
@@ -128,7 +128,7 @@ spec:
type: RuntimeDefault
containers:
- name: run
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
image: curlimages/curl:8.23.0@sha256:3fb7e13e5d179a845c97d78204f3f1064d1907037f26a3ebf91710571151fd93
command:
- sh
- -c
+1 -1
View File
@@ -25,7 +25,7 @@ spec:
fsGroup: 33
containers:
- name: wordpress
image: wordpress:7.1.3-php8.5-apache@sha256:ce31904fa1e51b73543fa2e35cc8f1747589b2abf48dc747b422749b88b77e9e
image: wordpress:7.1.4-php8.5-apache@sha256:131532d893b5dc4fdf4312a878b7e5e4e4d1d40eb54c854c78eddf6282457dfb
ports:
- name: http
containerPort: 80
@@ -89,6 +89,9 @@ spec:
substituteFrom:
- kind: ConfigMap
name: cluster-settings
# The LoadBalancer pool, from the site's Talos patch (site-*.yaml).
- kind: ConfigMap
name: lb-pool-site
dependsOn:
- name: infrastructure
---
+6 -2
View File
@@ -1,5 +1,5 @@
# kuma-sync controller: KumaMonitor -> Uptime Kuma monitors. Ships no KumaMonitor objects;
# they live with their app, the CRD in infrastructure/kuma-sync-crds.
# kuma-sync controller: KumaMonitor -> Uptime Kuma monitors. Ships only the root group KumaMonitor;
# service monitors live with their app, the CRD in infrastructure/kuma-sync-crds.
# healthCheckExprs: Flux has no status reader for ExternalSecret; fail only on the non-retrying reasons.
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
@@ -21,6 +21,10 @@ spec:
kind: ExternalSecret
failed: "has(status.conditions) && status.conditions.exists(c, c.type == 'Ready' && c.status == 'False' && (c.reason == 'SecretOwnedByOther' || c.reason == 'SecretImmutable'))"
current: "has(status.conditions) && status.conditions.exists(c, c.type == 'Ready' && c.status == 'True')"
# KumaMonitor Ready tracks Uptime Kuma on the VPS, not this apply.
- apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
current: "true"
dependsOn:
- name: infrastructure
- name: infrastructure-crds
@@ -69,7 +69,7 @@ spec:
# first build of that day — imagePullPolicy: Always does not help,
# because the tag itself resolves stale. Update this when the image
# is rebuilt.
image: gitea.renner.uno/gitea_admin/bird@sha256:65fc93efead1e9b065d2c78e748f6c3c1ad7216e28b757d9fa4e204fa2ffe9a9
image: gitea.renner.uno/gitea_admin/bird@sha256:fee5645ad0d718661fb90da6ea9090d30fe47597bebeecb246c01c2adc6df585
command: ["/bin/sh", "-c"]
args:
- |
@@ -95,7 +95,7 @@ spec:
mountPath: /etc/bird
containers:
- name: bird
image: gitea.renner.uno/gitea_admin/bird@sha256:65fc93efead1e9b065d2c78e748f6c3c1ad7216e28b757d9fa4e204fa2ffe9a9
image: gitea.renner.uno/gitea_admin/bird@sha256:fee5645ad0d718661fb90da6ea9090d30fe47597bebeecb246c01c2adc6df585
resources:
requests:
cpu: "50m"
@@ -0,0 +1,23 @@
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: freeradius
namespace: flux-system
spec:
image: docker.io/freeradius/freeradius-server
interval: 1h
---
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: freeradius
namespace: flux-system
spec:
imageRepositoryRef:
name: freeradius
filterTags:
pattern: '^(?P<v>3\.2\.\d+)-alpine$'
extract: '$v'
policy:
semver:
range: "3.2.x"
@@ -12,6 +12,7 @@ resources:
- cloudflare-lb-sync.yaml
- cni-plugins.yaml
- cnpg-barman-plugin.yaml
- freeradius.yaml
- gatus.yaml
- gitea-mcp.yaml
- gotenberg.yaml
@@ -223,6 +223,13 @@ spec:
- hostname
- port
type: object
resendInterval:
description: |-
Consecutive DOWN heartbeats after which Kuma sends the DOWN notification
again, not minutes; 0 sends it once per outage.
format: int32
minimum: 0
type: integer
retryInterval:
default: 60
description: Seconds.
@@ -24,7 +24,7 @@ spec:
# window. The StatefulSet rolls one pod at a time (updateStrategyType
# below). Undo after a pod has run the new server = restore the
# snapshot, not a git revert.
version: ">=0.30.4 <0.31.0"
version: ">=0.30.6 <0.31.0"
sourceRef:
kind: HelmRepository
name: openbao
@@ -71,7 +71,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-29 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-02 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -133,7 +133,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-29 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-02 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -195,7 +195,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-29 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-02 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -45,7 +45,7 @@ spec:
type: RuntimeDefault
containers:
- name: snapshot
image: quay.io/openbao/openbao:2.7.1@sha256:6d2b93856e3fcf7b18ad855a0b51eaba474dc8b79cf554379ea32034797d2acf # same image as the server — bao CLI included
image: quay.io/openbao/openbao:2.7.2@sha256:5046911075e137e154ce49ab051502f4ec7cf367d484ae4a4dce665d9b90561f # same image as the server — bao CLI included
securityContext:
allowPrivilegeEscalation: false
capabilities:
@@ -8,6 +8,10 @@
# Selector is opt-in via `lb-pool: bgp`. There is no default pool —
# Services without this label do not get a LoadBalancer IP.
#
# The ranges are a site fact: lb-pool-site from the site's Talos patch
# (talos/patches/site-*.yaml), by Flux substitution. A CIDR block uses all its
# addresses (allowFirstLastIPs defaults to Yes).
#
# IPv4 + IPv6 ranges keep matching last bytes (10.2.99.x ↔ fd99::x)
# so each LB has the same "tail" in both families.
apiVersion: cilium.io/v2alpha1
@@ -19,10 +23,8 @@ spec:
matchLabels:
lb-pool: bgp
blocks:
- start: "10.2.99.0"
stop: "10.2.99.255"
- start: "fd99::"
stop: "fd99::ff"
- cidr: "${LB_POOL_V4}"
- cidr: "${LB_POOL_V6}"
---
# Cilium BGPv2 control-plane config peering each node with the UDM over the
# ingress VLAN 47 — never over v30iot, which is the untrusted IoT segment.
@@ -5,7 +5,7 @@ metadata:
namespace: rook-ceph
spec:
cephVersion:
image: quay.io/ceph/ceph:v20.2.4 # {"$imagepolicy": "flux-system:ceph"}
image: quay.io/ceph/ceph:v20.2.5 # {"$imagepolicy": "flux-system:ceph"}
allowUnsupported: false
dataDirHostPath: /var/lib/rook
# Defaults to false. With three OSDs and size 3 there is no spare copy, so
@@ -38,7 +38,7 @@ spec:
serviceAccountName: rook-ceph-default
containers:
- name: rook-ceph-tools
image: quay.io/ceph/ceph:v20.2.4 # {"$imagepolicy": "flux-system:ceph"}
image: quay.io/ceph/ceph:v20.2.5 # {"$imagepolicy": "flux-system:ceph"}
command:
- /bin/bash
- -c
+12
View File
@@ -67,6 +67,18 @@ cluster:
nameservers:
- 185.12.64.1
- 185.12.64.2
# The LoadBalancer pool (see site-homelab.yaml). Services pin their
# addresses from the production pool, so a test run carries the same one.
- name: lb-pool-site
contents: |
apiVersion: v1
kind: ConfigMap
metadata:
name: lb-pool-site
namespace: flux-system
data:
LB_POOL_V4: 10.2.99.0/24
LB_POOL_V6: fd99::/120
---
# Host DNS asks k8s-gateway first, so containerd resolves in-cluster names
# (gitea.renner.uno) in the cluster; bootstrap.sh installs k8s-gateway right
@@ -85,6 +85,18 @@ cluster:
nameservers:
- fd02:47::1
- 10.2.47.1
# The LoadBalancer pool (bgp-pool, infrastructure/post-crds/cilium-bgp.yaml,
# by Flux substitution). The routing rules 1002/1003 below name it too.
- name: lb-pool-site
contents: |
apiVersion: v1
kind: ConfigMap
metadata:
name: lb-pool-site
namespace: flux-system
data:
LB_POOL_V4: 10.2.99.0/24
LB_POOL_V6: fd99::/120
---
# All pod traffic goes to table 47, and the table decides — longest prefix
# match, no exceptions to carve out. bird fills it with the node's attached
@@ -115,6 +127,21 @@ name: "1001"
src: 10.244.0.0/16
table: "47"
---
# A LoadBalancer reply is pod traffic too, but it reaches the routing decision
# with the VIP as its source, so the rules above miss it and main sends it out
# mgmt0 while the request came in over ingress0. The pool is lb-pool-site above.
apiVersion: v1alpha1
kind: RoutingRuleConfig
name: "1002"
src: fd99::/120
table: "47"
---
apiVersion: v1alpha1
kind: RoutingRuleConfig
name: "1003"
src: 10.2.99.0/24
table: "47"
---
# Host DNS asks k8s-gateway first, so containerd resolves in-cluster names
# (gitea.renner.uno) in the cluster; bootstrap.sh installs k8s-gateway right
# after the CNI, before anything else needs DNS. The UDM is the site's

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