Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5.5 64e19b4087 fix(radius): proxy_dedup_window above response_window
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
radiusd 3.2.10 segfaulted (exit 139) after the outpost came back from a
stall. A NAS retransmit forwarded to the outpost leaves the request in the
proxy hash while post-proxy runs; eapttls_postproxy() then sets
request->proxy->dst_port = 0 (ttls.c:880), which is part of the rbtree key,
so the later removal misses, the freed request stays in proxy_list and the
next reply lookup dereferences it in fr_packet_cmp(). Still present on the
v3.2.x head; 3.2.10 is the newest image.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:13:54 +01:00
claude-botandClaude Opus 5.5 6757eaf361 feat(radius): server certificate and secrets for radius.renner.uno
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:07:12 +01:00
claude-botandClaude Opus 5.5 da1dc5b00e docs: implementation plan for 802.1X RADIUS via authentik
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:59:10 +01:00
claude-botandClaude Opus 5.5 b05c84ac20 docs: rename VLAN group attribute to dot1x_vlan (WiFi and wired)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:54:11 +01:00
claude-botandClaude Opus 5.5 378496541c docs: record FreeRADIUS-as-own-service decision and outpost Message-Authenticator risk
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:50:00 +01:00
claude-botandClaude Opus 5.5 50d862d214 docs: switch 802.1X design to EAP-TTLS/PAP via authentik RADIUS outpost
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:27:38 +01:00
claude-botandClaude Opus 5.5 fae0276045 docs: design for in-cluster 802.1X RADIUS backed by authentik
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:50:32 +01:00
k.renner 35a387a3c1 Merge pull request 'chore: follow homelab to konairius/homelab (merge inside Cut-over 1 only)' (#1372) from claude/homelab-konairius into master
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1372
2026-10-19 08:52:21 +00:00
k.renner 093857c353 Merge pull request 'feat(rotation): sync claude_code_oauth_token and review_bot_token into konairius' (#1370) from claude/konairius-org-secrets into master
Reviewed-on: k.renner/homelab#1370
2026-10-19 08:50:17 +00:00
claude-botandClaude Opus 5.5 7f29ebb878 chore: follow homelab to konairius/homelab
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Flux source URL, flux-git rotator verify repo, review-bot REPOS, pipeline API,
clone and push URLs, the heating-can source label and the review-bot and
kuma-sync Go module paths. Merge right after the transfer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:43:23 +02:00
claude-botandClaude Opus 5.5 b07167ae1a feat(rotation): sync claude_code_oauth_token and review_bot_token into konairius
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Homelab moves to konairius/homelab; after the Woodpecker move its pipelines
resolve org secrets in konairius. The k.renner containers stay until the move
is done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:42:20 +02:00
k.renner df0b344f7f Merge pull request 'fix(hermes): push the Hermes image as registry-bot' (#1369) from fix/hermes-image-registry-bot into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline failed
Reviewed-on: k.renner/homelab#1369
2026-10-19 08:31:56 +00:00
hermes-bot 46b7d458b3 fix(hermes): push the Hermes image as registry-bot
review-bot reviewed: request changes
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
registry-bot is the konairius registry identity: write:package PAT,
konairius org secret registry_bot_token, rotated by
openbao/rotation/registry-bot.yaml. That rotation syncs the secret for
the push, tag, release and manual events, so the manual trigger is
`event: manual` instead of a deployment target.

registry_bot_token is a konairius org secret. Woodpecker resolves org
secrets through the repo's org link, so this pipeline gets it only once
homelab has moved to konairius/homelab
(docs/superpowers/plans/2026-10-18-homelab-konairius-main.md, cut-over 1).
2026-10-19 08:29:53 +00:00
security-bot 2d04575c51 chore(image-automation): bump gitea.renner.uno/konairius/hermes:build.1792397861
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-19 08:20:50 +00:00
k.renner 97310db78c Merge pull request 'feat(hermes): run the in-house Hermes image' (#1367) from feat/hermes-use-custom-image into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/push/hermes-image Pipeline was successful
Reviewed-on: k.renner/homelab#1367
2026-10-19 08:16:53 +00:00
k.renner 085f969f63 Merge pull request 'feat(hermes): build a Hermes image with the agent's CLI tools' (#1366) from feat/hermes-image into master
ci/woodpecker/push/build-ci-images Pipeline is pending
ci/woodpecker/push/hermes-image Pipeline is pending
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1366
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-19 08:12:26 +00:00
hermes-bot d00ee70b23 fix(hermes): run the Hermes image from the konairius org
review-bot reviewed: request changes
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
2026-10-19 08:10:18 +00:00
hermes-bot 8ea8318c6e Merge branch 'feat/hermes-image' into feat/hermes-use-custom-image 2026-10-19 08:10:13 +00:00
hermes-bot be9898f7b0 chore(renovate): leave gitea_admin/hermes to Flux image automation
review-bot reviewed: request changes
ci/woodpecker/pr/ci-scripts-test Pipeline was canceled
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
2026-10-19 08:06:10 +00:00
hermes-bot 3ed7741c68 docs(hermes): tea is in the image now, still not logged in
review-bot reviewed: request changes
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
2026-10-19 07:58:27 +00:00
hermes-bot c8849b8d9c feat(hermes): run the in-house Hermes image
Switches apps/hermes to gitea.renner.uno/gitea_admin/hermes, which
carries talosctl, kubectl, flux, bao, tea and jq in /usr/local/bin. The
image-automation policy now tracks its build.<unix-ts> tags instead of
upstream nousresearch/hermes-agent; .woodpecker/hermes-image.yml follows
upstream releases.

Drops the kubectl init container and its emptyDir: the binary sat in
/opt/data/.local/bin, which the agent's terminal does not have on PATH.
2026-10-19 07:58:27 +00:00
55 changed files with 2080 additions and 154 deletions
+4 -4
View File
@@ -30,9 +30,9 @@
# Triggering:
# - cron : a cron NAMED exactly "alert-monitor" (name filter, same
# mechanism as update-audit.yml):
# woodpecker repo cron add --repo k.renner/homelab \
# woodpecker repo cron add --repo konairius/homelab \
# --name alert-monitor --branch master --schedule '*/15 * * * *'
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> alert-monitor
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> alert-monitor
# where <n> is an existing pipeline to promote.
when:
- event: cron
@@ -130,7 +130,7 @@ steps:
| select([.receivers[]?.name] | (length == 0) or any(. != "null"))]' \
.ci-alert-monitor/alerts-raw.json > .ci-alert-monitor/alerts-active.json
echo "Active alerts (after heartbeat filter): $$(jq length .ci-alert-monitor/alerts-active.json)"
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
curl -sfL "$$API/issues?state=open&type=issues&limit=100" > .ci-alert-monitor/open-issues.json
TITLES=$$(jq -c '[.[].title]' .ci-alert-monitor/open-issues.json)
NOISE_BODY=$$(jq -r 'map(select(.title == "Alert monitor: noise log")) | .[0].body // ""' \
@@ -238,7 +238,7 @@ steps:
exit 0
fi
jq . /tmp/alert-verdicts.json > /dev/null # fail loudly on malformed output
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
STAMP=$$(date -u +%Y-%m-%d)
gitea_write() {
+5 -5
View File
@@ -48,13 +48,13 @@
# OWNER SETUP — the token + secret above are already provisioned. The one
# remaining step, to be run AFTER this merges to master (a cron references a
# pipeline that must exist on the target branch), is to register the schedule:
# woodpecker repo cron add --repo k.renner/homelab \
# woodpecker repo cron add --repo konairius/homelab \
# --name ci-claude-refresh --branch master --schedule '@weekly'
# Weekly is plenty; the pipelines tolerate being a few days behind a release.
#
# Triggering:
# - cron : the cron NAMED exactly "ci-claude-refresh" (see above).
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> ci-claude-refresh
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> ci-claude-refresh
# where <n> is an existing pipeline to promote.
# - push : a change to the Containerfile (or this file) ON master forces a
# rebuild of the current latest version so Containerfile fixes ship
@@ -174,9 +174,9 @@ steps:
# Both were validated non-empty by the decide step; re-assert before a
# sed that would corrupt every pin if either were blank.
[ -n "$$NEW" ] && [ -n "$$CUR" ] || { echo "empty version/current sentinel — aborting"; exit 1; }
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
BRANCH="ci-image/claude-$$NEW"
if git ls-remote --heads "https://gitea.renner.uno/k.renner/homelab.git" "$$BRANCH" | grep -q .; then
if git ls-remote --heads "https://gitea.renner.uno/konairius/homelab.git" "$$BRANCH" | grep -q .; then
echo "Branch $$BRANCH already exists — a PR for $$NEW is already open. Nothing to do."
exit 0
fi
@@ -197,7 +197,7 @@ steps:
git checkout -b "$$BRANCH"
git add $$CONSUMERS
git commit -m "chore(ci): bump ci-claude image to $$NEW"
git push "https://$$PUSH_TOKEN@gitea.renner.uno/k.renner/homelab.git" HEAD:"$$BRANCH"
git push "https://$$PUSH_TOKEN@gitea.renner.uno/konairius/homelab.git" HEAD:"$$BRANCH"
{
printf 'Automated bump of the ci-claude agent image from `%s` to `%s`.\n\n' "$$CUR" "$$NEW"
printf 'The refresh pipeline built and pushed `ci-claude:%s` (and `:latest`) from the\n' "$$NEW"
+1 -1
View File
@@ -7,7 +7,7 @@
# was harmless at daily/weekly cadence — but alert-monitor.yml
# fires every 15 minutes, so piggybacking must be opt-in now.
# A new cron that SHOULD also run e2e gets its name added here.
# - deploy : `woodpecker-cli pipeline deploy k.renner/homelab <n> e2e`, where
# - deploy : `woodpecker-cli pipeline deploy konairius/homelab <n> e2e`, where
# <n> is an existing pipeline to promote.
when:
+4 -5
View File
@@ -6,20 +6,19 @@ when:
path:
- "ci/images/hermes/**"
- ".woodpecker/hermes-image.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "hermes-image"'
- event: manual
steps:
- name: build
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/hermes
environment:
GITEA_USER: gitea_admin
# registry-bot write:package PAT (konairius org secret).
GITEA_TOKEN:
from_secret: ci_images_token
from_secret: registry_bot_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- AUTH=$(printf '%s:%s' registry-bot "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG="build.$(date +%s)"
- |
+6 -6
View File
@@ -32,9 +32,9 @@
#
# Triggering:
# - cron : a cron NAMED exactly "issue-fix":
# woodpecker repo cron add --repo k.renner/homelab \
# woodpecker repo cron add --repo konairius/homelab \
# --name issue-fix --branch master --schedule '41 * * * *'
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> issue-fix
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> issue-fix
# where <n> is an existing pipeline to promote.
when:
- event: cron
@@ -82,7 +82,7 @@ steps:
set -eu
TRUSTED_AUTHORS="k.renner" # space-separated; the trust anchor (see header)
mkdir -p /tmp/alert-fix
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
curl -sfL "$$API/issues?state=open&type=issues&limit=100" \
| jq '[.[] | select(.title | startswith("[alert] "))] | sort_by(.number)' \
> /tmp/alert-fix/candidates.json
@@ -163,7 +163,7 @@ steps:
# run skips this issue, and stop cleanly.
[ -f /tmp/alert-fix/issue.json ] || exit 0
set -eu
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
NUM=$$(jq -r '.number' /tmp/alert-fix/issue.json)
if git diff --quiet && git diff --cached --quiet \
&& [ -z "$$(git ls-files --others --exclude-standard)" ]; then
@@ -187,7 +187,7 @@ steps:
[ -f /tmp/alert-fix/issue.json ] || exit 0
if [ -f /tmp/alert-fix/no-edits ]; then exit 0; fi
set -eu
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
NUM=$$(jq -r '.number' /tmp/alert-fix/issue.json)
BRANCH=$$(cat /tmp/alert-fix/branch)
TITLE="fix: $$(jq -r '.title' /tmp/alert-fix/issue.json) (#$$NUM)"
@@ -196,7 +196,7 @@ steps:
printf '\n\nCloses #%s\n' "$$NUM" >> /tmp/alert-fix/summary.md
git add -A
git commit -m "$$TITLE"
git push "https://$$PUSH_TOKEN@gitea.renner.uno/k.renner/homelab.git" HEAD:"$$BRANCH"
git push "https://$$PUSH_TOKEN@gitea.renner.uno/konairius/homelab.git" HEAD:"$$BRANCH"
jq -n --arg title "$$TITLE" --rawfile body /tmp/alert-fix/summary.md \
--arg head "$$BRANCH" \
'{title: $$title, body: $$body, head: $$head, base: "master"}' > /tmp/gitea-pr.json
+3 -3
View File
@@ -2,9 +2,9 @@
# pull requests. This pipeline only starts when triggered by hand and is kept for reference.
#
# PR review bot — per-repo STUB. All logic lives centrally in
# k.renner/homelab: ci/agents/pr-review/run.sh (plus prompt.md,
# konairius/homelab: ci/agents/pr-review/run.sh (plus prompt.md,
# settings.json and scripts/ci/). This file is deployed byte-identical into every
# reviewed repo (k.renner/homelab, shelly-manager, authentik-samba-bridge,
# reviewed repo (konairius/homelab, shelly-manager, authentik-samba-bridge,
# homeassistant) and holds NO logic, so the copies cannot drift apart: change the
# review in homelab and every repo picks it up on its next PR event. Only edit this
# stub if the trigger, the image or the secrets change — then change all copies in
@@ -43,5 +43,5 @@ steps:
from_secret: review_bot_token
commands:
- rm -rf /tmp/pr-review-central
- git clone --quiet --depth 1 https://gitea.renner.uno/k.renner/homelab /tmp/pr-review-central
- git clone --quiet --depth 1 https://gitea.renner.uno/konairius/homelab /tmp/pr-review-central
- sh /tmp/pr-review-central/ci/agents/pr-review/run.sh
+3 -3
View File
@@ -5,7 +5,7 @@
# - cron : the named `daily-security-scan` cron, matched by NAME.
# Crons cannot carry variables — `woodpecker-cli repo cron add`
# has no --var — so a variable gate can never match a cron.
# - deployment : woodpecker-cli pipeline deploy k.renner/homelab <n> security-scan
# - deployment : woodpecker-cli pipeline deploy konairius/homelab <n> security-scan
# The target rides in pipeline metadata as
# CI_PIPELINE_DEPLOY_TARGET, which the workflow-root filter
# does see, unlike manual-run variables on Woodpecker 3.18
@@ -147,7 +147,7 @@ steps:
TITLE="security: agent-proposed fixes ($$(date +%Y-%m-%d))"
git add -A
git commit -m "$$TITLE"
git push "https://$$GITEA_TOKEN@gitea.renner.uno/k.renner/homelab.git" HEAD:"$$BRANCH"
git push "https://$$GITEA_TOKEN@gitea.renner.uno/konairius/homelab.git" HEAD:"$$BRANCH"
jq -n \
--arg title "$$TITLE" \
--arg body "$$BODY" \
@@ -157,5 +157,5 @@ steps:
-H "Authorization: token $$GITEA_TOKEN" \
-H "Content-Type: application/json" \
--data @- \
https://gitea.renner.uno/api/v1/repos/k.renner/homelab/pulls \
https://gitea.renner.uno/api/v1/repos/konairius/homelab/pulls \
| jq '{number, html_url, title}'
+3 -3
View File
@@ -14,9 +14,9 @@
# `repo cron add` takes name, branch, schedule and enabled — so the
# name filter is the only thing that can select a workflow for a
# scheduled run:
# woodpecker repo cron add --repo k.renner/homelab \
# woodpecker repo cron add --repo konairius/homelab \
# --name update-audit --branch master --schedule '@weekly'
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> update-audit
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> update-audit
# where <n> is an existing pipeline to promote.
#
# NOTE: e2e.yml runs on an explicit list of cron names that includes this one,
@@ -111,7 +111,7 @@ steps:
set -eu
TITLE="Infrastructure update audit"
STAMP=$$(date -u +%Y-%m-%dT%H:%M:%SZ)
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
# write <method> <url> <payload-file> -> prints HTTP code, fails non-2xx
gitea_write() {
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/sh
# PR review bot — the ONE implementation, run by every reviewed repo's
# .woodpecker/pr-review.yml stub (k.renner/homelab, shelly-manager,
# .woodpecker/pr-review.yml stub (konairius/homelab, shelly-manager,
# authentik-samba-bridge, homeassistant). Edit it here only; the stubs hold no
# logic. Design notes (event-driven trigger, dedup marker, tokens, model
# selection, posture) are in the stub and in prompt.md.
@@ -129,7 +129,7 @@ while IFS= read -r ITEM; do
# # Cross-repo context for the review agent: shallow, UNAUTHENTICATED clones of
# # the other fleet repos at ./.pr-review/context/<name>.
# CTX="$D/context"; mkdir -p "$CTX"
# for REPO in k.renner/homelab k.renner/homeassistant konairius/shelly-manager k.renner/authentik-samba-bridge; do
# for REPO in konairius/homelab konairius/homeassistant konairius/shelly-manager konairius/authentik-samba-bridge; do
# [ "$REPO" = "$SLUG" ] && continue
# GIT_TERMINAL_PROMPT=0 git clone --quiet --depth 1 "https://gitea.renner.uno/$REPO" "$CTX/${REPO#*/}" 2>/dev/null \
# || echo "No public context clone of $REPO (skipped)."
+1 -1
View File
@@ -63,7 +63,7 @@ ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
LABEL org.opencontainers.image.title="heating-can" \
org.opencontainers.image.description="open3e + E3onCAN, Viessmann E3 CAN bus readers" \
org.opencontainers.image.source="https://gitea.renner.uno/k.renner/homelab" \
org.opencontainers.image.source="https://gitea.renner.uno/konairius/homelab" \
uno.renner.open3e.ref="${OPEN3E_REF}" \
uno.renner.e3oncan.ref="${E3ONCAN_REF}"
+2 -2
View File
@@ -10,8 +10,8 @@ import (
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/app"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/app"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
func main() {
+1 -1
View File
@@ -1,4 +1,4 @@
module gitea.renner.uno/k.renner/homelab/kuma-sync
module gitea.renner.uno/konairius/homelab/kuma-sync
go 1.27
+3 -3
View File
@@ -19,9 +19,9 @@ import (
"sigs.k8s.io/controller-runtime/pkg/manager"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/controller"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/controller"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
// Options configures NewManager.
@@ -25,9 +25,9 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/app"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/app"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
const (
@@ -16,8 +16,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/manager"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get
@@ -15,8 +15,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
type gcEnv struct {
@@ -20,8 +20,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
const (
@@ -17,8 +17,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
// counting records calls on top of the Kuma fake.
@@ -4,8 +4,8 @@ import (
"maps"
"slices"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
// defaultMaxRedirects is Kuma's default for a monitor with no explicit value.
@@ -6,8 +6,8 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
func TestMappingAllTypes(t *testing.T) {
@@ -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>
+151
View File
@@ -0,0 +1,151 @@
# 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.
## authentik objects
Defined in `kubernetes/apps/authentik/blueprints-configmap.yaml`, key
`dot1x-radius.yaml`:
| Object | Name | Purpose |
|---|---|---|
| Flow | `dot1x-authentication-flow` | identification, password, login; no MFA, no enrollment |
| Expression policy | `dot1x-has-vlan` | admits users with a valid `dot1x_vlan` group |
| RADIUS property mapping | `dot1x-vlan` | puts the VLAN into the Access-Accept |
| RADIUS provider | `802.1X Radius` | shared secret `DOT1X_OUTPOST_SECRET`, clients = cluster pod CIDRs |
| Application | `dot1x` | bound to `dot1x-has-vlan` |
| Outpost | `802.1X Radius` | radius, 2 replicas, ClusterIP, namespace `authentik` |
The outpost's Kubernetes objects are named from the slugified outpost name:
Service/Deployment `ak-outpost-8021x-radius`, pod label
`goauthentik.io/outpost-name: 8021x-radius`.
The shared secret is key `DOT1X_OUTPOST_SECRET` of Secret `radius-secrets`
(ExternalSecret from OpenBao `apps/radius`), passed to the authentik worker
only (it applies the blueprints) as env `DOT1X_OUTPOST_SECRET`, with
`optional: true`: a missing Secret fails only the dot1x blueprint, not
authentik. Reloader restarts the worker when `radius-secrets` changes.
On the FreeRADIUS → outpost hop the shared secret is the only guard: the
authentik namespace baseline admits all same-namespace pods, and the
provider's client_networks is the pod CIDR.
FreeRADIUS resolves the outpost Service name at startup; a re-created
outpost Service needs the FreeRADIUS pods restarted.
## VLAN convention
- A group carries the VLAN as attribute `dot1x_vlan`, an integer VLAN id
(1–4094), e.g. `dot1x_vlan: 50`. It must be an unquoted integer: a string
such as `"50"` is ignored and the user is denied.
- Membership counts through group inheritance (`User.all_groups()`).
- A user without any such group is rejected (policy `dot1x-has-vlan`).
- A user in several such groups gets the lowest `dot1x_vlan`.
- The same attribute applies to WiFi and wired 802.1X.
Groups and memberships are managed by the owner in authentik, not in
blueprints.
## Reply format
The mapping sets both:
- RFC 3580 `Tunnel-Type = VLAN`, `Tunnel-Medium-Type = IEEE-802`,
`Tunnel-Private-Group-Id = "<id>"` (raw attribute codes 64, 65, 81; the
dictionary bundled with authentik does not define them).
- `Cisco-AV-Pair = "dot1x-vlan=<id>"`.
Contract for FreeRADIUS: if the outpost reply carries the standard Tunnel-*
attributes, use them; otherwise translate `Cisco-AV-Pair = "dot1x-vlan=<id>"`
into `Tunnel-Type = VLAN`, `Tunnel-Medium-Type = IEEE-802`,
`Tunnel-Private-Group-Id = "<id>"`. An Access-Accept without a VLAN is
rejected.
## Test-phase entries
The FreeRADIUS clients `test` (10.2.50.0/24) and `test6` (fd02:50::/64) and
their CIDRs in `freeradius-cnp.yaml` are for the test phase only and must be
removed after the test.
## Client setup (SSID "Renner Test")
All clients use EAP-TTLS with inner PAP. The server certificate is a public
Let's Encrypt certificate for `radius.renner.uno`, so no CA needs to be
installed. Username and password are the authentik credentials. PAP runs
inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air.
### Android
- EAP method: TTLS
- Phase 2 authentication: PAP
- CA certificate: Use system certificates
- Domain: `radius.renner.uno`
- Identity: authentik username
- Anonymous identity: `anonymous`
- 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`
- 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).
@@ -1316,3 +1316,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
@@ -0,0 +1,45 @@
# 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 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,331 @@
# FreeRADIUS 3.2 config for 802.1X: EAP-TTLS terminated here, inner PAP
# proxied to the authentik outpost ak-outpost-8021x-radius. 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
data:
radiusd.conf: |
prefix = /opt
exec_prefix = ${prefix}
sbindir = ${exec_prefix}/sbin
libdir = ${exec_prefix}/lib
raddbdir = /etc/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 response_window (proxy.conf): never forward a NAS
# retransmit to the outpost. 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
}
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-TTLS only.
if (!&EAP-Message) {
reject
}
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
}
post-auth {
update reply {
&Cisco-AVPair !* ANY
}
Post-Auth-Type REJECT {
eap
}
}
pre-proxy {
}
# Runs the TTLS callback that hands the outpost reply back into the tunnel.
post-proxy {
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
+195
View File
@@ -0,0 +1,195 @@
# 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
containers:
- name: freeradius
image: freeradius/freeradius-server:3.2.10-alpine # {"$imagepolicy": "flux-system:freeradius"}
args:
- -f
- -l
- stdout
- -d
- /etc/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
ports:
- name: radius
containerPort: 1812
protocol: UDP
- name: radius-acct
containerPort: 1813
protocol: UDP
readinessProbe:
exec:
command:
- /opt/bin/radclient
- -d
- /etc/raddb
- -q
- -r
- "1"
- -t
- "2"
- -f
- /etc/raddb/probe/status
- 127.0.0.1:18121
- status
- status-probe
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
exec:
command:
- /opt/bin/radclient
- -d
- /etc/raddb
- -q
- -r
- "1"
- -t
- "2"
- -f
- /etc/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
mountPath: /etc/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
# 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
+15 -2
View File
@@ -88,8 +88,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:
@@ -174,6 +174,19 @@ spec:
- 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:
@@ -9,6 +9,11 @@ resources:
- ldap-outpost-cert.yaml
- ldap-outpost-lb.yaml
- ldap-outpost-cnp.yaml
- radius-cert.yaml
- radius-externalsecret.yaml
- freeradius-config.yaml
- freeradius.yaml
- freeradius-cnp.yaml
# ak-pg CNPG Postgres backend (moved here from the retired seed-authentik KS;
# each carries prune: disabled so ownership transferred without a teardown).
# authentik has no data PVCs — all state lives in ak-pg. This KS depends on
@@ -0,0 +1,15 @@
---
# EAP-TTLS server certificate for the FreeRADIUS service (radius.renner.uno).
# Issued via the cluster-wide Cloudflare DNS-01 ClusterIssuer.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: radius-tls
namespace: authentik
spec:
secretName: radius-tls
issuerRef:
name: letsencrypt-cluster
kind: ClusterIssuer
dnsNames:
- radius.renner.uno
@@ -0,0 +1,15 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: radius-secrets
namespace: authentik
spec:
refreshInterval: 10m
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: radius-secrets
dataFrom:
- extract:
key: apps/radius
+1 -1
View File
@@ -9,7 +9,7 @@ When you use a pronoun for someone whose pronouns haven't been stated, use they/
- The homelab wiki through the `homelab-wiki` skill (read, and deposit facts).
- The web (HTTPS) and LAN hostnames under `renner.uno` through Traefik.
- Gitea (`https://gitea.renner.uno`) as `hermes-bot`: `git` over HTTPS authenticates and sets the commit identity on its own. For the API, send `Authorization: token <contents of /var/run/secrets/gitea/api-token>` (read it inside the command, never print it). There is no `tea`; use the REST API (`/api/v1`). You write only to repos where `hermes-bot` is a collaborator.
- Gitea (`https://gitea.renner.uno`) as `hermes-bot`: `git` over HTTPS authenticates and sets the commit identity on its own. For the API, send `Authorization: token <contents of /var/run/secrets/gitea/api-token>` (read it inside the command, never print it). `tea` is installed but not logged in; use the REST API (`/api/v1`). You write only to repos where `hermes-bot` is a collaborator.
All state of record lives in git: infrastructure in the `homelab` repo (Kubernetes/Flux, Ansible, OpenTofu, ESPHome), Home Assistant config in the `homeassistant` repo. Changes land as a feature branch plus a PR for the owner's review (see below).
+3 -23
View File
@@ -49,7 +49,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: nousresearch/hermes-agent:v0.22.2 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792397861 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -73,7 +73,7 @@ spec:
mountPath: /opt/data
# Skills from git (ConfigMap hermes-skills) beside the PVC's skills/.
- name: skills-config
image: nousresearch/hermes-agent:v0.22.2 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792397861 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -95,25 +95,9 @@ spec:
volumeMounts:
- name: data
mountPath: /opt/data
# kubectl for the agent. /opt/data/.local/bin is on the image's PATH;
# an emptyDir there keeps the binary out of the PVC and current with
# this pin.
- name: kubectl
image: docker.io/alpine/kubectl:1.37.1@sha256:b568e8beb6a79aee269c142343078a278b85e7710f1c0e7d8d6b21d1eb566a48
command: ["cp", "/usr/local/bin/kubectl", "/tools/kubectl"]
securityContext:
runAsUser: 10000
runAsGroup: 10000
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tools
mountPath: /tools
containers:
- name: hermes
image: nousresearch/hermes-agent:v0.22.2 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792397861 # {"$imagepolicy": "flux-system:hermes"}
args: ["gateway", "run"]
env:
- name: HERMES_DASHBOARD
@@ -181,8 +165,6 @@ spec:
volumeMounts:
- name: data
mountPath: /opt/data
- name: tools
mountPath: /opt/data/.local/bin
# Identity and house rules from git (SOUL.md beside this file).
- name: soul
mountPath: /opt/data/SOUL.md
@@ -201,8 +183,6 @@ spec:
- name: data
persistentVolumeClaim:
claimName: hermes-data
- name: tools
emptyDir: {}
- name: soul
configMap:
name: hermes-soul
@@ -84,4 +84,6 @@ resources:
# Gitea's data volume. It filled twice (#336, REN-580) and the chart's
# KubePersistentVolumeFillingUp warned 33 minutes before the second (REN-583).
- prometheusrule-gitea.yaml
# 802.1X: FreeRADIUS and the authentik outpost behind it.
- prometheusrule-radius.yaml
- probes-blackbox.yaml
@@ -0,0 +1,40 @@
# 802.1X RADIUS chain: FreeRADIUS (TTLS front end) and the authentik outpost
# behind it, 2 replicas each. Either at 0 means no 802.1X logins.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: radius
namespace: monitoring
labels:
release: kube-prometheus-stack
spec:
groups:
- name: radius.rules
rules:
- alert: RadiusDeploymentDown
expr: >-
kube_deployment_status_replicas_available{namespace="authentik",deployment=~"freeradius|ak-outpost-8021x-radius"}
< 1
for: 2m
labels:
severity: critical
annotations:
summary: "{{ $labels.deployment }} has no available replica"
description: >-
Deployment {{ $labels.deployment }} (ns authentik) has had no
available replica for 2m. 802.1X logins (radius.renner.uno) fail.
Check `kubectl -n authentik describe deploy {{ $labels.deployment }}`
and its pod logs.
- alert: RadiusDeploymentDegraded
expr: >-
kube_deployment_status_replicas_available{namespace="authentik",deployment=~"freeradius|ak-outpost-8021x-radius"}
< 2
for: 15m
labels:
severity: warning
annotations:
summary: "{{ $labels.deployment }} has {{ $value }} of 2 replicas available"
description: >-
Deployment {{ $labels.deployment }} (ns authentik) has run with
fewer than 2 available replicas for 15m. Logins still work but one
more failure stops them.
+1 -1
View File
@@ -35,7 +35,7 @@ spec:
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-19-0540 # {"$imagepolicy": "flux-system:review-bot"}
env:
- name: REPOS
value: k.renner/homelab,konairius/homeassistant,konairius/authentik-samba-bridge,konairius/shelly-manager
value: konairius/homelab,konairius/homeassistant,konairius/authentik-samba-bridge,konairius/shelly-manager
- name: GITEA_URL
value: https://gitea.renner.uno
- name: AGENT_NAME
@@ -11,7 +11,7 @@ spec:
branch: master
secretRef:
name: flux-system
url: https://gitea.renner.uno/k.renner/homelab.git
url: https://gitea.renner.uno/konairius/homelab.git
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
@@ -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"
@@ -4,13 +4,10 @@ metadata:
name: hermes
namespace: flux-system
spec:
image: nousresearch/hermes-agent
interval: 1h
image: gitea.renner.uno/konairius/hermes
interval: 10m
---
# v-prefix tags (v0.22.2). The filter skips rc.*, -desktop and per-arch tags.
# The repo also carries the retired date-versioned releases (v2026.4.3 ..
# v2026.9.24, last one 2026-09-24); as semver they sort above 0.x, so the
# range caps below 1.0.0 or the policy picks the old v2026.9.24.
# .woodpecker/hermes-image.yml tags every build `build.<unix-ts>`; latest push wins.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
@@ -20,8 +17,8 @@ spec:
imageRepositoryRef:
name: hermes
filterTags:
pattern: '^v(?P<ver>\d+\.\d+\.\d+)$'
extract: '$ver'
pattern: '^build\.(?P<ts>\d+)$'
extract: '$ts'
policy:
semver:
range: ">=0.22.2 <1.0.0"
numerical:
order: asc
@@ -7,6 +7,7 @@ resources:
- backup-verify.yaml
- ceph.yaml
- cnpg-barman-plugin.yaml
- freeradius.yaml
- gatus.yaml
- gitea-mcp.yaml
- gotenberg.yaml
@@ -112,3 +112,36 @@ spec:
memory: 16Mi
limits:
memory: 48Mi
- name: sync-woodpecker-konairius
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-07-30
args: ["woodpecker-secret"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: BAO_ADDR
value: http://openbao.openbao.svc.cluster.local:8200
- name: BAO_ROLE
value: rotator-claude-oauth
- name: BOOTSTRAP_PATH
value: homelab/infrastructure/secret-rotators-bootstrap/woodpecker-bot
- name: BAO_PATH
value: homelab/infrastructure/claude
- name: BAO_FIELD
value: setup-token
- name: WOODPECKER_URL
value: http://woodpecker.woodpecker.svc.cluster.local:8000
- name: WOODPECKER_ORG
value: konairius
- name: WOODPECKER_SECRET_NAME
value: claude_code_oauth_token
- name: WOODPECKER_EVENTS
value: cron,deployment,manual,push,release,tag,pull_request
resources:
requests:
cpu: 25m
memory: 16Mi
limits:
memory: 48Mi
@@ -78,10 +78,10 @@ spec:
- name: GITEA_URL
value: http://gitea.gitea.svc.cluster.local:3000
- name: GITEA_VERIFY_REPO
value: k.renner/homelab
value: konairius/homelab
- name: GITEA_SCOPES
value: write:repository
# Load-bearing: k.renner/homelab is public, so the pull check
# Load-bearing: konairius/homelab is public, so the pull check
# passes for ANY valid token — push (git-receive-pack, needs
# write) is the only real verification. Pinned explicitly so the
# safety property is visible here, not just a binary default.
@@ -197,3 +197,36 @@ spec:
memory: 16Mi
limits:
memory: 48Mi
- name: sync-woodpecker-konairius
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-07-30
args: ["woodpecker-secret"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: BAO_ADDR
value: http://openbao.openbao.svc.cluster.local:8200
- name: BAO_ROLE
value: rotator-review-bot
- name: BOOTSTRAP_PATH
value: homelab/infrastructure/secret-rotators-bootstrap/woodpecker-bot
- name: BAO_PATH
value: homelab/infrastructure/review-bot
- name: BAO_FIELD
value: token
- name: WOODPECKER_URL
value: http://woodpecker.woodpecker.svc.cluster.local:8000
- name: WOODPECKER_ORG
value: konairius
- name: WOODPECKER_SECRET_NAME
value: review_bot_token
- name: WOODPECKER_EVENTS
value: cron,manual,pull_request
resources:
requests:
cpu: 25m
memory: 16Mi
limits:
memory: 48Mi
+1
View File
@@ -45,6 +45,7 @@
"ghcr.io/usetrmnl/terminus",
"ghcr.io/usetrmnl/trmnl-ha-amd64",
"gitea.renner.uno/k.renner/shelly-manager",
"gitea.renner.uno/konairius/hermes",
"gitea.renner.uno/konairius/vsftpd",
"gitea.renner.uno/konairius/wiki",
"gotenberg/gotenberg",
+3 -3
View File
@@ -107,7 +107,7 @@ func TestAnthropicResumesAPausedTurnAndTakesTheFinalText(t *testing.T) {
messageStream("claude-opus-5-5", "end_turn", block(0, toolUse), block(1, toolResult),
textBlock(2, "## Summary\n\nok\n\nVerdict: comment")),
}
a, err := f.backend().Ask(context.Background(), "claude-opus-5-5", "Review pull request #7 of k.renner/homelab.")
a, err := f.backend().Ask(context.Background(), "claude-opus-5-5", "Review pull request #7 of konairius/homelab.")
if err != nil {
t.Fatal(err)
}
@@ -204,14 +204,14 @@ func TestSelectorPicksTheBackend(t *testing.T) {
f.rev.setReady("anthropic", true)
f.rev.selector = &TypeSafe{url: ts.URL, key: "ts-key", model: "jev-latest", http: &http.Client{Timeout: 5 * time.Second}}
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
posted := f.postedReviews()
if len(posted) != 1 || !strings.Contains(posted[0]["body"], "model `"+tc.wantModel+"`") {
t.Fatalf("want one review by %s, got %v", tc.wantModel, posted)
}
if pr := state["pull_request"].(map[string]any); pr["title"] != "feat: x" || pr["repository"] != "k.renner/homelab" {
if pr := state["pull_request"].(map[string]any); pr["title"] != "feat: x" || pr["repository"] != "konairius/homelab" {
t.Errorf("selector state: %v", state)
}
})
+1 -1
View File
@@ -14,7 +14,7 @@ func noFiles(string) ([]byte, error) { return nil, errors.New("no such file") }
func baseEnv() map[string]string {
return map[string]string{
"REVIEW_TOKEN": "t", "WEBHOOK_SECRET": "s", "MISTRAL_API_KEY": "k",
"CONNECTOR_ID": "c1", "REPOS": "k.renner/homelab, konairius/shelly-manager",
"CONNECTOR_ID": "c1", "REPOS": "konairius/homelab, konairius/shelly-manager",
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
module gitea.renner.uno/k.renner/homelab/review-bot
module gitea.renner.uno/konairius/homelab/review-bot
go 1.27
+3 -3
View File
@@ -14,7 +14,7 @@ func TestBrokenStreamIsAnsweredFromTheHistory(t *testing.T) {
{"type":"message.input","content":"Review pull request #7"},
{"type":"tool.execution","name":"conn_get_file_contents"},
{"type":"message.output","content":"## Summary\n\nfine\n\n## Recommendation\n\nLooks reasonable.\n\nVerdict: comment"}]}`
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if f.convCalls != 1 || f.historyCalls != 1 || len(f.postedReviews()) != 1 {
@@ -26,7 +26,7 @@ func TestBrokenStreamIsAnsweredFromTheHistory(t *testing.T) {
func TestBrokenStreamWithoutHistoryIsAskedAgain(t *testing.T) {
f := newFixture(t)
f.breakStreams = 1
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if f.convCalls != 2 || f.historyCalls != 2 || len(f.postedReviews()) != 1 {
@@ -37,7 +37,7 @@ func TestBrokenStreamWithoutHistoryIsAskedAgain(t *testing.T) {
func TestStreamErrorEventIsNotRetried(t *testing.T) {
f := newFixture(t)
f.streamError = "Failed to generate response."
err := f.rev.Review(context.Background(), "k.renner/homelab", 7)
err := f.rev.Review(context.Background(), "konairius/homelab", 7)
if err == nil || !strings.Contains(err.Error(), "Failed to generate response.") {
t.Fatalf("want the stream error, got %v", err)
}
+35 -35
View File
@@ -109,22 +109,22 @@ func newFixture(t *testing.T) *fixture {
f.mu.Lock()
defer f.mu.Unlock()
switch {
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/k.renner/homelab/pulls/7":
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/konairius/homelab/pulls/7":
_, _ = io.WriteString(w, prJSON)
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/k.renner/homelab/pulls/7/reviews":
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/konairius/homelab/pulls/7/reviews":
_, _ = io.WriteString(w, f.reviews)
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/k.renner/homelab/pulls":
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/konairius/homelab/pulls":
_, _ = io.WriteString(w, "["+prJSON+"]")
case r.Method == "POST" && r.URL.Path == "/api/v1/repos/k.renner/homelab/pulls/7/reviews":
case r.Method == "POST" && r.URL.Path == "/api/v1/repos/konairius/homelab/pulls/7/reviews":
var in map[string]string
_ = json.NewDecoder(r.Body).Decode(&in)
f.posted = append(f.posted, in)
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"html_url":"https://gitea/pr/7#review"}`)
case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/api/v1/repos/k.renner/homelab/statuses/"):
case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/api/v1/repos/konairius/homelab/statuses/"):
var in map[string]string
_ = json.NewDecoder(r.Body).Decode(&in)
in["sha"] = strings.TrimPrefix(r.URL.Path, "/api/v1/repos/k.renner/homelab/statuses/")
in["sha"] = strings.TrimPrefix(r.URL.Path, "/api/v1/repos/konairius/homelab/statuses/")
f.statuses = append(f.statuses, in)
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, "{}")
@@ -152,7 +152,7 @@ func newFixture(t *testing.T) *fixture {
var in map[string]any
_ = json.NewDecoder(r.Body).Decode(&in)
if in["agent_id"] != "ag_1" || in["stream"] != true || in["store"] != true ||
!strings.Contains(in["inputs"].(string), "pull request #7 of k.renner/homelab") {
!strings.Contains(in["inputs"].(string), "pull request #7 of konairius/homelab") {
http.Error(w, "unexpected conversation request", http.StatusBadRequest)
return
}
@@ -197,7 +197,7 @@ func newFixture(t *testing.T) *fixture {
GiteaURL: f.gitea.URL, BotLogin: "review-bot", ReviewToken: "review-token", WebhookSecret: "hook-secret",
MistralURL: f.mistral.URL, MistralKey: "mistral-key", AgentName: "pr-review",
Models: []Model{{Backend: "mistral", Name: "mistral-large-4-0"}},
ConnectorID: "c1", Repos: []string{"k.renner/homelab"}, Workers: 1, QueueSize: 4,
ConnectorID: "c1", Repos: []string{"konairius/homelab"}, Workers: 1, QueueSize: 4,
ReviewTimeout: 5 * time.Second, RateLimitRetries: 2, RateLimitWait: time.Millisecond,
}
f.metrics = newMetrics()
@@ -245,7 +245,7 @@ func (f *fixture) postedReviews() []map[string]string {
func TestReviewPostsTheCleanedReport(t *testing.T) {
f := newFixture(t)
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
got := f.postedReviews()
@@ -272,7 +272,7 @@ func TestReviewPostsTheCleanedReport(t *testing.T) {
func TestReviewSkipsWhatIsAlreadyReviewed(t *testing.T) {
f := newFixture(t)
f.reviews = `[{"body":"<!-- pr-review:sha=abc123 -->","user":{"login":"review-bot"}}]`
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if n := len(f.postedReviews()); n != 0 {
@@ -286,7 +286,7 @@ func TestPendingReviewIsReviewedAgain(t *testing.T) {
f := newFixture(t)
f.reviews = `[{"body":"<!-- pr-review:sha=abc123 -->","state":"PENDING","user":{"login":"review-bot"}}]`
f.answer = `{"outputs":[{"type":"message.output","content":"## Summary\n\nfine\n\n## Recommendation\n\nLooks reasonable.\n\nVerdict: approve"}]}`
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
posted := f.postedReviews()
@@ -299,7 +299,7 @@ func TestReviewRefusesWriteToolsAndPostsNothing(t *testing.T) {
f := newFixture(t)
f.answer = `{"outputs":[{"type":"tool.execution","name":"conn_create_or_update_file"},
{"type":"message.output","content":"## Summary\n\nx"}]}`
err := f.rev.Review(context.Background(), "k.renner/homelab", 7)
err := f.rev.Review(context.Background(), "konairius/homelab", 7)
if err == nil || !strings.Contains(err.Error(), "conn_create_or_update_file") {
t.Fatalf("want an error naming the tool, got %v", err)
}
@@ -311,7 +311,7 @@ func TestReviewRefusesWriteToolsAndPostsNothing(t *testing.T) {
func TestReviewWithoutAReportPostsNothing(t *testing.T) {
f := newFixture(t)
f.answer = `{"outputs":[{"type":"message.output","content":"I could not do it."}]}`
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err == nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err == nil {
t.Fatal("want an error")
}
if n := len(f.postedReviews()); n != 0 {
@@ -322,7 +322,7 @@ func TestReviewWithoutAReportPostsNothing(t *testing.T) {
func TestReviewNeedsTheAgent(t *testing.T) {
f := newFixture(t)
f.rev.setReady("mistral", false)
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err == nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err == nil {
t.Fatal("want an error while the agent is not ready")
}
if f.rev.Ready() {
@@ -333,7 +333,7 @@ func TestReviewNeedsTheAgent(t *testing.T) {
func TestReviewAsksAgainAfterTheRateLimit(t *testing.T) {
f := newFixture(t)
f.convStatus = []int{429, 429}
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if f.convCalls != 3 || len(f.postedReviews()) != 1 {
@@ -348,7 +348,7 @@ func TestReviewHonoursRetryAfter(t *testing.T) {
f.rev.cfg.RateLimitWait = 10 * time.Minute
f.retryAfter = "0"
f.convStatus = []int{429}
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if f.convCalls != 2 || len(f.postedReviews()) != 1 {
@@ -371,7 +371,7 @@ func TestLimitHeaders(t *testing.T) {
func TestReviewGivesUpWhenTheRateLimitStays(t *testing.T) {
f := newFixture(t)
f.convStatus = []int{429, 429, 429, 429}
err := f.rev.Review(context.Background(), "k.renner/homelab", 7)
err := f.rev.Review(context.Background(), "konairius/homelab", 7)
if err == nil || !strings.Contains(err.Error(), "still failing") {
t.Fatalf("want a rate limit error, got %v", err)
}
@@ -383,7 +383,7 @@ func TestReviewGivesUpWhenTheRateLimitStays(t *testing.T) {
func TestOtherErrorsAreNotRetried(t *testing.T) {
f := newFixture(t)
f.convStatus = []int{500}
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err == nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err == nil {
t.Fatal("want an error")
}
if f.convCalls != 1 {
@@ -440,7 +440,7 @@ func (f *fixture) hook(event, signature string, body string) *httptest.ResponseR
func TestHook(t *testing.T) {
f := newFixture(t)
body := `{"action":"opened","number":7,"repository":{"full_name":"k.renner/homelab"}}`
body := `{"action":"opened","number":7,"repository":{"full_name":"konairius/homelab"}}`
good := sign("hook-secret", []byte(body))
cases := []struct {
name, event, sig, body string
@@ -455,10 +455,10 @@ func TestHook(t *testing.T) {
strings.Replace(body, "opened", "closed", 1), 200},
{"synchronized", "pull_request", sign("hook-secret", []byte(strings.Replace(body, "opened", "synchronized", 1))),
strings.Replace(body, "opened", "synchronized", 1), 202},
{"repository that is not configured", "pull_request", sign("hook-secret", []byte(strings.Replace(body, "k.renner/homelab", "x/y", 1))),
strings.Replace(body, "k.renner/homelab", "x/y", 1), 200},
{"payload without a number", "pull_request", sign("hook-secret", []byte(`{"action":"opened","repository":{"full_name":"k.renner/homelab"}}`)),
`{"action":"opened","repository":{"full_name":"k.renner/homelab"}}`, 400},
{"repository that is not configured", "pull_request", sign("hook-secret", []byte(strings.Replace(body, "konairius/homelab", "x/y", 1))),
strings.Replace(body, "konairius/homelab", "x/y", 1), 200},
{"payload without a number", "pull_request", sign("hook-secret", []byte(`{"action":"opened","repository":{"full_name":"konairius/homelab"}}`)),
`{"action":"opened","repository":{"full_name":"konairius/homelab"}}`, 400},
{"payload that is not json", "pull_request", sign("hook-secret", []byte("nope")), "nope", 400},
}
for _, c := range cases {
@@ -473,10 +473,10 @@ func TestHook(t *testing.T) {
func TestQueueRunsAReviewAndDeduplicates(t *testing.T) {
f := newFixture(t)
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
if r := q.Enqueue("k.renner/homelab", 7, "abc123"); r != "queued" {
if r := q.Enqueue("konairius/homelab", 7, "abc123"); r != "queued" {
t.Fatalf("first enqueue: %s", r)
}
if r := q.Enqueue("k.renner/homelab", 7, "abc123"); r != "duplicate" {
if r := q.Enqueue("konairius/homelab", 7, "abc123"); r != "duplicate" {
t.Fatalf("second enqueue of the same PR: %s", r)
}
q.Start(context.Background(), 1)
@@ -484,7 +484,7 @@ func TestQueueRunsAReviewAndDeduplicates(t *testing.T) {
if n := len(f.postedReviews()); n != 1 {
t.Errorf("posted %d reviews, want 1", n)
}
if r := q.Enqueue("k.renner/homelab", 8, ""); r != "full" {
if r := q.Enqueue("konairius/homelab", 8, ""); r != "full" {
t.Errorf("a stopped queue takes nothing, got %s", r)
}
}
@@ -529,7 +529,7 @@ func TestFirstRetryWaitsTheConfiguredTime(t *testing.T) {
f.rev.cfg.RateLimitWait = 300 * time.Millisecond
f.convStatus = []int{429}
start := time.Now()
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if got := time.Since(start); got < 250*time.Millisecond {
@@ -566,7 +566,7 @@ func TestReviewOfAFileAboutRateLimitsIsNotDropped(t *testing.T) {
f.answer = `{"outputs":[
{"type":"tool.execution","name":"conn_get_file_contents","info":{"result":"var rateLimitText = rate limit handling"}},
{"type":"message.output","content":"## Summary\n\nreviewed\n\n## Recommendation\n\nApprove."}]}`
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if f.convCalls != 1 || len(f.postedReviews()) != 1 {
@@ -579,7 +579,7 @@ func TestReviewOfAFileAboutRateLimitsIsNotDropped(t *testing.T) {
func TestReviewDoesNotPostForAStaleHead(t *testing.T) {
f := newFixture(t)
f.pushAfter = "def456"
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
posted := f.postedReviews()
@@ -595,18 +595,18 @@ func TestReviewDoesNotPostForAStaleHead(t *testing.T) {
func TestQueueReviewsAgainWhenAskedForDuringTheReview(t *testing.T) {
f := newFixture(t)
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
if r := q.Enqueue("k.renner/homelab", 7, "abc123"); r != "queued" {
if r := q.Enqueue("konairius/homelab", 7, "abc123"); r != "queued" {
t.Fatalf("enqueue: %s", r)
}
q.mu.Lock()
q.running["k.renner/homelab#7"] = true // as if a worker had picked it up
q.running["konairius/homelab#7"] = true // as if a worker had picked it up
q.mu.Unlock()
if r := q.Enqueue("k.renner/homelab", 7, "abc123"); r != "duplicate" {
if r := q.Enqueue("konairius/homelab", 7, "abc123"); r != "duplicate" {
t.Fatalf("enqueue while running: %s", r)
}
q.mu.Lock()
again := q.again["k.renner/homelab#7"]
delete(q.running, "k.renner/homelab#7")
again := q.again["konairius/homelab#7"]
delete(q.running, "konairius/homelab#7")
q.mu.Unlock()
if !again {
t.Error("the request during the review was forgotten")
@@ -617,7 +617,7 @@ func TestReviewAsksAgainAfterAGatewayFailure(t *testing.T) {
for _, status := range []int{502, 503, 504} {
f := newFixture(t)
f.convStatus = []int{status}
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatalf("%d: %v", status, err)
}
if f.convCalls != 2 || len(f.postedReviews()) != 1 {
+17 -17
View File
@@ -13,10 +13,10 @@ import (
func TestQueueReportsQueuedRunningAndPosted(t *testing.T) {
f := newFixture(t)
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
if r := q.Enqueue("k.renner/homelab", 7, "abc123"); r != "queued" {
if r := q.Enqueue("konairius/homelab", 7, "abc123"); r != "queued" {
t.Fatalf("enqueue: %s", r)
}
if st, _ := f.status.Get("k.renner/homelab", 7); st.Phase != PhaseQueued || st.Position != 1 {
if st, _ := f.status.Get("konairius/homelab", 7); st.Phase != PhaseQueued || st.Position != 1 {
t.Errorf("before the worker runs: %+v, want queued at position 1", st)
}
q.Start(context.Background(), 1)
@@ -30,7 +30,7 @@ func TestQueueReportsQueuedRunningAndPosted(t *testing.T) {
if got := f.commitStatuses(); !slices.Equal(got, want) {
t.Errorf("commit statuses\n got %q\nwant %q", got, want)
}
st, _ := f.status.Get("k.renner/homelab", 7)
st, _ := f.status.Get("konairius/homelab", 7)
if st.Phase != PhasePosted || st.URL != "https://gitea/pr/7#review" || st.Head != "abc123" {
t.Errorf("after the review: %+v", st)
}
@@ -40,7 +40,7 @@ func TestAFailedReviewIsReportedAsAnError(t *testing.T) {
f := newFixture(t)
f.answer = `{"outputs":[{"type":"message.output","content":"I could not do it."}]}`
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
q.Enqueue("k.renner/homelab", 7, "abc123")
q.Enqueue("konairius/homelab", 7, "abc123")
q.Start(context.Background(), 1)
q.Stop(5 * time.Second)
@@ -48,7 +48,7 @@ func TestAFailedReviewIsReportedAsAnError(t *testing.T) {
if last := got[len(got)-1]; last != "abc123 error: review failed: the agent's answer holds no report" {
t.Errorf("last commit status %q", last)
}
if st, _ := f.status.Get("k.renner/homelab", 7); st.Phase != PhaseFailed || st.Detail != "the agent's answer holds no report" {
if st, _ := f.status.Get("konairius/homelab", 7); st.Phase != PhaseFailed || st.Detail != "the agent's answer holds no report" {
t.Errorf("after the failure: %+v", st)
}
}
@@ -56,7 +56,7 @@ func TestAFailedReviewIsReportedAsAnError(t *testing.T) {
func TestARetryIsReported(t *testing.T) {
f := newFixture(t)
f.convStatus = []int{429}
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
got := f.commitStatuses()
@@ -70,12 +70,12 @@ func TestARetryIsReported(t *testing.T) {
func TestSkipsResolveTheQueuedStatus(t *testing.T) {
f := newFixture(t)
f.title = "WIP: x"
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
f.title = "feat: x"
f.reviews = `[{"body":"<!-- pr-review:sha=abc123 -->","state":"APPROVED","html_url":"https://gitea/r/1","user":{"login":"review-bot"}}]`
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
want := []string{"abc123 success: skipped: wip", "abc123 success: reviewed: approve"}
@@ -87,7 +87,7 @@ func TestSkipsResolveTheQueuedStatus(t *testing.T) {
func TestAStaleHeadIsMarkedSuperseded(t *testing.T) {
f := newFixture(t)
f.pushAfter = "def456"
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
got := f.commitStatuses()
@@ -101,7 +101,7 @@ func TestStatusEndpoints(t *testing.T) {
f := newFixture(t)
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
s := &Server{cfg: f.cfg, queue: q, reviewer: f.rev, metrics: f.metrics, log: f.rev.log}
q.Enqueue("k.renner/homelab", 7, "abc123")
q.Enqueue("konairius/homelab", 7, "abc123")
get := func(path string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
s.routes().ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
@@ -114,14 +114,14 @@ func TestStatusEndpoints(t *testing.T) {
t.Errorf("/status: %d %s", rec.Code, rec.Body)
}
var one PRStatus
if rec := get("/status/k.renner/homelab/7"); rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &one) != nil ||
one.Repo != "k.renner/homelab" || one.Number != 7 || one.Head != "abc123" {
t.Errorf("/status/k.renner/homelab/7: %d %s", rec.Code, rec.Body)
if rec := get("/status/konairius/homelab/7"); rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &one) != nil ||
one.Repo != "konairius/homelab" || one.Number != 7 || one.Head != "abc123" {
t.Errorf("/status/konairius/homelab/7: %d %s", rec.Code, rec.Body)
}
if rec := get("/status/k.renner/homelab/8"); rec.Code != 404 {
if rec := get("/status/konairius/homelab/8"); rec.Code != 404 {
t.Errorf("untracked PR: %d, want 404", rec.Code)
}
if rec := get("/status/k.renner/homelab/x"); rec.Code != 400 {
if rec := get("/status/konairius/homelab/x"); rec.Code != 400 {
t.Errorf("bad number: %d, want 400", rec.Code)
}
}
@@ -130,8 +130,8 @@ func TestFinishedReviewsAreForgottenAfterADay(t *testing.T) {
f := newFixture(t)
now := time.Now()
f.status.now = func() time.Time { return now }
f.status.Set("k.renner/homelab", 7, "", PhasePosted, "comment", "")
f.status.Set("k.renner/homelab", 8, "", PhaseQueued, "", "")
f.status.Set("konairius/homelab", 7, "", PhasePosted, "comment", "")
f.status.Set("konairius/homelab", 8, "", PhaseQueued, "", "")
now = now.Add(keepFinished + time.Minute)
got := f.status.List()
if len(got) != 1 || got[0].Number != 8 {
+33
View File
@@ -0,0 +1,33 @@
# FreeRADIUS functional test
```sh
tests/radius/run.sh
```
Runs the production FreeRADIUS config from
`kubernetes/apps/authentik/freeradius-config.yaml` (rendered through the
projected volume in `freeradius.yaml`, same image, args and secret env names)
in Docker against a mock authentik outpost, and drives it with `eapol_test`
(EAP-TTLS, inner PAP) as a NAS in the test client ranges `10.2.50.0/24` and
`fd02:50::/64`. The v6 cases (`v6-*`) hit the IPv6 listeners and client `test6`.
| Case | Expected |
|---|---|
| `vlan-tunnel` (outpost sends Tunnel-* and Cisco-AV-Pair) | SUCCESS, Tunnel-* VLAN 50, no Cisco-AV-Pair |
| `vlan-avpair` (outpost sends only `Cisco-AV-Pair = "dot1x-vlan=50"`) | SUCCESS, Tunnel-* VLAN 50, no Cisco-AV-Pair |
| `novlan` (Accept without VLAN) | FAILURE (Access-Reject) |
| wrong password / unknown user (Reject without Message-Authenticator) | FAILURE (Access-Reject) within 1 s |
| Accounting-Request | answered locally, nothing sent to the outpost |
Pieces:
- `mock_outpost.py`: stand-in outpost on UDP 1812, reachable under the
outpost Service FQDN through a Docker network alias.
- `eapol.Dockerfile`: `eapol_test` from Debian `eapoltest`.
- Docker network `radtest`: internal bridge on `10.2.50.0/24` and
`fd02:50::/64` with no host address (`inhibit_ipv4`,
`gateway_mode_ipv6=isolated`), so the workstation LAN routes are untouched.
Requires Docker, `openssl` and `yq` (the jq wrapper). Secrets, password and
certificates are generated dummies. `RADTEST_DEBUG=1` runs radiusd with `-xx`
and prints all logs. Containers, network and temp files are removed on exit.
+4
View File
@@ -0,0 +1,4 @@
FROM debian:trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends eapoltest \
&& rm -rf /var/lib/apt/lists/*
+120
View File
@@ -0,0 +1,120 @@
"""Stand-in for the authentik RADIUS outpost `8021x-radius` (UDP 1812).
Users (password MOCK_PASSWORD):
vlan-tunnel Accept: Tunnel-Type/Medium-Type/Private-Group-Id 50 + Cisco-AV-Pair
vlan-avpair Accept: Cisco-AV-Pair "dot1x-vlan=50" only
novlan Accept without VLAN
anything else, or a wrong password: Access-Reject WITHOUT Message-Authenticator
Accepts carry Message-Authenticator; rejects do not (authentik behaviour).
"""
import hashlib
import hmac
import os
import socket
import struct
SECRET = os.environ["MOCK_SECRET"].encode()
PASSWORD = os.environ["MOCK_PASSWORD"].encode()
ACCESS_REQUEST, ACCESS_ACCEPT, ACCESS_REJECT = 1, 2, 3
ATTR_USER_NAME, ATTR_USER_PASSWORD, ATTR_MESSAGE_AUTHENTICATOR = 1, 2, 80
def attr(t, value):
return bytes([t, len(value) + 2]) + value
def vsa(vendor, vendor_type, value):
return attr(26, struct.pack("!I", vendor) + bytes([vendor_type, len(value) + 2]) + value)
TUNNEL_VLAN_50 = (
attr(64, b"\x00\x00\x00\x0d") # Tunnel-Type = VLAN
+ attr(65, b"\x00\x00\x00\x06") # Tunnel-Medium-Type = IEEE-802
+ attr(81, b"50") # Tunnel-Private-Group-Id = "50"
)
AVPAIR_VLAN_50 = vsa(9, 1, b"dot1x-vlan=50") # Cisco-AV-Pair
REPLIES = {
b"vlan-tunnel": TUNNEL_VLAN_50 + AVPAIR_VLAN_50,
b"vlan-avpair": AVPAIR_VLAN_50,
b"novlan": b"",
}
def parse_attrs(data):
out, i = [], 0
while i + 2 <= len(data):
t, length = data[i], data[i + 1]
if length < 2:
break
out.append((t, i, data[i + 2 : i + length]))
i += length
return out
def decrypt_password(enc, authenticator):
out, last = b"", authenticator
for i in range(0, len(enc), 16):
key = hashlib.md5(SECRET + last).digest()
chunk = enc[i : i + 16]
out += bytes(a ^ b for a, b in zip(chunk, key))
last = chunk
return out.rstrip(b"\x00")
def request_ma_ok(packet, attrs):
for t, off, value in attrs:
if t == ATTR_MESSAGE_AUTHENTICATOR:
start = 20 + off + 2
zeroed = packet[:start] + b"\x00" * 16 + packet[start + 16 :]
return hmac.compare_digest(hmac.new(SECRET, zeroed, hashlib.md5).digest(), value)
return None
def build_reply(code, ident, req_auth, attrs, with_ma):
if with_ma:
attrs += attr(ATTR_MESSAGE_AUTHENTICATOR, b"\x00" * 16)
hdr = struct.pack("!BBH", code, ident, 20 + len(attrs))
if with_ma:
ma = hmac.new(SECRET, hdr + req_auth + attrs, hashlib.md5).digest()
attrs = attrs[:-16] + ma
resp_auth = hashlib.md5(hdr + req_auth + attrs + SECRET).digest()
return hdr + resp_auth + attrs
def main():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", 1812))
print("mock outpost listening on udp/1812", flush=True)
while True:
packet, peer = sock.recvfrom(4096)
if len(packet) < 20:
continue
code, ident, _length = struct.unpack("!BBH", packet[:4])
req_auth = packet[4:20]
if code != ACCESS_REQUEST:
print(f"peer={peer[0]} code={code} ignored", flush=True)
continue
attrs = parse_attrs(packet[20:])
user = next((v for t, _, v in attrs if t == ATTR_USER_NAME), b"")
enc = next((v for t, _, v in attrs if t == ATTR_USER_PASSWORD), b"")
password_ok = decrypt_password(enc, req_auth) == PASSWORD
ma = request_ma_ok(packet, attrs)
if password_ok and user in REPLIES:
out = build_reply(ACCESS_ACCEPT, ident, req_auth, REPLIES[user], True)
result = "accept"
else:
out = build_reply(ACCESS_REJECT, ident, req_auth, b"", False)
result = "reject"
print(
f"peer={peer[0]} code={code} user={user.decode(errors='replace')} "
f"password_ok={password_ok} request_ma={ma} -> {result}",
flush=True,
)
sock.sendto(out, peer)
if __name__ == "__main__":
main()
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env bash
# Functional test of the FreeRADIUS config in kubernetes/apps/authentik/.
# See README.md. Needs: docker, openssl, yq (the jq wrapper, kislyuk/yq).
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
app="$repo/kubernetes/apps/authentik"
deploy="$app/freeradius.yaml"
config="$app/freeradius-config.yaml"
net=radtest
# test client ranges from clients.conf
subnet=10.2.50.0/24
subnet6=fd02:50::/64
ip_acct=10.2.50.249
ip_nas=10.2.50.250
ip_radius=10.2.50.251
ip_mock=10.2.50.252
ip6_acct=fd02:50::249
ip6_nas=fd02:50::250
ip6_radius=fd02:50::251
outpost_fqdn=ak-outpost-8021x-radius.authentik.svc.cluster.local
mock_image=python:3.13-alpine
eapol_image=radtest-eapol:local
password=dummy-correct-password
work="$(mktemp -d)"
fails=0
cleanup() {
local ids=()
mapfile -t ids < <(docker ps -aq --filter name='^radtest-')
if ((${#ids[@]} > 0)); then docker rm -f "${ids[@]}" >/dev/null; fi
if [[ -n "$(docker network ls -q --filter name="^${net}\$")" ]]; then docker network rm "$net" >/dev/null; fi
rm -rf "$work"
}
trap cleanup EXIT
pass() { printf 'PASS %s\n' "$1"; }
fail() { printf 'FAIL %s\n' "$1"; fails=$((fails + 1)); }
deployment() { yq -r 'select(.kind == "Deployment")' "$deploy"; }
# --- render the production ConfigMap through the Deployment's projected volume
render() {
local raddb="$work/raddb" key path
mkdir -p "$raddb"
while read -r key path; do
mkdir -p "$raddb/$(dirname "$path")"
yq -r --arg k "$key" 'select(.kind == "ConfigMap") | .data[$k]' "$config" >"$raddb/$path"
done < <(deployment | yq -r '.spec.template.spec.volumes[] | select(.projected) | .projected.sources[]
| select(.configMap) | .configMap.items[] | "\(.key) \(.path)"')
# self-signed CA + server cert in place of the cert-manager Secret
local crt key_path
crt="$(deployment | yq -r '.spec.template.spec.volumes[] | select(.projected) | .projected.sources[]
| select(.secret) | .secret.items[] | select(.key == "tls.crt") | .path')"
key_path="$(deployment | yq -r '.spec.template.spec.volumes[] | select(.projected) | .projected.sources[]
| select(.secret) | .secret.items[] | select(.key == "tls.key") | .path')"
mkdir -p "$raddb/$(dirname "$crt")" "$work/pki" "$work/eapol"
openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj "/CN=radtest CA" \
-keyout "$work/pki/ca.key" -out "$work/eapol/ca.pem" >"$work/pki/openssl.log" 2>&1
openssl req -newkey rsa:2048 -nodes -subj "/CN=radius.renner.uno" \
-keyout "$raddb/$key_path" -out "$work/pki/server.csr" >>"$work/pki/openssl.log" 2>&1
printf 'subjectAltName=DNS:radius.renner.uno\nextendedKeyUsage=serverAuth\n' >"$work/pki/ext"
openssl x509 -req -in "$work/pki/server.csr" -CA "$work/eapol/ca.pem" -CAkey "$work/pki/ca.key" \
-CAcreateserial -days 1 -extfile "$work/pki/ext" -out "$raddb/$crt" >>"$work/pki/openssl.log" 2>&1
chmod -R a+rX "$work"
}
# --- dummy secrets for every secretKeyRef env var of the Deployment
secret_env=()
load_env() {
local name
while read -r name; do
secret_env+=(-e "$name=dummy-${name,,}")
done < <(deployment | yq -r '.spec.template.spec.containers[] | select(.name == "freeradius")
| .env[] | select(.valueFrom.secretKeyRef) | .name')
}
start() {
local image args=()
image="$(deployment | yq -r '.spec.template.spec.containers[] | select(.name == "freeradius") | .image')"
mapfile -t args < <(deployment | yq -r '.spec.template.spec.containers[] | select(.name == "freeradius") | .args[]')
if [[ "${RADTEST_DEBUG:-0}" == 1 ]]; then args+=(-xx); fi
radius_image="$image"
# Internal bridge without a host address: both test ranges are also the
# workstation LAN, so the host must get no address or route on them.
docker network create --internal --subnet "$subnet" --ipv6 --subnet "$subnet6" \
-o com.docker.network.bridge.inhibit_ipv4=true \
-o com.docker.network.bridge.gateway_mode_ipv6=isolated "$net" >/dev/null
local bridge
bridge="br-$(docker network inspect -f '{{printf "%.12s" .Id}}' "$net")"
if [[ -n "$(ip -4 -o addr show dev "$bridge")" ]] \
|| [[ -n "$(ip -6 -o addr show dev "$bridge" scope global)" ]] \
|| [[ -n "$(ip -6 route show dev "$bridge" "$subnet6")" ]]; then
echo "bridge $bridge got a host address or route in a test range; aborting" >&2
exit 1
fi
docker run -d --name radtest-mock --network "$net" --ip "$ip_mock" \
--network-alias "$outpost_fqdn" \
-e MOCK_SECRET=dummy-dot1x_outpost_secret -e MOCK_PASSWORD="$password" \
-v "$here/mock_outpost.py:/mock_outpost.py:ro" \
"$mock_image" python3 -u /mock_outpost.py >/dev/null
# Same restrictions as the pod: non-root, read-only root, no capabilities.
local run_opts=(--network "$net" --user 65534:65534 --read-only --tmpfs /tmp:rw,size=16m
--cap-drop ALL --security-opt no-new-privileges "${secret_env[@]}"
-v "$work/raddb:/etc/raddb:ro")
echo "--- radiusd -XC (config check)"
if docker run --rm "${run_opts[@]}" "$image" radiusd -XC -d /etc/raddb >"$work/configcheck.log" 2>&1 \
&& grep -q "Configuration appears to be OK" "$work/configcheck.log"; then
pass "radiusd -XC: Configuration appears to be OK"
else
cat "$work/configcheck.log"
fail "radiusd -XC"
exit 1
fi
docker run -d --name radtest-freeradius --ip "$ip_radius" --ip6 "$ip6_radius" "${run_opts[@]}" "$image" "${args[@]}" >/dev/null
sleep 2
if [[ "$(docker inspect -f '{{.State.Running}}' radtest-freeradius)" != true ]]; then
docker logs radtest-freeradius
fail "freeradius container running"
exit 1
fi
pass "freeradius container running with production args: ${args[*]}"
local probe=()
mapfile -t probe < <(deployment | yq -r '.spec.template.spec.containers[] | select(.name == "freeradius")
| .readinessProbe.exec.command[]')
if docker exec radtest-freeradius "${probe[@]}" >"$work/probe.out" 2>&1; then
pass "readiness probe command succeeds"
else
cat "$work/probe.out"
fail "readiness probe command succeeds"
fi
}
eapol_conf() { # case user password
cat >"$work/eapol/$1.conf" <<EOF
network={
key_mgmt=WPA-EAP
eap=TTLS
identity="$2"
anonymous_identity="anonymous"
password="$3"
phase2="auth=PAP"
ca_cert="/eapol/ca.pem"
domain_suffix_match="radius.renner.uno"
}
EOF
}
eapol() { # name timeout server
docker run --rm --network "$net" --ip "$ip_nas" --ip6 "$ip6_nas" -v "$work/eapol:/eapol:ro" "$eapol_image" \
eapol_test -c "/eapol/$1.conf" -a "$3" -s "dummy-dot1x_test_secret" -t "$2" \
>"$work/$1.out" 2>&1 || true
}
# Attribute lines of the Access-Accept as the NAS received it.
accept_attrs() {
awk '/RADIUS message: code=2 /{f=1; next} f && /^ /{print; next} f{f=0}' "$work/$1.out"
}
attr_value() { # case attr-number
accept_attrs "$1" | awk -v a="Attribute $2 " 'index($0, a){getline; sub(/^ *Value: /, ""); print; exit}'
}
check_accept() { # case user server
local c="$1"
eapol_conf "$c" "$2" "$password"
eapol "$c" 5 "$3"
if [[ "$(tail -n 1 "$work/$c.out")" == SUCCESS ]]; then pass "$c: SUCCESS"; else fail "$c: SUCCESS"; fi
[[ "$(attr_value "$c" 81)" =~ ^(00)?3530$ ]] && pass "$c: Tunnel-Private-Group-Id 50" \
|| fail "$c: Tunnel-Private-Group-Id 50 (got '$(attr_value "$c" 81)')"
[[ "$(attr_value "$c" 64)" == 0000000d ]] && pass "$c: Tunnel-Type VLAN" \
|| fail "$c: Tunnel-Type VLAN (got '$(attr_value "$c" 64)')"
[[ "$(attr_value "$c" 65)" == 00000006 ]] && pass "$c: Tunnel-Medium-Type IEEE-802" \
|| fail "$c: Tunnel-Medium-Type IEEE-802 (got '$(attr_value "$c" 65)')"
# Cisco-AV-Pair = VSA vendor 9; "dot1x-vlan" in hex
if accept_attrs "$c" | grep -Eq 'Value: 00000009|646f7431782d766c616e'; then
fail "$c: no Cisco-AV-Pair to the NAS"
else
pass "$c: no Cisco-AV-Pair to the NAS"
fi
}
check_reject() { # case user password timeout server
local c="$1"
eapol_conf "$c" "$2" "$3"
eapol "$c" "$4" "$5"
if [[ "$(tail -n 1 "$work/$c.out")" == FAILURE ]] && grep -q "RADIUS message: code=3 (Access-Reject)" "$work/$c.out" \
&& ! grep -q "EAPOL test timed out" "$work/$c.out"; then
pass "$c: FAILURE via Access-Reject within ${4}s"
else
fail "$c: FAILURE via Access-Reject within ${4}s"
fi
}
check_accounting() { # case server
printf 'Acct-Status-Type = Start\nUser-Name = "acct-test"\nAcct-Session-Id = "%s"\n' \
"$1" >"$work/eapol/$1.txt"
docker run --rm --network "$net" --ip "$ip_acct" --ip6 "$ip6_acct" -v "$work/eapol:/eapol:ro" "$radius_image" \
radclient -x -r 1 -t 2 -f "/eapol/$1.txt" "$2:1813" acct dummy-dot1x_test_secret \
>"$work/$1.out" 2>&1 || true
if grep -q "Received Accounting-Response" "$work/$1.out"; then
pass "$1: accounting answered locally"
else
fail "$1: accounting answered locally"
fi
}
# Outpost stalls until FreeRADIUS marks it dead, then comes back: a login must
# succeed again within the bound.
check_recovery() { # bound-seconds
local bound="$1" start now elapsed n=0
eapol_conf recovery vlan-avpair "$password"
docker pause radtest-mock >/dev/null
start=$SECONDS
# Requests during the stall: timeouts mark the home server zombie, then
# dead. Unpause right after the dead marking (worst case for recovery).
until docker logs radtest-freeradius 2>&1 | grep -q "port 1812 as dead"; do
if ((SECONDS - start > 90)); then
docker unpause radtest-mock >/dev/null
fail "recovery: home server marked dead during the stall"
return
fi
eapol recovery 4 "$ip_radius"
done
echo "recovery: home server marked dead $((SECONDS - start))s into the stall"
docker unpause radtest-mock >/dev/null
start=$SECONDS
while :; do
n=$((n + 1))
eapol recovery 5 "$ip_radius"
if [[ "$(tail -n 1 "$work/recovery.out")" == SUCCESS ]]; then break; fi
now=$SECONDS
if ((now - start > 120)); then break; fi
sleep 1
done
elapsed=$((SECONDS - start))
echo "recovery: first SUCCESS ${elapsed}s after unpause (attempt $n)"
if [[ "$(tail -n 1 "$work/recovery.out")" == SUCCESS ]] && ((elapsed <= bound)); then
pass "recovery: login succeeds within ${bound}s after the outpost returns (${elapsed}s)"
else
fail "recovery: login succeeds within ${bound}s after the outpost returns (${elapsed}s)"
local probe=()
mapfile -t probe < <(deployment | yq -r '.spec.template.spec.containers[] | select(.name == "freeradius")
| .livenessProbe.exec.command[]')
if docker exec radtest-freeradius "${probe[@]}" >"$work/recovery-probe.out" 2>&1; then
echo "recovery: liveness probe still succeeds"
else
echo "recovery: liveness probe fails"
fi
fi
}
main() {
for f in "$deploy" "$config"; do
[[ -f "$f" ]] || { echo "missing $f" >&2; exit 1; }
done
docker build -q -t "$eapol_image" -f "$here/eapol.Dockerfile" "$here" >/dev/null
render
load_env
start
check_accept vlan-tunnel vlan-tunnel "$ip_radius"
check_accept vlan-avpair vlan-avpair "$ip_radius"
check_reject novlan novlan "$password" 5 "$ip_radius"
check_reject bad-password vlan-tunnel wrong-password 1 "$ip_radius"
check_reject unknown-user bad "$password" 1 "$ip_radius"
check_accounting acct "$ip_radius"
# IPv6 listeners and clients
check_accept v6-vlan-avpair vlan-avpair "$ip6_radius"
check_reject v6-novlan novlan "$password" 5 "$ip6_radius"
check_reject v6-bad-password vlan-tunnel wrong-password 1 "$ip6_radius"
check_accounting v6-acct "[$ip6_radius]"
# dead -> alive needs num_answers_to_alive (3) status checks, check_interval 6s
check_recovery 20
if grep -q "code=4" < <(docker logs radtest-mock 2>&1); then
fail "no accounting forwarded to the outpost"
else
pass "no accounting forwarded to the outpost"
fi
if ((fails > 0)) || [[ "${RADTEST_DEBUG:-0}" == 1 ]]; then
echo "--- freeradius log"; docker logs radtest-freeradius 2>&1
echo "--- mock outpost log"; docker logs radtest-mock 2>&1
for o in "$work"/*.out; do echo "--- $(basename "$o")"; cat "$o"; done
fi
echo
if ((fails > 0)); then echo "$fails check(s) failed"; exit 1; fi
echo "all checks passed"
}
main "$@"