Compare commits
125
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed3be3156b | ||
|
|
697856cbf8 | ||
|
|
4962af5ed1 | ||
|
|
bc698ea22d | ||
|
|
d42862e2d8 | ||
|
|
73dd6f6362 | ||
|
|
7434894f14 | ||
|
|
7798a17ba5 | ||
|
|
61fc16ff67 | ||
|
|
1e785e028e | ||
|
|
265b745429 | ||
|
|
014fe5efc0 | ||
|
|
22fd0f8e54 | ||
|
|
c382831464 | ||
|
|
49017ac5d7 | ||
|
|
64e19b4087 | ||
|
|
f9eb38e2da | ||
|
|
3ed987dd74 | ||
|
|
da58096453 | ||
|
|
06a916ddba | ||
|
|
7e95f4cc7d | ||
|
|
c6467a54ef | ||
|
|
eb7949b0f0 | ||
|
|
f1d0dde23b | ||
|
|
e33abd320f | ||
|
|
a8affe6955 | ||
|
|
6757eaf361 | ||
|
|
da1dc5b00e | ||
|
|
b05c84ac20 | ||
|
|
378496541c | ||
|
|
50d862d214 | ||
|
|
fae0276045 | ||
|
|
d8000634a9 | ||
|
|
1d4bc326d8 | ||
|
|
6a62bf54e4 | ||
|
|
27a34fa5f5 | ||
|
|
e633b1a0c5 | ||
|
|
da60eba710 | ||
|
|
ed59b8a32a | ||
|
|
46d91c8fad | ||
|
|
c7e018e7b6 | ||
|
|
cd5e947baf | ||
|
|
ce292d629b | ||
|
|
e5d0ffc3e5 | ||
|
|
dae7550e6f | ||
|
|
1177bb839d | ||
|
|
3c9c001685 | ||
|
|
c68befa4a4 | ||
|
|
68e4d42f77 | ||
|
|
eb035613ab | ||
|
|
e8645d11ed | ||
|
|
f3c9188807 | ||
|
|
c68b9ad7c2 | ||
|
|
59c9cc29fe | ||
|
|
3501586841 | ||
|
|
9e1452cfe7 | ||
|
|
fe397f906d | ||
|
|
48a28d1c8f | ||
|
|
a05dcfdc94 | ||
|
|
9616ee2ec6 | ||
|
|
e0e07c0327 | ||
|
|
50307fe748 | ||
|
|
fd0d2ef301 | ||
|
|
141b11a9ad | ||
|
|
03cf6a0e45 | ||
|
|
88576f7022 | ||
|
|
8c85a49ae5 | ||
|
|
8fa68bc9cd | ||
|
|
20d928aeba | ||
|
|
60dfcf5382 | ||
|
|
5e814c525a | ||
|
|
1a99c02996 | ||
|
|
09a03dfd76 | ||
|
|
f76defb1e1 | ||
|
|
3e0557cfe8 | ||
|
|
8739efe026 | ||
|
|
e518b46045 | ||
|
|
d7a6c2bdc7 | ||
|
|
53d7e716f8 | ||
|
|
61e4ee2ba8 | ||
|
|
b447c57e78 | ||
|
|
4f3cf541a4 | ||
|
|
6463c38f68 | ||
|
|
b86aaf3f88 | ||
|
|
c9944cfce8 | ||
|
|
717d1ad619 | ||
|
|
40f30412aa | ||
|
|
3f73e7cd3a | ||
|
|
a7543cb78b | ||
|
|
229e0cf5d8 | ||
|
|
b7cf7632c6 | ||
|
|
b77b0a4d0d | ||
|
|
2485b3ad38 | ||
|
|
a49c852666 | ||
|
|
3aa2cf7616 | ||
|
|
0a36c324da | ||
|
|
8d3578f020 | ||
|
|
10f1a88802 | ||
|
|
4dd46a8e7b | ||
|
|
859d1a7c65 | ||
|
|
0575a50d9e | ||
|
|
33b7b58849 | ||
|
|
dab7c10eca | ||
|
|
378f7e3429 | ||
|
|
fdc08827fb | ||
|
|
3d504c2f62 | ||
|
|
d56deb096e | ||
|
|
222965ba47 | ||
|
|
394bf91c37 | ||
|
|
2f7bc33b84 | ||
|
|
6c045e2e56 | ||
|
|
811178e321 | ||
|
|
6a39dd1f6e | ||
|
|
70a9c66902 | ||
|
|
dcf902f53a | ||
|
|
83235b2b07 | ||
|
|
b656422890 | ||
|
|
9f5acfd3a6 | ||
|
|
30db24335e | ||
|
|
0e33efa3b1 | ||
|
|
9fcf5259cb | ||
|
|
ff6a96438d | ||
|
|
2e3d055310 | ||
|
|
b741f4bdb0 | ||
|
|
cd39bf648a |
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,25 +35,25 @@ when:
|
||||
steps:
|
||||
- name: model-selection-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
|
||||
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.311
|
||||
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.311
|
||||
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.311
|
||||
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
@@ -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.311
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 ./...
|
||||
|
||||
@@ -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.311
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -30,7 +30,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: update-audit-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -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,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,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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,204 @@
|
||||
# 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.
|
||||
|
||||
## UniFi objects
|
||||
|
||||
Created through the UniFi Network API (site `default`):
|
||||
|
||||
| Object | ID | Settings |
|
||||
|---|---|---|
|
||||
| Firewall policy "Allow APs RADIUS" | `62c21e63-8c15-4720-adf8-b2333c090e2f` | zone Isolated `10.2.10.0/24` → zone External `10.2.99.91`, UDP `1812-1813`, ALLOW, IPv4 |
|
||||
| RADIUS profile "Cluster 802.1X" | `3e5fcfed-63dc-4e7d-ad51-17cbc074e4bb` | auth `10.2.99.91:1812`, acct `10.2.99.91:1813`, secret `DOT1X_NAS_SECRET`, VLAN assignment on (`vlan_wlan_mode: optional`) |
|
||||
| WLAN "Renner Test" | `3eba27e7-ddc2-41fb-ad3f-82add317847a` | copy of "Renner UniFi" (WPA2/WPA3 Enterprise transition, fast roaming, BSS transition, same AP group and bands), network (90) Guest as fallback, RADIUS profile "Cluster 802.1X" |
|
||||
|
||||
## Live login check
|
||||
|
||||
`tests/radius/live.sh` runs EAP-TTLS/PAP logins with `eapol_test` against the
|
||||
deployed FreeRADIUS from a test client range and prints result, duration and
|
||||
the VLAN attributes of the Access-Accept (hex) per login; secrets are read from
|
||||
files and never printed:
|
||||
|
||||
```
|
||||
LIVE_PASSWORD_VAR=CLAUDE_BOT_PASSWORD tests/radius/live.sh 10.2.99.91 claude-bot \
|
||||
~/.config/claude-bot/authentik ~/.config/claude-bot/radius-secret 20
|
||||
```
|
||||
|
||||
For IPv6 set `LIVE_SOURCE` to the workstation's `fd02:50::` address and use
|
||||
`fd99::91`. `group-id=[3530]` is VLAN `50`, `tunnel-type=[0000000d]` VLAN,
|
||||
`medium=[00000006]` IEEE-802.
|
||||
|
||||
## 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).
|
||||
@@ -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
|
||||
|
||||
@@ -103,6 +103,16 @@ data:
|
||||
slug: paperless-proxy
|
||||
state: absent
|
||||
|
||||
# syncthing.paperless.renner.uno is gated by authentik-gate-sync
|
||||
# (kubernetes/apps/paperless/syncthing.yaml), which admits this group.
|
||||
# Members are managed in Authentik.
|
||||
- model: authentik_core.group
|
||||
identifiers:
|
||||
name: Paperless Admins
|
||||
state: present
|
||||
attrs:
|
||||
is_superuser: false
|
||||
|
||||
# vscode.home.renner.uno is gated by authentik-gate-sync
|
||||
# (kubernetes/apps/home-assistant/vscode-ingress.yaml), which admits this
|
||||
# group. Members are managed in Authentik.
|
||||
@@ -1437,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -105,7 +105,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-28-0712 # {"$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
|
||||
|
||||
@@ -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
|
||||
@@ -30,7 +30,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-28-0718 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
|
||||
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-29-1355 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
|
||||
env:
|
||||
- name: CLOUDFLARE_API_TOKEN
|
||||
valueFrom:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,7 +32,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: sync
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-28-0704 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/python
|
||||
- /opt/hermes-skills/gitea-issues/scripts/sync_webhook.py
|
||||
|
||||
@@ -51,7 +51,7 @@ spec:
|
||||
# connects from the pod network. No env equivalent exists, so it is
|
||||
# set in config.yaml on every start (idempotent).
|
||||
- name: config
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-28-0704 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/hermes
|
||||
- config
|
||||
@@ -75,7 +75,7 @@ spec:
|
||||
mountPath: /opt/data
|
||||
# WebSocket keepalive ping interval in seconds (default 20).
|
||||
- name: config-ws-ping
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-28-0704 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/hermes
|
||||
- config
|
||||
@@ -99,7 +99,7 @@ spec:
|
||||
mountPath: /opt/data
|
||||
# Skills from git (ConfigMap hermes-skills) beside the PVC's skills/.
|
||||
- name: skills-config
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-28-0704 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/hermes
|
||||
- config
|
||||
@@ -130,7 +130,7 @@ spec:
|
||||
# - alert-monitor: hourly; the script wakes the agent only for
|
||||
# untriaged alerts.
|
||||
- name: cron-jobs
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-28-0704 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
|
||||
# Non-fatal: a broken jobs.json or a changed cron CLI after an image
|
||||
# bump logs a warning instead of keeping Hermes from starting.
|
||||
command:
|
||||
@@ -187,7 +187,7 @@ spec:
|
||||
readOnly: true
|
||||
# Home Assistant MCP server; token resolved from HA_TOKEN at connect, writes need approval.
|
||||
- name: config-mcp-homeassistant
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-28-0704 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/hermes
|
||||
- config
|
||||
@@ -214,7 +214,7 @@ spec:
|
||||
# skills, the Home Assistant MCP and the tea login are re-applied on
|
||||
# every start. default gets the kanban toolset to hand cards to them.
|
||||
- name: experts
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-28-0704 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
|
||||
# Non-fatal like cron-jobs: a broken expert logs a warning.
|
||||
command:
|
||||
- sh
|
||||
@@ -305,7 +305,7 @@ spec:
|
||||
# Gitea issues -> kanban (skills/gitea-issues): route script, webhook route on default,
|
||||
# completion shell hook plus its allowlist entry on every profile.
|
||||
- name: gitea-issues
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-28-0704 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
|
||||
# Non-fatal like cron-jobs: a failure logs a warning.
|
||||
command:
|
||||
- sh
|
||||
@@ -368,7 +368,7 @@ spec:
|
||||
readOnly: true
|
||||
# Multiplexed default reads platform credentials only from .env (rationale: commit message).
|
||||
- name: default-env
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-28-0704 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
|
||||
# Fatal on a writer failure: without these Matrix and the API server stay down.
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/python
|
||||
@@ -428,7 +428,7 @@ spec:
|
||||
# tea login hermes-bot, written from the mounted token into an
|
||||
# emptyDir at the agent's ~/.config/tea.
|
||||
- name: tea-login
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-28-0704 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- sh
|
||||
- -ec
|
||||
@@ -457,7 +457,7 @@ spec:
|
||||
readOnly: true
|
||||
containers:
|
||||
- name: hermes
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-28-0704 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
|
||||
args: ["gateway", "run"]
|
||||
env:
|
||||
- name: HERMES_DASHBOARD
|
||||
|
||||
@@ -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-29-0949 # {"$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
|
||||
@@ -106,6 +106,9 @@ spec:
|
||||
# The URL devices download firmware from: Service shelly-manager-firmware.
|
||||
- name: Firmware__PublicBaseUrl
|
||||
value: http://firmware.shelly-manager.renner.uno
|
||||
# Battery devices get their update when they call in.
|
||||
- name: Firmware__SleepyRollout
|
||||
value: "true"
|
||||
- name: ShellyManager__DnsSuffix
|
||||
value: iot.renner.uno
|
||||
# BLE radios: ESP32 esphome bluetooth_proxy nodes (e.g. the M5 Atom
|
||||
|
||||
@@ -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-28-0657 # {"$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
|
||||
|
||||
@@ -66,7 +66,7 @@ spec:
|
||||
# (machine.registries.mirrors, applied by bootstrap.sh), and containerd
|
||||
# keeps the repository path when it falls through — so the path has to
|
||||
# exist on both registries. Steady state still pulls from Gitea.
|
||||
image: gitea.renner.uno/konairius/alpine-jq:2026-10-28-0711 # {"$imagepolicy": "flux-system:alpine-jq"}
|
||||
image: gitea.renner.uno/konairius/alpine-jq:2026-10-29-1348 # {"$imagepolicy": "flux-system:alpine-jq"}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -30,7 +30,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-28-0712 # {"$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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -142,4 +142,4 @@ spec:
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
labels:
|
||||
dns_name: syncthing.paperless.renner.uno
|
||||
dns_name: sync.paperless.renner.uno
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ spec:
|
||||
prober: dns
|
||||
timeout: 5s
|
||||
dns:
|
||||
query_name: syncthing.paperless.renner.uno
|
||||
query_name: sync.paperless.renner.uno
|
||||
query_type: A
|
||||
valid_rcodes: [NOERROR]
|
||||
validate_answer_rrs:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -155,7 +155,7 @@ spec:
|
||||
# exist on both registries — hence konairius/, which matches the ghcr
|
||||
# namespace. Steady state still pulls from Gitea and never leaves the
|
||||
# network.
|
||||
image: gitea.renner.uno/konairius/alpine-openssl:2026-10-28-0711 # {"$imagepolicy": "flux-system:alpine-openssl"}
|
||||
image: gitea.renner.uno/konairius/alpine-openssl:2026-10-29-1348 # {"$imagepolicy": "flux-system:alpine-openssl"}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -182,7 +182,7 @@ spec:
|
||||
drop: ["ALL"]
|
||||
containers:
|
||||
- name: paperless
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:3.3.4 # {"$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"
|
||||
|
||||
@@ -52,11 +52,8 @@ spec:
|
||||
- name: syncthing
|
||||
image: syncthing/syncthing:2.1.7@sha256:2b71da7c0d0a91a6ae5285ad079226fecc0a365d08b27720e1f7037b018f7546
|
||||
env:
|
||||
# UI bind: any-interface so the ClusterIP Service can reach
|
||||
# it. Network exposure is gated by the absence of an Ingress
|
||||
# and the namespace default-deny CNP — set a UI password in
|
||||
# the Syncthing UI on first port-forward access before
|
||||
# publishing it.
|
||||
# UI bind: any-interface so the ClusterIP Service can reach it.
|
||||
# External access goes through the syncthing-ui Ingress only.
|
||||
- name: STGUIADDRESS
|
||||
value: "0.0.0.0:8384"
|
||||
- name: PUID
|
||||
@@ -115,10 +112,6 @@ spec:
|
||||
persistentVolumeClaim:
|
||||
claimName: paperless-consume
|
||||
---
|
||||
# UI Service — ClusterIP only. Access via `kubectl -n paperless
|
||||
# port-forward svc/syncthing-ui 8384:8384` for first-time setup
|
||||
# (set admin password). Future Ingress + Authentik gate is a
|
||||
# separate change once auth is configured.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
@@ -132,6 +125,36 @@ spec:
|
||||
port: 8384
|
||||
targetPort: ui
|
||||
---
|
||||
# LAN-only UI: Paperless Admins through the Authentik gate.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: syncthing-ui
|
||||
namespace: paperless
|
||||
labels:
|
||||
dns.renner.uno/internal: "true"
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
auth.renner.uno/group: Paperless Admins
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: syncthing.paperless.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: syncthing-ui
|
||||
port:
|
||||
number: 8384
|
||||
tls:
|
||||
- secretName: syncthing-ui-tls
|
||||
hosts:
|
||||
- syncthing.paperless.renner.uno
|
||||
---
|
||||
# Sync ports — LAN-reachable LoadBalancer. Peers (phones, laptops)
|
||||
# connect here for the dropbox sync. external-dns publishes the
|
||||
# hostname into UniFi DNS only (no cloudflare label); off-LAN peers
|
||||
@@ -146,7 +169,7 @@ metadata:
|
||||
dns.renner.uno/internal: "true"
|
||||
annotations:
|
||||
io.cilium/lb-ipam-ips: "10.2.99.85,fd99::85"
|
||||
external-dns.kubernetes.io/hostname: syncthing.paperless.renner.uno
|
||||
external-dns.kubernetes.io/hostname: sync.paperless.renner.uno
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
externalTrafficPolicy: Local
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,7 +32,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: review-bot
|
||||
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-28-0709 # {"$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
|
||||
|
||||
@@ -184,7 +184,7 @@ spec:
|
||||
# (machine.registries.mirrors, applied by bootstrap.sh), and containerd
|
||||
# keeps the repository path, so it has to exist on both registries.
|
||||
# Steady state still pulls from Gitea.
|
||||
image: gitea.renner.uno/konairius/alpine-jq:2026-10-28-0711 # {"$imagepolicy": "flux-system:alpine-jq"}
|
||||
image: gitea.renner.uno/konairius/alpine-jq:2026-10-29-1348 # {"$imagepolicy": "flux-system:alpine-jq"}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,7 +34,7 @@ spec:
|
||||
# Image automation, range 3.x (see image-automation/woodpecker.yaml).
|
||||
# MUST stay version-matched with woodpecker-server (gRPC parity) — both
|
||||
# track the same range/filter so they bump together on each release.
|
||||
image: woodpeckerci/woodpecker-agent:v3.20.0 # {"$imagepolicy": "flux-system:woodpecker-agent"}
|
||||
image: woodpeckerci/woodpecker-agent:v3.20.1 # {"$imagepolicy": "flux-system:woodpecker-agent"}
|
||||
env:
|
||||
- name: WOODPECKER_SERVER
|
||||
value: woodpecker.woodpecker.svc.cluster.local:9000 # in-cluster gRPC, replaces LXC :9000
|
||||
|
||||
@@ -40,7 +40,7 @@ spec:
|
||||
# minor + patch auto-update within 3.x; only the 4.0 major is held for
|
||||
# a reviewed bump. Safe now the DB is postgres (the old sqlite-surprise
|
||||
# -bump hazard is gone). Stays in lockstep with the agent.
|
||||
image: woodpeckerci/woodpecker-server:v3.20.0 # {"$imagepolicy": "flux-system:woodpecker-server"}
|
||||
image: woodpeckerci/woodpecker-server:v3.20.1 # {"$imagepolicy": "flux-system:woodpecker-server"}
|
||||
env:
|
||||
- name: WOODPECKER_HOST
|
||||
value: https://ci.renner.uno
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -95,19 +95,20 @@ spec:
|
||||
# VLAN-tagged frames, accounted as "VLAN traffic disallowed by VLAN
|
||||
# filter" in cilium_drop_count_total.
|
||||
#
|
||||
# Only VLAN 30 (v30iot) needs listing. Cilium attaches to devices that
|
||||
# carry an address, so it sees mgmt0's frames already untagged by the
|
||||
# 8021q layer — nothing for a VLAN filter to match. ens19 is deliberately
|
||||
# address-less, so its frames only meet the filter on the shared parent,
|
||||
# still tagged. Adding 40 would change nothing and would touch the
|
||||
# datapath carrying etcd and the API.
|
||||
# Only the address-less macvlan parents need listing: VLAN 30 (ens19,
|
||||
# v30iot) and VLAN 11 (radius0, freeradius-udm). Cilium attaches to
|
||||
# devices that carry an address, so it sees mgmt0's frames already
|
||||
# untagged by the 8021q layer — nothing for a VLAN filter to match.
|
||||
# ens19 and radius0 are deliberately address-less, so their frames only
|
||||
# meet the filter on the shared parent, still tagged. Adding 40 would
|
||||
# change nothing and would touch the datapath carrying etcd and the API.
|
||||
#
|
||||
# The symptom when this is wrong is not a DHCP timeout: a tc-layer drop
|
||||
# reaches an AF_PACKET sender as ENOBUFS, so the CNI dhcp plugin reports
|
||||
# "no buffer space available" (#592).
|
||||
#
|
||||
# Same three-copy problem as policyMapMax above. Takes an agent restart.
|
||||
vlanBypass: [30]
|
||||
vlanBypass: [30, 11]
|
||||
# Let pods reach the cluster's own LoadBalancer external IPs; Cilium
|
||||
# redirects the socket directly to the backend pod instead of sending
|
||||
# traffic out to the VIP and back (which fails with L2 announcements).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ metadata:
|
||||
name: lidarr
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: lscr.io/linuxserver/lidarr
|
||||
image: ghcr.io/linuxserver/lidarr
|
||||
interval: 1h
|
||||
---
|
||||
# Tags are <maj>.<min>.<patch>.<build>-ls<n>. Two numbers move, and BOTH
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user