Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64e19b4087 | ||
|
|
f9eb38e2da | ||
|
|
3ed987dd74 | ||
|
|
da58096453 | ||
|
|
06a916ddba | ||
|
|
c6467a54ef | ||
|
|
eb7949b0f0 | ||
|
|
f1d0dde23b | ||
|
|
e33abd320f | ||
|
|
a8affe6955 | ||
|
|
6757eaf361 | ||
|
|
da1dc5b00e | ||
|
|
b05c84ac20 | ||
|
|
378496541c | ||
|
|
50d862d214 | ||
|
|
fae0276045 | ||
|
|
35a387a3c1 | ||
|
|
093857c353 | ||
|
|
7f29ebb878 | ||
|
|
b07167ae1a | ||
|
|
df0b344f7f | ||
|
|
46b7d458b3 | ||
|
|
2d04575c51 | ||
|
|
97310db78c | ||
|
|
085f969f63 | ||
|
|
d00ee70b23 | ||
|
|
8ea8318c6e | ||
|
|
fbc1aaef21 | ||
|
|
be9898f7b0 | ||
|
|
3c5c04d4a5 | ||
|
|
3ed7741c68 | ||
|
|
f73cc54705 | ||
|
|
c8849b8d9c | ||
|
|
9d0707c1aa | ||
|
|
74db40710e | ||
|
|
368dc24add | ||
|
|
dfb2f71c85 | ||
|
|
6b78f4cb5b | ||
|
|
d8d356779d | ||
|
|
2b1016bd4a | ||
|
|
58695ceb9a | ||
|
|
055cc6a6e3 | ||
|
|
2a0a14fb25 | ||
|
|
67372c673f | ||
|
|
ab1f004b48 | ||
|
|
afbc21edc3 | ||
|
|
91d940499e | ||
|
|
4d4ea98b7c | ||
|
|
7e1b46108a | ||
|
|
4fb3a4ba40 | ||
|
|
72927293e2 | ||
|
|
67aadbc0f9 | ||
|
|
bb992833c2 | ||
|
|
fd2f3b7be4 | ||
|
|
a35bc6b91d | ||
|
|
e614c748b9 | ||
|
|
30b0f43911 | ||
|
|
10819fac62 | ||
|
|
286564a0d7 | ||
|
|
1dc5ae7816 | ||
|
|
dd954ce23a | ||
|
|
ada5b7ea4d | ||
|
|
38c186d424 | ||
|
|
d659097b8f | ||
|
|
4baf5aa8be | ||
|
|
fafc73cb3f | ||
|
|
ab876a1231 | ||
|
|
790e258894 | ||
|
|
d8d676b711 | ||
|
|
3f43d5fa17 | ||
|
|
142a21883d | ||
|
|
dc3a299cf6 | ||
|
|
b73e1b5636 | ||
|
|
c8d2d073a5 | ||
|
|
8d3b3c4324 | ||
|
|
7b1041c188 | ||
|
|
9e62fadcb1 | ||
|
|
5e99ca4370 | ||
|
|
94090c68c3 | ||
|
|
b555277634 | ||
|
|
f8b98907e8 | ||
|
|
d7d99365e8 |
@@ -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
|
||||
@@ -101,7 +101,7 @@ steps:
|
||||
bash scripts/ci/make-diag-kubeconfig.sh
|
||||
|
||||
- name: alert-triage-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.302
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -35,25 +35,25 @@ when:
|
||||
steps:
|
||||
- name: model-selection-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.302
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
- name: pr-review-mistral-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.302
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
|
||||
commands:
|
||||
- sh scripts/ci/test-pr-review-mistral.sh
|
||||
|
||||
- name: promtool-rules-guard-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.302
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
|
||||
commands:
|
||||
- sh scripts/ci/test-promtool-rules.sh
|
||||
|
||||
- name: yaml-citations-guard-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.302
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
|
||||
commands:
|
||||
- sh scripts/ci/test-yaml-citations.sh
|
||||
|
||||
|
||||
+2
-2
@@ -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:
|
||||
@@ -26,7 +26,7 @@ steps:
|
||||
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
|
||||
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
|
||||
# test from rotting (it exercises the exact injection shapes).
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.302
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Builds ci/images/hermes (nousresearch/hermes-agent plus the agent's CLIs) as
|
||||
# gitea.renner.uno/konairius/hermes:build.<unix-ts> and :latest.
|
||||
when:
|
||||
- event: push
|
||||
branch: master
|
||||
path:
|
||||
- "ci/images/hermes/**"
|
||||
- ".woodpecker/hermes-image.yml"
|
||||
- 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:
|
||||
# registry-bot write:package PAT (konairius org secret).
|
||||
GITEA_TOKEN:
|
||||
from_secret: registry_bot_token
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- 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)"
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context . \
|
||||
--dockerfile Containerfile \
|
||||
--destination gitea.renner.uno/konairius/hermes:$TAG \
|
||||
--destination gitea.renner.uno/konairius/hermes:latest
|
||||
@@ -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
|
||||
@@ -62,7 +62,7 @@ steps:
|
||||
- bash scripts/ci/make-diag-kubeconfig.sh
|
||||
|
||||
- name: issue-fix-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.302
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -18,12 +18,12 @@ when:
|
||||
|
||||
steps:
|
||||
- name: iac-scan
|
||||
image: aquasec/trivy:latest@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa
|
||||
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
|
||||
commands:
|
||||
- trivy fs --format json -o trivy-iac.json --severity CRITICAL,HIGH --ignore-unfixed .
|
||||
|
||||
- name: image-scans
|
||||
image: aquasec/trivy:latest@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa
|
||||
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
|
||||
commands:
|
||||
- trivy image --download-db-only
|
||||
- |
|
||||
@@ -70,7 +70,7 @@ steps:
|
||||
# this step is skipped — otherwise we'd fork-bomb PRs.
|
||||
when:
|
||||
- branch: master
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.302
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
@@ -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}'
|
||||
|
||||
@@ -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,
|
||||
@@ -30,7 +30,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: update-audit-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.302
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.303
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
@@ -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() {
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
# Prerequisites:
|
||||
# - netbird-server play applied (docker, Traefik on netbird_netbird)
|
||||
# - OpenBao homelab/infrastructure/uptime-kuma with keys username, password
|
||||
# - OpenBao homelab/infrastructure/uptime-kuma/smtp with keys server, port, username, token
|
||||
# - homelab/infrastructure/uptime-kuma-metrics (key api_key) is written by the role
|
||||
# - VAULT_TOKEN in the environment (policy: uptime-kuma-vps)
|
||||
# - DNS for kuma.renner.uno pointing at the VPS
|
||||
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
---
|
||||
uptime_kuma_version: "2.5.6"
|
||||
uptime_kuma_host: kuma.renner.uno
|
||||
# Not routed yet; reserved for the public status page.
|
||||
uptime_kuma_base_url: "https://{{ uptime_kuma_host }}"
|
||||
# Custom domain of the status page; not routed by Traefik yet.
|
||||
uptime_kuma_status_host: uptime.renner.uno
|
||||
uptime_kuma_status_slug: renner
|
||||
uptime_kuma_status_title: Renner
|
||||
uptime_kuma_dir: /opt/uptime-kuma
|
||||
uptime_kuma_traefik_network: netbird_netbird
|
||||
|
||||
uptime_kuma_smtp_from: uptime@renner.uno
|
||||
# Alertmanager email receiver in kubernetes/apps/monitoring/helmrelease-prometheus.yaml.
|
||||
uptime_kuma_smtp_to: admin@renner.uno
|
||||
|
||||
uptime_kuma_openbao_url: https://openbao.renner.uno
|
||||
uptime_kuma_openbao_mount: homelab
|
||||
uptime_kuma_openbao_path: infrastructure/uptime-kuma
|
||||
uptime_kuma_openbao_smtp_path: infrastructure/uptime-kuma/smtp
|
||||
uptime_kuma_openbao_metrics_path: infrastructure/uptime-kuma-metrics
|
||||
|
||||
@@ -1,46 +1,225 @@
|
||||
// Creates the Uptime Kuma admin user on first start; exits 0 if already set up.
|
||||
// Credentials come from KUMA_SETUP_USERNAME / KUMA_SETUP_PASSWORD only.
|
||||
// Bootstraps Uptime Kuma: admin user, general settings, SMTP notification, status page,
|
||||
// and (when KUMA_METRICS_ROTATE=true) a fresh Prometheus API key written to KUMA_METRICS_KEY_FILE.
|
||||
// All input comes from environment variables; secrets are never printed.
|
||||
// Output lines: "admin user created" / "already set up", then "changed: <step>" or "ok: <step>".
|
||||
const fs = require("fs");
|
||||
const { io } = require("socket.io-client");
|
||||
|
||||
const username = process.env.KUMA_SETUP_USERNAME;
|
||||
const password = process.env.KUMA_SETUP_PASSWORD;
|
||||
if (!username || !password) {
|
||||
console.error("KUMA_SETUP_USERNAME and KUMA_SETUP_PASSWORD must be set");
|
||||
const KUMA_URL = "http://127.0.0.1:3001";
|
||||
const EMIT_TIMEOUT_MS = 30000;
|
||||
const TOTAL_TIMEOUT_MS = 180000;
|
||||
// Defaults the Kuma settings page applies to unset keys before saving (src/pages/Settings.vue).
|
||||
const SETTINGS_DEFAULTS = {
|
||||
checkUpdate: true,
|
||||
searchEngineIndex: false,
|
||||
entryPage: "dashboard",
|
||||
nscd: true,
|
||||
keepDataPeriodDays: 180,
|
||||
tlsExpiryNotifyDays: [7, 14, 21],
|
||||
domainExpiryNotifyDays: [7, 14, 21],
|
||||
trustProxy: false,
|
||||
};
|
||||
|
||||
const REQUIRED = [
|
||||
"KUMA_SETUP_USERNAME",
|
||||
"KUMA_SETUP_PASSWORD",
|
||||
"KUMA_BASE_URL",
|
||||
"KUMA_SMTP_HOST",
|
||||
"KUMA_SMTP_PORT",
|
||||
"KUMA_SMTP_USERNAME",
|
||||
"KUMA_SMTP_PASSWORD",
|
||||
"KUMA_SMTP_FROM",
|
||||
"KUMA_SMTP_TO",
|
||||
"KUMA_STATUS_SLUG",
|
||||
"KUMA_STATUS_TITLE",
|
||||
"KUMA_STATUS_DOMAIN",
|
||||
];
|
||||
const missing = REQUIRED.filter((name) => !process.env[name]);
|
||||
const rotateMetricsKey = process.env.KUMA_METRICS_ROTATE === "true";
|
||||
if (rotateMetricsKey && !process.env.KUMA_METRICS_KEY_FILE) {
|
||||
missing.push("KUMA_METRICS_KEY_FILE");
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
console.error(`missing environment variables: ${missing.join(", ")}`);
|
||||
process.exit(2);
|
||||
}
|
||||
const env = process.env;
|
||||
|
||||
const socket = io("http://127.0.0.1:3001", { transports: ["websocket"], reconnection: false });
|
||||
const timer = setTimeout(() => {
|
||||
console.error("timeout: no response from Uptime Kuma within 30s");
|
||||
process.exit(1);
|
||||
}, 30000);
|
||||
const socket = io(KUMA_URL, { transports: ["websocket"], reconnection: false });
|
||||
const lists = {};
|
||||
const LIST_EVENTS = ["notificationList", "apiKeyList", "statusPageList"];
|
||||
for (const event of LIST_EVENTS) {
|
||||
socket.on(event, (data) => {
|
||||
lists[event] = data;
|
||||
});
|
||||
}
|
||||
|
||||
let finished = false;
|
||||
function done(code, msg) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timer);
|
||||
(code === 0 ? console.log : console.error)(msg);
|
||||
clearTimeout(watchdog);
|
||||
if (msg) (code === 0 ? console.log : console.error)(msg);
|
||||
socket.close();
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
const watchdog = setTimeout(() => done(1, `timeout: bootstrap did not finish within ${TOTAL_TIMEOUT_MS / 1000}s`), TOTAL_TIMEOUT_MS);
|
||||
socket.on("connect_error", (err) => done(1, `connect_error: ${err.message}`));
|
||||
socket.on("disconnect", (reason) => done(1, `disconnected before setup finished: ${reason}`));
|
||||
socket.on("disconnect", (reason) => done(1, `disconnected before bootstrap finished: ${reason}`));
|
||||
|
||||
// Kuma registers socket handlers only after emitting info.
|
||||
socket.once("info", () => {
|
||||
socket.emit("needSetup", (needSetup) => {
|
||||
if (!needSetup) {
|
||||
done(0, "already set up");
|
||||
return;
|
||||
}
|
||||
socket.emit("setup", username, password, (res) => {
|
||||
if (res && res.ok) {
|
||||
done(0, "admin user created");
|
||||
} else {
|
||||
done(1, `setup failed: ${res && res.msg ? res.msg : JSON.stringify(res)}`);
|
||||
}
|
||||
function call(event, ...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`timeout waiting for ${event}`)), EMIT_TIMEOUT_MS);
|
||||
socket.emit(event, ...args, (res) => {
|
||||
clearTimeout(timer);
|
||||
resolve(res);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function callOk(event, ...args) {
|
||||
const res = await call(event, ...args);
|
||||
if (!res || !res.ok) {
|
||||
throw new Error(`${event} failed: ${res && res.msg ? res.msg : JSON.stringify(res)}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function report(step, changed) {
|
||||
console.log(`${changed ? "changed" : "ok"}: ${step}`);
|
||||
}
|
||||
|
||||
// Kuma registers socket handlers only after emitting info.
|
||||
function waitForInfo() {
|
||||
return new Promise((resolve) => socket.once("info", resolve));
|
||||
}
|
||||
|
||||
async function setupAdmin() {
|
||||
const needSetup = await call("needSetup");
|
||||
if (!needSetup) {
|
||||
console.log("already set up");
|
||||
return;
|
||||
}
|
||||
await callOk("setup", env.KUMA_SETUP_USERNAME, env.KUMA_SETUP_PASSWORD);
|
||||
console.log("admin user created");
|
||||
}
|
||||
|
||||
async function login() {
|
||||
await callOk("login", { username: env.KUMA_SETUP_USERNAME, password: env.KUMA_SETUP_PASSWORD, token: "" });
|
||||
// afterLogin sends the lists before the login ack but swallows send failures.
|
||||
for (const event of LIST_EVENTS) {
|
||||
if (lists[event] === undefined) {
|
||||
throw new Error(`${event} not received after login`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSettings() {
|
||||
const { data } = await callOk("getSettings");
|
||||
if (data.trustProxy === true && data.primaryBaseURL === env.KUMA_BASE_URL) {
|
||||
report("settings", false);
|
||||
return;
|
||||
}
|
||||
const updated = { ...SETTINGS_DEFAULTS, ...data, trustProxy: true, primaryBaseURL: env.KUMA_BASE_URL };
|
||||
await callOk("setSettings", updated, "");
|
||||
report("settings", true);
|
||||
}
|
||||
|
||||
async function ensureNotification() {
|
||||
const port = Number(env.KUMA_SMTP_PORT);
|
||||
const desired = {
|
||||
name: "email",
|
||||
type: "smtp",
|
||||
isDefault: true,
|
||||
smtpHost: env.KUMA_SMTP_HOST,
|
||||
smtpPort: port,
|
||||
// 465 is implicit TLS; any other port uses STARTTLS.
|
||||
smtpSecure: port === 465,
|
||||
smtpIgnoreSTARTTLS: false,
|
||||
smtpUsername: env.KUMA_SMTP_USERNAME,
|
||||
smtpPassword: env.KUMA_SMTP_PASSWORD,
|
||||
smtpFrom: env.KUMA_SMTP_FROM,
|
||||
smtpTo: env.KUMA_SMTP_TO,
|
||||
};
|
||||
const existing = (lists.notificationList || []).filter((n) => n.name === desired.name).sort((a, b) => a.id - b.id)[0];
|
||||
const current = existing ? JSON.parse(existing.config || "{}") : {};
|
||||
const upToDate =
|
||||
existing && existing.isDefault === true && Object.keys(desired).every((key) => current[key] === desired[key]);
|
||||
if (upToDate) {
|
||||
report("notification", false);
|
||||
return;
|
||||
}
|
||||
await callOk("addNotification", { ...current, ...desired, applyExisting: true }, existing ? existing.id : null);
|
||||
report("notification", true);
|
||||
}
|
||||
|
||||
async function fetchPublicGroupList(slug) {
|
||||
const res = await fetch(`${KUMA_URL}/api/status-page/${encodeURIComponent(slug)}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET /api/status-page/${slug} returned ${res.status}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
return (body.publicGroupList || []).map((group) => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
monitorList: (group.monitorList || []).map((monitor) => {
|
||||
const entry = { id: monitor.id };
|
||||
if (monitor.sendUrl !== undefined) entry.sendUrl = monitor.sendUrl;
|
||||
if (monitor.url !== undefined) entry.url = monitor.url;
|
||||
return entry;
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
async function ensureStatusPage() {
|
||||
const slug = env.KUMA_STATUS_SLUG;
|
||||
let changed = false;
|
||||
const pages = Object.values(lists.statusPageList || {});
|
||||
if (!pages.some((page) => page.slug === slug)) {
|
||||
await callOk("addStatusPage", env.KUMA_STATUS_TITLE, slug);
|
||||
changed = true;
|
||||
}
|
||||
const { config } = await callOk("getStatusPage", slug);
|
||||
const domains = config.domainNameList || [];
|
||||
if (config.title !== env.KUMA_STATUS_TITLE || !domains.includes(env.KUMA_STATUS_DOMAIN)) {
|
||||
// saveStatusPage replaces all groups, so the current ones are sent back unchanged.
|
||||
const publicGroupList = await fetchPublicGroupList(slug);
|
||||
const updated = {
|
||||
...config,
|
||||
title: env.KUMA_STATUS_TITLE,
|
||||
domainNameList: domains.includes(env.KUMA_STATUS_DOMAIN) ? domains : [...domains, env.KUMA_STATUS_DOMAIN],
|
||||
};
|
||||
await callOk("saveStatusPage", slug, updated, config.icon, publicGroupList);
|
||||
changed = true;
|
||||
}
|
||||
report("status page", changed);
|
||||
}
|
||||
|
||||
async function rotateMetricsApiKey() {
|
||||
if (!rotateMetricsKey) {
|
||||
report("metrics api key", false);
|
||||
return;
|
||||
}
|
||||
for (const key of (lists.apiKeyList || []).filter((k) => k.name === "prometheus")) {
|
||||
await callOk("deleteAPIKey", key.id);
|
||||
}
|
||||
const res = await callOk("addAPIKey", { name: "prometheus", expires: null, active: 1 });
|
||||
fs.writeFileSync(env.KUMA_METRICS_KEY_FILE, res.key, { mode: 0o600 });
|
||||
report("metrics api key", true);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await waitForInfo();
|
||||
await setupAdmin();
|
||||
await login();
|
||||
await ensureSettings();
|
||||
await ensureNotification();
|
||||
await ensureStatusPage();
|
||||
await rotateMetricsApiKey();
|
||||
}
|
||||
|
||||
main().then(
|
||||
() => done(0),
|
||||
(err) => done(1, err.message)
|
||||
);
|
||||
|
||||
@@ -14,6 +14,33 @@
|
||||
register: _uptime_kuma_secret
|
||||
no_log: true
|
||||
|
||||
- name: Read SMTP credentials from OpenBao
|
||||
community.hashi_vault.vault_kv2_get:
|
||||
url: "{{ uptime_kuma_openbao_url }}"
|
||||
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
|
||||
path: "{{ uptime_kuma_openbao_smtp_path }}"
|
||||
auth_method: token
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
run_once: true
|
||||
register: _uptime_kuma_smtp
|
||||
no_log: true
|
||||
|
||||
- name: Read metrics API key from OpenBao
|
||||
community.hashi_vault.vault_kv2_get:
|
||||
url: "{{ uptime_kuma_openbao_url }}"
|
||||
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
|
||||
path: "{{ uptime_kuma_openbao_metrics_path }}"
|
||||
auth_method: token
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
run_once: true
|
||||
register: _uptime_kuma_metrics_secret
|
||||
failed_when:
|
||||
- _uptime_kuma_metrics_secret.failed | default(false)
|
||||
- "'Invalid or missing path' not in (_uptime_kuma_metrics_secret.msg | default(''))"
|
||||
no_log: true
|
||||
|
||||
- name: Ensure Uptime Kuma directories exist
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
@@ -22,11 +49,12 @@
|
||||
loop:
|
||||
- "{{ uptime_kuma_dir }}"
|
||||
- "{{ uptime_kuma_dir }}/data"
|
||||
- "{{ uptime_kuma_dir }}/setup"
|
||||
|
||||
- name: Deploy kuma-setup.js
|
||||
ansible.builtin.copy:
|
||||
src: kuma-setup.js
|
||||
dest: "{{ uptime_kuma_dir }}/kuma-setup.js"
|
||||
dest: "{{ uptime_kuma_dir }}/setup/kuma-setup.js"
|
||||
mode: "0644"
|
||||
|
||||
- name: Check for setup marker
|
||||
@@ -54,6 +82,11 @@
|
||||
chdir: "{{ uptime_kuma_dir }}"
|
||||
changed_when: false
|
||||
|
||||
- name: Remove the script from its former single-file mount path
|
||||
ansible.builtin.file:
|
||||
path: "{{ uptime_kuma_dir }}/kuma-setup.js"
|
||||
state: absent
|
||||
|
||||
- name: Wait for Uptime Kuma on loopback
|
||||
ansible.builtin.uri:
|
||||
url: http://127.0.0.1:3001
|
||||
@@ -63,15 +96,80 @@
|
||||
retries: 30
|
||||
delay: 5
|
||||
|
||||
- name: Create the admin user if Uptime Kuma is not set up
|
||||
ansible.builtin.command: docker compose exec -T -e KUMA_SETUP_USERNAME -e KUMA_SETUP_PASSWORD uptime-kuma node /app/kuma-setup.js
|
||||
- name: Check the stored metrics API key against Uptime Kuma
|
||||
ansible.builtin.uri:
|
||||
url: http://127.0.0.1:3001/metrics
|
||||
headers:
|
||||
Authorization: "Basic {{ (':' ~ _uptime_kuma_metrics_key) | b64encode }}"
|
||||
status_code: [200, 401]
|
||||
vars:
|
||||
_uptime_kuma_metrics_key: "{{ (_uptime_kuma_metrics_secret.secret | default({})).api_key | default('') }}"
|
||||
when: _uptime_kuma_metrics_key | length > 0
|
||||
register: _uptime_kuma_metrics_check
|
||||
no_log: true
|
||||
|
||||
- name: Remove a leftover metrics API key handoff file
|
||||
ansible.builtin.file:
|
||||
path: "{{ uptime_kuma_dir }}/data/.metrics-api-key"
|
||||
state: absent
|
||||
|
||||
- name: Bootstrap Uptime Kuma
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- docker
|
||||
- compose
|
||||
- exec
|
||||
- -T
|
||||
- -e
|
||||
- KUMA_SETUP_USERNAME
|
||||
- -e
|
||||
- KUMA_SETUP_PASSWORD
|
||||
- -e
|
||||
- KUMA_BASE_URL
|
||||
- -e
|
||||
- KUMA_SMTP_HOST
|
||||
- -e
|
||||
- KUMA_SMTP_PORT
|
||||
- -e
|
||||
- KUMA_SMTP_USERNAME
|
||||
- -e
|
||||
- KUMA_SMTP_PASSWORD
|
||||
- -e
|
||||
- KUMA_SMTP_FROM
|
||||
- -e
|
||||
- KUMA_SMTP_TO
|
||||
- -e
|
||||
- KUMA_STATUS_SLUG
|
||||
- -e
|
||||
- KUMA_STATUS_TITLE
|
||||
- -e
|
||||
- KUMA_STATUS_DOMAIN
|
||||
- -e
|
||||
- KUMA_METRICS_ROTATE
|
||||
- -e
|
||||
- KUMA_METRICS_KEY_FILE
|
||||
- uptime-kuma
|
||||
- node
|
||||
- /app/setup/kuma-setup.js
|
||||
args:
|
||||
chdir: "{{ uptime_kuma_dir }}"
|
||||
environment:
|
||||
KUMA_SETUP_USERNAME: "{{ _uptime_kuma_secret.secret.username }}"
|
||||
KUMA_SETUP_PASSWORD: "{{ _uptime_kuma_secret.secret.password }}"
|
||||
KUMA_BASE_URL: "{{ uptime_kuma_base_url }}"
|
||||
KUMA_SMTP_HOST: "{{ _uptime_kuma_smtp.secret.server }}"
|
||||
KUMA_SMTP_PORT: "{{ _uptime_kuma_smtp.secret.port }}"
|
||||
KUMA_SMTP_USERNAME: "{{ _uptime_kuma_smtp.secret.username }}"
|
||||
KUMA_SMTP_PASSWORD: "{{ _uptime_kuma_smtp.secret.token }}"
|
||||
KUMA_SMTP_FROM: "{{ uptime_kuma_smtp_from }}"
|
||||
KUMA_SMTP_TO: "{{ uptime_kuma_smtp_to }}"
|
||||
KUMA_STATUS_SLUG: "{{ uptime_kuma_status_slug }}"
|
||||
KUMA_STATUS_TITLE: "{{ uptime_kuma_status_title }}"
|
||||
KUMA_STATUS_DOMAIN: "{{ uptime_kuma_status_host }}"
|
||||
KUMA_METRICS_ROTATE: "{{ 'false' if (_uptime_kuma_metrics_check.status | default(0)) == 200 else 'true' }}"
|
||||
KUMA_METRICS_KEY_FILE: /app/data/.metrics-api-key
|
||||
register: _uptime_kuma_setup
|
||||
changed_when: "'admin user created' in _uptime_kuma_setup.stdout"
|
||||
changed_when: "'admin user created' in _uptime_kuma_setup.stdout or 'changed:' in _uptime_kuma_setup.stdout"
|
||||
failed_when: false
|
||||
no_log: true
|
||||
|
||||
@@ -80,6 +178,33 @@
|
||||
msg: "kuma-setup.js failed: rc={{ _uptime_kuma_setup.rc | default('undefined') }} stdout={{ _uptime_kuma_setup.stdout | default('') }} stderr={{ _uptime_kuma_setup.stderr | default('') }}"
|
||||
when: (_uptime_kuma_setup.rc | default(1)) != 0
|
||||
|
||||
- name: Store the new metrics API key in OpenBao
|
||||
when: "'changed: metrics api key' in _uptime_kuma_setup.stdout"
|
||||
block:
|
||||
- name: Read the new metrics API key
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ uptime_kuma_dir }}/data/.metrics-api-key"
|
||||
register: _uptime_kuma_new_metrics_key
|
||||
no_log: true
|
||||
|
||||
- name: Write the metrics API key to OpenBao
|
||||
community.hashi_vault.vault_kv2_write:
|
||||
url: "{{ uptime_kuma_openbao_url }}"
|
||||
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
|
||||
path: "{{ uptime_kuma_openbao_metrics_path }}"
|
||||
auth_method: token
|
||||
data:
|
||||
api_key: "{{ _uptime_kuma_new_metrics_key.content | b64decode }}"
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
run_once: true
|
||||
no_log: true
|
||||
always:
|
||||
- name: Remove the metrics API key handoff file
|
||||
ansible.builtin.file:
|
||||
path: "{{ uptime_kuma_dir }}/data/.metrics-api-key"
|
||||
state: absent
|
||||
|
||||
- name: Record setup completion
|
||||
ansible.builtin.copy:
|
||||
content: ""
|
||||
|
||||
@@ -9,7 +9,8 @@ services:
|
||||
- "127.0.0.1:3001:3001"
|
||||
volumes:
|
||||
- {{ uptime_kuma_dir }}/data:/app/data
|
||||
- {{ uptime_kuma_dir }}/kuma-setup.js:/app/kuma-setup.js:ro
|
||||
# Directory mount: a single-file bind mount keeps the inode from container start.
|
||||
- {{ uptime_kuma_dir }}/setup:/app/setup:ro
|
||||
labels:
|
||||
- "traefik.enable={{ uptime_kuma_route_enabled | lower }}"
|
||||
- "traefik.http.routers.uptime-kuma.rule=Host(`{{ uptime_kuma_host }}`)"
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
|
||||
# The unit tests gate the image: the final stage copies the controller out of
|
||||
# this stage, so a failing test fails the build.
|
||||
FROM python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170 AS test
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce AS test
|
||||
WORKDIR /src
|
||||
COPY controller.py test_controller.py ./
|
||||
RUN python3 -B -m unittest -v
|
||||
|
||||
FROM python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
|
||||
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
|
||||
COPY --from=test /src/controller.py /opt/controller.py
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
|
||||
FROM node:24-alpine@sha256:44fce696f38ba8c987c4db89954051000bbd05ffd8d8659ba15ed4a8602e937f
|
||||
|
||||
# Default to the latest Claude Code. Pinning a fixed version is the wrong
|
||||
# default here: old releases eventually stop working against the API, so a
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
# namespace, so can0 is only reachable with network_mode: host. That is also
|
||||
# how the local MQTT broker on 127.0.0.1:1883 is reached. CAN_RAW sockets need
|
||||
# no capabilities, so both run unprivileged.
|
||||
FROM python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170 AS build
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce AS build
|
||||
|
||||
# Pins live here, in the artifact, rather than in Ansible variables.
|
||||
#
|
||||
@@ -56,14 +56,14 @@ RUN git clone --quiet https://github.com/MyHomeMyData/E3onCAN.git /opt/e3oncan \
|
||||
&& rm -rf /opt/e3oncan/.git
|
||||
|
||||
|
||||
FROM python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
|
||||
|
||||
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
|
||||
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}"
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM nousresearch/hermes-agent:v0.22.2@sha256:36099926da316742423544e055d4734777105ffe6341873af5459b957d0c2919
|
||||
|
||||
USER root
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends jq \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=ghcr.io/siderolabs/talosctl:v1.13.10@sha256:3116f64c97ddffb53bd3c5bf87c8614d99f04df4326177dbbb7702b5087f0e4d /talosctl /usr/local/bin/talosctl
|
||||
COPY --from=alpine/kubectl:1.36.3@sha256:dae261106d5555008acede256b7102c8a6decde7209198434f5999f051460f57 /usr/local/bin/kubectl /usr/local/bin/kubectl
|
||||
COPY --from=ghcr.io/fluxcd/flux-cli:v2.9.5@sha256:704d5529535570a495249b643159c93a9aa88ffca07813c974a54b7ba6edbd86 /usr/local/bin/flux /usr/local/bin/flux
|
||||
COPY --from=quay.io/openbao/openbao:2.7.1@sha256:6d2b93856e3fcf7b18ad855a0b51eaba474dc8b79cf554379ea32034797d2acf /usr/bin/bao /usr/local/bin/bao
|
||||
COPY --from=gitea/tea:0.16.0@sha256:af50a2060ce4d8fd76b7ebe75a3d6db4bb0c615c5bc8f2bdfd8ea83207c4752d /usr/bin/tea /usr/local/bin/tea
|
||||
|
||||
RUN talosctl version --client \
|
||||
&& kubectl version --client \
|
||||
&& flux version --client \
|
||||
&& bao version \
|
||||
&& tea --version \
|
||||
&& jq --version
|
||||
@@ -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,4 +1,4 @@
|
||||
module gitea.renner.uno/k.renner/homelab/kuma-sync
|
||||
module gitea.renner.uno/konairius/homelab/kuma-sync
|
||||
|
||||
go 1.27
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -7,12 +7,14 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
kuma "github.com/breml/go-uptime-kuma-client"
|
||||
"github.com/breml/go-uptime-kuma-client/monitor"
|
||||
"github.com/breml/go-uptime-kuma-client/notification"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
@@ -23,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 (
|
||||
@@ -395,6 +397,60 @@ func TestE2EDriftRepaired(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func (f *e2e) webhook(name string, isDefault bool) int64 {
|
||||
f.t.Helper()
|
||||
id, err := f.raw.CreateNotification(f.t.Context(), notification.Webhook{
|
||||
Base: notification.Base{Name: name, IsActive: true, IsDefault: isDefault},
|
||||
WebhookDetails: notification.WebhookDetails{
|
||||
WebhookURL: "http://127.0.0.1:9/" + name, WebhookContentType: "json",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
f.t.Fatalf("create notification %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestE2EDefaultNotifications(t *testing.T) {
|
||||
f, env := newE2E(t)
|
||||
first := f.webhook("first", true)
|
||||
f.webhook("opt-in", false)
|
||||
f.start(env)
|
||||
f.apply(group("g", ""), dns("probe", "g"))
|
||||
|
||||
carry := func(want ...int64) func() (bool, string) {
|
||||
return func() (bool, string) {
|
||||
m := byKey(f.owned())
|
||||
for _, k := range []string{"default/g", "default/probe"} {
|
||||
got, ok := m[k]
|
||||
if !ok {
|
||||
return false, k + " missing"
|
||||
}
|
||||
if !slices.Equal(got.NotificationIDs, want) {
|
||||
return false, fmt.Sprintf("%s carries %v, want %v", k, got.NotificationIDs, want)
|
||||
}
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
eventually(t, 30*time.Second, "monitors created with the default notification", carry(first))
|
||||
|
||||
second := f.webhook("second", true)
|
||||
want := []int64{first, second}
|
||||
slices.Sort(want)
|
||||
eventually(t, 30*time.Second, "new default notification attached by a resync", carry(want...))
|
||||
|
||||
var live monitor.DNS
|
||||
if err := f.raw.GetMonitorAs(t.Context(), byKey(f.owned())["default/probe"].ID, &live); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
live.NotificationIDs = nil
|
||||
if err := f.raw.UpdateMonitor(t.Context(), &live); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventually(t, 30*time.Second, "notifications removed by hand restored by a resync", carry(want...))
|
||||
}
|
||||
|
||||
// A monitor that lost its ownership tag in the UI must not get a twin when the
|
||||
// controller restarts: the resource reports NotOwned and nothing is created.
|
||||
func TestE2ETagRemovedNeverDuplicates(t *testing.T) {
|
||||
|
||||
@@ -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 (
|
||||
@@ -129,22 +129,28 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
|
||||
if fail != nil {
|
||||
return r.fail(ctx, km, fail)
|
||||
}
|
||||
notifications, err := r.Kuma.DefaultNotifications(ctx)
|
||||
if err != nil {
|
||||
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
|
||||
}
|
||||
desired.NotificationIDs = notifications
|
||||
|
||||
var id int64
|
||||
if res.mon == nil {
|
||||
// The monitor this resource synced before may have lost its tag; a
|
||||
// second one would then sit beside it.
|
||||
// second one would then sit beside it. An ID now carrying another
|
||||
// owner's key was reused by Kuma, so ours is gone.
|
||||
if prev := km.Status.MonitorID; prev != 0 {
|
||||
found, key, err := r.Kuma.Probe(ctx, prev)
|
||||
switch {
|
||||
case err != nil:
|
||||
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
|
||||
case found && key != desired.OwnerKey:
|
||||
case found && key == "":
|
||||
log.FromContext(ctx).Error(nil, "previous monitor exists without our ownership tag, not creating another",
|
||||
"ownerKey", desired.OwnerKey, "monitorID", prev, "tagValue", key)
|
||||
err := r.setReady(ctx, km, metav1.ConditionFalse, ReasonNotOwned, msgNotOwned, nil)
|
||||
return ctrl.Result{RequeueAfter: r.resync()}, err
|
||||
case found:
|
||||
case found && key == desired.OwnerKey:
|
||||
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable,
|
||||
fmt.Errorf("monitor %d carries our key but is missing from the owned list", prev)})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -16,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.
|
||||
@@ -310,6 +311,79 @@ func TestReconcileListErrorNoCreate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileCreateGetsDefaultNotifications(t *testing.T) {
|
||||
group := &v1.KumaMonitor{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "grp"},
|
||||
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: "grp", Interval: 60, RetryInterval: 60},
|
||||
}
|
||||
e := newEnv(t, httpMonitor("a"), group)
|
||||
e.kuma.SetDefaultNotifications(7, 3)
|
||||
for _, name := range []string{"a", "grp"} {
|
||||
if _, err := e.reconcile(t, name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := e.kuma.Get(e.get(t, name).Status.MonitorID)
|
||||
if got == nil || !slices.Equal(got.NotificationIDs, []int64{3, 7}) {
|
||||
t.Errorf("%s: kuma monitor = %+v, want notifications [3 7]", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileRevertsNotificationDrift(t *testing.T) {
|
||||
km := httpMonitor("a")
|
||||
e := newEnv(t, km)
|
||||
e.kuma.SetDefaultNotifications(1, 2)
|
||||
drifted := Desired(km)
|
||||
drifted.NotificationIDs = []int64{2, 9}
|
||||
id, _ := e.kuma.Create(t.Context(), drifted)
|
||||
if _, err := e.reconcile(t, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.cnt.updates != 1 || e.cnt.creates != 0 {
|
||||
t.Fatalf("updates=%d creates=%d", e.cnt.updates, e.cnt.creates)
|
||||
}
|
||||
if got := e.kuma.Get(id).NotificationIDs; !slices.Equal(got, []int64{1, 2}) {
|
||||
t.Errorf("notifications = %v, want [1 2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileUnchangedNotificationsNoUpdate(t *testing.T) {
|
||||
km := httpMonitor("a")
|
||||
e := newEnv(t, km)
|
||||
e.kuma.SetDefaultNotifications(4, 2)
|
||||
existing := Desired(km)
|
||||
existing.NotificationIDs = []int64{4, 2}
|
||||
e.kuma.Create(t.Context(), existing)
|
||||
for range 2 {
|
||||
if _, err := e.reconcile(t, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if e.cnt.updates != 0 || e.cnt.creates != 0 {
|
||||
t.Fatalf("updates=%d creates=%d, want 0 and 0", e.cnt.updates, e.cnt.creates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileNotificationListErrorNoWrite(t *testing.T) {
|
||||
adopt := httpMonitor("b")
|
||||
e := newEnv(t, httpMonitor("a"), adopt)
|
||||
drifted := Desired(adopt)
|
||||
drifted.Interval = 30
|
||||
e.kuma.Create(t.Context(), drifted)
|
||||
e.kuma.SetNotificationsErr(errors.New("boom"))
|
||||
for _, name := range []string{"a", "b"} {
|
||||
if _, err := e.reconcile(t, name); err == nil {
|
||||
t.Fatalf("%s: expected error", name)
|
||||
}
|
||||
if c := ready(t, e.get(t, name)); c.Status != metav1.ConditionFalse || c.Reason != ReasonKumaUnavailable {
|
||||
t.Errorf("%s: Ready = %+v", name, c)
|
||||
}
|
||||
}
|
||||
if e.cnt.creates != 0 || e.cnt.updates != 0 {
|
||||
t.Errorf("creates=%d updates=%d", e.cnt.creates, e.cnt.updates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileOwnerTagMissing(t *testing.T) {
|
||||
e := newEnv(t, httpMonitor("a"))
|
||||
e.kuma.SetErrs(kumaclient.ErrOwnerTagMissing, nil, nil, nil)
|
||||
@@ -428,7 +502,7 @@ func TestReconcileRefusesCreateBesideUntaggedPrevious(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileRefusesCreateBesideOtherOwnersMonitor(t *testing.T) {
|
||||
func TestReconcileCreatesWhenPreviousIDReusedByOtherOwner(t *testing.T) {
|
||||
e := newEnv(t, withPreviousMonitor(1))
|
||||
if _, err := e.kuma.Create(t.Context(), kumaclient.Monitor{Name: "x", OwnerKey: "default/other"}); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -436,12 +510,15 @@ func TestReconcileRefusesCreateBesideOtherOwnersMonitor(t *testing.T) {
|
||||
if _, err := e.reconcile(t, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.cnt.creates != 0 {
|
||||
t.Errorf("creates = %d, want 0", e.cnt.creates)
|
||||
if e.cnt.creates != 1 {
|
||||
t.Errorf("creates = %d, want 1", e.cnt.creates)
|
||||
}
|
||||
if c := ready(t, e.get(t, "a")); c.Reason != ReasonNotOwned {
|
||||
if c := ready(t, e.get(t, "a")); c.Reason != ReasonSynced {
|
||||
t.Errorf("Ready = %+v", c)
|
||||
}
|
||||
if m := e.kuma.Get(1); m == nil || m.OwnerKey != "default/other" {
|
||||
t.Errorf("other owner's monitor 1 = %+v, want untouched", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileCreatesWhenPreviousMonitorIsGone(t *testing.T) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
kuma "github.com/breml/go-uptime-kuma-client"
|
||||
"github.com/breml/go-uptime-kuma-client/monitor"
|
||||
"github.com/breml/go-uptime-kuma-client/notification"
|
||||
"github.com/breml/go-uptime-kuma-client/tag"
|
||||
)
|
||||
|
||||
@@ -373,8 +374,8 @@ func (b *Breml) update(ctx context.Context, m Monitor) error {
|
||||
return fmt.Errorf("update %d: monitor carries owner key %q, not %q: %w", m.ID, key, m.OwnerKey, ErrNotOwned)
|
||||
}
|
||||
// Start from the full current object, also across a type change, so
|
||||
// fields we do not manage (notifications, proxy, resend interval, upside
|
||||
// down, auth) survive the overwrite.
|
||||
// fields we do not manage (proxy, resend interval, upside down, auth)
|
||||
// survive the overwrite.
|
||||
wire, err := toWire(m, &cur)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -404,18 +405,42 @@ func (b *Breml) Delete(ctx context.Context, id int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// DefaultNotifications reads the notification list cached by the session.
|
||||
// Kuma has no command to request it: it pushes the list at login and to every
|
||||
// session of the user after each change.
|
||||
func (b *Breml) DefaultNotifications(ctx context.Context) ([]int64, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return defaultIDs(b.c.GetNotifications(ctx)), nil
|
||||
}
|
||||
|
||||
func defaultIDs(list []notification.Base) []int64 {
|
||||
var ids []int64
|
||||
for _, n := range list {
|
||||
if n.IsDefault {
|
||||
ids = append(ids, n.ID)
|
||||
}
|
||||
}
|
||||
slices.Sort(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
// toDomain converts a monitor from the Kuma monitor list.
|
||||
func toDomain(base monitor.Base, ownerKey string) (Monitor, error) {
|
||||
m := Monitor{
|
||||
ID: base.ID,
|
||||
Type: base.Type(),
|
||||
Name: base.Name,
|
||||
Parent: base.Parent,
|
||||
Active: base.IsActive,
|
||||
Interval: int32(base.Interval),
|
||||
RetryInterval: int32(base.RetryInterval),
|
||||
MaxRetries: int32(base.MaxRetries),
|
||||
OwnerKey: ownerKey,
|
||||
ID: base.ID,
|
||||
Type: base.Type(),
|
||||
Name: base.Name,
|
||||
Parent: base.Parent,
|
||||
Active: base.IsActive,
|
||||
Interval: int32(base.Interval),
|
||||
RetryInterval: int32(base.RetryInterval),
|
||||
MaxRetries: int32(base.MaxRetries),
|
||||
NotificationIDs: slices.Clone(base.NotificationIDs),
|
||||
OwnerKey: ownerKey,
|
||||
}
|
||||
if base.Description != nil {
|
||||
m.Description = *base.Description
|
||||
@@ -565,6 +590,7 @@ func writeBase(b *monitor.Base, m Monitor) {
|
||||
b.RetryInterval = int64(m.RetryInterval)
|
||||
b.MaxRetries = int64(m.MaxRetries)
|
||||
b.IsActive = m.Active
|
||||
b.NotificationIDs = slices.Clone(m.NotificationIDs)
|
||||
}
|
||||
|
||||
// writeHTTP expects m to be normalized.
|
||||
|
||||
@@ -287,12 +287,10 @@ func TestUpdateKeepsUnchangedFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// setUnmanaged sets state through the raw library that Monitor does not model.
|
||||
func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
|
||||
func createWebhook(t *testing.T, b *Breml, name string, isDefault bool) int64 {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
nid, err := b.c.CreateNotification(ctx, ¬ification.Webhook{
|
||||
Base: notification.Base{Name: "n", IsActive: true},
|
||||
nid, err := b.c.CreateNotification(context.Background(), ¬ification.Webhook{
|
||||
Base: notification.Base{Name: name, IsActive: true, IsDefault: isDefault},
|
||||
WebhookDetails: notification.WebhookDetails{
|
||||
WebhookURL: "http://example.com/hook", WebhookContentType: "application/json",
|
||||
},
|
||||
@@ -300,6 +298,15 @@ func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
|
||||
if err != nil {
|
||||
t.Fatalf("create notification: %v", err)
|
||||
}
|
||||
return nid
|
||||
}
|
||||
|
||||
// setUnmanaged sets state through the raw library that Monitor does not
|
||||
// model, and attaches a notification by hand.
|
||||
func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
nid := createWebhook(t, b, "n", false)
|
||||
var kw monitor.HTTPKeyword
|
||||
if err := b.c.GetMonitorAs(ctx, id, &kw); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -313,16 +320,15 @@ func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
|
||||
return nid
|
||||
}
|
||||
|
||||
func assertUnmanaged(t *testing.T, c *Breml, id, nid int64, when string) {
|
||||
func assertUnmanaged(t *testing.T, c *Breml, id int64, when string) {
|
||||
t.Helper()
|
||||
var base monitor.Base
|
||||
base, err := c.c.GetMonitor(t.Context(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !base.UpsideDown || base.ResendInterval != 7 || !reflect.DeepEqual(base.NotificationIDs, []int64{nid}) {
|
||||
t.Errorf("%s: unmanaged state lost: upsideDown=%v resend=%d notifications=%v",
|
||||
when, base.UpsideDown, base.ResendInterval, base.NotificationIDs)
|
||||
if !base.UpsideDown || base.ResendInterval != 7 {
|
||||
t.Errorf("%s: unmanaged state lost: upsideDown=%v resend=%d", when, base.UpsideDown, base.ResendInterval)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,14 +368,15 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := byID(got)[id]
|
||||
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 {
|
||||
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 || m.NotificationIDs != nil {
|
||||
t.Errorf("managed fields not cleared: %+v", m)
|
||||
}
|
||||
assertUnmanaged(t, fresh, id, nid, "after plain update")
|
||||
assertUnmanaged(t, fresh, id, "after plain update")
|
||||
|
||||
// Type change must not wipe unmanaged state either.
|
||||
changed := cleared
|
||||
changed.Type = "http"
|
||||
changed.NotificationIDs = []int64{nid}
|
||||
if err := b.Update(ctx, changed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -377,10 +384,55 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := byID(got)[id]; m.Type != "http" {
|
||||
t.Errorf("type = %q, want http", m.Type)
|
||||
if m := byID(got)[id]; m.Type != "http" || !reflect.DeepEqual(m.NotificationIDs, []int64{nid}) {
|
||||
t.Errorf("type = %q, notifications = %v, want http and [%d]", m.Type, m.NotificationIDs, nid)
|
||||
}
|
||||
assertUnmanaged(t, fresh, id, "after type change")
|
||||
}
|
||||
|
||||
func TestDefaultNotificationsAndWrite(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
url := startKuma(t)
|
||||
b := setupClient(t, url)
|
||||
other := freshClient(t, url)
|
||||
|
||||
if ids, err := other.DefaultNotifications(ctx); err != nil || ids != nil {
|
||||
t.Fatalf("no notifications: ids=%v err=%v", ids, err)
|
||||
}
|
||||
def := createWebhook(t, b, "default", true)
|
||||
createWebhook(t, b, "opt-in", false)
|
||||
// Kuma pushes the list to every session of the user, not only the writer.
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
ids, err := other.DefaultNotifications(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reflect.DeepEqual(ids, []int64{def}) {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("other session sees defaults %v, want [%d]", ids, def)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
m := Monitor{
|
||||
Type: "group", Name: "grp", Active: true, Interval: 60, RetryInterval: 60,
|
||||
NotificationIDs: []int64{def}, OwnerKey: "default/grp",
|
||||
}
|
||||
id, err := b.Create(ctx, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := freshClient(t, url).ListOwned(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.ID = id
|
||||
if !reflect.DeepEqual(byID(got)[id], m.Normalized()) {
|
||||
t.Errorf("read-back differs:\n got %+v\n want %+v", byID(got)[id], m.Normalized())
|
||||
}
|
||||
assertUnmanaged(t, fresh, id, nid, "after type change")
|
||||
}
|
||||
|
||||
func TestRestartAdoptsByTag(t *testing.T) {
|
||||
|
||||
@@ -63,6 +63,7 @@ func Conflicts(err error) (conflicts []*ConflictError, only bool) {
|
||||
func (m Monitor) clone() Monitor {
|
||||
m.Headers = maps.Clone(m.Headers)
|
||||
m.AcceptedStatusCodes = slices.Clone(m.AcceptedStatusCodes)
|
||||
m.NotificationIDs = slices.Clone(m.NotificationIDs)
|
||||
if m.Parent != nil {
|
||||
p := *m.Parent
|
||||
m.Parent = &p
|
||||
@@ -80,12 +81,18 @@ func isHTTPFamily(typ string) bool {
|
||||
// For http, keyword and json-query: an empty Method becomes GET, empty
|
||||
// AcceptedStatusCodes become ["200-299"], and Timeout 0 becomes
|
||||
// round(0.8*Interval). For all other types Timeout is stored as 0 and the
|
||||
// HTTP-only slices are nil. Empty Headers become nil. It is idempotent.
|
||||
// HTTP-only slices are nil. Empty Headers become nil. NotificationIDs are
|
||||
// sorted and deduplicated, empty becomes nil. It is idempotent.
|
||||
func (m Monitor) Normalized() Monitor {
|
||||
m = m.clone()
|
||||
if len(m.Headers) == 0 {
|
||||
m.Headers = nil
|
||||
}
|
||||
slices.Sort(m.NotificationIDs)
|
||||
m.NotificationIDs = slices.Compact(m.NotificationIDs)
|
||||
if len(m.NotificationIDs) == 0 {
|
||||
m.NotificationIDs = nil
|
||||
}
|
||||
if len(m.AcceptedStatusCodes) == 0 {
|
||||
m.AcceptedStatusCodes = nil
|
||||
}
|
||||
@@ -143,6 +150,9 @@ type Monitor struct {
|
||||
DNSResolveType string
|
||||
DNSResolveServer string
|
||||
|
||||
// NotificationIDs are the Kuma notifications attached to the monitor.
|
||||
NotificationIDs []int64
|
||||
|
||||
// OwnerKey is the value of the kuma-sync tag; "" means not ours.
|
||||
OwnerKey string
|
||||
}
|
||||
@@ -170,4 +180,7 @@ type Client interface {
|
||||
// Delete removes an owned monitor. It wraps ErrNotFound for an unknown ID
|
||||
// and ErrNotOwned for a monitor without the kuma-sync tag.
|
||||
Delete(ctx context.Context, id int64) error
|
||||
// DefaultNotifications returns the IDs of all notifications marked
|
||||
// "default enabled" in Kuma, sorted. An empty result is valid.
|
||||
DefaultNotifications(ctx context.Context) ([]int64, error)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package kumaclient
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
@@ -20,6 +21,10 @@ type Fake struct {
|
||||
UpdateErr error
|
||||
DeleteErr error
|
||||
ProbeErr error
|
||||
// NotificationsErr is returned by DefaultNotifications.
|
||||
NotificationsErr error
|
||||
// Defaults are the IDs DefaultNotifications returns.
|
||||
Defaults []int64
|
||||
// ListPartial makes ListOwned return the stored monitors together with
|
||||
// ListErr, as the real client does for owner conflicts.
|
||||
ListPartial bool
|
||||
@@ -90,6 +95,32 @@ func (f *Fake) Probe(_ context.Context, id int64) (bool, string, error) {
|
||||
return ok, m.OwnerKey, nil
|
||||
}
|
||||
|
||||
// SetDefaultNotifications sets the IDs DefaultNotifications returns.
|
||||
func (f *Fake) SetDefaultNotifications(ids ...int64) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.Defaults = slices.Clone(ids)
|
||||
}
|
||||
|
||||
// SetNotificationsErr makes DefaultNotifications return err.
|
||||
func (f *Fake) SetNotificationsErr(err error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.NotificationsErr = err
|
||||
}
|
||||
|
||||
// DefaultNotifications returns Defaults, sorted.
|
||||
func (f *Fake) DefaultNotifications(_ context.Context) ([]int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.NotificationsErr != nil {
|
||||
return nil, f.NotificationsErr
|
||||
}
|
||||
ids := slices.Clone(f.Defaults)
|
||||
slices.Sort(ids)
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (f *Fake) allocID() int64 {
|
||||
id := f.nextID
|
||||
f.nextID++
|
||||
|
||||
@@ -118,6 +118,21 @@ func TestFakeInjectedErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeDefaultNotifications(t *testing.T) {
|
||||
f := NewFake()
|
||||
if ids, err := f.DefaultNotifications(context.Background()); err != nil || ids != nil {
|
||||
t.Fatalf("empty: %v %v", ids, err)
|
||||
}
|
||||
f.SetDefaultNotifications(4, 1)
|
||||
if ids, err := f.DefaultNotifications(context.Background()); err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 4 {
|
||||
t.Fatalf("set: %v %v", ids, err)
|
||||
}
|
||||
f.SetNotificationsErr(errors.New("boom"))
|
||||
if _, err := f.DefaultNotifications(context.Background()); err == nil {
|
||||
t.Error("expected injected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeUpdateRejectsOtherOwnerKey(t *testing.T) {
|
||||
f := NewFake()
|
||||
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
kuma "github.com/breml/go-uptime-kuma-client"
|
||||
"github.com/breml/go-uptime-kuma-client/notification"
|
||||
"github.com/breml/go-uptime-kuma-client/tag"
|
||||
)
|
||||
|
||||
@@ -167,3 +168,29 @@ func TestFakeStoresNormalized(t *testing.T) {
|
||||
t.Fatalf("fake did not normalize: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizedNotificationIDs(t *testing.T) {
|
||||
in := Monitor{Type: "group", NotificationIDs: []int64{10, 2, 10}}
|
||||
got := in.Normalized()
|
||||
if !reflect.DeepEqual(got.NotificationIDs, []int64{2, 10}) {
|
||||
t.Errorf("notifications = %v, want [2 10]", got.NotificationIDs)
|
||||
}
|
||||
if !reflect.DeepEqual(in.NotificationIDs, []int64{10, 2, 10}) {
|
||||
t.Errorf("Normalized changed its receiver: %v", in.NotificationIDs)
|
||||
}
|
||||
if e := (Monitor{Type: "group", NotificationIDs: []int64{}}).Normalized(); e.NotificationIDs != nil {
|
||||
t.Errorf("empty notifications must be nil: %v", e.NotificationIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultIDs(t *testing.T) {
|
||||
got := defaultIDs([]notification.Base{
|
||||
{ID: 5, IsDefault: true}, {ID: 1}, {ID: 3, IsDefault: true},
|
||||
})
|
||||
if !reflect.DeepEqual(got, []int64{3, 5}) {
|
||||
t.Errorf("defaultIDs = %v, want [3 5]", got)
|
||||
}
|
||||
if got := defaultIDs(nil); got != nil {
|
||||
t.Errorf("defaultIDs(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# domains -> TLS passthrough -> Traefik ClusterIP) from Ingresses carrying the
|
||||
# public label. Standard library only, so the image is the interpreter plus one
|
||||
# file; runs unprivileged as its own user.
|
||||
FROM python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
|
||||
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
|
||||
COPY controller.py /opt/controller.py
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.wifi.managed</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>uno.renner.wifi.renner-test.network</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>c7814579-fc2b-4f38-bcc8-77e1123ab710</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Renner Test Wi-Fi</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string>Renner</string>
|
||||
<key>SSID_STR</key>
|
||||
<string>Renner Test</string>
|
||||
<key>HIDDEN_NETWORK</key>
|
||||
<false/>
|
||||
<key>AutoJoin</key>
|
||||
<true/>
|
||||
<key>EncryptionType</key>
|
||||
<string>Any</string>
|
||||
<key>EAPClientConfiguration</key>
|
||||
<dict>
|
||||
<key>OuterIdentity</key>
|
||||
<string>anonymous</string>
|
||||
<key>AcceptEAPTypes</key>
|
||||
<array>
|
||||
<integer>21</integer>
|
||||
</array>
|
||||
<key>TTLSInnerAuthentication</key>
|
||||
<string>PAP</string>
|
||||
<key>TLSTrustedServerNames</key>
|
||||
<array>
|
||||
<string>radius.renner.uno</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>uno.renner.wifi.renner-test</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>b35513d4-97e5-4804-a77b-5ca1f723a408</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Renner Test Wi-Fi (802.1X)</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string>Renner</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,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).
|
||||
@@ -974,6 +974,71 @@ data:
|
||||
negate: false
|
||||
failure_result: false
|
||||
|
||||
# Hermes Agent dashboard (apps/hermes): its self-hosted OIDC login,
|
||||
# also brokered for Hermes Desktop's native sign-in. Public client +
|
||||
# PKCE (Hermes does not support confidential clients); RS256 so the
|
||||
# dashboard can verify the ID token against the JWKS.
|
||||
- id: hermes-users-group
|
||||
model: authentik_core.group
|
||||
identifiers:
|
||||
name: Hermes Users
|
||||
state: present
|
||||
attrs:
|
||||
is_superuser: false
|
||||
# Members get the agent, which holds a read-only kubeconfig and
|
||||
# the model login. Add/remove members HERE.
|
||||
users:
|
||||
- !Find [authentik_core.user, [username, k.renner]]
|
||||
|
||||
- id: hermes-provider
|
||||
model: authentik_providers_oauth2.oauth2provider
|
||||
identifiers:
|
||||
name: Hermes Dashboard
|
||||
state: present
|
||||
attrs:
|
||||
authentication_flow: null
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
client_type: public
|
||||
client_id: hermes-dashboard
|
||||
# See the Matrix (MAS) provider: a blueprint-made provider starts
|
||||
# with no grant types.
|
||||
grant_types:
|
||||
- authorization_code
|
||||
- refresh_token
|
||||
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
|
||||
property_mappings:
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
|
||||
redirect_uris:
|
||||
- url: https://hermes.renner.uno/auth/callback
|
||||
matching_mode: strict
|
||||
|
||||
# The slug is the issuer path: https://auth.renner.uno/application/o/hermes/
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: hermes
|
||||
state: present
|
||||
attrs:
|
||||
name: Hermes
|
||||
slug: hermes
|
||||
provider: !KeyOf hermes-provider
|
||||
meta_launch_url: https://hermes.renner.uno
|
||||
meta_description: "Hermes Agent dashboard"
|
||||
|
||||
# Only the Hermes Users group passes.
|
||||
- model: authentik_policies.policybinding
|
||||
identifiers:
|
||||
target: !Find [authentik_core.application, [slug, hermes]]
|
||||
group: !KeyOf hermes-users-group
|
||||
order: 0
|
||||
state: present
|
||||
attrs:
|
||||
enabled: true
|
||||
negate: false
|
||||
failure_result: false
|
||||
|
||||
# OpenProject, decommissioned. Application first: Application.provider
|
||||
# is on_delete=PROTECT. Its policy binding cascades with it.
|
||||
- model: authentik_core.application
|
||||
@@ -1251,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
|
||||
@@ -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
|
||||
@@ -114,7 +114,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-15-0857 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
|
||||
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-19-0544 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,80 @@
|
||||
You are Hermes Agent, built by Nous Research, running in the owner's homelab Kubernetes cluster (namespace `hermes`). Be direct: match the length of your reply to the weight of the ask — a one-line question gets a one-line answer, and finished work gets a short report of what changed, what's verified, and what's left, never a replay of the process. No filler ("Great question," "I'd be happy to"), no restating the request back, no re-summarizing what you already said, no narrating tool calls the user can see. Plain claims over adjectives; when unsure, say so plainly. Agree because it's right, not because the user said it. Depth is earned — give it when the user asks for detail, teaches, or the stakes demand it, not by default.
|
||||
|
||||
When you use a pronoun for someone whose pronouns haven't been stated, use they/them.
|
||||
|
||||
# What you can touch
|
||||
|
||||
- `kubectl`: read-only cluster access (the `view` ClusterRole, no Secrets). You cannot change the cluster.
|
||||
- Monitoring read APIs: Prometheus `http://kube-prometheus-stack-prometheus.monitoring.svc:9090`, Alertmanager `http://kube-prometheus-stack-alertmanager.monitoring.svc:9093`, Loki `http://loki.monitoring.svc:3100`.
|
||||
- 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). `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).
|
||||
|
||||
# Ask the wiki first. Always.
|
||||
|
||||
Before kubectl, Prometheus, Loki, web search, or answering from your own knowledge, ask the wiki (`homelab-wiki` skill). Every question, every task, every assumption, including ones that look trivially factual or obviously outside its scope. Then proceed with what it returns, including "gap".
|
||||
|
||||
- Never fill a wiki gap from training data. Say it isn't covered.
|
||||
- Ground every claim through the ladder, in order: wiki → repo → live state (kubectl, metrics, logs) → upstream docs. Never fill a missing rung from recall.
|
||||
- When a task uncovers a non-trivial verified fact, mechanism or decision, deposit it as a wiki fact before you're done. Facts only, never hypotheses.
|
||||
|
||||
# Verify before you claim
|
||||
|
||||
- Verify the exact claim, not an adjacent one: "X works" means you checked X itself (that hostname, that service), not its neighbour.
|
||||
- Read live config and resources before speculating about architecture or proposing a fix.
|
||||
- The first failure is the diagnostic: read its logs and act on them; don't wait for it to heal.
|
||||
- Probe an endpoint (DNS, TCP, HTTP) before telling anyone to point a client at host:port.
|
||||
- Never report something missing or broken from an error string alone; check the exact path/resource first.
|
||||
- `kubectl` pod AGE is creationTimestamp; it says nothing about when a container last failed. Use `containerStatuses[].lastState.terminated.finishedAt`.
|
||||
- "Check the documentation" means the docs site, API reference or man page first; source code only when docs are silent, and say so.
|
||||
- Research a third-party component's upstream docs before describing how to configure it; never from recall.
|
||||
|
||||
# Design rules of this homelab
|
||||
|
||||
Check every proposal against these. If a tool or request contradicts one, name the contradiction instead of designing around it.
|
||||
|
||||
- IPv6 first. Design, troubleshoot and list addresses v6-first; IPv4 is legacy compatibility.
|
||||
- No IP address is fixed. Addresses are lease state; always resolve the hostname.
|
||||
- DHCP is the default; static addressing is bad practice except on the isolated VLANs 47/48. Never suggest DHCP reservations, as fix, workaround or precondition.
|
||||
- The cluster never uses external DNS for its own services.
|
||||
- The PVE DNS VIP `10.2.40.53` is decommissioned and is never a DNS upstream. The upstream is the UDM.
|
||||
- The UDM intercepts DNS: `dig @1.1.1.1` (even against an authoritative server) returns the UDM's split-horizon answer. Use DoH (`https://cloudflare-dns.com/dns-query`, `accept: application/dns-json`) for real public answers.
|
||||
- Never pin a workload to a node without a hardware reason (device, GPU, dongle). Not for "stability"; reach for BGP/native routing instead.
|
||||
- Never propose `hostNetwork` as a shortcut for source IPs, tcpdump or port binding. An existing hostNetwork pod is never there "for discovery".
|
||||
- Control-plane traffic (BGP, leader election, management) never crosses the IoT VLAN.
|
||||
- All changes go in git. State that exists only on a PVC or in a UI is drift, not done.
|
||||
- Zero-touch or nothing: a deploy that needs a manual step to become usable isn't done.
|
||||
- Each service reads only its own `apps/<service>` secrets in OpenBao; sharing a key across services is forbidden.
|
||||
- Fix the data, never loosen auth (e.g. a key with a trailing newline gets fixed at the source, not trimmed by the handler).
|
||||
- Never fork or patch third-party software, and never pin a Helm chart version; make our config work with current upstream.
|
||||
- In-house images are bumped by Flux image automation, not manual tag PRs.
|
||||
- Treat every existing service as essential; never propose decommissioning by pattern.
|
||||
- NetBird proxy-side auth is not redundant with Authentik.
|
||||
- Core infrastructure (e.g. OpenBao) being unavailable is a critical page on first evaluation.
|
||||
- nas-1 is RAID 5 + RAID 1; never describe its drives as SMR/shingled.
|
||||
|
||||
# When proposing operational changes
|
||||
|
||||
- Staged rollouts: apply a multi-target change with no automatic rollback to one target, validate, then broaden. If the first target fails, diagnose it; never roll to more targets to learn more.
|
||||
- Drain before reboot; between rolling reboots wait for Ceph `HEALTH_OK`, not just node Ready.
|
||||
- `rebuild-k8s` wipes disks; it is disaster recovery only. Never propose it; use `talosctl patch machineconfig`.
|
||||
- Never remove OpenTofu state; never use `ansible --limit` (stage with `serial:`); never bypass or loosen a CI pipeline's restrictions.
|
||||
- Moving objects between pruned Flux Kustomizations is an outage unless done in two phases (`prune: false` on the giver first).
|
||||
- Every change goes through a feature branch and a PR for the owner's review; never a direct push, never force-push, never self-merge.
|
||||
- If git history shows a chain of reverts in the area, ask what should change before redoing it.
|
||||
|
||||
# How to work with the owner
|
||||
|
||||
- Lead with the answer. Skip narrating reasoning and caveat surveys; length is earned by content.
|
||||
- Don't overframe: a one-time step is procedure, not a design dilemma with lettered options.
|
||||
- After pushback, correct only the sub-claim the evidence invalidates; don't swing to the opposite claim. When you hold evidence for a settled claim, cite it rather than absorbing doubt.
|
||||
- Stop after two failed attempts at the same subgoal and say so; announce any pivot away from an agreed plan and wait for confirmation.
|
||||
- "Stop doing X" covers the whole category, not just the one artifact. "Clean it up" means only the artifact in context.
|
||||
- A restriction the owner removed stays removed.
|
||||
- Stay in the assigned scope; don't diagnose neighbouring systems because they appeared in the output.
|
||||
- A proposal doc calling an option "recommended" isn't a decision; check it wasn't rejected.
|
||||
- Never print, probe or extract secrets or tokens.
|
||||
- Comments in config files are one terse factual line; rationale and history belong in the commit message or PR.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Bearer key for the OpenAI-compatible API server (hermes-api.renner.uno).
|
||||
# Generated once in-cluster, nothing in OpenBao. Read it with
|
||||
# kubectl -n hermes get secret hermes-api-key -o jsonpath='{.data.API_SERVER_KEY}' | base64 -d
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
metadata:
|
||||
name: hermes-api-key
|
||||
namespace: hermes
|
||||
spec:
|
||||
length: 64
|
||||
digits: 16
|
||||
symbols: 0
|
||||
allowRepeat: true
|
||||
noUpper: false
|
||||
secretKeys:
|
||||
- API_SERVER_KEY
|
||||
---
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: hermes-api-key
|
||||
namespace: hermes
|
||||
spec:
|
||||
refreshPolicy: CreatedOnce
|
||||
target:
|
||||
name: hermes-api-key
|
||||
dataFrom:
|
||||
- sourceRef:
|
||||
generatorRef:
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
name: hermes-api-key
|
||||
@@ -0,0 +1,18 @@
|
||||
# Gitea API token of hermes-bot, mounted as a file for git and the API.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: hermes-gitea
|
||||
namespace: hermes
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: hermes-gitea
|
||||
data:
|
||||
- secretKey: api-token
|
||||
remoteRef:
|
||||
key: apps/hermes/gitea
|
||||
property: api-token
|
||||
@@ -0,0 +1,221 @@
|
||||
# All Hermes state: config.yaml, .env (keys entered in the dashboard), auth.json
|
||||
# (the Anthropic OAuth login), sessions, memories, skills, cron. Mounted at
|
||||
# /opt/data, the image's HERMES_HOME.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: hermes-data
|
||||
namespace: hermes
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: ceph-block
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
---
|
||||
# Hermes Agent: the gateway (messaging platforms, cron, API server on 8642) and
|
||||
# the dashboard (9119, also the Hermes Desktop remote-gateway backend), both
|
||||
# supervised by the image's s6-overlay. s6 must be PID 1 and starts as root,
|
||||
# remapping and dropping to the `hermes` user (10000).
|
||||
#
|
||||
# One replica, Recreate: two gateways must never share one data directory.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: hermes
|
||||
namespace: hermes
|
||||
labels:
|
||||
app.kubernetes.io/name: hermes
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: hermes
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: hermes
|
||||
spec:
|
||||
serviceAccountName: hermes
|
||||
securityContext:
|
||||
fsGroup: 10000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
initContainers:
|
||||
# The dashboard trusts X-Forwarded-* only from listed peers; Traefik
|
||||
# connects from the pod network. No env equivalent exists, so it is
|
||||
# set in config.yaml on every start (idempotent).
|
||||
- name: config
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792397861 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/hermes
|
||||
- config
|
||||
- set
|
||||
- dashboard.trusted_proxies
|
||||
- '["10.244.0.0/16","fd10:244::/48"]'
|
||||
env:
|
||||
- name: HERMES_HOME
|
||||
value: /opt/data
|
||||
- name: HOME
|
||||
value: /opt/data
|
||||
securityContext:
|
||||
runAsUser: 10000
|
||||
runAsGroup: 10000
|
||||
runAsNonRoot: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /opt/data
|
||||
# Skills from git (ConfigMap hermes-skills) beside the PVC's skills/.
|
||||
- name: skills-config
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792397861 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/hermes
|
||||
- config
|
||||
- set
|
||||
- skills.external_dirs
|
||||
- '["/opt/hermes-skills"]'
|
||||
env:
|
||||
- name: HERMES_HOME
|
||||
value: /opt/data
|
||||
- name: HOME
|
||||
value: /opt/data
|
||||
securityContext:
|
||||
runAsUser: 10000
|
||||
runAsGroup: 10000
|
||||
runAsNonRoot: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /opt/data
|
||||
containers:
|
||||
- name: hermes
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792397861 # {"$imagepolicy": "flux-system:hermes"}
|
||||
args: ["gateway", "run"]
|
||||
env:
|
||||
- name: HERMES_DASHBOARD
|
||||
value: "1"
|
||||
- name: HERMES_DASHBOARD_PUBLIC_URL
|
||||
value: https://hermes.renner.uno
|
||||
# Dashboard login: Authentik OIDC, public PKCE client (blueprint
|
||||
# in apps/authentik/blueprints-configmap.yaml, group Hermes Users).
|
||||
- name: HERMES_DASHBOARD_OIDC_ISSUER
|
||||
value: https://auth.renner.uno/application/o/hermes/
|
||||
- name: HERMES_DASHBOARD_OIDC_CLIENT_ID
|
||||
value: hermes-dashboard
|
||||
- name: API_SERVER_ENABLED
|
||||
value: "true"
|
||||
- name: API_SERVER_HOST
|
||||
value: 0.0.0.0
|
||||
# git as hermes-bot: the credential helper reads the mounted token.
|
||||
- name: GIT_CONFIG_COUNT
|
||||
value: "3"
|
||||
- name: GIT_CONFIG_KEY_0
|
||||
value: credential.https://gitea.renner.uno.helper
|
||||
- name: GIT_CONFIG_VALUE_0
|
||||
value: '!f() { test "$1" = get && echo username=hermes-bot && echo "password=$(cat /var/run/secrets/gitea/api-token)"; }; f'
|
||||
- name: GIT_CONFIG_KEY_1
|
||||
value: user.name
|
||||
- name: GIT_CONFIG_VALUE_1
|
||||
value: hermes-bot
|
||||
- name: GIT_CONFIG_KEY_2
|
||||
value: user.email
|
||||
- name: GIT_CONFIG_VALUE_2
|
||||
value: hermes-bot@konstantin-renner.de
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: hermes-api-key
|
||||
ports:
|
||||
- name: dashboard
|
||||
containerPort: 9119
|
||||
- name: api
|
||||
containerPort: 8642
|
||||
# The dashboard is the user-facing surface; /api/status is public.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/status
|
||||
port: dashboard
|
||||
periodSeconds: 10
|
||||
failureThreshold: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/status
|
||||
port: dashboard
|
||||
periodSeconds: 15
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/status
|
||||
port: dashboard
|
||||
periodSeconds: 60
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
memory: 4Gi
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /opt/data
|
||||
# Identity and house rules from git (SOUL.md beside this file).
|
||||
- name: soul
|
||||
mountPath: /opt/data/SOUL.md
|
||||
subPath: SOUL.md
|
||||
readOnly: true
|
||||
- name: skills
|
||||
mountPath: /opt/hermes-skills
|
||||
readOnly: true
|
||||
- name: wiki-key
|
||||
mountPath: /var/run/secrets/wiki
|
||||
readOnly: true
|
||||
- name: gitea-token
|
||||
mountPath: /var/run/secrets/gitea
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: hermes-data
|
||||
- name: soul
|
||||
configMap:
|
||||
name: hermes-soul
|
||||
- name: skills
|
||||
configMap:
|
||||
name: hermes-skills
|
||||
items:
|
||||
- key: homelab-wiki-SKILL.md
|
||||
path: homelab-wiki/SKILL.md
|
||||
- key: homelab-wiki-wiki.py
|
||||
path: homelab-wiki/scripts/wiki.py
|
||||
- name: wiki-key
|
||||
secret:
|
||||
secretName: hermes-wiki
|
||||
optional: true
|
||||
- name: gitea-token
|
||||
secret:
|
||||
secretName: hermes-gitea
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: hermes
|
||||
namespace: hermes
|
||||
labels:
|
||||
app.kubernetes.io/name: hermes
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: hermes
|
||||
ports:
|
||||
- name: dashboard
|
||||
port: 9119
|
||||
targetPort: dashboard
|
||||
- name: api
|
||||
port: 8642
|
||||
targetPort: api
|
||||
@@ -0,0 +1,59 @@
|
||||
# Both hosts are LAN-only (no dns.renner.uno/public or netbird.renner.uno/public
|
||||
# label).
|
||||
#
|
||||
# hermes.renner.uno — dashboard and Hermes Desktop remote gateway. Plain
|
||||
# Traefik, not the authentik-gate class: Hermes Desktop calls the API with a
|
||||
# Bearer token from its native (PKCE) sign-in, which a forward-auth gate would
|
||||
# redirect. Authentik still guards it: the dashboard's own OIDC login against
|
||||
# the `hermes` application, restricted to the Hermes Users group.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: hermes
|
||||
namespace: hermes
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- hermes.renner.uno
|
||||
secretName: hermes-tls
|
||||
rules:
|
||||
- host: hermes.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: hermes
|
||||
port:
|
||||
number: 9119
|
||||
---
|
||||
# hermes-api.renner.uno — the OpenAI-compatible API server. Hermes checks
|
||||
# `Authorization: Bearer <API_SERVER_KEY>` itself (secret hermes-api-key).
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: hermes-api
|
||||
namespace: hermes
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- hermes-api.renner.uno
|
||||
secretName: hermes-api-tls
|
||||
rules:
|
||||
- host: hermes-api.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: hermes
|
||||
port:
|
||||
number: 8642
|
||||
@@ -0,0 +1,19 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- rbac.yaml
|
||||
- api-key.yaml
|
||||
- wiki-externalsecret.yaml
|
||||
- gitea-externalsecret.yaml
|
||||
- hermes.yaml
|
||||
- ingress.yaml
|
||||
configMapGenerator:
|
||||
- name: hermes-soul
|
||||
namespace: hermes
|
||||
files:
|
||||
- SOUL.md
|
||||
- name: hermes-skills
|
||||
namespace: hermes
|
||||
files:
|
||||
- homelab-wiki-SKILL.md=skills/homelab-wiki/SKILL.md
|
||||
- homelab-wiki-wiki.py=skills/homelab-wiki/scripts/wiki.py
|
||||
@@ -0,0 +1,22 @@
|
||||
# Read-only cluster access for the agent's kubectl, as for claude-workstation:
|
||||
# the built-in `view` ClusterRole, which excludes Secrets. The token is the
|
||||
# pod's projected ServiceAccount token; kubectl picks it up as in-cluster
|
||||
# config.
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: hermes
|
||||
namespace: hermes
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: hermes-view
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: view
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: hermes
|
||||
namespace: hermes
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: homelab-wiki
|
||||
description: Ask and extend the homelab wiki, the owner's long-term knowledge base (services, hosts, network, past incidents, decisions, tool quirks). Use FIRST for every question, task or assumption about the homelab, before kubectl, Prometheus, Loki, web search or answering from memory; and to deposit verified findings.
|
||||
version: 1.0.0
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [homelab, knowledge, wiki]
|
||||
category: research
|
||||
---
|
||||
|
||||
# Homelab wiki
|
||||
|
||||
The wiki service (`http://wiki.ai.svc.cluster.local:8080`) owns the whole pipeline:
|
||||
raw sources → distilled notes. Talk to it only through
|
||||
`/opt/hermes-skills/homelab-wiki/scripts/wiki.py` (Python stdlib; it reads the API key itself, never print it):
|
||||
|
||||
```
|
||||
python3 /opt/hermes-skills/homelab-wiki/scripts/wiki.py question "<one self-contained question>"
|
||||
python3 /opt/hermes-skills/homelab-wiki/scripts/wiki.py search "<query>" [limit]
|
||||
python3 /opt/hermes-skills/homelab-wiki/scripts/wiki.py note <slug>
|
||||
python3 /opt/hermes-skills/homelab-wiki/scripts/wiki.py fact --title "<title>" --source "<where it came from>" [--tag X] [--observed 2026-10-19T08:00:00Z] <<'EOF'
|
||||
<content, ≥40 characters>
|
||||
EOF
|
||||
```
|
||||
|
||||
## Reading
|
||||
|
||||
- One question per `question` call; mixed questions degrade every answer.
|
||||
- `covered` ≥ 0.7: trust the answer. 0.4–0.7: partial; use what the evidence
|
||||
supports and say what is missing. Below 0.4 or `gap` set: the wiki does not
|
||||
know. Say so; never fill the gap from training data.
|
||||
- Cite the `[[slug]]`s when relaying facts. Preserve disagreement the wiki
|
||||
surfaces; don't silently pick a winner.
|
||||
- `search` + `note` when evidence is cut off or points at another note. Exact
|
||||
identifiers (hostnames, error strings, commit hashes) work as queries.
|
||||
- Answers may come back in German; relay them in the user's language.
|
||||
|
||||
## Writing
|
||||
|
||||
- Deposit with `fact` when a task uncovered a non-trivial, verified fact,
|
||||
mechanism or decision rationale. The service distills it into notes.
|
||||
- Facts only: observed state, command output, quoted docs, decisions the owner
|
||||
stated. No hypotheses, suspicions or same-session interpretations that have
|
||||
not survived challenge.
|
||||
- `--source` names the evidence (command, URL, file@commit, "owner, Matrix
|
||||
2026-10-19").
|
||||
- To correct a wrong note, deposit the verified correction with its evidence;
|
||||
there is no edit endpoint.
|
||||
- Never put secrets in a fact.
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Client for the homelab wiki service (in-cluster, API key auth).
|
||||
|
||||
wiki.py question "<question>" answer with evidence and a coverage gate
|
||||
wiki.py search "<query>" [limit] ranked slugs + titles
|
||||
wiki.py note <slug> full note
|
||||
wiki.py fact --title T --source S [--tag X]... [--observed ISO] < content
|
||||
deposit a raw source (the service distills it)
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
URL = os.environ.get("WIKI_URL", "http://wiki.ai.svc.cluster.local:8080")
|
||||
KEY_FILE = os.environ.get("WIKI_API_KEY_FILE", "/var/run/secrets/wiki/api-key")
|
||||
KEY = open(KEY_FILE).read().strip() if os.path.exists(KEY_FILE) else ""
|
||||
|
||||
|
||||
def call(method, path, body=None):
|
||||
headers = {"Accept": "application/json"}
|
||||
if KEY:
|
||||
headers["X-Api-Key"] = KEY
|
||||
data = None
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(URL + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
return json.load(resp)
|
||||
except urllib.error.HTTPError as e:
|
||||
sys.exit(f"wiki: HTTP {e.code}\n{e.read().decode(errors='replace')}")
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(prog="wiki.py")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
q = sub.add_parser("question")
|
||||
q.add_argument("question")
|
||||
s = sub.add_parser("search")
|
||||
s.add_argument("query")
|
||||
s.add_argument("limit", nargs="?", type=int, default=10)
|
||||
n = sub.add_parser("note")
|
||||
n.add_argument("slug")
|
||||
f = sub.add_parser("fact")
|
||||
f.add_argument("--title", required=True)
|
||||
f.add_argument("--source", required=True)
|
||||
f.add_argument("--tag", action="append", default=[])
|
||||
f.add_argument("--observed")
|
||||
a = p.parse_args()
|
||||
|
||||
if a.cmd == "question":
|
||||
r = call("POST", "/api/v1/questions", {"question": a.question, "save": False})
|
||||
out = {k: r.get(k) for k in ("answer", "gap", "covered")}
|
||||
out["evidence"] = [{k: e.get(k) for k in ("slug", "heading", "text")} for e in r.get("evidence") or []]
|
||||
print(json.dumps(out, indent=2, ensure_ascii=False))
|
||||
elif a.cmd == "search":
|
||||
qs = urllib.parse.urlencode({"q": a.query, "limit": a.limit})
|
||||
for hit in call("GET", f"/api/v1/notes?{qs}"):
|
||||
print(f"{str(hit['score'])[:5]} {hit['slug']} — {hit['title']}")
|
||||
elif a.cmd == "note":
|
||||
r = call("GET", "/api/v1/notes/" + urllib.parse.quote(a.slug, safe=""))
|
||||
print(f"slug: {r['slug']} updated: {r.get('updated')}")
|
||||
print("sources:\n" + "\n".join(" - " + s["text"] for s in r.get("sources") or []))
|
||||
print("related: " + ", ".join(r.get("related") or []))
|
||||
print("backlinks: " + ", ".join(sorted({b["fromSlug"] for b in r.get("backlinks") or []})))
|
||||
print()
|
||||
print(r.get("body", ""))
|
||||
elif a.cmd == "fact":
|
||||
if not KEY:
|
||||
sys.exit(f"wiki: fact needs the API key at {KEY_FILE}")
|
||||
content = sys.stdin.read()
|
||||
if len(content) < 40:
|
||||
sys.exit("wiki: content must be at least 40 characters (service rule)")
|
||||
body = {"title": a.title, "source": a.source, "kind": "fact", "content": content, "tags": a.tag}
|
||||
if a.observed:
|
||||
body["observed_at"] = a.observed
|
||||
r = call("POST", "/api/v1/facts", body)
|
||||
print(f"stored {r['path']} (commit {r['commit'][:8]}, by {r['capturedBy']})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,19 @@
|
||||
# Hermes's wiki API key (identity `hermes`); the wiki's copy is
|
||||
# apps/ai/wiki-api-key-hermes.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: hermes-wiki
|
||||
namespace: hermes
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: hermes-wiki
|
||||
data:
|
||||
- secretKey: api-key
|
||||
remoteRef:
|
||||
key: apps/hermes/wiki
|
||||
property: api_key
|
||||
@@ -61,7 +61,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: shelly-manager
|
||||
image: gitea.renner.uno/k.renner/shelly-manager:build.1791777553 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
image: gitea.renner.uno/k.renner/shelly-manager:build.1792387459 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
|
||||
@@ -159,7 +159,7 @@ spec:
|
||||
# substitution to POD_IP so nginx-runtime variables like $host
|
||||
# / $http_x_forwarded_for in the template stay intact.
|
||||
- name: proxy
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b9241c6e7b8e9a862f129d8d4199ab64b10390949a78bdd5603379b32c844083
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:378d0c32f6562f7fd9629170797f57d2b953aa09be983b3daaf4809bb039ca00
|
||||
env:
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
@@ -203,7 +203,7 @@ spec:
|
||||
# readiness probe on purpose: a broker outage must not take HA out of
|
||||
# its Service endpoints.
|
||||
- name: mqtt-tls
|
||||
image: ghostunnel/ghostunnel:v1.11.3@sha256:2599b8a04bae16d70a4209495618dea61b46a00a9a05fcf74da282688ad3517f
|
||||
image: ghostunnel/ghostunnel:v1.11.4@sha256:e5248233863aadd36383a11c92360f2981141881ef05e38360a6ada8630d36b1
|
||||
args:
|
||||
- client
|
||||
- --listen=127.0.0.1:11883
|
||||
@@ -304,7 +304,7 @@ spec:
|
||||
# dedicated long-lived token.
|
||||
# Runs as root to match HA's root-owned /config files. See config-sync.sh.
|
||||
- name: config-sync
|
||||
image: alpine/git:2.54.0@sha256:ce84fd6a057b4dc5cf5009da2dd832f1c6d096c1984bf50496cf3c256402264d
|
||||
image: alpine/git:2.54.0@sha256:03228d7ded483b0b3e55e1de7fa660d70d3e1bb432fa7021d29e192640de11aa
|
||||
command: ["/bin/sh", "/scripts/sync.sh"]
|
||||
env:
|
||||
- name: SYNC_BRANCH
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.23.0
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled
|
||||
name: kumamonitors.uptime.renner.uno
|
||||
spec:
|
||||
group: uptime.renner.uno
|
||||
names:
|
||||
kind: KumaMonitor
|
||||
listKind: KumaMonitorList
|
||||
plural: kumamonitors
|
||||
singular: kumamonitor
|
||||
scope: Namespaced
|
||||
versions:
|
||||
- additionalPrinterColumns:
|
||||
- jsonPath: .spec.type
|
||||
name: Type
|
||||
type: string
|
||||
- jsonPath: .status.conditions[?(@.type=="Ready")].status
|
||||
name: Ready
|
||||
type: string
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
properties:
|
||||
apiVersion:
|
||||
description: |-
|
||||
APIVersion defines the versioned schema of this representation of an object.
|
||||
Servers should convert recognized schemas to the latest internal value, and
|
||||
may reject unrecognized values.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||
type: string
|
||||
kind:
|
||||
description: |-
|
||||
Kind is a string value representing the REST resource this object represents.
|
||||
Servers may infer this from the endpoint the client submits requests to.
|
||||
Cannot be updated.
|
||||
In CamelCase.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
displayName:
|
||||
minLength: 1
|
||||
type: string
|
||||
dns:
|
||||
properties:
|
||||
hostname:
|
||||
minLength: 1
|
||||
type: string
|
||||
port:
|
||||
default: 53
|
||||
format: int32
|
||||
maximum: 65535
|
||||
minimum: 1
|
||||
type: integer
|
||||
resolveServer:
|
||||
default: 1.1.1.1
|
||||
type: string
|
||||
resolveType:
|
||||
enum:
|
||||
- A
|
||||
- AAAA
|
||||
- CNAME
|
||||
- MX
|
||||
- NS
|
||||
- PTR
|
||||
- SOA
|
||||
- SRV
|
||||
- TXT
|
||||
- CAA
|
||||
type: string
|
||||
required:
|
||||
- hostname
|
||||
- resolveType
|
||||
type: object
|
||||
http:
|
||||
description: HTTPSpec configures http, keyword and json-query monitors.
|
||||
properties:
|
||||
acceptedStatusCodes:
|
||||
default:
|
||||
- 200-299
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
body:
|
||||
type: string
|
||||
expiryNotification:
|
||||
type: boolean
|
||||
headers:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
ignoreTLS:
|
||||
type: boolean
|
||||
maxRedirects:
|
||||
format: int32
|
||||
type: integer
|
||||
method:
|
||||
default: GET
|
||||
type: string
|
||||
url:
|
||||
minLength: 1
|
||||
type: string
|
||||
required:
|
||||
- url
|
||||
type: object
|
||||
interval:
|
||||
default: 60
|
||||
description: Seconds.
|
||||
format: int32
|
||||
minimum: 20
|
||||
type: integer
|
||||
jsonQuery:
|
||||
properties:
|
||||
expected:
|
||||
type: string
|
||||
expression:
|
||||
description: JSONata expression.
|
||||
minLength: 1
|
||||
type: string
|
||||
operator:
|
||||
enum:
|
||||
- ==
|
||||
- '!='
|
||||
- <
|
||||
- <=
|
||||
- '>'
|
||||
- '>='
|
||||
- contains
|
||||
type: string
|
||||
required:
|
||||
- expected
|
||||
- expression
|
||||
- operator
|
||||
type: object
|
||||
keyword:
|
||||
properties:
|
||||
invert:
|
||||
type: boolean
|
||||
keyword:
|
||||
minLength: 1
|
||||
type: string
|
||||
required:
|
||||
- keyword
|
||||
type: object
|
||||
maxRetries:
|
||||
default: 0
|
||||
format: int32
|
||||
minimum: 0
|
||||
type: integer
|
||||
parentRef:
|
||||
description: LocalRef references a KumaMonitor in the same namespace.
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
paused:
|
||||
type: boolean
|
||||
port:
|
||||
properties:
|
||||
hostname:
|
||||
minLength: 1
|
||||
type: string
|
||||
port:
|
||||
format: int32
|
||||
maximum: 65535
|
||||
minimum: 1
|
||||
type: integer
|
||||
required:
|
||||
- hostname
|
||||
- port
|
||||
type: object
|
||||
retryInterval:
|
||||
default: 60
|
||||
description: Seconds.
|
||||
format: int32
|
||||
minimum: 20
|
||||
type: integer
|
||||
timeout:
|
||||
description: Seconds.
|
||||
format: int32
|
||||
minimum: 1
|
||||
type: integer
|
||||
type:
|
||||
enum:
|
||||
- group
|
||||
- http
|
||||
- keyword
|
||||
- json-query
|
||||
- dns
|
||||
- port
|
||||
type: string
|
||||
required:
|
||||
- displayName
|
||||
- type
|
||||
type: object
|
||||
x-kubernetes-validations:
|
||||
- message: http is required for types http, keyword and json-query, and
|
||||
forbidden otherwise
|
||||
rule: (self.type in ['http','keyword','json-query']) == has(self.http)
|
||||
- message: keyword is required for type keyword and forbidden otherwise
|
||||
rule: (self.type == 'keyword') == has(self.keyword)
|
||||
- message: jsonQuery is required for type json-query and forbidden otherwise
|
||||
rule: (self.type == 'json-query') == has(self.jsonQuery)
|
||||
- message: dns is required for type dns and forbidden otherwise
|
||||
rule: (self.type == 'dns') == has(self.dns)
|
||||
- message: port is required for type port and forbidden otherwise
|
||||
rule: (self.type == 'port') == has(self.port)
|
||||
- message: type group takes no type blocks
|
||||
rule: self.type != 'group' || !(has(self.http) || has(self.keyword)
|
||||
|| has(self.jsonQuery) || has(self.dns) || has(self.port))
|
||||
status:
|
||||
properties:
|
||||
conditions:
|
||||
items:
|
||||
description: Condition contains details for one aspect of the current
|
||||
state of this API Resource.
|
||||
properties:
|
||||
lastTransitionTime:
|
||||
description: |-
|
||||
lastTransitionTime is the last time the condition transitioned from one status to another.
|
||||
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
||||
format: date-time
|
||||
type: string
|
||||
message:
|
||||
description: |-
|
||||
message is a human readable message indicating details about the transition.
|
||||
This may be an empty string.
|
||||
maxLength: 32768
|
||||
type: string
|
||||
observedGeneration:
|
||||
description: |-
|
||||
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
||||
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
||||
with respect to the current state of the instance.
|
||||
format: int64
|
||||
minimum: 0
|
||||
type: integer
|
||||
reason:
|
||||
description: |-
|
||||
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
||||
Producers of specific condition types may define expected values and meanings for this field,
|
||||
and whether the values are considered a guaranteed API.
|
||||
The value should be a CamelCase string.
|
||||
This field may not be empty.
|
||||
maxLength: 1024
|
||||
minLength: 1
|
||||
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
||||
type: string
|
||||
status:
|
||||
description: status of the condition, one of True, False, Unknown.
|
||||
enum:
|
||||
- "True"
|
||||
- "False"
|
||||
- Unknown
|
||||
type: string
|
||||
type:
|
||||
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||
maxLength: 316
|
||||
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
||||
type: string
|
||||
required:
|
||||
- lastTransitionTime
|
||||
- message
|
||||
- reason
|
||||
- status
|
||||
- type
|
||||
type: object
|
||||
type: array
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
monitorID:
|
||||
format: int64
|
||||
type: integer
|
||||
observedGeneration:
|
||||
format: int64
|
||||
type: integer
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
type: object
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
||||
@@ -0,0 +1,71 @@
|
||||
# kuma-sync: KumaMonitor -> Uptime Kuma monitors on kuma.renner.uno (ci/images/kuma-sync).
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: kuma-sync
|
||||
namespace: kuma-sync
|
||||
labels:
|
||||
app.kubernetes.io/name: kuma-sync
|
||||
annotations:
|
||||
secret.reloader.stakater.com/reload: kuma-sync-kuma
|
||||
spec:
|
||||
replicas: 1
|
||||
# One writer against Kuma at a time; the controller runs without leader election.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: kuma-sync
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: kuma-sync
|
||||
spec:
|
||||
serviceAccountName: kuma-sync
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-19-0646 # {"$imagepolicy": "flux-system:kuma-sync"}
|
||||
env:
|
||||
- name: KUMA_URL
|
||||
value: https://kuma.renner.uno
|
||||
- name: KUMA_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: kuma-sync-kuma
|
||||
key: username
|
||||
- name: KUMA_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: kuma-sync-kuma
|
||||
key: password
|
||||
ports:
|
||||
- name: metrics
|
||||
containerPort: 8080
|
||||
- name: probes
|
||||
containerPort: 8081
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: probes
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: probes
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
memory: 256Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
terminationGracePeriodSeconds: 30
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
# Uptime Kuma login for kuma-sync; OpenBao homelab/infrastructure/kuma-sync.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: kuma-sync-kuma
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: kuma-sync-kuma
|
||||
data:
|
||||
- secretKey: username
|
||||
remoteRef:
|
||||
key: infrastructure/kuma-sync
|
||||
property: username
|
||||
- secretKey: password
|
||||
remoteRef:
|
||||
key: infrastructure/kuma-sync
|
||||
property: password
|
||||
@@ -0,0 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- crd-kumamonitors.yaml
|
||||
- externalsecret.yaml
|
||||
- rbac.yaml
|
||||
- deployment.yaml
|
||||
- monitors
|
||||
@@ -0,0 +1,53 @@
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: audiobooks
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: group
|
||||
displayName: Audiobooks
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: audiobooks-health
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: keyword
|
||||
displayName: Audiobookshelf Health
|
||||
parentRef:
|
||||
name: audiobooks
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 10
|
||||
http:
|
||||
url: https://audiobooks.renner.uno/healthcheck
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
keyword:
|
||||
keyword: OK
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: audiobooks-status
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: json-query
|
||||
displayName: Audiobookshelf Server
|
||||
parentRef:
|
||||
name: audiobooks
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 10
|
||||
http:
|
||||
# initialized-server flag from the unauthenticated status endpoint
|
||||
url: https://audiobooks.renner.uno/status
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
jsonQuery:
|
||||
expression: isInit
|
||||
operator: ==
|
||||
expected: "true"
|
||||
@@ -0,0 +1,48 @@
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: auth
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: group
|
||||
displayName: Authentik
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: auth-live
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: http
|
||||
displayName: Authentik Live
|
||||
parentRef:
|
||||
name: auth
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 10
|
||||
http:
|
||||
# ungated health endpoint, empty body
|
||||
url: https://auth.renner.uno/-/health/live/
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: auth-ready
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: http
|
||||
displayName: Authentik Ready
|
||||
parentRef:
|
||||
name: auth
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 10
|
||||
http:
|
||||
# ungated readiness endpoint (database and cache), empty body
|
||||
url: https://auth.renner.uno/-/health/ready/
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
@@ -0,0 +1,30 @@
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: docs
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: group
|
||||
displayName: Documents
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: docs-gate
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: keyword
|
||||
displayName: Documents Login
|
||||
parentRef:
|
||||
name: docs
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 20
|
||||
http:
|
||||
# Paperless is fully behind the Authentik gate; asserts the login page the redirect chain ends on
|
||||
url: https://docs.renner.uno/
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
keyword:
|
||||
keyword: <title>authentik</title>
|
||||
@@ -0,0 +1,53 @@
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: home
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: group
|
||||
displayName: Home Assistant
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: home-api
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: json-query
|
||||
displayName: Home Assistant API
|
||||
parentRef:
|
||||
name: home
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 10
|
||||
http:
|
||||
# unauthenticated login-provider list, served by the HA core backend
|
||||
url: https://home.renner.uno/auth/providers
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
jsonQuery:
|
||||
expression: providers[type="homeassistant"].name
|
||||
operator: ==
|
||||
expected: Home Assistant Local
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: home-web
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: keyword
|
||||
displayName: Home Assistant Web
|
||||
parentRef:
|
||||
name: home
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 10
|
||||
http:
|
||||
url: https://home.renner.uno/
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
keyword:
|
||||
keyword: <title>Home Assistant</title>
|
||||
@@ -0,0 +1,31 @@
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: immich
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: group
|
||||
displayName: Immich
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: immich-api
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: json-query
|
||||
displayName: Immich API
|
||||
parentRef:
|
||||
name: immich
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 10
|
||||
http:
|
||||
url: https://immich.renner.uno/api/server/ping
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
jsonQuery:
|
||||
expression: res
|
||||
operator: ==
|
||||
expected: pong
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- renner-uno.yaml
|
||||
- home.yaml
|
||||
- immich.yaml
|
||||
- auth.yaml
|
||||
- docs.yaml
|
||||
- audiobooks.yaml
|
||||
- third-party.yaml
|
||||
@@ -0,0 +1,51 @@
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: renner-uno
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: group
|
||||
displayName: Renner.uno Website
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: renner-uno-www
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: keyword
|
||||
displayName: www.renner.uno
|
||||
parentRef:
|
||||
name: renner-uno
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 10
|
||||
http:
|
||||
url: https://www.renner.uno/
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
keyword:
|
||||
keyword: Familie Renner
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: renner-uno-apex
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: keyword
|
||||
displayName: renner.uno
|
||||
parentRef:
|
||||
name: renner-uno
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 10
|
||||
http:
|
||||
# 301 to www, followed
|
||||
url: https://renner.uno/
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
keyword:
|
||||
keyword: Familie Renner
|
||||
@@ -0,0 +1,87 @@
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: third-party
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: group
|
||||
displayName: Third-party
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: mcb-portal
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: http
|
||||
displayName: MCB Portal
|
||||
parentRef:
|
||||
name: third-party
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 15
|
||||
http:
|
||||
# root 302s to / to set a session cookie; Kuma keeps no cookies, so do not follow
|
||||
url: https://portal.multi-circuit-boards.eu/
|
||||
acceptedStatusCodes: ["200-299", "302"]
|
||||
maxRedirects: 0
|
||||
expiryNotification: true
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: mcb-tech
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: http
|
||||
displayName: MCB Technician Portal
|
||||
parentRef:
|
||||
name: third-party
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 15
|
||||
http:
|
||||
url: https://tech.multi-circuit-boards.eu/
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: mcb-www
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: http
|
||||
displayName: MCB Website
|
||||
parentRef:
|
||||
name: third-party
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 15
|
||||
http:
|
||||
# 301 to /index.html, followed
|
||||
url: https://www.multi-circuit-boards.eu/
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
---
|
||||
apiVersion: uptime.renner.uno/v1alpha1
|
||||
kind: KumaMonitor
|
||||
metadata:
|
||||
name: turm1388
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
type: http
|
||||
displayName: Wohnen im Turm
|
||||
parentRef:
|
||||
name: third-party
|
||||
interval: 60
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
timeout: 15
|
||||
http:
|
||||
url: https://turm1388.de/
|
||||
acceptedStatusCodes: ["200-299"]
|
||||
expiryNotification: true
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: kuma-sync
|
||||
namespace: kuma-sync
|
||||
labels:
|
||||
app.kubernetes.io/name: kuma-sync
|
||||
---
|
||||
# Cluster-wide: KumaMonitor objects live in every app namespace.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: kuma-sync
|
||||
rules:
|
||||
- apiGroups: ["uptime.renner.uno"]
|
||||
resources: ["kumamonitors"]
|
||||
verbs: ["get", "list", "watch", "update", "patch"]
|
||||
- apiGroups: ["uptime.renner.uno"]
|
||||
resources: ["kumamonitors/status"]
|
||||
verbs: ["get", "update", "patch"]
|
||||
- apiGroups: ["uptime.renner.uno"]
|
||||
resources: ["kumamonitors/finalizers"]
|
||||
verbs: ["update"]
|
||||
- apiGroups: [""]
|
||||
resources: ["events"]
|
||||
verbs: ["create", "patch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["namespaces"]
|
||||
verbs: ["get"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: kuma-sync
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: kuma-sync
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: kuma-sync
|
||||
namespace: kuma-sync
|
||||
@@ -18,6 +18,7 @@ resources:
|
||||
- prometheusrule-netbird-availability.yaml
|
||||
- prometheusrule-netbird-ingress.yaml
|
||||
- prometheusrule-openbao.yaml
|
||||
- prometheusrule-uptime-kuma.yaml
|
||||
# Prometheus's own retention. Until REN-107 `retention: 90d` and
|
||||
# `retentionSize: 30GB` were both set, the size cap bound first, and the
|
||||
# effective window was a 20.5-27.2 day saw-tooth with nothing reporting it.
|
||||
@@ -83,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
|
||||
|
||||
@@ -44,6 +44,8 @@ spec:
|
||||
# traverses the WAN and detects a controller/DC outage (the
|
||||
# 2026-07-24 one ran ~20 h with no signal). Dashboard root, 200.
|
||||
- https://netbird.renner.uno
|
||||
# Dead-man probe for the off-site Uptime Kuma.
|
||||
- https://kuma.renner.uno
|
||||
# NetBird relay health endpoints on the netcup members (relay
|
||||
# binary serves :9000/health, verified on 0.73.2). Plain http,
|
||||
# ports outside the hairpin's 80/443 — real WAN path.
|
||||
|
||||
@@ -26,6 +26,7 @@ spec:
|
||||
# proxy-1/2:9000/health same file, relay redundancy
|
||||
# ci.renner.uno WoodpeckerDown
|
||||
# openbao .../sys/health OpenBaoUnavailable
|
||||
# kuma.renner.uno UptimeKumaUnreachable
|
||||
#
|
||||
# Warning, not critical, on purpose: this rule cannot know what a given
|
||||
# target means. A target whose loss deserves a critical page gets its
|
||||
@@ -33,7 +34,7 @@ spec:
|
||||
# exclusion list from being arbitrary.
|
||||
# anchor: blackbox-probe-failing
|
||||
- alert: BlackboxProbeFailing
|
||||
expr: probe_success{job="blackbox-http", instance!~"https://fsn1\\.your-objectstorage\\.com|https://netbird\\.renner\\.uno|http://proxy-[12]\\.renner\\.uno:9000/health|https://ci\\.renner\\.uno|https://openbao\\.renner\\.uno/v1/sys/health.*"} == 0
|
||||
expr: probe_success{job="blackbox-http", instance!~"https://fsn1\\.your-objectstorage\\.com|https://netbird\\.renner\\.uno|http://proxy-[12]\\.renner\\.uno:9000/health|https://ci\\.renner\\.uno|https://kuma\\.renner\\.uno|https://openbao\\.renner\\.uno/v1/sys/health.*"} == 0
|
||||
for: 10m
|
||||
annotations:
|
||||
summary: "Blackbox probe of {{ $labels.instance }} has been failing for 10 minutes"
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,35 @@
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: uptime-kuma
|
||||
namespace: monitoring
|
||||
labels:
|
||||
# kube-prometheus-stack's Prometheus selects on this label.
|
||||
release: kube-prometheus-stack
|
||||
spec:
|
||||
groups:
|
||||
- name: uptime-kuma.rules
|
||||
rules:
|
||||
# Dead-man probe for the off-site Uptime Kuma.
|
||||
- alert: UptimeKumaUnreachable
|
||||
expr: probe_success{job="blackbox-http", instance="https://kuma.renner.uno"} == 0
|
||||
for: 10m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Uptime Kuma (kuma.renner.uno) unreachable for 10m+"
|
||||
description: "The blackbox probe of the off-site Uptime Kuma has been failing for 10 minutes. The external endpoints it monitors are unwatched until it returns. Check the NetBird VPS (netbird.renner.uno), the uptime-kuma container and Traefik."
|
||||
|
||||
# monitor_response_time is ms; -1 when a beat has no ping (DOWN, group).
|
||||
# anchor: uptime-kuma-slow-response
|
||||
- alert: UptimeKumaSlowResponse
|
||||
expr: avg by (monitor_name) (avg_over_time((monitor_response_time > 0)[15m:1m])) > 1500
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Uptime Kuma monitor {{ $labels.monitor_name }} averaging {{ $value | humanize }} ms response time"
|
||||
description: >-
|
||||
The average response time of monitor {{ $labels.monitor_name }},
|
||||
as measured from the off-site Uptime Kuma, has stayed above
|
||||
1500 ms for 15 minutes.
|
||||
@@ -32,6 +32,7 @@ retired the depth rule in favour of one persistence rule (`> 5` for `20m`).
|
||||
| `ceph_test.yaml` | Hand-written suite for the `CephPoolLostRedundancyBlind` canary in `prometheusrule-ceph.yaml` (REN-93). |
|
||||
| `disk-wear_test.yaml` | Hand-written suite for the EPHEMERAL exclusion in `prometheusrule-disk-wear.yaml` (REN-419). |
|
||||
| `gitea_test.yaml` | Hand-written suite for `GiteaDataVolumeLow` in `prometheusrule-gitea.yaml` (REN-583). |
|
||||
| `uptime-kuma_test.yaml` | Hand-written suite for `prometheusrule-uptime-kuma.yaml` and the kuma exclusion in `prometheusrule-blackbox.yaml`. |
|
||||
| `mutations.txt` | Negative controls. Each must make the suite fail. |
|
||||
|
||||
## Adding a suite
|
||||
|
||||
@@ -172,3 +172,12 @@ disk-wear-ephemeral-keeps-partition|kubernetes/apps/monitoring-rules/prometheusr
|
||||
# exactly-25% case in gitea_test.yaml.
|
||||
gitea-for-10m|kubernetes/apps/monitoring-rules/prometheusrule-gitea.yaml|/alert: GiteaDataVolumeLow/,/for:/s/for: 15m/for: 10m/
|
||||
gitea-floor-le|kubernetes/apps/monitoring-rules/prometheusrule-gitea.yaml|s/^\([[:space:]]*\)< 0\.25$/\1<= 0.25/
|
||||
|
||||
# prometheusrule-uptime-kuma.yaml / blackbox kuma exclusion. Detected by the
|
||||
# cases in uptime-kuma_test.yaml.
|
||||
kuma-slow-no-positive-filter|kubernetes/apps/monitoring-rules/prometheusrule-uptime-kuma.yaml|s/(monitor_response_time > 0)/(monitor_response_time)/
|
||||
kuma-slow-threshold-2500|kubernetes/apps/monitoring-rules/prometheusrule-uptime-kuma.yaml|s/\[15m:1m\])) > 1500$/[15m:1m])) > 2500/
|
||||
kuma-slow-threshold-900|kubernetes/apps/monitoring-rules/prometheusrule-uptime-kuma.yaml|s/\[15m:1m\])) > 1500$/[15m:1m])) > 900/
|
||||
kuma-slow-for-30m|kubernetes/apps/monitoring-rules/prometheusrule-uptime-kuma.yaml|/alert: UptimeKumaSlowResponse/,/for:/s/for: 15m/for: 30m/
|
||||
kuma-down-for-20m|kubernetes/apps/monitoring-rules/prometheusrule-uptime-kuma.yaml|/alert: UptimeKumaUnreachable/,/for:/s/for: 10m/for: 20m/
|
||||
kuma-blackbox-exclusion-dropped|kubernetes/apps/monitoring-rules/prometheusrule-blackbox.yaml|s/kuma\\\\\.renner/kumx\\\\.renner/
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Suite for prometheusrule-uptime-kuma.yaml and the kuma exclusion in
|
||||
# prometheusrule-blackbox.yaml.
|
||||
rule_files:
|
||||
- ../prometheusrule-uptime-kuma.yaml
|
||||
- ../prometheusrule-blackbox.yaml
|
||||
|
||||
evaluation_interval: 1m
|
||||
|
||||
tests:
|
||||
# 2000 ms sustained: pending at 0m, fires 15m later, not before.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'monitor_response_time{monitor_name="slow-mon"}'
|
||||
values: '2000+0x40'
|
||||
alert_rule_test:
|
||||
- eval_time: 14m
|
||||
alertname: UptimeKumaSlowResponse
|
||||
exp_alerts: []
|
||||
- eval_time: 16m
|
||||
alertname: UptimeKumaSlowResponse
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
monitor_name: slow-mon
|
||||
exp_annotations:
|
||||
summary: "Uptime Kuma monitor slow-mon averaging 2k ms response time"
|
||||
description: "The average response time of monitor slow-mon, as measured from the off-site Uptime Kuma, has stayed above 1500 ms for 15 minutes."
|
||||
|
||||
# 1000 ms stays silent; a monitor reporting only -1 (DOWN, group) stays silent.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'monitor_response_time{monitor_name="fast-mon"}'
|
||||
values: '1000+0x40'
|
||||
- series: 'monitor_response_time{monitor_name="down-mon"}'
|
||||
values: '-1+0x40'
|
||||
alert_rule_test:
|
||||
- eval_time: 35m
|
||||
alertname: UptimeKumaSlowResponse
|
||||
exp_alerts: []
|
||||
|
||||
# Alternating 1000 and -1: the -1 samples must not drag a slow average down
|
||||
# nor count as slow; positives average 1000, silent.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'monitor_response_time{monitor_name="flap-fast"}'
|
||||
values: '1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1'
|
||||
alert_rule_test:
|
||||
- eval_time: 35m
|
||||
alertname: UptimeKumaSlowResponse
|
||||
exp_alerts: []
|
||||
|
||||
# Alternating 2000 and -1: the -1 samples are excluded, so the average of the
|
||||
# real samples is 2000 and the alert fires. Unfiltered, the mean is ~500.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'monitor_response_time{monitor_name="flap-slow"}'
|
||||
values: '2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1'
|
||||
alert_rule_test:
|
||||
- eval_time: 35m
|
||||
alertname: UptimeKumaSlowResponse
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
monitor_name: flap-slow
|
||||
exp_annotations:
|
||||
summary: "Uptime Kuma monitor flap-slow averaging 2k ms response time"
|
||||
description: "The average response time of monitor flap-slow, as measured from the off-site Uptime Kuma, has stayed above 1500 ms for 15 minutes."
|
||||
|
||||
# Dead-man probe: critical after 10m of failure; the catch-all warning must
|
||||
# not also fire for kuma, but still fires for another failing instance.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'probe_success{job="blackbox-http",instance="https://kuma.renner.uno"}'
|
||||
values: '0+0x40'
|
||||
- series: 'probe_success{job="blackbox-http",instance="https://other.renner.uno"}'
|
||||
values: '0+0x40'
|
||||
alert_rule_test:
|
||||
- eval_time: 9m
|
||||
alertname: UptimeKumaUnreachable
|
||||
exp_alerts: []
|
||||
- eval_time: 11m
|
||||
alertname: UptimeKumaUnreachable
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: critical
|
||||
job: blackbox-http
|
||||
instance: https://kuma.renner.uno
|
||||
exp_annotations:
|
||||
summary: "Uptime Kuma (kuma.renner.uno) unreachable for 10m+"
|
||||
description: "The blackbox probe of the off-site Uptime Kuma has been failing for 10 minutes. The external endpoints it monitors are unwatched until it returns. Check the NetBird VPS (netbird.renner.uno), the uptime-kuma container and Traefik."
|
||||
- eval_time: 15m
|
||||
alertname: BlackboxProbeFailing
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
job: blackbox-http
|
||||
instance: https://other.renner.uno
|
||||
exp_annotations:
|
||||
summary: "Blackbox probe of https://other.renner.uno has been failing for 10 minutes"
|
||||
description: "The endpoint is not answering as expected. For the Authentik-gated hostnames this is a reachability check covering DNS, TLS, Traefik and the gate outpost — it says nothing about the app behind the gate, which the internal /-/healthy probes cover separately."
|
||||
|
||||
# Healthy probe stays silent.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'probe_success{job="blackbox-http",instance="https://kuma.renner.uno"}'
|
||||
values: '1+0x40'
|
||||
alert_rule_test:
|
||||
- eval_time: 30m
|
||||
alertname: UptimeKumaUnreachable
|
||||
exp_alerts: []
|
||||
@@ -97,6 +97,10 @@ spec:
|
||||
resources:
|
||||
requests:
|
||||
storage: 50Gi
|
||||
# Mounted at /etc/prometheus/secrets/<name>/; see
|
||||
# ./uptime-kuma-metrics-externalsecret.yaml.
|
||||
secrets:
|
||||
- uptime-kuma-metrics
|
||||
# Additional scrape targets not covered by ServiceMonitors.
|
||||
additionalScrapeConfigs:
|
||||
# Gitea exposes /metrics anonymously on its main HTTP port. The
|
||||
@@ -106,6 +110,16 @@ spec:
|
||||
static_configs:
|
||||
- targets: ["gitea.gitea.svc.cluster.local:3000"]
|
||||
metrics_path: /metrics
|
||||
# Off-site Uptime Kuma. /metrics takes the API key as the basic-auth
|
||||
# password with an empty username.
|
||||
- job_name: uptime-kuma
|
||||
scheme: https
|
||||
metrics_path: /metrics
|
||||
basic_auth:
|
||||
username: ""
|
||||
password_file: /etc/prometheus/secrets/uptime-kuma-metrics/api_key
|
||||
static_configs:
|
||||
- targets: ["kuma.renner.uno:443"]
|
||||
|
||||
# Grafana
|
||||
grafana:
|
||||
|
||||
@@ -3,6 +3,7 @@ kind: Kustomization
|
||||
resources:
|
||||
- grafana-oauth-externalsecret.yaml
|
||||
- alertmanager-smtp-externalsecret.yaml
|
||||
- uptime-kuma-metrics-externalsecret.yaml
|
||||
- helmrelease-prometheus.yaml
|
||||
- helmrelease-loki.yaml
|
||||
- helmrelease-alloy-syslog.yaml
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
# Uptime Kuma /metrics API key for Prometheus; OpenBao homelab/infrastructure/uptime-kuma-metrics.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: uptime-kuma-metrics
|
||||
namespace: monitoring
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: uptime-kuma-metrics
|
||||
data:
|
||||
- secretKey: api_key
|
||||
remoteRef:
|
||||
key: infrastructure/uptime-kuma-metrics
|
||||
property: api_key
|
||||
@@ -32,10 +32,10 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: review-bot
|
||||
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-18-0706 # {"$imagepolicy": "flux-system:review-bot"}
|
||||
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
|
||||
|
||||
@@ -24,7 +24,8 @@ spec:
|
||||
key: apps/ai/wiki-index
|
||||
property: webhook_secret
|
||||
---
|
||||
# One API key per machine identity: OpenBao apps/ai/wiki-api-keys, field = name.
|
||||
# One API key per machine identity: OpenBao apps/ai/wiki-api-keys, field = name;
|
||||
# hermes in apps/ai/wiki-api-key-hermes.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
@@ -42,6 +43,10 @@ spec:
|
||||
remoteRef:
|
||||
key: apps/ai/wiki-api-keys
|
||||
property: workstation
|
||||
- secretKey: Auth__ApiKeys__hermes
|
||||
remoteRef:
|
||||
key: apps/ai/wiki-api-key-hermes
|
||||
property: api_key
|
||||
---
|
||||
# TypeSafe System One key for the judge adapter (Wiki:Judges:Models:typesafe).
|
||||
apiVersion: external-secrets.io/v1
|
||||
|
||||
@@ -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,18 @@
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: hermes
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 2m
|
||||
timeout: 5m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./kubernetes/apps/hermes
|
||||
prune: true
|
||||
wait: true
|
||||
dependsOn:
|
||||
- name: infrastructure
|
||||
- name: infrastructure-crds
|
||||
@@ -0,0 +1,25 @@
|
||||
# kuma-sync controller: KumaMonitor -> Uptime Kuma monitors. Ships no KumaMonitor objects.
|
||||
# healthCheckExprs: Flux has no status reader for ExternalSecret; fail only on the non-retrying reasons.
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: kuma-sync
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 2m
|
||||
timeout: 5m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./kubernetes/apps/kuma-sync
|
||||
prune: true
|
||||
wait: true
|
||||
healthCheckExprs:
|
||||
- apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
failed: "has(status.conditions) && status.conditions.exists(c, c.type == 'Ready' && c.status == 'False' && (c.reason == 'SecretOwnedByOther' || c.reason == 'SecretImmutable'))"
|
||||
current: "has(status.conditions) && status.conditions.exists(c, c.type == 'Ready' && c.status == 'True')"
|
||||
dependsOn:
|
||||
- name: infrastructure
|
||||
- name: infrastructure-crds
|
||||
@@ -44,6 +44,7 @@ existing `flux-system` GitRepository whose Secret was created by
|
||||
| netbird-ingress | `0.x` | `-rootless` suffix filter; pre-1.0, bounded to the `0.x` major |
|
||||
| wyoming-{whisper,openwakeword,speech-to-phrase} | `3.x` / `2.x` / `1.x` | |
|
||||
| authentik-gate-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
|
||||
| kuma-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
|
||||
| gatus | `5.x` | v-prefix filter |
|
||||
| velero-plugin-for-aws | `1.13.x` | Tight: must match velero server major.minor |
|
||||
| gotenberg | `8.x` | Plain-variant filter (rejects `-libreoffice`/`-chromium`/`-cloudrun`/`-aws-lambda`) |
|
||||
|
||||
@@ -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,7 +4,7 @@ metadata:
|
||||
name: gitea-mcp
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: docker.gitea.com/gitea-mcp-server
|
||||
image: docker.gitea.com/gitea-mcp-server@sha256:20701c3dcf3b71df384417d04f9f6382205b7f362380b258fb8e4dcf9599091f
|
||||
interval: 1h
|
||||
---
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: hermes
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: gitea.renner.uno/konairius/hermes
|
||||
interval: 10m
|
||||
---
|
||||
# .woodpecker/hermes-image.yml tags every build `build.<unix-ts>`; latest push wins.
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: hermes
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: hermes
|
||||
filterTags:
|
||||
pattern: '^build\.(?P<ts>\d+)$'
|
||||
extract: '$ts'
|
||||
policy:
|
||||
numerical:
|
||||
order: asc
|
||||
@@ -0,0 +1,23 @@
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: kuma-sync
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: gitea.renner.uno/gitea_admin/kuma-sync
|
||||
interval: 30m
|
||||
---
|
||||
# build-ci-images.yml tags each build YYYY-MM-DD-HHMM; newest wins.
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: kuma-sync
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: kuma-sync
|
||||
filterTags:
|
||||
pattern: '^\d{4}-\d{2}-\d{2}-\d{4}$'
|
||||
policy:
|
||||
alphabetical:
|
||||
order: asc
|
||||
@@ -7,11 +7,14 @@ resources:
|
||||
- backup-verify.yaml
|
||||
- ceph.yaml
|
||||
- cnpg-barman-plugin.yaml
|
||||
- freeradius.yaml
|
||||
- gatus.yaml
|
||||
- gitea-mcp.yaml
|
||||
- gotenberg.yaml
|
||||
- hermes.yaml
|
||||
- home-assistant.yaml
|
||||
- immich.yaml
|
||||
- kuma-sync.yaml
|
||||
- lidarr.yaml
|
||||
- matter-server.yaml
|
||||
- mmwave-configurator.yaml
|
||||
|
||||
@@ -288,3 +288,18 @@ apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: renovate
|
||||
---
|
||||
# kuma-sync controller; declared here because it has a CiliumNetworkPolicy applied by infrastructure-crds.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: kuma-sync
|
||||
---
|
||||
# Hermes Agent (kubernetes/apps/hermes). Declared here so it exists before
|
||||
# infrastructure-crds applies its CNPs. No pod-security labels: the image's
|
||||
# s6-overlay init starts as root and drops to the `hermes` user, which the
|
||||
# cluster's default `baseline` enforcement allows.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: hermes
|
||||
|
||||
@@ -141,8 +141,8 @@ spec:
|
||||
protocol: TCP
|
||||
---
|
||||
# wiki: Traefik (the machine door wiki-index.renner.uno, BasicAuth with
|
||||
# X-Authentik-Username stripped) and Gitea (push webhook) may reach it; the
|
||||
# gate outpost (humans, after Authentik) is admitted
|
||||
# X-Authentik-Username stripped), Gitea (push webhook) and Hermes Agent (API
|
||||
# key) may reach it; the gate outpost (humans, after Authentik) is admitted
|
||||
# by the CNP wiki-gate that authentik-gate-sync generates. Gitea and wiki-embed
|
||||
# are reached through the namespace egress above; no OpenBao access.
|
||||
apiVersion: cilium.io/v2
|
||||
@@ -162,6 +162,9 @@ spec:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": gitea
|
||||
app.kubernetes.io/name: gitea
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": hermes
|
||||
app.kubernetes.io/name: hermes
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
# Baseline policy for the `hermes` namespace (Hermes Agent, apps/hermes).
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: default-deny
|
||||
namespace: hermes
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": hermes
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": monitoring
|
||||
- fromEntities:
|
||||
- host
|
||||
- remote-node
|
||||
- fromEndpoints:
|
||||
- matchExpressions:
|
||||
- key: io.kubernetes.pod.namespace
|
||||
operator: Exists
|
||||
icmps:
|
||||
- fields:
|
||||
- { type: 0, family: IPv4 }
|
||||
- { type: 3, family: IPv4 }
|
||||
- { type: 8, family: IPv4 }
|
||||
- { type: 11, family: IPv4 }
|
||||
- { type: 12, family: IPv4 }
|
||||
- { type: 1, family: IPv6 }
|
||||
- { type: 2, family: IPv6 }
|
||||
- { type: 3, family: IPv6 }
|
||||
- { type: 4, family: IPv6 }
|
||||
- { type: 128, family: IPv6 }
|
||||
- { type: 129, family: IPv6 }
|
||||
- { type: 133, family: IPv6 }
|
||||
- { type: 134, family: IPv6 }
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": hermes
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
# Every LAN hostname (gitea, home, auth for the dashboard's OIDC
|
||||
# discovery, matrix) via Traefik.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ingress
|
||||
app.kubernetes.io/name: traefik
|
||||
---
|
||||
# The agent itself: model APIs (api.anthropic.com), web tools, messaging
|
||||
# platforms, read-only kubectl, and the monitoring read APIs. The matching
|
||||
# ingress grant on the monitoring side is monitoring-ns.yaml
|
||||
# (monitoring-allow-hermes).
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: hermes-egress
|
||||
namespace: hermes
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: hermes
|
||||
egress:
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
- port: "80"
|
||||
protocol: TCP
|
||||
# `kube-apiserver` is its own entity in Cilium; port is the post-DNAT 6443.
|
||||
- toEntities:
|
||||
- kube-apiserver
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "6443"
|
||||
protocol: TCP
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": monitoring
|
||||
app.kubernetes.io/name: prometheus
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "9090"
|
||||
protocol: TCP
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": monitoring
|
||||
app.kubernetes.io/name: alertmanager
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "9093"
|
||||
protocol: TCP
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": monitoring
|
||||
app.kubernetes.io/name: loki
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "3100"
|
||||
protocol: TCP
|
||||
# The wiki service API; ingress side is the `wiki` CNP in ai-ns.yaml.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ai
|
||||
app.kubernetes.io/name: wiki
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
---
|
||||
# Traefik serves the dashboard (9119) and the OpenAI-compatible API (8642).
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: hermes-allow-traefik
|
||||
namespace: hermes
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: hermes
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ingress
|
||||
app.kubernetes.io/name: traefik
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "9119"
|
||||
protocol: TCP
|
||||
- port: "8642"
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,38 @@
|
||||
# Baseline policy for the `kuma-sync` namespace: egress to kube-dns, kube-apiserver and kuma.renner.uno:443; ingress only kubelet probes.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: default-deny
|
||||
namespace: kuma-sync
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
- fromEntities:
|
||||
- host
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8081"
|
||||
protocol: TCP
|
||||
egress:
|
||||
# rules.dns lets Cilium learn the toFQDNs mapping
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
- toEntities:
|
||||
- kube-apiserver
|
||||
- toFQDNs:
|
||||
- matchName: "kuma.renner.uno"
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user