Compare commits
118
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adedc98b50 | ||
|
|
1f228c94ef | ||
|
|
9232937b85 | ||
|
|
707f6ab840 | ||
|
|
7600a9dbf9 | ||
|
|
b12ef67002 | ||
|
|
e6e0e44b6a | ||
|
|
39b7fc7fc3 | ||
|
|
33dd968a25 | ||
|
|
d8813a68b7 | ||
|
|
9dcaf42f96 | ||
|
|
d033503fc3 | ||
|
|
66c56288a7 | ||
|
|
2b309dec92 | ||
|
|
396f839567 | ||
|
|
2e412e6448 | ||
|
|
7f61ad0ce9 | ||
|
|
6503161146 | ||
|
|
01133ca106 | ||
|
|
ce5be4d067 | ||
|
|
150e8229fd | ||
|
|
0247b3dfd5 | ||
|
|
9b7a64e48c | ||
|
|
aee26df4b8 | ||
|
|
0bf2cd309e | ||
|
|
73d85b4aa8 | ||
|
|
b78cacb6c4 | ||
|
|
b753ba4776 | ||
|
|
84c0561146 | ||
|
|
be4e605d65 | ||
|
|
10cb1c2152 | ||
|
|
ae8ce26a01 | ||
|
|
1f1125c49c | ||
|
|
1467596309 | ||
|
|
8ef130da2e | ||
|
|
2e677483db | ||
|
|
532b853929 | ||
|
|
af7103fd75 | ||
|
|
1984b01e5d | ||
|
|
e02ad7639b | ||
|
|
80f5310653 | ||
|
|
9895b7f4f4 | ||
|
|
c380c7c2b8 | ||
|
|
0f86537bb1 | ||
|
|
31459dac6d | ||
|
|
57c96a9a62 | ||
|
|
ffd24865ee | ||
|
|
439e4b4b83 | ||
|
|
622a4cc593 | ||
|
|
96ea510143 | ||
|
|
60d3acc202 | ||
|
|
cb697e2812 | ||
|
|
1e26d5cdb2 | ||
|
|
fbd3fbd119 | ||
|
|
3c05098780 | ||
|
|
5ecd17d26b | ||
|
|
f29e9f965a | ||
|
|
42a3a31460 | ||
|
|
188784d406 | ||
|
|
6f5c4f7b81 | ||
|
|
76f3d4b6b5 | ||
|
|
67ff176875 | ||
|
|
2ddb9544e6 | ||
|
|
25e70dc555 | ||
|
|
a4b18f44c3 | ||
|
|
49856fe577 | ||
|
|
576611faae | ||
|
|
cc898ec553 | ||
|
|
7d53c99062 | ||
|
|
8ce5ebd8ac | ||
|
|
9258947fef | ||
|
|
1955a11b81 | ||
|
|
da614f83fe | ||
|
|
e5c99655e5 | ||
|
|
392a010daa | ||
|
|
f04f449163 | ||
|
|
32d0f0cc7b | ||
|
|
c79ccfb3f3 | ||
|
|
0904f73721 | ||
|
|
ef2024c565 | ||
|
|
3c86f0970c | ||
|
|
4c5665aa75 | ||
|
|
38091cbe11 | ||
|
|
33c0266fc1 | ||
|
|
9bcc1cb420 | ||
|
|
b10b50fd2d | ||
|
|
f9c517bd37 | ||
|
|
49cafb2f6d | ||
|
|
6fa10a7744 | ||
|
|
b341b26673 | ||
|
|
aad24d4186 | ||
|
|
7ef4475952 | ||
|
|
aca39a5a8f | ||
|
|
d7ca8dda11 | ||
|
|
c8483b3545 | ||
|
|
bb090e6809 | ||
|
|
5aee6a54ad | ||
|
|
8a53171ad0 | ||
|
|
299194247e | ||
|
|
6b6e1e7e04 | ||
|
|
752fcd5d03 | ||
|
|
55cd083484 | ||
|
|
ada3d3d0b4 | ||
|
|
1bd3111912 | ||
|
|
0e12e90509 | ||
|
|
613e1da011 | ||
|
|
6998845854 | ||
|
|
430c710fd0 | ||
|
|
6accd1d3cd | ||
|
|
7bf1681c2d | ||
|
|
c9cd0783df | ||
|
|
7e57b42316 | ||
|
|
9afb56536c | ||
|
|
23c089c8b7 | ||
|
|
a3eb69ee20 | ||
|
|
08004a508a | ||
|
|
3d3510b7bd | ||
|
|
bd9f0cb18d |
@@ -319,7 +319,7 @@ steps:
|
||||
# writes its stripped mirror under mktemp and never into the workspace.
|
||||
- name: promtool
|
||||
depends_on: [] # see note above `steps:`
|
||||
image: prom/prometheus:v3.15.0@sha256:efd719c99d83b060d9daefdcf00360461adf279f45ef5391f8d111892118753e
|
||||
image: prom/prometheus:v3.15.1@sha256:fa3302279710be2f21a8301ec17a8b4f2301fd829bd113839b3b6b02b6f191a2
|
||||
commands:
|
||||
- sh scripts/ci/promtool-rules.sh
|
||||
|
||||
|
||||
@@ -35,25 +35,25 @@ when:
|
||||
steps:
|
||||
- name: model-selection-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
- name: pr-review-mistral-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
|
||||
commands:
|
||||
- sh scripts/ci/test-pr-review-mistral.sh
|
||||
|
||||
- name: promtool-rules-guard-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
|
||||
commands:
|
||||
- sh scripts/ci/test-promtool-rules.sh
|
||||
|
||||
- name: yaml-citations-guard-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
|
||||
commands:
|
||||
- sh scripts/ci/test-yaml-citations.sh
|
||||
|
||||
@@ -63,7 +63,7 @@ steps:
|
||||
# bridge, and the secret guard.
|
||||
- name: hermes-skill-tests
|
||||
depends_on: []
|
||||
image: python:3.14-alpine@sha256:f6a589d43c42b9e7f7dc67a12d37132491f362859a5d750607710cc56da3bc72
|
||||
image: python:3.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
|
||||
environment:
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
commands:
|
||||
|
||||
@@ -13,7 +13,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: validate-secrets
|
||||
image: alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
image: alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
environment:
|
||||
TF_VAR_proxmox_api_url:
|
||||
from_secret: proxmox_api_url
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
when:
|
||||
# Deploy only on main (merge) + manual — never auto-deploy from a branch.
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
- "ansible/roles/ssh-ca/**"
|
||||
- "ansible/plays/configure-ssh-ca.yml"
|
||||
- "ansible/inventory/pve_hosts.yml"
|
||||
- ".woodpecker/deploy-ssh-ca.yml"
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-ssh-ca"'
|
||||
|
||||
timeout: 30
|
||||
|
||||
steps:
|
||||
- name: deploy-ssh-ca
|
||||
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
|
||||
environment:
|
||||
ANSIBLE_FORCE_COLOR: "true"
|
||||
ANSIBLE_HOST_KEY_CHECKING: "false"
|
||||
ANSIBLE_ROLES_PATH: "../roles:./roles"
|
||||
SSH_PRIVATE_KEY:
|
||||
from_secret: ssh_private_key
|
||||
VAULT_PASS:
|
||||
from_secret: vault_pass
|
||||
commands:
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
|
||||
- chmod 600 ~/.ssh/id_ed25519
|
||||
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
|
||||
- cd ansible
|
||||
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
|
||||
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
|
||||
- ansible-playbook -i inventory/ plays/configure-ssh-ca.yml -v
|
||||
+1
-1
@@ -26,7 +26,7 @@ steps:
|
||||
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
|
||||
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
|
||||
# test from rotting (it exercises the exact injection shapes).
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: go-test
|
||||
image: golang:1.27.2-bookworm@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61
|
||||
image: golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5
|
||||
commands:
|
||||
- test -z "$(gofmt -l review-bot)"
|
||||
- go -C review-bot vet ./...
|
||||
|
||||
@@ -18,12 +18,12 @@ when:
|
||||
|
||||
steps:
|
||||
- name: iac-scan
|
||||
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
|
||||
image: aquasec/trivy:latest@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
|
||||
commands:
|
||||
- trivy fs --format json -o trivy-iac.json --severity CRITICAL,HIGH --ignore-unfixed .
|
||||
|
||||
- name: image-scans
|
||||
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
|
||||
image: aquasec/trivy:latest@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
|
||||
commands:
|
||||
- trivy image --download-db-only
|
||||
- |
|
||||
@@ -70,7 +70,7 @@ steps:
|
||||
# this step is skipped — otherwise we'd fork-bomb PRs.
|
||||
when:
|
||||
- branch: main
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -30,7 +30,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: update-audit-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
+13
-14
@@ -72,14 +72,14 @@ none of these commands.
|
||||
|
||||
## Secrets (before first play run)
|
||||
|
||||
Create `inventory/group_vars/edge_host/vault.yml` with ansible-vault; the
|
||||
current live values come straight out of the cluster:
|
||||
Create `inventory/group_vars/edge_host/vault.yml` with ansible-vault. The
|
||||
cluster broker has no `open3e` user: edge-1 logs in only as the register
|
||||
account `edge-1` (see "MQTT bridge" below), so
|
||||
`vault_edge_open3e_mqtt_password` reaches only the loopback broker, which
|
||||
allows anonymous clients; any random value works (`openssl rand -hex 24`).
|
||||
The zwave-js value comes out of the cluster:
|
||||
|
||||
```
|
||||
# sml2mqtt broker password
|
||||
kubectl -n home-assistant get secret mosquitto-sml2mqtt-credentials -o jsonpath='{.data.password}' | base64 -d
|
||||
# open3e broker password
|
||||
kubectl -n home-assistant get secret mosquitto-open3e-credentials -o jsonpath='{.data.password}' | base64 -d
|
||||
# zwave-js session secret
|
||||
kubectl -n home-assistant get secret zwave-js-session -o jsonpath='{.data.session-secret}' | base64 -d
|
||||
```
|
||||
@@ -230,15 +230,14 @@ They connect to a **local mosquitto on loopback** (`127.0.0.1:1883`,
|
||||
broker is briefly unreachable, and flushes on reconnect — edge-1 no longer
|
||||
hard-depends on cluster/network uptime for every publish.
|
||||
|
||||
Two bridge `connection`s preserve the existing cluster identities, so **no
|
||||
cluster-side change was needed**: one authenticates as the `open3e` user
|
||||
(carries `open3e/#` out + `open3e/cmnd` back), one as `sml2mqtt` (`sml2mqtt/#`
|
||||
out). The local broker is `allow_anonymous` on loopback — the trust boundary
|
||||
is the host; nothing off-host can reach it.
|
||||
The local broker is `allow_anonymous` on loopback — the trust boundary is the
|
||||
host; nothing off-host can reach it.
|
||||
|
||||
**Bridge credential (REN-1307).** The two vault-password bridges are being
|
||||
replaced by one bridge as the register account `edge-1`
|
||||
(`kubernetes/apps/home-assistant/mqtt-accounts.yaml`). Its password is pushed
|
||||
**Bridge credential (REN-1307).** One bridge `connection` logs in as the
|
||||
register account `edge-1` (`kubernetes/apps/home-assistant/mqtt-accounts.yaml`)
|
||||
and carries `open3e/#` out and `open3e/cmnd` back. The cluster broker does not
|
||||
accept the vault-password bridges in `bridge.d/legacy.conf`, which exists only
|
||||
until the first push. Its password is pushed
|
||||
by mqtt-account-controller to `edge-mqtt-cred` (`:8443`, the controller's
|
||||
esp32 API over HTTPS). That endpoint accepts only a client certificate from
|
||||
the MQTT CA with CN `mqtt-account-controller`; edge-1 holds the public CA and
|
||||
|
||||
@@ -69,3 +69,11 @@ edge_host
|
||||
# home, so it must NOT be used as ansible_host.
|
||||
[claude_workstation]
|
||||
claude-ws ansible_host=10.3.40.78 ansible_user=root
|
||||
|
||||
# Hosts that trust the OpenBao SSH user CA (plays/configure-ssh-ca.yml).
|
||||
# Rolled out one host at a time: add the next host after the previous one
|
||||
# was verified.
|
||||
[ssh_ca]
|
||||
proxy-1.renner.uno
|
||||
proxy-2.renner.uno
|
||||
netbird.renner.uno
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
# Trust the OpenBao SSH user CA (ssh-client-signer) on the hosts in [ssh_ca]:
|
||||
# owner certificates (principal k.renner) for root/ladmin, Claude's 1h
|
||||
# certificates (principal claude-bot) for its own claude-bot account with
|
||||
# limited sudo. Existing authorized_keys stay untouched.
|
||||
#
|
||||
# serial: 1 so a host whose sshd rejects the change stops the run before the
|
||||
# next host is touched.
|
||||
- name: Configure the SSH user CA
|
||||
hosts: ssh_ca
|
||||
become: true
|
||||
gather_facts: true
|
||||
serial: 1
|
||||
roles:
|
||||
- ssh-ca
|
||||
@@ -95,9 +95,8 @@ the VM enrolled as `claude-ws-190-112.nb.renner.uno` / `100.96.190.112` (+ IPv6)
|
||||
> inventory targets the tunnel DHCP IP.
|
||||
|
||||
> **DNS trap.** `claude-ws.obg.renner.uno` resolves to the VM's lease **only at
|
||||
> the off-site DNS** (`@10.3.40.1`). The home resolver catch-alls `*.renner.uno`
|
||||
> to the public proxy (`162.55.50.97`, the Netbird VPS) — so from home the FQDN
|
||||
> lands on `root@netbird`, **not** the VM. Never use it as `ansible_host`.
|
||||
> the off-site DNS** (`@10.3.40.1`). From home this FQDN does not resolve (it
|
||||
> exists only at the off-site DNS @10.3.40.1). Never use it as `ansible_host`.
|
||||
|
||||
**Optional — mesh route to the k8s API (redundant read path):** to reach the API
|
||||
over the mesh too, add a Netbird dashboard **network route** advertising the
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#
|
||||
# Enforces the decisions the in-cluster CrowdSec already detects, at the point
|
||||
# where public traffic actually enters: these hosts terminate TLS for every
|
||||
# *.renner.uno name. A drop here never reaches the mesh, the relay or the
|
||||
# public renner.uno name. A drop here never reaches the mesh, the relay or the
|
||||
# cluster.
|
||||
#
|
||||
# Detection stays in the cluster. The reverse-proxy produces no parseable
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
# SSH user CA (OpenBao ssh-client-signer). Hosts trust certificates signed by
|
||||
# it; the public key is fetched from OpenBao on the controller at run time.
|
||||
ssh_ca_public_key_url: "https://openbao.renner.uno/v1/ssh-client-signer/public_key"
|
||||
|
||||
# Owner certificates (role owner) carry this principal. It is accepted for
|
||||
# whichever admin login the distro has; users that do not exist are skipped.
|
||||
ssh_ca_owner_principal: k.renner
|
||||
ssh_ca_owner_users:
|
||||
- root
|
||||
- ladmin
|
||||
|
||||
# Claude's own account. Certificates (role claude, 1h) carry the principal.
|
||||
ssh_ca_claude_user: claude-bot
|
||||
ssh_ca_claude_principal: claude-bot
|
||||
|
||||
# Units claude-bot may restart/reload via sudo on this host, e.g. [netbird].
|
||||
ssh_ca_claude_sudo_units: []
|
||||
@@ -0,0 +1,2 @@
|
||||
# Public keys whose certificates sshd must reject (one per line, OpenSSH format).
|
||||
# Revoking a key also revokes every certificate issued for it.
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
- name: Fetch the SSH CA public key from OpenBao
|
||||
ansible.builtin.uri:
|
||||
url: "{{ ssh_ca_public_key_url }}"
|
||||
return_content: true
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
run_once: true
|
||||
register: ssh_ca_key
|
||||
|
||||
- name: Check the fetched CA key is an SSH public key
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ssh_ca_key.content is match('^ssh-ed25519 AAAA')
|
||||
fail_msg: "Unexpected CA key from {{ ssh_ca_public_key_url }}"
|
||||
run_once: true
|
||||
|
||||
- name: Install the trusted user CA key
|
||||
ansible.builtin.copy:
|
||||
content: "{{ ssh_ca_key.content | trim }}\n"
|
||||
dest: /etc/ssh/trusted_user_ca_keys
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
|
||||
# sshd refuses all public key logins when RevokedKeys is set but unreadable,
|
||||
# so the file is always deployed, even when empty.
|
||||
- name: Install the revoked keys list
|
||||
ansible.builtin.copy:
|
||||
src: revoked_keys
|
||||
dest: /etc/ssh/revoked_keys
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
|
||||
- name: Create the Claude account
|
||||
ansible.builtin.user:
|
||||
name: "{{ ssh_ca_claude_user }}"
|
||||
shell: /bin/bash
|
||||
create_home: true
|
||||
password: "!"
|
||||
|
||||
- name: Look up the owner admin logins
|
||||
ansible.builtin.getent:
|
||||
database: passwd
|
||||
key: "{{ item }}"
|
||||
fail_key: false
|
||||
loop: "{{ ssh_ca_owner_users }}"
|
||||
register: ssh_ca_owner_lookup
|
||||
|
||||
- name: Create the principals directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/ssh/auth_principals
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: Accept the owner principal for the existing admin logins
|
||||
ansible.builtin.copy:
|
||||
content: "{{ ssh_ca_owner_principal }}\n"
|
||||
dest: "/etc/ssh/auth_principals/{{ item.item }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
loop: "{{ ssh_ca_owner_lookup.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item }}"
|
||||
when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none
|
||||
|
||||
- name: Accept the Claude principal for the Claude account
|
||||
ansible.builtin.copy:
|
||||
content: "{{ ssh_ca_claude_principal }}\n"
|
||||
dest: "/etc/ssh/auth_principals/{{ ssh_ca_claude_user }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
|
||||
- name: Install the Claude sudo rules
|
||||
ansible.builtin.template:
|
||||
src: sudoers-claude.j2
|
||||
dest: "/etc/sudoers.d/{{ ssh_ca_claude_user }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0440"
|
||||
validate: visudo -cf %s
|
||||
|
||||
# A broken drop-in only bites on the next sshd restart, so it is tested here
|
||||
# and removed again if the full config does not parse.
|
||||
- name: Configure sshd for the user CA
|
||||
block:
|
||||
- name: Install the sshd drop-in
|
||||
ansible.builtin.template:
|
||||
src: ssh-ca.conf.j2
|
||||
dest: /etc/ssh/sshd_config.d/50-ssh-ca.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
|
||||
- name: Test the full sshd config
|
||||
ansible.builtin.command: sshd -t
|
||||
changed_when: false
|
||||
rescue:
|
||||
- name: Remove the drop-in that broke sshd -t
|
||||
ansible.builtin.file:
|
||||
path: /etc/ssh/sshd_config.d/50-ssh-ca.conf
|
||||
state: absent
|
||||
|
||||
- name: Fail the host
|
||||
ansible.builtin.fail:
|
||||
msg: "sshd -t failed with the SSH CA drop-in; it was removed again"
|
||||
|
||||
- name: Read the effective sshd settings
|
||||
ansible.builtin.command: sshd -T
|
||||
changed_when: false
|
||||
register: ssh_ca_effective
|
||||
|
||||
# Keyword case in sshd -T output differs between OpenSSH versions (mixed case
|
||||
# on 10.6), so the lines are compared lowercased.
|
||||
- name: Check sshd uses the CA, principals and revocation list
|
||||
vars:
|
||||
ssh_ca_effective_lines: "{{ ssh_ca_effective.stdout_lines | map('lower') | list }}"
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'trustedusercakeys /etc/ssh/trusted_user_ca_keys' in ssh_ca_effective_lines"
|
||||
- "'authorizedprincipalsfile /etc/ssh/auth_principals/%u' in ssh_ca_effective_lines"
|
||||
- "'revokedkeys /etc/ssh/revoked_keys' in ssh_ca_effective_lines"
|
||||
fail_msg: "sshd -T does not show the SSH CA settings; another config line takes precedence"
|
||||
|
||||
# The unit is sshd.service on Arch and RedHat, ssh.service on Debian/Ubuntu.
|
||||
# Ubuntu also lists sshd.service as a not-found alias, so the running one is
|
||||
# picked. sshd -T only parses the files, so the running daemon is reloaded on
|
||||
# every run; a reload keeps open sessions.
|
||||
- name: Read the service list
|
||||
ansible.builtin.service_facts:
|
||||
|
||||
- name: Reload the running sshd unit
|
||||
vars:
|
||||
ssh_ca_sshd_units: "{{ ansible_facts.services | dict2items | selectattr('key', 'in', ['sshd.service', 'ssh.service']) | selectattr('value.state', 'equalto', 'running') | map(attribute='key') | list }}"
|
||||
block:
|
||||
- name: Check exactly one sshd unit is running
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ssh_ca_sshd_units | length == 1
|
||||
fail_msg: "Expected one running sshd.service or ssh.service, found {{ ssh_ca_sshd_units }}"
|
||||
|
||||
- name: Reload sshd so the running daemon uses the CA config
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ ssh_ca_sshd_units | first }}"
|
||||
state: reloaded
|
||||
@@ -0,0 +1,4 @@
|
||||
# {{ ansible_managed }}
|
||||
TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys
|
||||
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
|
||||
RevokedKeys /etc/ssh/revoked_keys
|
||||
@@ -0,0 +1,6 @@
|
||||
# {{ ansible_managed }}
|
||||
# NOEXEC: journalctl/systemctl open a pager, which must not spawn a shell.
|
||||
{{ ssh_ca_claude_user }} ALL=(root) NOPASSWD:NOEXEC: /usr/bin/journalctl, /usr/bin/journalctl *, /usr/bin/systemctl status, /usr/bin/systemctl status *, /usr/bin/systemctl list-units, /usr/bin/systemctl list-units *, /usr/bin/ss -tulpn, /usr/sbin/sshd -T
|
||||
{% for unit in ssh_ca_claude_sudo_units %}
|
||||
{{ ssh_ca_claude_user }} ALL=(root) NOPASSWD:NOEXEC: /usr/bin/systemctl restart {{ unit }}, /usr/bin/systemctl reload {{ unit }}
|
||||
{% endfor %}
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
# Alpine plus jq. Nothing application-specific — a general-purpose base for any
|
||||
# workload that needs to read JSON from a shell script.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
# Alpine plus the openssl CLI. Nothing application-specific — named for what it
|
||||
# is, so anything needing `openssl s_client` / `x509` from a shell script can use
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
RUN apk add --no-cache \
|
||||
ansible \
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
|
||||
# The unit tests gate the image: the final stage copies the controller out of
|
||||
# this stage, so a failing test fails the build.
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce AS test
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS test
|
||||
WORKDIR /src
|
||||
COPY controller.py test_controller.py ./
|
||||
RUN python3 -B -m unittest -v
|
||||
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
|
||||
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
|
||||
COPY --from=test /src/controller.py /opt/controller.py
|
||||
|
||||
@@ -210,9 +210,10 @@ def resolve_backend(gate, service):
|
||||
|
||||
|
||||
def embedded_config(config):
|
||||
"""The embedded outpost's config with the hosts the gates depend on: the API
|
||||
over the in-cluster Service, the browser sent to the public URL."""
|
||||
return {**(config or {}), "authentik_host": AK_URL, "authentik_host_insecure": False,
|
||||
"""The embedded outpost's config with the host the gates depend on. The
|
||||
embedded outpost builds the sign-in redirect from authentik_host, so it is
|
||||
the public URL, which is also Authentik's default for it."""
|
||||
return {**(config or {}), "authentik_host": AK_BROWSER_URL, "authentik_host_insecure": False,
|
||||
"authentik_host_browser": AK_BROWSER_URL}
|
||||
|
||||
|
||||
|
||||
@@ -167,8 +167,8 @@ class Objects(unittest.TestCase):
|
||||
self.assertEqual(c.drift({"a": 1, "b": 2}, {"a": 1, "b": 3}), {"b": 3})
|
||||
|
||||
def test_embedded_config_keeps_other_keys(self):
|
||||
cfg = c.embedded_config({"log_level": "debug", "authentik_host": "https://auth.renner.uno/"})
|
||||
self.assertEqual(cfg, {"log_level": "debug", "authentik_host": c.AK_URL,
|
||||
cfg = c.embedded_config({"log_level": "debug", "authentik_host": c.AK_URL})
|
||||
self.assertEqual(cfg, {"log_level": "debug", "authentik_host": c.AK_BROWSER_URL,
|
||||
"authentik_host_insecure": False, "authentik_host_browser": c.AK_BROWSER_URL})
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ class Reconcile(unittest.TestCase):
|
||||
c.ensure_embedded_config(self.ak)
|
||||
cfg = self.ak.get(f"outposts/instances/{self.embedded['pk']}/")["config"]
|
||||
self.assertEqual((cfg["log_level"], cfg["authentik_host"], cfg["authentik_host_browser"]),
|
||||
("info", c.AK_URL, c.AK_BROWSER_URL))
|
||||
("info", c.AK_BROWSER_URL, c.AK_BROWSER_URL))
|
||||
before = len(self.ak.calls)
|
||||
c.ensure_embedded_config(self.ak)
|
||||
self.assertEqual(self.ak.calls[before:], [])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
FROM hetznercloud/cli:v1.70.1@sha256:795ac09a5ecabd48cc13a8f1c3dc45983c8f75ae1277c70d621719e07f276ccb AS hcloud
|
||||
FROM hetznercloud/cli:v1.70.2@sha256:3b3a8f66323a5754089df7939fd9d3f350127072deed13418f3d3052b4bbeeae AS hcloud
|
||||
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
# bird: BGP daemon for the pod-network mesh over pod0. Distro package, no build
|
||||
# step — the image is the binary plus its runtime.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24-alpine@sha256:44fce696f38ba8c987c4db89954051000bbd05ffd8d8659ba15ed4a8602e937f
|
||||
FROM node:24-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53
|
||||
|
||||
# Default to the latest Claude Code. Pinning a fixed version is the wrong
|
||||
# default here: old releases eventually stop working against the API, so a
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Cloudflare. The unit tests (no tags, so no envtest) gate the image: the
|
||||
# final stage copies the binary out of this stage, so a failing test fails the
|
||||
# build.
|
||||
FROM golang:1.27@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8 AS build
|
||||
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
@@ -10,7 +10,7 @@ COPY . .
|
||||
RUN go test ./...
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/cloudflare-lb-sync ./cmd/cloudflare-lb-sync
|
||||
|
||||
FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
|
||||
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
|
||||
COPY --from=build /out/cloudflare-lb-sync /cloudflare-lb-sync
|
||||
USER 65532
|
||||
ENTRYPOINT ["/cloudflare-lb-sync"]
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
# amd64 only, matching the ARCH the installer already hardcoded and the cluster's
|
||||
# node architecture. Add a second stage if a different arch is ever needed.
|
||||
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 AS fetch
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a AS fetch
|
||||
|
||||
ARG CNI_PLUGINS_VERSION=v1.9.1
|
||||
ARG CNI_PLUGINS_SHA256=b98f74a0f8522f0a83867178729c1aa70f2158f90c45a2ca8fa791db1c76b303
|
||||
@@ -61,7 +61,7 @@ RUN set -eu; \
|
||||
[ -x "/plugins/$p" ] || { echo "FATAL: /plugins/$p missing or not executable" >&2; exit 1; }; \
|
||||
done
|
||||
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
# Alpine rather than scratch: the installer needs a shell to copy the binaries
|
||||
# onto the host. busybox covers cp/mv/chown/ls, so nothing is added.
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
# namespace, so can0 is only reachable with network_mode: host. That is also
|
||||
# how the local MQTT broker on 127.0.0.1:1883 is reached. CAN_RAW sockets need
|
||||
# no capabilities, so both run unprivileged.
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce AS build
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
|
||||
|
||||
# Pins live here, in the artifact, rather than in Ansible variables.
|
||||
#
|
||||
@@ -56,7 +56,7 @@ RUN git clone --quiet https://github.com/MyHomeMyData/E3onCAN.git /opt/e3oncan \
|
||||
&& rm -rf /opt/e3oncan/.git
|
||||
|
||||
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
|
||||
|
||||
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
|
||||
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM nousresearch/hermes-agent:v0.22.2@sha256:36099926da316742423544e055d4734777105ffe6341873af5459b957d0c2919
|
||||
FROM nousresearch/hermes-agent:v0.22.5@sha256:9c5561579f3ffdf333401797094ac3a743738d6c25d40f4331c7bf77b694238d
|
||||
|
||||
USER root
|
||||
|
||||
@@ -6,9 +6,9 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends jq \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=ghcr.io/siderolabs/talosctl:v1.13.10@sha256:3116f64c97ddffb53bd3c5bf87c8614d99f04df4326177dbbb7702b5087f0e4d /talosctl /usr/local/bin/talosctl
|
||||
COPY --from=alpine/kubectl:1.36.3@sha256:dae261106d5555008acede256b7102c8a6decde7209198434f5999f051460f57 /usr/local/bin/kubectl /usr/local/bin/kubectl
|
||||
COPY --from=ghcr.io/fluxcd/flux-cli:v2.9.5@sha256:704d5529535570a495249b643159c93a9aa88ffca07813c974a54b7ba6edbd86 /usr/local/bin/flux /usr/local/bin/flux
|
||||
COPY --from=ghcr.io/siderolabs/talosctl:v1.14.4@sha256:b3233234327350f20ed194f3fad83d90a64344c1940f839c7b0c0abef50c8c33 /talosctl /usr/local/bin/talosctl
|
||||
COPY --from=alpine/kubectl:1.37.1@sha256:a5d0fbecab3f7b6c2ab7398f1daa73a3e0ed6ece18556d21698233e9e3759cc7 /usr/local/bin/kubectl /usr/local/bin/kubectl
|
||||
COPY --from=ghcr.io/fluxcd/flux-cli:v2.9.6@sha256:b1ac18156f227af9a524b842a96f2c20986c7a779ef721df3ba4e4540f449d76 /usr/local/bin/flux /usr/local/bin/flux
|
||||
COPY --from=quay.io/openbao/openbao:2.7.1@sha256:6d2b93856e3fcf7b18ad855a0b51eaba474dc8b79cf554379ea32034797d2acf /usr/bin/bao /usr/local/bin/bao
|
||||
COPY --from=gitea/tea:0.16.0@sha256:af50a2060ce4d8fd76b7ebe75a3d6db4bb0c615c5bc8f2bdfd8ea83207c4752d /usr/bin/tea /usr/local/bin/tea
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
ARG TALOS_VERSION=v1.13.12
|
||||
ARG VELERO_VERSION=v1.16.0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. The unit tests
|
||||
# (no tags, so no Docker and no envtest) gate the image: the final stage copies
|
||||
# the binary out of this stage, so a failing test fails the build.
|
||||
FROM golang:1.27@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8 AS build
|
||||
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
@@ -9,7 +9,7 @@ COPY . .
|
||||
RUN go test ./...
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/kuma-sync ./cmd/kuma-sync
|
||||
|
||||
FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
|
||||
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
|
||||
COPY --from=build /out/kuma-sync /kuma-sync
|
||||
USER 65532
|
||||
ENTRYPOINT ["/kuma-sync"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# domains -> TLS passthrough -> Traefik ClusterIP) from Ingresses carrying the
|
||||
# public label. Standard library only, so the image is the interpreter plus one
|
||||
# file; runs unprivileged as its own user.
|
||||
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
|
||||
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
|
||||
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
|
||||
COPY controller.py /opt/controller.py
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
ARG TOFU_VERSION=1.11.0
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
|
||||
|
||||
# Everything `bootstrap.sh vmtest` needs, so the rebuild test can run as a Job in
|
||||
# the cluster instead of on a workstation. The workstation has 31G and the test
|
||||
|
||||
@@ -48,7 +48,7 @@ spec:
|
||||
# cert when doing server-side OIDC token exchange. Bundle both
|
||||
# roots into one PEM since NODE_EXTRA_CA_CERTS accepts one file.
|
||||
- name: fetch-le-staging-roots
|
||||
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
|
||||
image: curlimages/curl:8.23.0@sha256:11e64e28c5f58c8d93ee95fcf22ac055f48b0bc440e0ce7d8013bbb5ad6aa8ac
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -70,7 +70,7 @@ spec:
|
||||
mountPath: /ca
|
||||
containers:
|
||||
- name: audiobookshelf
|
||||
image: ghcr.io/advplyr/audiobookshelf:2.37.1 # {"$imagepolicy": "flux-system:audiobookshelf"}
|
||||
image: ghcr.io/advplyr/audiobookshelf:2.37.2 # {"$imagepolicy": "flux-system:audiobookshelf"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 80
|
||||
|
||||
@@ -4,8 +4,8 @@ metadata:
|
||||
name: audiobookshelf
|
||||
namespace: audiobookshelf
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: audiobooks.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -706,7 +706,7 @@ data:
|
||||
# return entries instead of short-circuiting. Built from
|
||||
# the samba-bridge repo at
|
||||
# gitea.renner.uno/konairius/authentik-samba-bridge .
|
||||
container_image: gitea.renner.uno/konairius/authentik-samba-bridge-ldap-outpost:2026.8.3-9dd92dd
|
||||
container_image: gitea.renner.uno/konairius/authentik-samba-bridge-ldap-outpost:2026.8.4-3c8f413
|
||||
object_naming_template: "ak-outpost-%(name)s"
|
||||
kubernetes_namespace: authentik
|
||||
kubernetes_replicas: 1
|
||||
@@ -1034,9 +1034,19 @@ data:
|
||||
negate: false
|
||||
failure_result: false
|
||||
|
||||
# wiki.renner.uno signs in via OIDC application `wiki`; only this group may sign in.
|
||||
# Members are managed in Authentik.
|
||||
- id: wiki-readers-group
|
||||
model: authentik_core.group
|
||||
identifiers:
|
||||
name: Wiki Readers
|
||||
state: present
|
||||
attrs:
|
||||
is_superuser: false
|
||||
|
||||
# Wiki (apps/wiki): its own OIDC login. Public client + PKCE; RS256
|
||||
# so the service verifies the ID token against the JWKS. No policy
|
||||
# binding: every Authentik user may sign in.
|
||||
# so the service verifies the ID token against the JWKS. Only the
|
||||
# Wiki Readers group passes.
|
||||
- id: wiki-provider
|
||||
model: authentik_providers_oauth2.oauth2provider
|
||||
identifiers:
|
||||
@@ -1072,6 +1082,83 @@ data:
|
||||
meta_launch_url: https://wiki.renner.uno
|
||||
meta_description: "Homelab wiki"
|
||||
|
||||
- model: authentik_policies.policybinding
|
||||
identifiers:
|
||||
target: !Find [authentik_core.application, [slug, wiki]]
|
||||
group: !KeyOf wiki-readers-group
|
||||
order: 0
|
||||
state: present
|
||||
attrs:
|
||||
enabled: true
|
||||
negate: false
|
||||
failure_result: false
|
||||
|
||||
# OpenBao human login (auth/oidc, role owner). Confidential client:
|
||||
# OpenBao's OIDC method requires a secret. Authentik generates it; it
|
||||
# lives only in OpenBao's auth/oidc/config, never in git.
|
||||
- id: openbao-admins-group
|
||||
model: authentik_core.group
|
||||
identifiers:
|
||||
name: OpenBao Admins
|
||||
state: present
|
||||
attrs:
|
||||
is_superuser: false
|
||||
# Members may log in to OpenBao via OIDC. Add/remove members HERE.
|
||||
users:
|
||||
- !Find [authentik_core.user, [username, k.renner]]
|
||||
|
||||
- id: openbao-provider
|
||||
model: authentik_providers_oauth2.oauth2provider
|
||||
identifiers:
|
||||
name: OpenBao
|
||||
state: present
|
||||
attrs:
|
||||
authentication_flow: null
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
client_type: confidential
|
||||
client_id: openbao
|
||||
# See the Matrix (MAS) provider: a blueprint-made provider starts
|
||||
# with no grant types.
|
||||
grant_types:
|
||||
- authorization_code
|
||||
- refresh_token
|
||||
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
|
||||
property_mappings:
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
|
||||
redirect_uris:
|
||||
# bao login -method=oidc (CLI listener).
|
||||
- url: http://localhost:8250/oidc/callback
|
||||
matching_mode: strict
|
||||
- url: https://openbao.renner.uno/ui/vault/auth/oidc/oidc/callback
|
||||
matching_mode: strict
|
||||
|
||||
# The slug is the issuer path: https://auth.renner.uno/application/o/openbao/
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: openbao
|
||||
state: present
|
||||
attrs:
|
||||
name: OpenBao
|
||||
slug: openbao
|
||||
provider: !KeyOf openbao-provider
|
||||
meta_launch_url: https://openbao.renner.uno
|
||||
meta_description: "OpenBao secrets"
|
||||
|
||||
# Only the OpenBao Admins group passes.
|
||||
- model: authentik_policies.policybinding
|
||||
identifiers:
|
||||
target: !Find [authentik_core.application, [slug, openbao]]
|
||||
group: !KeyOf openbao-admins-group
|
||||
order: 0
|
||||
state: present
|
||||
attrs:
|
||||
enabled: true
|
||||
negate: false
|
||||
failure_result: false
|
||||
|
||||
# OpenProject, decommissioned. Application first: Application.provider
|
||||
# is on_delete=PROTECT. Its policy binding cascades with it.
|
||||
- model: authentik_core.application
|
||||
|
||||
@@ -105,7 +105,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-22-1858 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
|
||||
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-27-0801 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
|
||||
@@ -20,7 +20,7 @@ spec:
|
||||
# Pinned to match the samba-bridge fork's app image (server.Dockerfile
|
||||
# AUTHENTIK_VERSION). Bump in lockstep with the bridge rebuild — a
|
||||
# floating range drifts the chart ahead of the pinned app image.
|
||||
version: "2026.8.3"
|
||||
version: "2026.8.4"
|
||||
sourceRef:
|
||||
kind: HelmRepository
|
||||
name: authentik
|
||||
@@ -58,7 +58,7 @@ spec:
|
||||
# 2. Pinning means the deployed binary is traceable to a
|
||||
# specific repo commit. Bump the tag whenever a new build
|
||||
# is published from gitea.renner.uno/konairius/authentik-samba-bridge .
|
||||
tag: "2026.8.3-9dd92dd"
|
||||
tag: "2026.8.4-3c8f413"
|
||||
envFrom:
|
||||
# SAMBA_BRIDGE_DOMAIN_SID / _ENABLED_GROUP / _UID_OFFSET
|
||||
# consumed by the bridge module at Django app startup. Missing
|
||||
@@ -161,8 +161,8 @@ spec:
|
||||
enabled: true
|
||||
ingressClassName: traefik
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the
|
||||
# UDM external-dns instance skips it, LAN uses the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: auth.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -30,7 +30,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-22-1904 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
|
||||
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-27-0807 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
|
||||
env:
|
||||
- name: CLOUDFLARE_API_TOKEN
|
||||
valueFrom:
|
||||
|
||||
@@ -28,7 +28,7 @@ spec:
|
||||
enableServiceLinks: false
|
||||
containers:
|
||||
- name: evcc
|
||||
image: evcc/evcc:0.317.1@sha256:c50cd4f83c22b39722a76a76b8d428b69bf8ab3334209ed9ed24ec0e3c48dde5
|
||||
image: evcc/evcc:0.318.1@sha256:2f8163ab22f04863afd86cd511c09607ba71debd9eebd65511f3d654922a80d1
|
||||
# evcc auto-discovers /etc/evcc.yaml on its default search path;
|
||||
# the file is delivered (with the MQTT password) via the
|
||||
# sops-encrypted evcc-config Secret mounted below.
|
||||
|
||||
@@ -20,7 +20,7 @@ metadata:
|
||||
# services group infrastructure group
|
||||
# Web Service 1 = Authentik Infrastructure 1 = retired (was PBS 2)
|
||||
# Web Service 2 = Gitea Infrastructure 2 = retired (was Proxmox VE)
|
||||
# Web Service 3 = Immich Infrastructure 3 = UDM DNS (10.2.40.1)
|
||||
# Web Service 3 = Immich Infrastructure 3 = UDM DNS (10.2.47.1)
|
||||
# Web Service 4 = Woodpecker CI Infrastructure 4 = Mosquitto MQTT broker
|
||||
# Web Service 5 = Grafana
|
||||
# Web Service 6 = Home Assistant
|
||||
@@ -290,9 +290,11 @@ data:
|
||||
# a backup server that nothing backs up to reports on nothing. Re-add
|
||||
# alongside the first host that is declared to back up to it.
|
||||
|
||||
# The UDM on the segment pod traffic leaves on (k8s-gateway-site in
|
||||
# talos/patches/site-homelab.yaml).
|
||||
- name: Infrastructure 3
|
||||
group: infrastructure
|
||||
url: 10.2.40.1
|
||||
url: 10.2.47.1
|
||||
interval: 30s
|
||||
dns:
|
||||
query-name: udm.renner.uno
|
||||
|
||||
@@ -4,8 +4,8 @@ metadata:
|
||||
name: gatus
|
||||
namespace: apps
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: status.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gitea-mcp
|
||||
image: docker.gitea.com/gitea-mcp-server:1.10.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
|
||||
image: docker.gitea.com/gitea-mcp-server:1.11.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
|
||||
# Image has no ENTRYPOINT (binary is in CMD), so args alone would replace it.
|
||||
command:
|
||||
- /app/gitea-mcp
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: gitea-mcp.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: git.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
@@ -90,7 +90,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: gitea.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -115,7 +115,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: retention
|
||||
image: docker.io/library/python:3.14-alpine@sha256:f6a589d43c42b9e7f7dc67a12d37132491f362859a5d750607710cc56da3bc72
|
||||
image: docker.io/library/python:3.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
|
||||
command: ["python3", "/app/package-retention.py"]
|
||||
env:
|
||||
# In-cluster Service; the public name hairpins off the ingress
|
||||
|
||||
@@ -32,7 +32,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: sync
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/python
|
||||
- /opt/hermes-skills/gitea-issues/scripts/sync_webhook.py
|
||||
|
||||
@@ -51,7 +51,7 @@ spec:
|
||||
# connects from the pod network. No env equivalent exists, so it is
|
||||
# set in config.yaml on every start (idempotent).
|
||||
- name: config
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/hermes
|
||||
- config
|
||||
@@ -75,7 +75,7 @@ spec:
|
||||
mountPath: /opt/data
|
||||
# WebSocket keepalive ping interval in seconds (default 20).
|
||||
- name: config-ws-ping
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/hermes
|
||||
- config
|
||||
@@ -99,7 +99,7 @@ spec:
|
||||
mountPath: /opt/data
|
||||
# Skills from git (ConfigMap hermes-skills) beside the PVC's skills/.
|
||||
- name: skills-config
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/hermes
|
||||
- config
|
||||
@@ -130,7 +130,7 @@ spec:
|
||||
# - alert-monitor: hourly; the script wakes the agent only for
|
||||
# untriaged alerts.
|
||||
- name: cron-jobs
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
|
||||
# Non-fatal: a broken jobs.json or a changed cron CLI after an image
|
||||
# bump logs a warning instead of keeping Hermes from starting.
|
||||
command:
|
||||
@@ -187,7 +187,7 @@ spec:
|
||||
readOnly: true
|
||||
# Home Assistant MCP server; token resolved from HA_TOKEN at connect, writes need approval.
|
||||
- name: config-mcp-homeassistant
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/hermes
|
||||
- config
|
||||
@@ -214,7 +214,7 @@ spec:
|
||||
# skills, the Home Assistant MCP and the tea login are re-applied on
|
||||
# every start. default gets the kanban toolset to hand cards to them.
|
||||
- name: experts
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
|
||||
# Non-fatal like cron-jobs: a broken expert logs a warning.
|
||||
command:
|
||||
- sh
|
||||
@@ -305,7 +305,7 @@ spec:
|
||||
# Gitea issues -> kanban (skills/gitea-issues): route script, webhook route on default,
|
||||
# completion shell hook plus its allowlist entry on every profile.
|
||||
- name: gitea-issues
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
|
||||
# Non-fatal like cron-jobs: a failure logs a warning.
|
||||
command:
|
||||
- sh
|
||||
@@ -368,7 +368,7 @@ spec:
|
||||
readOnly: true
|
||||
# Multiplexed default reads platform credentials only from .env (rationale: commit message).
|
||||
- name: default-env
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
|
||||
# Fatal on a writer failure: without these Matrix and the API server stay down.
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/python
|
||||
@@ -428,7 +428,7 @@ spec:
|
||||
# tea login hermes-bot, written from the mounted token into an
|
||||
# emptyDir at the agent's ~/.config/tea.
|
||||
- name: tea-login
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
|
||||
command:
|
||||
- sh
|
||||
- -ec
|
||||
@@ -457,7 +457,7 @@ spec:
|
||||
readOnly: true
|
||||
containers:
|
||||
- name: hermes
|
||||
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
|
||||
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
|
||||
args: ["gateway", "run"]
|
||||
env:
|
||||
- name: HERMES_DASHBOARD
|
||||
|
||||
@@ -19,8 +19,11 @@ expert's PR goes back to the card that opened it, without a manual step.
|
||||
labels, PR reviews, PR comments, PR open/close) posts to
|
||||
`http://hermes.hermes.svc.cluster.local:8644/webhooks/gitea-issues`.
|
||||
The CronJob `gitea-webhook-sync` (namespace hermes, every 15 min) keeps that hook in
|
||||
place, creates the domain labels and backfills, all only in repos where hermes-bot
|
||||
has issue write (org labels for their orgs, repo labels for user-owned ones): open
|
||||
place, creates the domain labels and backfills, all in every non-archived repo with
|
||||
issues of the orgs hermes-bot belongs to, where an org team grants it issue write
|
||||
(today: all repos of `konairius` through team `Bots`). Labels are org labels only.
|
||||
User-owned repos and repos where hermes-bot is only a direct collaborator get neither
|
||||
labels nor backfill; their new issues still become cards through the system hook. Backfill: open
|
||||
issues without a card comment are replayed to the route as signed `opened`
|
||||
deliveries, at most 10 per run, and the reviews of open hermes-bot PRs are replayed
|
||||
(step 8). Its site-admin PAT lives at OpenBao `apps/gitea-webhook-sync/admin`,
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
active system hook carries the desired name, a new one is created first and every
|
||||
other hook with the prefix is deleted afterwards.
|
||||
2. Domain labels. hestia/atlas/apollo/clio/daedalus are created only where Hermes
|
||||
works issues: repos with an issue tracker in which BOT_LOGIN (hermes-bot) has
|
||||
issue write. They become org labels in each org owning such a repo and repo labels
|
||||
in such user-owned repos (org labels do not reach those). Existing labels are left
|
||||
as they are; only missing ones are created.
|
||||
works issues: the non-archived org repos with an issue tracker in which BOT_LOGIN
|
||||
(hermes-bot) may write issues through an org team (it labels and comments). They
|
||||
are org labels of those orgs, never repo labels; user-owned repos are out of scope.
|
||||
Existing labels are left as they are; only missing ones are created.
|
||||
3. Backfill. Open issues of the same repos without the bridge's card comment are
|
||||
replayed to the Hermes webhook route as signed `opened` deliveries, at most
|
||||
BACKFILL_LIMIT per run and none started after RUN_BUDGET seconds, in random order
|
||||
@@ -60,9 +60,10 @@ BACKFILL_SPACING = float(os.environ.get("BACKFILL_SPACING_SECONDS", "3"))
|
||||
RUN_BUDGET = float(os.environ.get("RUN_BUDGET_SECONDS", "900"))
|
||||
DELIVER_TIMEOUT = 60
|
||||
CARD_MARKER = "<!-- hermes-kanban-card:"
|
||||
# Labels and backfill only where this account may write issues (it labels and comments).
|
||||
# Labels and backfill only in org repos this account may write issues in (it labels and comments).
|
||||
BOT_LOGIN = os.environ.get("BOT_LOGIN", "hermes-bot")
|
||||
BOT_WRITE = ("write", "admin", "owner")
|
||||
ISSUES_UNIT = "repo.issues"
|
||||
STARTED = time.monotonic()
|
||||
# Review replay (step 4): reviews by these accounts on open BOT_LOGIN PRs.
|
||||
REVIEW_BOT_LOGIN = os.environ.get("REVIEW_BOT_LOGIN", "review-bot")
|
||||
@@ -110,13 +111,11 @@ def api(method: str, path: str, token: str, body: dict | None = None):
|
||||
|
||||
|
||||
def paged(path: str, token: str) -> list[dict]:
|
||||
"""All pages of a list endpoint; /repos/search wraps its list in {"data": [...]}."""
|
||||
"""All pages of a list endpoint."""
|
||||
out, page = [], 1
|
||||
sep = "&" if "?" in path else "?"
|
||||
while True:
|
||||
batch = api("GET", f"{path}{sep}page={page}&limit=50", token) or []
|
||||
if isinstance(batch, dict):
|
||||
batch = batch.get("data") or []
|
||||
out.extend(batch)
|
||||
if len(batch) < 50:
|
||||
return out
|
||||
@@ -160,25 +159,56 @@ def reconcile(token: str, secret: str) -> str:
|
||||
|
||||
# --- 2. domain labels --------------------------------------------------------
|
||||
|
||||
def team_writes_issues(team: dict) -> bool:
|
||||
"""Issue write from the team's unit map (Gitea >= 1.20), else its team-wide mode."""
|
||||
if team.get("permission") in ("admin", "owner"):
|
||||
return True
|
||||
units = team.get("units_map") or {}
|
||||
return (units.get(ISSUES_UNIT) if units else team.get("permission")) in BOT_WRITE
|
||||
|
||||
|
||||
def issue_repos(token: str, errors: list[str] | None = None) -> list[dict]:
|
||||
"""Repos whose issues Hermes works: issue tracker on, not archived, and hermes-bot may
|
||||
write issues there (it sets the labels and comments). A site admin sees every repo.
|
||||
A repo whose permission lookup fails is skipped and named in `errors`."""
|
||||
out = []
|
||||
for r in paged("/repos/search", token):
|
||||
if not r.get("has_issues", True) or r.get("archived"):
|
||||
"""Repos whose issues Hermes works: in each org BOT_LOGIN belongs to, the repos of its
|
||||
teams that grant issue write (every org repo for an includes_all_repositories team),
|
||||
issue tracker on, not archived. Not /collaborators/{user}/permission: that reports
|
||||
the repo-wide access mode, which stays "none" when the write comes from a unit-based
|
||||
team. The admin route lists private orgs too and needs only the PAT's admin scope
|
||||
(/users/{u}/orgs also requires read:user). An org or team whose lookup fails is
|
||||
skipped and named in `errors`."""
|
||||
def failed(what: str, exc: RuntimeError) -> None:
|
||||
if errors is None:
|
||||
raise exc
|
||||
errors.append(f"{what}: {exc}")
|
||||
|
||||
found: dict[str, dict] = {}
|
||||
for org in paged(f"/admin/users/{q(BOT_LOGIN)}/orgs", token):
|
||||
name = org.get("username") or org.get("name") or ""
|
||||
if not name:
|
||||
continue
|
||||
owner, _, name = r["full_name"].partition("/")
|
||||
try:
|
||||
perm = api("GET", f"/repos/{q(owner)}/{q(name)}/collaborators/{q(BOT_LOGIN)}/permission", token)
|
||||
teams = paged(f"/orgs/{q(name)}/teams", token)
|
||||
except RuntimeError as exc:
|
||||
if errors is None:
|
||||
raise
|
||||
errors.append(f"{r['full_name']}: {exc}")
|
||||
failed(name, exc)
|
||||
continue
|
||||
if (perm or {}).get("permission") in BOT_WRITE:
|
||||
out.append(r)
|
||||
return out
|
||||
for team in teams:
|
||||
if not team_writes_issues(team):
|
||||
continue
|
||||
try:
|
||||
try:
|
||||
api("GET", f"/teams/{team['id']}/members/{q(BOT_LOGIN)}", token)
|
||||
except RuntimeError as exc:
|
||||
if str(exc).endswith("HTTP 404"):
|
||||
continue # not a member of this team
|
||||
raise
|
||||
repos = paged(f"/orgs/{q(name)}/repos" if team.get("includes_all_repositories")
|
||||
else f"/teams/{team['id']}/repos", token)
|
||||
except RuntimeError as exc:
|
||||
failed(f"{name} team {team.get('name')}", exc)
|
||||
continue
|
||||
for r in repos:
|
||||
if r.get("has_issues", True) and not r.get("archived"):
|
||||
found.setdefault(r["full_name"], r)
|
||||
return [found[k] for k in sorted(found)]
|
||||
|
||||
|
||||
def ensure_labels_at(path: str, token: str) -> list[str]:
|
||||
@@ -192,18 +222,14 @@ def ensure_labels_at(path: str, token: str) -> list[str]:
|
||||
|
||||
|
||||
def ensure_labels(token: str, repos: list[dict]) -> str:
|
||||
"""Org labels in each org that owns one of `repos`, repo labels in the user-owned ones."""
|
||||
orgs = {n for o in paged("/admin/orgs", token) if (n := o.get("username") or o.get("name"))}
|
||||
owners = {(r.get("owner") or {}).get("login") for r in repos}
|
||||
targets = [f"/orgs/{q(org)}/labels" for org in sorted(orgs & owners)]
|
||||
targets += [f"/repos/{q(r['owner']['login'])}/{q(r['name'])}/labels" for r in repos
|
||||
if (r.get("owner") or {}).get("login") not in orgs]
|
||||
"""Org labels in each org that owns one of `repos` (all org repos); never repo labels."""
|
||||
orgs = sorted({o for r in repos if (o := (r.get("owner") or {}).get("login"))})
|
||||
actions = []
|
||||
for path in targets:
|
||||
created = ensure_labels_at(path, token)
|
||||
for org in orgs:
|
||||
created = ensure_labels_at(f"/orgs/{q(org)}/labels", token)
|
||||
if created:
|
||||
actions.append(f"{path.rsplit('/labels', 1)[0]}: created {','.join(created)}")
|
||||
return "; ".join(actions) or f"labels present in {len(targets)} orgs/repos"
|
||||
actions.append(f"/orgs/{org}: created {','.join(created)}")
|
||||
return "; ".join(actions) or f"labels present in {len(orgs)} orgs"
|
||||
|
||||
|
||||
# --- 3. backfill -------------------------------------------------------------
|
||||
@@ -372,9 +398,9 @@ def main() -> int:
|
||||
def load_repos() -> str:
|
||||
errors: list[str] = []
|
||||
repos.extend(issue_repos(token, errors))
|
||||
msg = f"{len(repos)} with issues writable by {BOT_LOGIN}"
|
||||
msg = f"{len(repos)} org repos with issues writable by {BOT_LOGIN}"
|
||||
if errors: # the other repos still get labels and backfill; the Job fails
|
||||
raise RuntimeError(f"{msg}; permission lookup failed, skipped: {'; '.join(errors)}")
|
||||
raise RuntimeError(f"{msg}; team lookup failed, skipped: {'; '.join(errors)}")
|
||||
return msg
|
||||
|
||||
failures = 0
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
is a TLS-passthrough service on :443 with a PROXY v2 header, going to Traefik `websecure`.
|
||||
There are no UDM port forwards and no other public L4 path. LB VIPs (`bgp-pool`,
|
||||
`fd99::/64`, `10.2.99.0/24`) are LAN/mesh-only.
|
||||
- DNS labels on an Ingress: `dns.renner.uno/internal` writes the UDM (LAN) record
|
||||
(opt-in), `dns.renner.uno/public` writes the Cloudflare CNAME to `proxy.renner.uno`, and
|
||||
`netbird.renner.uno/public` makes `netbird-service-sync` publish the proxy service. A
|
||||
public host needs both public labels.
|
||||
- `netbird-service-sync` publishes **only hosts on Ingresses** labelled
|
||||
`dns.renner.uno/public` (or `netbird.renner.uno/public`), and it reads no other kind.
|
||||
`netbird.renner.uno/public`, and it reads no other kind.
|
||||
To publish a TLS host served by an `IngressRouteTCP` (TLS terminated by Traefik), put the
|
||||
host on its **own** labelled Ingress annotated
|
||||
`traefik.ingress.kubernetes.io/router.entrypoints: web` with **no** `tls:` section, and
|
||||
|
||||
@@ -16,15 +16,15 @@ monitor, and its history starts over.
|
||||
the KumaMonitor `healthCheckExprs` entry with `current: "true"` (see SKILL.md pitfalls).
|
||||
|
||||
## Which hosts are public
|
||||
The public set is exactly the hosts on Ingresses labelled `dns.renner.uno/public` or
|
||||
`netbird.renner.uno/public`. To confirm it, do a TLS handshake against the proxy-1 and
|
||||
A public host needs both labels on its Ingress: `netbird.renner.uno/public` for the proxy
|
||||
service and `dns.renner.uno/public` for the Cloudflare CNAME. To confirm it, do a TLS handshake against the proxy-1 and
|
||||
proxy-2 addresses (resolved via DoH) with each candidate hostname as the SNI. Only public hosts
|
||||
return a CA-verified certificate. The proxies do not answer for their own names, so a
|
||||
handshake with `proxy-N.renner.uno` as the SNI fails.
|
||||
|
||||
## Checks per host (owner's spec: DNS + HTTP reachability + response validation)
|
||||
- **DNS.** A cluster host has no record of its own and resolves through the `*.renner.uno`
|
||||
wildcard. Check `resolveType: CNAME` with `conditions: [{operator: equals, value:
|
||||
- **DNS.** A cluster host's public record is a CNAME to `proxy.renner.uno` created by
|
||||
external-dns from `dns.renner.uno/public`. Check `resolveType: CNAME` with `conditions: [{operator: equals, value:
|
||||
proxy.renner.uno}]`. An external host checks its own AAAA record, or A when it has no
|
||||
AAAA. Use `resolveServer: "2606:4700:4700::1111,1.1.1.1"` so IPv6 comes first; Kuma
|
||||
fails over through the list in order.
|
||||
@@ -71,7 +71,7 @@ reached Kuma.
|
||||
parent exists and is a group.
|
||||
- Emulate each http/keyword/json-query check the way Kuma does it: axios, Kuma's
|
||||
`evaluateJsonQuery` logic via `jsonata`, and a custom `lookup` that resolves through DoH,
|
||||
because this pod's DNS is split-horizon. Run each wildcard host pinned to proxy-1 and to
|
||||
because this pod's DNS is split-horizon. Run each public host pinned to proxy-1 and to
|
||||
proxy-2.
|
||||
- DNS monitors can't be checked from the cluster, because outbound :53 to external
|
||||
resolvers is blocked. After merge, read `monitor_status` from Prometheus instead
|
||||
|
||||
@@ -701,62 +701,120 @@ def repo(full, has_issues=True, archived=False):
|
||||
"has_issues": has_issues, "archived": archived}
|
||||
|
||||
|
||||
def team(tid, name, issues="write", all_repos=True, permission="write"):
|
||||
return {"id": tid, "name": name, "includes_all_repositories": all_repos,
|
||||
"permission": permission, "units_map": {"repo.issues": issues, "repo.code": "write"}}
|
||||
|
||||
|
||||
def fake_gitea(orgs, teams, members, repos, fail=()):
|
||||
"""api() stand-in: orgs of the bot, teams per org, bot's team ids, repos per org or team."""
|
||||
seen = []
|
||||
|
||||
def fake(method, path, token, body=None):
|
||||
p = path.split("?")[0]
|
||||
seen.append(p)
|
||||
if p in fail:
|
||||
raise RuntimeError(f"GET {p}: HTTP 500")
|
||||
if p == "/admin/users/hermes-bot/orgs":
|
||||
return [{"username": o} for o in orgs]
|
||||
if p.startswith("/orgs/") and p.endswith("/teams"):
|
||||
return teams.get(p.split("/")[2], [])
|
||||
if p.startswith("/teams/") and "/members/" in p:
|
||||
if int(p.split("/")[2]) in members:
|
||||
return {"login": "hermes-bot"}
|
||||
raise RuntimeError(f"GET {p}: HTTP 404")
|
||||
if p.endswith("/repos"):
|
||||
return repos.get(p, [])
|
||||
raise AssertionError(f"unexpected {method} {p}")
|
||||
|
||||
return fake, seen
|
||||
|
||||
|
||||
class DomainLabels(unittest.TestCase):
|
||||
def test_labels_only_for_orgs_and_user_repos_hermes_works(self):
|
||||
def test_org_labels_only_never_repo_labels(self):
|
||||
calls = []
|
||||
|
||||
def fake(method, path, token, body=None):
|
||||
calls.append((method, path, body))
|
||||
if path.startswith("/admin/orgs"):
|
||||
return [{"username": "konairius"}, {"username": "unrelated-org"}]
|
||||
if path.startswith("/orgs/konairius/labels"):
|
||||
return [{"name": "Atlas"}, {"name": "bug"}]
|
||||
return []
|
||||
|
||||
repos = [repo("konairius/homelab"), repo("k.renner/turm1388")]
|
||||
repos = [repo("konairius/homelab"), repo("konairius/shelly-manager")]
|
||||
with mock.patch.object(sync, "api", side_effect=fake):
|
||||
msg = sync.ensure_labels("tok", repos)
|
||||
posts = [(p, b["name"]) for m, p, b in calls if m == "POST"]
|
||||
self.assertEqual(sorted(n for p, n in posts if p == "/orgs/konairius/labels"),
|
||||
["apollo", "clio", "daedalus", "hestia"]) # atlas exists (any case)
|
||||
self.assertEqual(len([p for p, _ in posts if p == "/repos/k.renner/turm1388/labels"]), 5)
|
||||
self.assertFalse([p for p, _ in posts if p.startswith("/repos/konairius/")]) # org labels reach it
|
||||
self.assertFalse([c for c in calls if "unrelated-org" in c[1]]) # no repo of it is worked
|
||||
self.assertFalse([c for c in calls if c[1].startswith("/repos/")]) # org labels reach them
|
||||
self.assertFalse([c for c in calls if c[1].startswith("/admin/")])
|
||||
self.assertIn("created", msg)
|
||||
|
||||
def test_repos_without_issues_archived_or_bot_write_are_skipped(self):
|
||||
perms = {"a/x": "write", "a/w": "admin", "gitea_admin/_cargo-index": "read", "a/n": "none"}
|
||||
seen = []
|
||||
|
||||
def fake(method, path, token, body=None):
|
||||
if path.startswith("/repos/search"):
|
||||
return {"ok": True, "data": [
|
||||
repo("a/x"), repo("a/w"), repo("gitea_admin/_cargo-index"), repo("a/n"),
|
||||
repo("a/y", has_issues=False), repo("a/z", archived=True)]}
|
||||
seen.append(path)
|
||||
full = path.split("/repos/")[1].split("/collaborators/")[0]
|
||||
return {"permission": perms[full]}
|
||||
|
||||
def test_team_write_covers_every_org_repo_without_collaborator(self):
|
||||
# Live 2026-10-21: hermes-bot is only in team Bots (all repos, issues write);
|
||||
# /collaborators/hermes-bot/permission says "none" for 9 of the 10 repos.
|
||||
org_repos = [repo("konairius/homelab"), repo("konairius/shelly-manager"),
|
||||
repo("konairius/ical-proxy"), repo("konairius/off", has_issues=False),
|
||||
repo("konairius/old", archived=True)]
|
||||
fake, seen = fake_gitea(
|
||||
orgs=["konairius"],
|
||||
teams={"konairius": [team(1, "Owners", "owner", permission="owner"), team(2, "Bots"),
|
||||
team(5, "Reviewers", "read", permission="none")]},
|
||||
members={2},
|
||||
repos={"/orgs/konairius/repos": org_repos})
|
||||
with mock.patch.object(sync, "api", side_effect=fake):
|
||||
got = [r["full_name"] for r in sync.issue_repos("tok")]
|
||||
self.assertEqual(got, ["a/x", "a/w"])
|
||||
self.assertIn("/repos/a/x/collaborators/hermes-bot/permission", seen)
|
||||
self.assertFalse([p for p in seen if p.startswith(("/repos/a/y/", "/repos/a/z/"))])
|
||||
self.assertEqual(got, ["konairius/homelab", "konairius/ical-proxy", "konairius/shelly-manager"])
|
||||
self.assertFalse([p for p in seen if "/collaborators/" in p or p.startswith("/repos/")])
|
||||
self.assertNotIn("/teams/5/members/hermes-bot", seen) # read-only team is not even checked
|
||||
|
||||
def test_failed_permission_lookup_skips_only_that_repo(self):
|
||||
def fake(method, path, token, body=None):
|
||||
if path.startswith("/repos/search"):
|
||||
return {"data": [repo("a/x"), repo("a/broken"), repo("a/w")]}
|
||||
if path.startswith("/repos/a/broken/"):
|
||||
raise RuntimeError("GET /repos/a/broken/...: HTTP 500")
|
||||
return {"permission": "write"}
|
||||
def test_team_with_selected_repos_and_no_issue_write(self):
|
||||
fake, seen = fake_gitea(
|
||||
orgs=["a", "b"],
|
||||
teams={"a": [team(3, "Some", all_repos=False)],
|
||||
"b": [team(4, "CodeOnly", issues="read")]},
|
||||
members={3, 4},
|
||||
repos={"/teams/3/repos": [repo("a/x"), repo("a/y")],
|
||||
"/orgs/a/repos": [repo("a/x"), repo("a/y"), repo("a/not-in-team")],
|
||||
"/orgs/b/repos": [repo("b/z")]})
|
||||
with mock.patch.object(sync, "api", side_effect=fake):
|
||||
got = [r["full_name"] for r in sync.issue_repos("tok")]
|
||||
self.assertEqual(got, ["a/x", "a/y"])
|
||||
self.assertNotIn("/orgs/b/repos", seen)
|
||||
|
||||
def test_team_issue_write_from_units_map_or_team_mode(self):
|
||||
self.assertTrue(sync.team_writes_issues(team(2, "Bots")))
|
||||
self.assertFalse(sync.team_writes_issues(team(5, "Reviewers", "read", permission="none")))
|
||||
self.assertTrue(sync.team_writes_issues(team(1, "Owners", "read", permission="owner")))
|
||||
self.assertTrue(sync.team_writes_issues({"permission": "write"})) # no units_map
|
||||
self.assertFalse(sync.team_writes_issues({"permission": "read", "units_map": {}}))
|
||||
|
||||
def test_user_repos_are_out_of_scope(self):
|
||||
fake, seen = fake_gitea(orgs=[], teams={}, members=set(), repos={})
|
||||
with mock.patch.object(sync, "api", side_effect=fake):
|
||||
self.assertEqual(sync.issue_repos("tok"), [])
|
||||
self.assertEqual(seen, ["/admin/users/hermes-bot/orgs"]) # no /repos/search over user repos
|
||||
|
||||
def test_failed_team_lookup_skips_only_that_team(self):
|
||||
fake, _ = fake_gitea(
|
||||
orgs=["a", "broken"],
|
||||
teams={"a": [team(3, "Bots", all_repos=False), team(6, "More", all_repos=False)]},
|
||||
members={3, 6},
|
||||
repos={"/teams/3/repos": [repo("a/x")], "/teams/6/repos": [repo("a/w"), repo("a/x")]},
|
||||
fail={"/orgs/broken/teams", "/teams/6/members/hermes-bot"})
|
||||
errors = []
|
||||
with mock.patch.object(sync, "api", side_effect=fake):
|
||||
got = [r["full_name"] for r in sync.issue_repos("tok", errors)]
|
||||
self.assertEqual(got, ["a/x", "a/w"])
|
||||
self.assertEqual(len(errors), 1)
|
||||
self.assertIn("a/broken", errors[0])
|
||||
self.assertEqual(got, ["a/x"])
|
||||
self.assertEqual(len(errors), 2)
|
||||
self.assertIn("a team More", errors[0])
|
||||
self.assertTrue(errors[1].startswith("broken:"))
|
||||
|
||||
def test_failed_lookup_raises_without_error_list(self):
|
||||
fake, _ = fake_gitea(orgs=["broken"], teams={}, members=set(), repos={},
|
||||
fail={"/orgs/broken/teams"})
|
||||
with mock.patch.object(sync, "api", side_effect=fake), self.assertRaises(RuntimeError):
|
||||
sync.issue_repos("tok")
|
||||
|
||||
|
||||
class Backfill(unittest.TestCase):
|
||||
|
||||
@@ -88,7 +88,7 @@ spec:
|
||||
# deliver, and the ClusterIP has no backend while HA restarts. The
|
||||
# trap forwards SIGTERM so the pod still terminates promptly.
|
||||
- name: socat-ipv4
|
||||
image: alpine/socat:1.8.1.3@sha256:fb3862c949af7c79996d0e514ee96128644d840835ed5368eeecb14a869de361
|
||||
image: alpine/socat:1.8.1.3@sha256:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /bin/sh
|
||||
@@ -130,7 +130,7 @@ spec:
|
||||
# depends on the kernel's bindv6only default rather than on anything
|
||||
# stated here, so the roles are made explicit instead of inherited.
|
||||
- name: socat-ipv6
|
||||
image: alpine/socat:1.8.1.3@sha256:fb3862c949af7c79996d0e514ee96128644d840835ed5368eeecb14a869de361
|
||||
image: alpine/socat:1.8.1.3@sha256:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /bin/sh
|
||||
|
||||
@@ -61,7 +61,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: shelly-manager
|
||||
image: gitea.renner.uno/k.renner/shelly-manager:build.1792387459 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
image: gitea.renner.uno/k.renner/shelly-manager:build.1792997278 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
@@ -90,8 +90,7 @@ spec:
|
||||
# BLE radios: ESP32 esphome bluetooth_proxy nodes (e.g. the M5 Atom
|
||||
# Echo) are AUTO-DISCOVERED on the IoT VLAN via mDNS
|
||||
# (_esphomelib._tcp) over the same net1 macvlan route as the Shellys —
|
||||
# no static hostname/DNS (which the *.renner.uno wildcard would poison
|
||||
# over IPv6). The shared fleet Noise key (= the dashboard's
|
||||
# no static hostname/DNS. The shared fleet Noise key (= the dashboard's
|
||||
# shelly_ble_proxy_api_key) lets shelly-manager complete the encrypted
|
||||
# native-API handshake to each discovered proxy: DeviceInfo on
|
||||
# /ble-radios today, the BLE scan/GATT provisioning path next.
|
||||
@@ -178,7 +177,7 @@ spec:
|
||||
# No probes: it only serves the API-key path, and a probe failure would
|
||||
# also pull the pod out of the shelly-manager Service.
|
||||
- name: api-proxy
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b9241c6e7b8e9a862f129d8d4199ab64b10390949a78bdd5603379b32c844083
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b5047b9b76845eac124358373c6852377c52e4b05834a47647e955dd91cb8f1e
|
||||
env:
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
# Proposal: enrol the mosquitto broker credentials in Class-A rotation
|
||||
|
||||
**Status:** proposal, nothing implemented.
|
||||
**Scope:** the 8 MQTT broker users and the credentials behind them.
|
||||
|
||||
## Problem
|
||||
|
||||
Every self-generated secret in this cluster rotates automatically except these.
|
||||
`kubernetes/infrastructure/openbao/rotation/` holds 13 manifests defining 15
|
||||
rotator CronJobs (`home-assistant.yaml` alone defines three). Five are **Class-A**
|
||||
— authentik, crowdsec, paperless, shelly-manager and woodpecker — meaning
|
||||
"self-generated random secret", which is exactly what a broker password is.
|
||||
The mosquitto credentials are the only Class-A-shaped secrets not enrolled.
|
||||
|
||||
They are currently minted once by seven near-identical bootstrap Jobs
|
||||
(`mosquitto-bootstrap.yaml`, 508 lines — the ServiceAccount, Role and RoleBinding
|
||||
are inside that same file) which generate a password, hand-build Secret JSON with
|
||||
`jq` and POST it to the Kubernetes API with `curl`. Once created they never
|
||||
change again.
|
||||
|
||||
Three consequences:
|
||||
|
||||
1. **They never rotate.** Not on a schedule, not on compromise, not ever.
|
||||
2. **They exist only in the Secret object** — not in OpenBao, so not covered by
|
||||
the OpenBao snapshot CronJob, and unrecoverable if lost.
|
||||
3. **The Jobs cannot be modified.** A Job's `spec.template` is immutable, so
|
||||
changing even the container image makes the whole `home-assistant`
|
||||
Kustomization fail to reconcile with `field is immutable`. Any edit requires
|
||||
deleting or renaming the Jobs.
|
||||
|
||||
## Why the obvious fixes do not work
|
||||
|
||||
Recorded because each was tried and rejected on evidence, and the reasons are
|
||||
not obvious from the code.
|
||||
|
||||
| Approach | Why it fails |
|
||||
| --- | --- |
|
||||
| ExternalSecrets reading hand-seeded OpenBao paths | Depends on content copied in by hand. Git cannot recreate it; a fresh OpenBao leaves the paths empty and ESO then overwrites live broker credentials with nothing. |
|
||||
| ESO `Password` generator | **Overwrites pre-existing Secrets.** Verified: a Secret holding `KNOWN-PREEXISTING-VALUE` was replaced with a freshly generated password on first sync. Adopting it rotates the 7 Job-minted credentials at once, uncoordinated (`evcc` is already ESO-extract-backed and so unaffected). |
|
||||
| Keep the Jobs, fix only the image | Blocked by `spec.template` immutability. Renaming the Jobs works but is a hack that recurs on every future edit. |
|
||||
|
||||
The common thread: each treats the credential as something to *place*, when the
|
||||
real requirement is a credential that can be *replaced*.
|
||||
|
||||
## How rotation actually has to work here
|
||||
|
||||
The broker rebuilds its password file from scratch on **every pod start**
|
||||
(`mosquitto-deployment.yaml`): `rm -f /mosquitto/auth/passwd` followed by
|
||||
`mosquitto_passwd -c -b …` for each of the 8 users, sourced from env vars backed
|
||||
by the 8 Secrets.
|
||||
|
||||
Two things follow, and they shape the whole design:
|
||||
|
||||
- **A Secret change has no effect until the broker restarts.** There is no
|
||||
Reloader annotation on the mosquitto Deployment today, unlike the consumers of
|
||||
the existing Class-A rotators.
|
||||
- **There is no make-before-break.** A mosquitto user has exactly one password.
|
||||
The LLAT rotators can mint a second token and revoke the first afterwards; a
|
||||
broker user cannot hold old and new simultaneously. Unless a transitional
|
||||
second user is created, broker and client must cut over together.
|
||||
|
||||
### Who holds each credential
|
||||
|
||||
This is the part that determines cutover cost, and it is not visible from the
|
||||
manifests alone.
|
||||
|
||||
| Broker user | Held by | Cutover |
|
||||
| --- | --- | --- |
|
||||
| `health` | the broker's own liveness/readiness probes | in-cluster, same pod restart |
|
||||
| `zigbee2mqtt` | zigbee2mqtt Deployment (`secretKeyRef`) | in-cluster, pod restart |
|
||||
| `evcc` | evcc app; already OpenBao-backed via ESO | in-cluster, pod restart |
|
||||
| `sml2mqtt` | edge host, from `vault_edge_sml2mqtt_mqtt_password` | re-run the edge-host play |
|
||||
| `open3e` | edge host, from `vault_edge_open3e_mqtt_password` | re-run the edge-host play |
|
||||
| `homeassistant` | HA MQTT integration — a config entry created in the UI | needs an API/WebSocket update, or a manual re-entry |
|
||||
| `shellies` | Shelly device firmware | pushed per-device; `shelly-manager` is deployed and is the natural mechanism |
|
||||
| `admin` | a workstation client, held by a human | read the new value from OpenBao |
|
||||
|
||||
Note `sml2mqtt` and `open3e` currently keep **the same password in two unlinked
|
||||
places** — the Kubernetes Secret for the broker, and `ansible-vault` for the
|
||||
client — synchronised by hand. That duplication, not any client limitation, is
|
||||
what makes rotation feel impossible today.
|
||||
|
||||
## Proposed design
|
||||
|
||||
Follow the existing Class-A pattern exactly (`rotation/paperless.yaml` is the
|
||||
closest model): *generate random bytes → merge-patch into OpenBao → ESO syncs the
|
||||
Secret → restart the consumer*.
|
||||
|
||||
1. **One rotator CronJob per broker user**, running the existing `secret-rotator`
|
||||
binary in random mode, writing `apps/home-assistant/mosquitto-<user>-credentials`
|
||||
in OpenBao. Reuses the established policy/role/SA pattern; no new machinery.
|
||||
2. **ExternalSecrets** project each OpenBao entry to the existing Secret name and
|
||||
key, so `mosquitto-deployment.yaml` and the in-cluster consumers are unchanged.
|
||||
Key names stay as they are (`homeassistant-password` on one, `password` on the
|
||||
rest) — consumers reference them, so normalising is a separate change.
|
||||
3. **Add a Reloader annotation to the mosquitto Deployment** so a Secret change
|
||||
restarts the broker, instead of silently taking effect at some unrelated future
|
||||
restart.
|
||||
4. **Move the edge host off `ansible-vault` for these two values**, reading the
|
||||
same OpenBao entry instead, so there is one source of truth per credential.
|
||||
5. **Rotate one user at a time.** Each rotation then affects exactly one client,
|
||||
not all eight.
|
||||
|
||||
### The cutover window
|
||||
|
||||
With no make-before-break, each rotation has a window where broker and client
|
||||
disagree. Two options:
|
||||
|
||||
- **Accept a brief window** (recommended for most users). Order: rotator writes
|
||||
OpenBao → client picks up the new value → broker restarts. MQTT clients
|
||||
reconnect automatically; the window is seconds. Because rotation is per-user,
|
||||
only that one client is affected.
|
||||
|
||||
One failure mode to expect rather than be alarmed by: the broker rolls with
|
||||
`maxUnavailable: 0` / `maxSurge: 1`, so mid-rollout **two broker pods with
|
||||
different passwd files sit behind the LB**. A client reconnecting during that
|
||||
window can land on the old pod and fail auth until the rollout completes. It is
|
||||
self-healing and lasts seconds to a minute, but it will look alarming in logs
|
||||
during the first `health` / `zigbee2mqtt` rotation, so it is worth knowing in
|
||||
advance.
|
||||
- **Transitional dual user** (`<user>` and `<user>-next` in the passwd file and
|
||||
ACL) for any credential where even a brief outage is unacceptable. More moving
|
||||
parts; probably only worth it for `homeassistant`, which carries everything.
|
||||
|
||||
## Suggested phasing
|
||||
|
||||
Ordered so the risky work happens after the pattern is proven, and so each phase
|
||||
is independently valuable.
|
||||
|
||||
1. **`health` and `zigbee2mqtt` first.** Both are entirely in-cluster and pick up
|
||||
a new password on pod restart with no external coordination. Proves rotator →
|
||||
OpenBao → ESO → Reloader end to end with the smallest blast radius. `health`
|
||||
is the safest possible start: only the broker's own probes consume it.
|
||||
2. **`evcc`.** Already OpenBao-backed via ESO; only the rotator is missing.
|
||||
3. **`sml2mqtt` and `open3e`**, together with switching the edge host to read from
|
||||
OpenBao. This is where the duplicate-source-of-truth problem gets fixed.
|
||||
4. **`shellies`**, via shelly-manager pushing to devices. Needs investigation:
|
||||
shelly-manager has its own Class-A rotator today, but that rotates
|
||||
shelly-manager's own secret and does not touch device MQTT credentials.
|
||||
5. **`homeassistant`** last. Highest blast radius, and the config entry is UI-created,
|
||||
so it needs an API/WebSocket update path — the HA LLAT rotators
|
||||
(`rotation/home-assistant.yaml`) already talk to HA over WebSocket and are the
|
||||
obvious precedent.
|
||||
6. **`admin`** whenever convenient; a human reads the new value from OpenBao.
|
||||
|
||||
Only once a user is enrolled does its bootstrap Job become dead code. Delete the
|
||||
Jobs incrementally as their users migrate, rather than in one step — that also
|
||||
avoids the immutability problem entirely, since the Jobs are deleted, never
|
||||
patched.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Does `secret-rotator` random mode fit unchanged?** The existing rotators write
|
||||
a field into an OpenBao entry; here the entry holds a single password field, so
|
||||
it should, but this needs confirming against the binary rather than assumed.
|
||||
- **Can shelly-manager push MQTT credentials to devices**, and does it cover both
|
||||
Gen1 (`/settings/mqtt`) and Gen2+ (`Mqtt.SetConfig`) firmwares? If not, the
|
||||
`shellies` user may need a different mechanism or stay manual longest.
|
||||
- **How is the HA MQTT config entry updated programmatically?** Reusing the
|
||||
WebSocket approach from the LLAT rotator is plausible but unverified.
|
||||
- **Rotation cadence.** The existing rotators' schedules should be the reference
|
||||
rather than inventing a new one.
|
||||
- **ESO refresh latency.** Rotator-writes-OpenBao → Secret-updates →
|
||||
Reloader-restarts is delayed by up to one `refreshInterval`; the `evcc`
|
||||
ExternalSecret uses `1h`. That does not widen the outage window — broker and
|
||||
client still move together — but it does stretch how long a rotation takes to
|
||||
land, which matters for verifying one completed and for the cold-rebuild
|
||||
ordering question above. Worth deciding whether these ExternalSecrets want a
|
||||
shorter interval than 1h, or whether the rotator should trigger the sync
|
||||
directly.
|
||||
- **Bootstrap on a cold rebuild.** On a fresh cluster the rotator must create the
|
||||
OpenBao entry before ESO can sync it; check this against
|
||||
`kubernetes/RUNBOOK-cold-rebuild.md` so broker bring-up is not blocked.
|
||||
|
||||
## What this does not change
|
||||
|
||||
- Secret names, key names and every `secretKeyRef` in `mosquitto-deployment.yaml`.
|
||||
- The ACL model and the per-user topic scoping.
|
||||
- The `evcc` ExternalSecret, which already follows the target pattern.
|
||||
@@ -128,7 +128,7 @@ spec:
|
||||
memory: 32Mi
|
||||
containers:
|
||||
- name: home-assistant
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.10.2 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.10.4 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
env:
|
||||
- name: TZ
|
||||
value: Europe/Berlin
|
||||
@@ -192,7 +192,7 @@ spec:
|
||||
# substitution to POD_IP so nginx-runtime variables like $host
|
||||
# / $http_x_forwarded_for in the template stay intact.
|
||||
- name: proxy
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:378d0c32f6562f7fd9629170797f57d2b953aa09be983b3daaf4809bb039ca00
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b5047b9b76845eac124358373c6852377c52e4b05834a47647e955dd91cb8f1e
|
||||
env:
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
@@ -236,7 +236,7 @@ spec:
|
||||
# readiness probe on purpose: a broker outage must not take HA out of
|
||||
# its Service endpoints.
|
||||
- name: mqtt-tls
|
||||
image: ghostunnel/ghostunnel:v1.11.4@sha256:e5248233863aadd36383a11c92360f2981141881ef05e38360a6ada8630d36b1
|
||||
image: ghostunnel/ghostunnel:v1.11.5@sha256:65c4e34ec0ee0f30ea1f72b7411ec70a91c32fc50f067a8466ad2adb1e7420f2
|
||||
args:
|
||||
- client
|
||||
- --listen=127.0.0.1:11883
|
||||
@@ -275,7 +275,7 @@ spec:
|
||||
# any LAN client can edit, matching the user's trust-the-network
|
||||
# model. Runs as root so its writes match HA's root-owned files.
|
||||
- name: code-server
|
||||
image: codercom/code-server:4.141.0@sha256:72ab24b5680ee597dd647b30d82625752f40109b5f61bef90ffbf1ea1f02dbb8
|
||||
image: codercom/code-server:4.143.1@sha256:377b66bec5645510ed52549bb00e648c273edc604d230a3f90fd2b2433afcb97
|
||||
args:
|
||||
- "--auth=none"
|
||||
- "--bind-addr=0.0.0.0:8080"
|
||||
@@ -337,7 +337,7 @@ spec:
|
||||
# dedicated long-lived token.
|
||||
# Runs as root to match HA's root-owned /config files. See config-sync.sh.
|
||||
- name: config-sync
|
||||
image: alpine/git:2.54.0@sha256:03228d7ded483b0b3e55e1de7fa660d70d3e1bb432fa7021d29e192640de11aa
|
||||
image: alpine/git:2.54.0@sha256:e8ceedc73bd80992ae8d58a7d24c58b8322146c354f1d2e81a97db4452068ec0
|
||||
command: ["/bin/sh", "/scripts/sync.sh"]
|
||||
env:
|
||||
- name: SYNC_BRANCH
|
||||
|
||||
@@ -36,7 +36,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: esphome
|
||||
image: ghcr.io/esphome/esphome:2026.9.1@sha256:776a845d508909bf426d2ee7bdd81796361ebe6a7f091807ce1d6d0b48985413
|
||||
image: ghcr.io/esphome/esphome:2026.10.0@sha256:1dff19a59070cdb523b76576cdc74d200f862d43c1407809088855920cf57145
|
||||
imagePullPolicy: IfNotPresent
|
||||
# Default entrypoint runs the dashboard (Device Builder) against the
|
||||
# mounted /config — no command override needed.
|
||||
|
||||
@@ -12,26 +12,20 @@ resources:
|
||||
- config-sync-token-externalsecret.yaml
|
||||
- config-sync-gitea-externalsecret.yaml
|
||||
- matrix-externalsecret.yaml
|
||||
- mosquitto-bootstrap.yaml
|
||||
# Former `health` probe credential. The broker no longer reads it (the
|
||||
# probes use the cert account mosquitto-probe, REN-953); removed with its
|
||||
# rotator in the REN-802 cleanup step.
|
||||
- mosquitto-health-credentials-externalsecret.yaml
|
||||
- mosquitto-admin-password.yaml
|
||||
- mosquitto-config.yaml
|
||||
- mosquitto-certificate.yaml
|
||||
- mosquitto-service.yaml
|
||||
- mosquitto-iot-service.yaml
|
||||
- mosquitto-deployment.yaml
|
||||
- mosquitto-evcc-credentials-externalsecret.yaml
|
||||
# shelly-manager and the mDNS relay sit on the IoT VLAN: ../home-assistant-iot.
|
||||
- esphome-pvc.yaml
|
||||
- esphome-deployment.yaml
|
||||
- esphome-service.yaml
|
||||
- esphome-ingress.yaml
|
||||
# Zigbee2MQTT — ZHA replacement bridge. Ships scaled to 0; the cutover
|
||||
# runbook (zigbee2mqtt-migration-runbook.md) scales it up. The MQTT
|
||||
# `zigbee2mqtt` broker user is provisioned by mosquitto-bootstrap.yaml
|
||||
# (above) and wired into mosquitto-deployment.yaml (passwd + ACL).
|
||||
# runbook (zigbee2mqtt-migration-runbook.md) scales it up. It logs in to
|
||||
# the broker as register account `zigbee2mqtt-bridge` (mqtt-accounts.yaml).
|
||||
- zigbee2mqtt-coordinator-backup-externalsecret.yaml
|
||||
- zigbee2mqtt-config.yaml
|
||||
- zigbee2mqtt-pvc.yaml
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Password of the broker's `admin` workstation login (mosquitto-deployment.yaml).
|
||||
# Generated once in-cluster, nothing in OpenBao. Read it with
|
||||
# kubectl -n home-assistant get secret mosquitto-admin-password -o jsonpath='{.data.password}' | base64 -d
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
metadata:
|
||||
name: mosquitto-admin-password
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
length: 32
|
||||
digits: 8
|
||||
symbols: 0
|
||||
allowRepeat: true
|
||||
noUpper: false
|
||||
secretKeys:
|
||||
- password
|
||||
---
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: mosquitto-admin-password
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
refreshPolicy: CreatedOnce
|
||||
target:
|
||||
name: mosquitto-admin-password
|
||||
dataFrom:
|
||||
- sourceRef:
|
||||
generatorRef:
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
name: mosquitto-admin-password
|
||||
@@ -1,394 +0,0 @@
|
||||
---
|
||||
# Generates mosquitto-credentials on first deploy. The password is created
|
||||
# in-cluster, lives only in the Secret object, and is never written to git
|
||||
# or seen by a human. The Mosquitto broker hashes it via mosquitto_passwd
|
||||
# -U at startup; HA reads the same value from this Secret (configured
|
||||
# manually in the HA MQTT integration UI).
|
||||
#
|
||||
# Idempotent: if the Secret already exists, the Job exits 0 without
|
||||
# regenerating, so subsequent Flux reconciles don't rotate the password.
|
||||
# To rotate, delete both the Secret and this Job; Flux will re-create
|
||||
# the Job and a fresh password.
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: mosquitto-bootstrap
|
||||
namespace: home-assistant
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: mosquitto-bootstrap
|
||||
namespace: home-assistant
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["get", "create"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: mosquitto-bootstrap
|
||||
namespace: home-assistant
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: mosquitto-bootstrap
|
||||
namespace: home-assistant
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: mosquitto-bootstrap
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: mosquitto-credentials-bootstrap
|
||||
namespace: home-assistant
|
||||
annotations:
|
||||
# Job pod templates are immutable; force-recreate so image bumps
|
||||
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
|
||||
kustomize.toolkit.fluxcd.io/force: enabled
|
||||
spec:
|
||||
backoffLimit: 4
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto-bootstrap
|
||||
spec:
|
||||
serviceAccountName: mosquitto-bootstrap
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: bootstrap
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
apk add --no-cache curl jq >/dev/null
|
||||
|
||||
NS=home-assistant
|
||||
NAME=mosquitto-credentials
|
||||
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
|
||||
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||
API=https://kubernetes.default.svc
|
||||
|
||||
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
|
||||
echo "$NAME already exists; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
|
||||
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
|
||||
|
||||
body=$(jq -nc \
|
||||
--arg name "$NAME" \
|
||||
--arg ns "$NS" \
|
||||
--arg pw "$PW_B64" \
|
||||
'{apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {name: $name, namespace: $ns},
|
||||
type: "Opaque",
|
||||
data: {"homeassistant-password": $pw}}')
|
||||
|
||||
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" -X POST \
|
||||
--data "$body" \
|
||||
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
|
||||
|
||||
echo "generated $NAME"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
---
|
||||
# Third broker user: `admin`. Full readwrite (same as HA) but kept on
|
||||
# its own credential so a workstation client can hold a password the
|
||||
# in-cluster HA config never sees, and rotating one doesn't affect the
|
||||
# other. Retrieve with:
|
||||
# kubectl -n home-assistant get secret mosquitto-admin-credentials \
|
||||
# -o jsonpath='{.data.password}' | base64 -d
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: mosquitto-admin-credentials-bootstrap
|
||||
namespace: home-assistant
|
||||
annotations:
|
||||
# Job pod templates are immutable; force-recreate so image bumps
|
||||
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
|
||||
kustomize.toolkit.fluxcd.io/force: enabled
|
||||
spec:
|
||||
backoffLimit: 4
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto-bootstrap
|
||||
spec:
|
||||
serviceAccountName: mosquitto-bootstrap
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: bootstrap
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
apk add --no-cache curl jq >/dev/null
|
||||
|
||||
NS=home-assistant
|
||||
NAME=mosquitto-admin-credentials
|
||||
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
|
||||
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||
API=https://kubernetes.default.svc
|
||||
|
||||
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
|
||||
echo "$NAME already exists; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
|
||||
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
|
||||
|
||||
body=$(jq -nc \
|
||||
--arg name "$NAME" \
|
||||
--arg ns "$NS" \
|
||||
--arg pw "$PW_B64" \
|
||||
'{apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {name: $name, namespace: $ns},
|
||||
type: "Opaque",
|
||||
data: {password: $pw}}')
|
||||
|
||||
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" -X POST \
|
||||
--data "$body" \
|
||||
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
|
||||
|
||||
echo "generated $NAME"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
---
|
||||
# Fourth broker user: `sml2mqtt` — the power-meter bridge. Publish-only
|
||||
# under `sml2mqtt/#` (ACL in mosquitto-deployment.yaml). Own credential so
|
||||
# its lifecycle is independent of the others. The sml2mqtt pod reads this
|
||||
# password via its init container and renders it into config.yml.
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: mosquitto-sml2mqtt-credentials-bootstrap
|
||||
namespace: home-assistant
|
||||
annotations:
|
||||
# Job pod templates are immutable; force-recreate so image bumps
|
||||
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
|
||||
kustomize.toolkit.fluxcd.io/force: enabled
|
||||
spec:
|
||||
backoffLimit: 4
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto-bootstrap
|
||||
spec:
|
||||
serviceAccountName: mosquitto-bootstrap
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: bootstrap
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
apk add --no-cache curl jq >/dev/null
|
||||
|
||||
NS=home-assistant
|
||||
NAME=mosquitto-sml2mqtt-credentials
|
||||
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
|
||||
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||
API=https://kubernetes.default.svc
|
||||
|
||||
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
|
||||
echo "$NAME already exists; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
|
||||
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
|
||||
|
||||
body=$(jq -nc \
|
||||
--arg name "$NAME" \
|
||||
--arg ns "$NS" \
|
||||
--arg pw "$PW_B64" \
|
||||
'{apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {name: $name, namespace: $ns},
|
||||
type: "Opaque",
|
||||
data: {password: $pw}}')
|
||||
|
||||
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" -X POST \
|
||||
--data "$body" \
|
||||
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
|
||||
|
||||
echo "generated $NAME"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
---
|
||||
# Fifth broker user: `open3e` — the Viessmann E3 CAN bridge (Vitocal 250-A
|
||||
# + Vitocharge VX3 via open3e). Scoped to `open3e/#` (ACL in
|
||||
# mosquitto-deployment.yaml). Own credential so its lifecycle is
|
||||
# independent of the others. Retrieve with:
|
||||
# kubectl -n home-assistant get secret mosquitto-open3e-credentials \
|
||||
# -o jsonpath='{.data.password}' | base64 -d
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: mosquitto-open3e-credentials-bootstrap
|
||||
namespace: home-assistant
|
||||
annotations:
|
||||
# Job pod templates are immutable; force-recreate so image bumps
|
||||
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
|
||||
kustomize.toolkit.fluxcd.io/force: enabled
|
||||
spec:
|
||||
backoffLimit: 4
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto-bootstrap
|
||||
spec:
|
||||
serviceAccountName: mosquitto-bootstrap
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: bootstrap
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
apk add --no-cache curl jq >/dev/null
|
||||
|
||||
NS=home-assistant
|
||||
NAME=mosquitto-open3e-credentials
|
||||
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
|
||||
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||
API=https://kubernetes.default.svc
|
||||
|
||||
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
|
||||
echo "$NAME already exists; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
|
||||
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
|
||||
|
||||
body=$(jq -nc \
|
||||
--arg name "$NAME" \
|
||||
--arg ns "$NS" \
|
||||
--arg pw "$PW_B64" \
|
||||
'{apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {name: $name, namespace: $ns},
|
||||
type: "Opaque",
|
||||
data: {password: $pw}}')
|
||||
|
||||
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" -X POST \
|
||||
--data "$body" \
|
||||
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
|
||||
|
||||
echo "generated $NAME"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
---
|
||||
# Broker user `zigbee2mqtt` — the Zigbee2MQTT bridge (ZHA replacement). Scoped
|
||||
# to its own `zigbee2mqtt/#` subtree plus `write homeassistant/#` (so it can
|
||||
# publish HA MQTT-discovery configs) and
|
||||
# `read homeassistant/status` (HA's birth/will topic; on `online` Z2M
|
||||
# republishes cached device states, NOT discovery configs — ACL and the full
|
||||
# reasoning in mosquitto-deployment.yaml). Own credential so its lifecycle is
|
||||
# independent of the others. The Z2M init container reads this password and
|
||||
# renders it into /app/data/secret.yaml.
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: mosquitto-zigbee2mqtt-credentials-bootstrap
|
||||
namespace: home-assistant
|
||||
annotations:
|
||||
# Job pod templates are immutable; force-recreate so image bumps
|
||||
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
|
||||
kustomize.toolkit.fluxcd.io/force: enabled
|
||||
spec:
|
||||
backoffLimit: 4
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mosquitto-bootstrap
|
||||
spec:
|
||||
serviceAccountName: mosquitto-bootstrap
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: bootstrap
|
||||
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
apk add --no-cache curl jq >/dev/null
|
||||
|
||||
NS=home-assistant
|
||||
NAME=mosquitto-zigbee2mqtt-credentials
|
||||
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
|
||||
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||
API=https://kubernetes.default.svc
|
||||
|
||||
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
|
||||
echo "$NAME already exists; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
|
||||
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
|
||||
|
||||
body=$(jq -nc \
|
||||
--arg name "$NAME" \
|
||||
--arg ns "$NS" \
|
||||
--arg pw "$PW_B64" \
|
||||
'{apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {name: $name, namespace: $ns},
|
||||
type: "Opaque",
|
||||
data: {password: $pw}}')
|
||||
|
||||
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" -X POST \
|
||||
--data "$body" \
|
||||
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
|
||||
|
||||
echo "generated $NAME"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
@@ -77,18 +77,15 @@ data:
|
||||
|
||||
allow_anonymous false
|
||||
|
||||
# Two account sources side by side (REN-802 step 2, tested on 2.0.22 in
|
||||
# REN-900, document `coexistence`):
|
||||
# Dynamic Security: accounts from the register mqtt-accounts.yaml,
|
||||
# managed live by mqtt-account-controller over $CONTROL.
|
||||
# password_file/acl_file: the legacy users, unchanged until each client
|
||||
# moves to the register (step 3).
|
||||
# Accounts (tested on 2.0.22 in REN-900, document `coexistence`):
|
||||
# Dynamic Security: every client account, from the register
|
||||
# mqtt-accounts.yaml, managed live by mqtt-account-controller over
|
||||
# $CONTROL. Applies to all listeners (per_listener_settings false).
|
||||
# password_file/acl_file: the owner's workstation login `admin` only
|
||||
# (mosquitto-deployment.yaml).
|
||||
# Dynamic Security is asked first. For a username it does not know, and
|
||||
# for a request none of its roles match, it defers to the files. A
|
||||
# register entry with the same name as a legacy user therefore replaces
|
||||
# that user's password, while its acl_file block still applies to
|
||||
# whatever the entry's roles do not cover. Delete the legacy user when
|
||||
# its client moves.
|
||||
# register entry named `admin` therefore replaces the file login.
|
||||
#
|
||||
# The acl_file check of 2.0 allows every SUBSCRIBE and checks only on
|
||||
# delivery, so the Dynamic Security default must deny publishClientReceive.
|
||||
|
||||
@@ -64,67 +64,15 @@ spec:
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
# `admin` is the owner's workstation login over the LoadBalancer
|
||||
# (1883/8883), full readwrite. The only password_file user: the
|
||||
# controller cannot deliver a password account to a Secret, and
|
||||
# the cert listener :8884 is in-cluster only.
|
||||
rm -f /mosquitto/auth/passwd
|
||||
mosquitto_passwd -c -b /mosquitto/auth/passwd homeassistant "$HA_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd admin "$ADMIN_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd sml2mqtt "$SML2MQTT_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd open3e "$OPEN3E_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd evcc "$EVCC_PASSWORD"
|
||||
mosquitto_passwd -b /mosquitto/auth/passwd zigbee2mqtt "$ZIGBEE2MQTT_PASSWORD"
|
||||
# ACL: HA keeps full access (subscribes to everything). Shellies
|
||||
# are not legacy users any more: each has its own Dynamic Security
|
||||
# account from mqtt-accounts.yaml (REN-802, REN-1017).
|
||||
# `admin` is a workstation/debugging credential — full
|
||||
# readwrite, kept on its own credential so its password can
|
||||
# be rotated independently of the HA session.
|
||||
# `sml2mqtt` is the power-meter bridge — WRITE-only under its own
|
||||
# `sml2mqtt/#` subtree (it only publishes; HA reads it via the
|
||||
# full-access homeassistant user).
|
||||
# `open3e` is the Viessmann E3 CAN bridge (Vitocal/VX3) —
|
||||
# scoped to its own `open3e/#` subtree: publishes datapoints
|
||||
# and later subscribes to `open3e/cmnd` in listener mode (HA
|
||||
# reads via the full-access homeassistant user).
|
||||
# `evcc` is the (cross-namespace) EV charge controller — reads
|
||||
# `open3e/#` (VX3 grid/PV/battery/SoC datapoints), publishes its
|
||||
# own state under `evcc/#`, and reads the grid price from
|
||||
# `energy/#` (HA republishes input_number.strompreis to
|
||||
# energy/tariff/grid; kept OUTSIDE evcc/# so evcc's own retained-
|
||||
# topic management can't wipe it).
|
||||
mosquitto_passwd -c -b /mosquitto/auth/passwd admin "$ADMIN_PASSWORD"
|
||||
cat > /mosquitto/auth/acl <<'EOF'
|
||||
user homeassistant
|
||||
topic readwrite #
|
||||
|
||||
user admin
|
||||
topic readwrite #
|
||||
|
||||
user sml2mqtt
|
||||
topic write sml2mqtt/#
|
||||
|
||||
user open3e
|
||||
topic readwrite open3e/#
|
||||
|
||||
user evcc
|
||||
topic read open3e/#
|
||||
topic readwrite evcc/#
|
||||
topic read energy/#
|
||||
|
||||
# `zigbee2mqtt` is the Zigbee2MQTT bridge (the ZHA replacement).
|
||||
# Owns its own `zigbee2mqtt/#` subtree; may WRITE `homeassistant/#`
|
||||
# to publish HA MQTT-discovery configs (write-only there, to deny
|
||||
# discovery-tree recon from a compromised bridge); and READs `homeassistant/status`, HA's birth/will topic.
|
||||
#
|
||||
# That read grant does NOT buy a discovery re-publish: on
|
||||
# `online`, Z2M 2.12.1 republishes bridge/state and cached device
|
||||
# states only (lib/extension/homeassistant.ts onMQTTMessage) —
|
||||
# discover() runs solely from start(). The grant is still correct,
|
||||
# because the cached-state republish is what makes HA show current
|
||||
# values right after an HA restart. Restoring discovery after a
|
||||
# BROKER restart is handled by the discovery-guard sidecar in
|
||||
# zigbee2mqtt-deployment.yaml.
|
||||
user zigbee2mqtt
|
||||
topic readwrite zigbee2mqtt/#
|
||||
topic write homeassistant/#
|
||||
topic read homeassistant/status
|
||||
EOF
|
||||
# Dynamic Security start file (REN-802): the controller's seed
|
||||
# if it exists, else the static base file. The seed carries
|
||||
@@ -143,38 +91,11 @@ spec:
|
||||
chmod 0600 /mosquitto/auth/passwd /mosquitto/auth/acl /mosquitto/auth/dynamic-security.json
|
||||
exec mosquitto -c /mosquitto/config/mosquitto.conf
|
||||
env:
|
||||
- name: HA_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mosquitto-credentials
|
||||
key: homeassistant-password
|
||||
# mosquitto-admin-password.yaml
|
||||
- name: ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mosquitto-admin-credentials
|
||||
key: password
|
||||
- name: SML2MQTT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mosquitto-sml2mqtt-credentials
|
||||
key: password
|
||||
- name: OPEN3E_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mosquitto-open3e-credentials
|
||||
key: password
|
||||
- name: EVCC_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mosquitto-evcc-credentials
|
||||
key: password
|
||||
# Zigbee2MQTT bridge credential (mosquitto-zigbee2mqtt-credentials-
|
||||
# bootstrap). Hashed into the passwd file here; Z2M reads the same
|
||||
# Secret via its init container to authenticate to the broker.
|
||||
- name: ZIGBEE2MQTT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mosquitto-zigbee2mqtt-credentials
|
||||
name: mosquitto-admin-password
|
||||
key: password
|
||||
ports:
|
||||
- name: mqtt
|
||||
@@ -231,12 +152,12 @@ spec:
|
||||
# Service and the probe dials 127.0.0.1; the chain is still checked
|
||||
# against mqtt-ca.
|
||||
#
|
||||
# mosquitto is a single-replica SPOF (HA, Shelly, sml2mqtt, open3e,
|
||||
# evcc). Liveness is deliberately slow to fire — failureThreshold 6
|
||||
# x 30s = 3 min of sustained failure before a restart — so a
|
||||
# transient hiccup never bounces the broker every client depends
|
||||
# on. The startupProbe (tcpSocket) suspends both while the
|
||||
# container builds the passwd/acl files and mosquitto binds :1883.
|
||||
# mosquitto is a single-replica SPOF (HA, Shelly, Zigbee2MQTT,
|
||||
# edge-1, evcc). Liveness is deliberately slow to fire —
|
||||
# failureThreshold 6 x 30s = 3 min of sustained failure before a
|
||||
# restart — so a transient hiccup never bounces the broker every
|
||||
# client depends on. The startupProbe (tcpSocket) suspends both while
|
||||
# the container prepares /mosquitto/auth and mosquitto binds :1883.
|
||||
startupProbe:
|
||||
tcpSocket:
|
||||
port: mqtt
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: mosquitto-evcc-credentials
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: mosquitto-evcc-credentials # keeps the old Secret's name — consumers unchanged
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: apps/home-assistant/mosquitto-evcc-credentials
|
||||
@@ -1,36 +0,0 @@
|
||||
# Projects the mosquitto `health` broker credential from OpenBao into the Secret
|
||||
# the broker already consumes. FIRST of the eight broker users to move off the
|
||||
# bootstrap Jobs and onto Class-A rotation (see
|
||||
# PROPOSAL-mosquitto-credential-rotation.md (PR #453 — not yet merged; this
|
||||
# file lands with it)); the rotator lives at
|
||||
# kubernetes/infrastructure/openbao/rotation/mosquitto-health.yaml.
|
||||
#
|
||||
# Secret name and key are UNCHANGED (`password`), so mosquitto-deployment.yaml's
|
||||
# secretKeyRef and the exec probes that read $HEALTH_PASSWORD need no edit.
|
||||
#
|
||||
# refreshInterval is 10m rather than the 1h used by mosquitto-evcc-credentials:
|
||||
# a rotation only lands once ESO re-reads, so 1h would mean up to an hour between
|
||||
# the rotator running and the broker actually using the new password, with the
|
||||
# old one still valid in the meantime. 10m keeps that window short without
|
||||
# polling OpenBao pointlessly hard.
|
||||
#
|
||||
# ⚠️ OWNERSHIP: creationPolicy is implicitly Owner, so ESO sets an ownerReference
|
||||
# on the Secret. Deleting this ExternalSecret therefore DELETES the live Secret
|
||||
# and with it the broker's probe credential — the bootstrap Job it replaces had
|
||||
# no such coupling. To detach without destroying, set creationPolicy: Orphan
|
||||
# before removing it.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: mosquitto-health-credentials
|
||||
namespace: home-assistant
|
||||
spec:
|
||||
refreshInterval: 10m
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: mosquitto-health-credentials
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: apps/home-assistant/mosquitto-health-credentials
|
||||
@@ -5,9 +5,7 @@
|
||||
# mqtt-accounts.yaml, every INTERVAL (2m).
|
||||
#
|
||||
# Manages the production broker `mosquitto` over its mTLS listener :8884
|
||||
# (REN-802 step 2, REN-900). The legacy users in that broker's password_file
|
||||
# are not Dynamic Security clients, so the controller never sees or touches
|
||||
# them.
|
||||
# (REN-802 step 2, REN-900).
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
@@ -87,6 +85,9 @@ subjects:
|
||||
# delivered to this namespace. Add the name with the register entry.
|
||||
# mqtt-ha-cert-sync: sha256 of the client certificate last accepted by
|
||||
# Home Assistant's MQTT config entry (HA cert sync, REN-953/REN-1284).
|
||||
# mqtt-<name>-password: the login of a password account delivered to this
|
||||
# namespace, written, rewritten and deleted on revocation. One entry per
|
||||
# account.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
@@ -99,6 +100,11 @@ rules:
|
||||
resources: ["secrets"]
|
||||
resourceNames: ["mqtt-account-hashes", "mqtt-dynsec-seed", "mqtt-ha-cert-sync"]
|
||||
verbs: ["get", "create", "patch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
resourceNames:
|
||||
- mqtt-admin-password
|
||||
verbs: ["get", "create", "patch", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
resourceNames:
|
||||
@@ -219,7 +225,7 @@ spec:
|
||||
- name: controller
|
||||
# Woodpecker builds main-<sha> on every merge to main; no sortable
|
||||
# tag, so bumps are manual (same as ical-proxy).
|
||||
image: gitea.renner.uno/konairius/mqtt-account-controller:main-cb96b1a@sha256:9d9b9e83a9c0f9f567e57edacab11cf4c58e8c0e49e38e8d7b51421cd1242911
|
||||
image: gitea.renner.uno/konairius/mqtt-account-controller:main-b1f294d@sha256:8081adfee171c36cfa83734e5e7ccc3aad3debf1c0154488de70143674cde85f
|
||||
env:
|
||||
- name: BROKER_URL
|
||||
value: ssl://mosquitto.home-assistant.svc.cluster.local:8884
|
||||
|
||||
@@ -7,51 +7,50 @@
|
||||
# configMapGenerator in kustomization.yaml). An empty list is refused, so it
|
||||
# never revokes everyone.
|
||||
#
|
||||
# The controller manages the production broker `mosquitto` (REN-900). Its
|
||||
# legacy users (password_file in mosquitto-deployment.yaml) are not listed
|
||||
# here; each moves here with its client (REN-802 step 3). `test-cert` is the
|
||||
# certificate test account from REN-888.
|
||||
# The controller manages the production broker `mosquitto` (REN-900).
|
||||
# `test-cert` is the certificate test account from REN-888.
|
||||
#
|
||||
# A cert account needs its Secret `mqtt-<name>-tls` added to the
|
||||
# A cert account needs its Secret `mqtt-<name>-tls`, a password account
|
||||
# delivered to a namespace its Secret `mqtt-<name>-password`, added to the
|
||||
# `mqtt-account-controller-secrets-<namespace>` Role in
|
||||
# mqtt-account-controller.yaml, or revoking it fails with 403.
|
||||
# mqtt-account-controller.yaml, or writing or revoking it fails with 403.
|
||||
# The owner's workstation login over the LoadBalancer (1883/8883). Password in
|
||||
# the Secret mqtt-admin-password (home-assistant); deleting the Secret makes a
|
||||
# new one.
|
||||
- name: admin
|
||||
auth: password
|
||||
deliver: {namespace: home-assistant}
|
||||
acl: ["readwrite #"]
|
||||
- name: test-cert
|
||||
auth: cert
|
||||
deliver: {namespace: home-assistant}
|
||||
acl: ["readwrite test/cert/#"]
|
||||
# The broker's own liveness/readiness probe (mosquitto-deployment.yaml).
|
||||
# Not named `health`: a Dynamic Security client of that name would shadow the
|
||||
# legacy password user while the old broker pod still probes with it.
|
||||
- name: mosquitto-probe
|
||||
auth: cert
|
||||
deliver: {namespace: home-assistant}
|
||||
acl: ["readwrite health/#"]
|
||||
# Zigbee2MQTT and its two sidecars (discovery-guard, interview-watch), one
|
||||
# certificate for the pod. Same ACL as the legacy user `zigbee2mqtt`; not
|
||||
# named like it, so this entry cannot shadow the password login of a running
|
||||
# Z2M (REN-953).
|
||||
# certificate for the pod (REN-953).
|
||||
- name: zigbee2mqtt-bridge
|
||||
auth: cert
|
||||
deliver: {namespace: home-assistant}
|
||||
acl: ["readwrite zigbee2mqtt/#", "write homeassistant/#", "read homeassistant/status"]
|
||||
# evcc (evcc namespace). Same ACL as the legacy user `evcc`, different name
|
||||
# so it cannot shadow the running password login (REN-953). evcc takes the
|
||||
# certificate PEMs by env (EVCC_MQTT_*), see apps/evcc/deployment.yaml.
|
||||
# evcc (evcc namespace, REN-953). evcc takes the certificate PEMs by env
|
||||
# (EVCC_MQTT_*), see apps/evcc/deployment.yaml.
|
||||
- name: evcc-client
|
||||
auth: cert
|
||||
deliver: {namespace: evcc}
|
||||
acl: ["read open3e/#", "readwrite evcc/#", "read energy/#"]
|
||||
# Home Assistant, via the ghostunnel sidecar `mqtt-tls` in its pod (HA keeps
|
||||
# client certificates only as uploaded PEMs in its config entry, so a renewal
|
||||
# could not reach it without a reconfigure). Same ACL as the legacy user
|
||||
# `homeassistant`; not named like it, so it cannot shadow that login (REN-953).
|
||||
# could not reach it without a reconfigure, REN-953).
|
||||
- name: home-assistant
|
||||
auth: cert
|
||||
deliver: {namespace: home-assistant}
|
||||
acl: ["readwrite #"]
|
||||
# edge-1's local mosquitto bridge (REN-1307): one bridge for open3e/e3oncan
|
||||
# and sml2mqtt, replacing the legacy password users `open3e` and `sml2mqtt`
|
||||
# (different name, so it cannot shadow their logins). The controller pushes
|
||||
# edge-1's local mosquitto bridge (REN-1307): one bridge for open3e/e3oncan.
|
||||
# The controller pushes
|
||||
# the password to edge-mqtt-cred on edge-1 (ansible role mqtt-bridge) over
|
||||
# HTTPS with its own client certificate, and pins edge-1's self-generated
|
||||
# server certificate on first contact (key edge-1.pin in mqtt-account-hashes;
|
||||
|
||||
@@ -50,13 +50,13 @@ release it before Z2M connects (step 3).
|
||||
Confirm the standing pieces are in place (nothing is running yet):
|
||||
|
||||
```
|
||||
kubectl -n home-assistant get secret mosquitto-zigbee2mqtt-credentials # exists (bootstrap Job ran)
|
||||
kubectl -n home-assistant get secret mqtt-zigbee2mqtt-bridge-tls # exists (register account zigbee2mqtt-bridge)
|
||||
kubectl -n home-assistant get externalsecret zigbee2mqtt-coordinator-backup # SecretSyncedError / NotReady — EXPECTED
|
||||
kubectl -n home-assistant get deploy zigbee2mqtt # 0/0 — EXPECTED
|
||||
```
|
||||
|
||||
The `mosquitto` pod will have rolled to pick up the new `zigbee2mqtt` passwd/ACL
|
||||
entry. Verify the broker still serves HA (it will — the change is additive).
|
||||
Z2M logs in to the broker by certificate on `:8884` as the register account
|
||||
`zigbee2mqtt-bridge` (`mqtt-accounts.yaml`).
|
||||
|
||||
## Step 1 — export the Open Coordinator Backup (non-disruptive)
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ metadata:
|
||||
name: immich
|
||||
namespace: immich
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: immich.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -27,7 +27,7 @@ spec:
|
||||
# Pinned to 7.4-alpine to match the LXC 225 compose exactly.
|
||||
# Any redis major-version bump is a separate migration.
|
||||
- name: redis
|
||||
image: redis:7.4.11-alpine # {"$imagepolicy": "flux-system:redis-immich"}
|
||||
image: redis:7.4.12-alpine # {"$imagepolicy": "flux-system:redis-immich"}
|
||||
args:
|
||||
- redis-server
|
||||
- --save
|
||||
|
||||
@@ -66,7 +66,7 @@ spec:
|
||||
# The library PVC is populated by the seed phase before this
|
||||
# Deployment reconciles, so no rsync init is needed here.
|
||||
- name: fetch-le-staging-roots
|
||||
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
|
||||
image: curlimages/curl:8.23.0@sha256:11e64e28c5f58c8d93ee95fcf22ac055f48b0bc440e0ce7d8013bbb5ad6aa8ac
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -57,7 +57,7 @@ spec:
|
||||
runAsNonRoot: false
|
||||
initContainers:
|
||||
- name: wait-for-influxd
|
||||
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
|
||||
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
|
||||
env:
|
||||
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
|
||||
- { name: INFLUX_ORG, value: renner }
|
||||
@@ -80,7 +80,7 @@ spec:
|
||||
echo "[$(date -u +%H:%M:%S)] $INFLUX_HOST ready and authenticated"
|
||||
containers:
|
||||
- name: create
|
||||
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
|
||||
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
|
||||
env:
|
||||
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
|
||||
- { name: INFLUX_ORG, value: renner }
|
||||
|
||||
@@ -88,7 +88,7 @@ spec:
|
||||
# state). Two-stage so we don't try to create an auth before
|
||||
# the chart's `influx setup` has finished.
|
||||
- name: wait-for-influxd
|
||||
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
|
||||
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
|
||||
env:
|
||||
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
|
||||
- { name: INFLUX_ORG, value: renner }
|
||||
@@ -111,7 +111,7 @@ spec:
|
||||
echo "[$(date -u +%H:%M:%S)] $INFLUX_HOST ready and authenticated"
|
||||
containers:
|
||||
- name: create
|
||||
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
|
||||
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
|
||||
env:
|
||||
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
|
||||
- { name: INFLUX_ORG, value: renner }
|
||||
|
||||
@@ -55,7 +55,7 @@ spec:
|
||||
repository: influxdb
|
||||
# Was pinned to VM 108's 2.7.4 for the one-time `influx restore`; that
|
||||
# migration is done. No downgrade below 2.9 without `influxd downgrade`.
|
||||
tag: "2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac"
|
||||
tag: "2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2"
|
||||
# Single replica — InfluxDB OSS doesn't cluster. RWO Ceph-block + Velero
|
||||
# backups (pending) is the durability story.
|
||||
persistence:
|
||||
|
||||
@@ -30,7 +30,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-22-1858 # {"$imagepolicy": "flux-system:kuma-sync"}
|
||||
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-27-0801 # {"$imagepolicy": "flux-system:kuma-sync"}
|
||||
env:
|
||||
- name: KUMA_URL
|
||||
value: https://kuma.renner.uno
|
||||
|
||||
@@ -15,9 +15,8 @@ metadata:
|
||||
name: llama-server
|
||||
namespace: ai
|
||||
labels:
|
||||
# Public: Cloudflare wildcard → tunnel → NetBird proxy → Traefik. The UDM
|
||||
# external-dns instance skips anything carrying this, and LAN clients reach
|
||||
# the same hostname through the hairpin NAT.
|
||||
# Public: Cloudflare CNAME → NetBird proxy → Traefik. No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: llm.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -72,7 +72,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: element-web
|
||||
image: ghcr.io/element-hq/element-web:v1.12.30@sha256:3e7dbd4424de9e11c812bc740acf2725b2abe66ea0d0802023b56fd85d3fa09a
|
||||
image: ghcr.io/element-hq/element-web:v1.12.32@sha256:f8700633d4336258af7a05b53865a4f479e0d628cf2d9ca83d28b5939314d4ce
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Public (REN-663): each Ingress carries dns.renner.uno/public, so
|
||||
# netbird-service-sync publishes its host through the NetBird proxy
|
||||
# (Cloudflare wildcard -> proxy.renner.uno -> TLS passthrough to Traefik) and
|
||||
# external-dns-unifi skips it; LAN clients follow the public answer and the UDM
|
||||
# hairpins back in. Same path as audiobooks/immich. Federation stays off.
|
||||
# (explicit Cloudflare CNAME -> proxy.renner.uno -> TLS passthrough to Traefik)
|
||||
# and there is no dns.renner.uno/internal label, so external-dns-unifi writes
|
||||
# no UDM record; LAN clients follow the public answer and the UDM hairpins
|
||||
# back in. Same path as audiobooks/immich. Federation stays off.
|
||||
#
|
||||
# Synapse: only the client-facing paths. /_synapse/admin stays off the
|
||||
# Ingress (in-cluster only); /_matrix/federation and /_matrix/key are not
|
||||
@@ -13,8 +14,8 @@ metadata:
|
||||
name: synapse
|
||||
namespace: matrix
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
@@ -123,8 +124,8 @@ metadata:
|
||||
name: element-web
|
||||
namespace: matrix
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
@@ -154,8 +155,8 @@ metadata:
|
||||
name: mas
|
||||
namespace: matrix
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: matrix.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
@@ -136,7 +136,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: account.matrix.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
@@ -202,7 +202,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: element.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
@@ -289,7 +289,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: rtc.matrix.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
@@ -354,7 +354,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: turn.matrix.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -137,7 +137,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: mas
|
||||
image: ghcr.io/element-hq/matrix-authentication-service:1.26.0@sha256:e089f1048a1d4a9a492ed17b9fe759100f1bd619407b001f5927928d88b780c4
|
||||
image: ghcr.io/element-hq/matrix-authentication-service:1.27.1@sha256:02712e68c90f3743fedbd5e3343517481b2b4f246a9fd9e2f050dc2553033847
|
||||
# The server applies database migrations and syncs the upstream
|
||||
# providers from the config on start.
|
||||
args: ["server", "--config", "/config/config.yaml"]
|
||||
|
||||
@@ -124,7 +124,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: livekit
|
||||
image: livekit/livekit-server:v1.13.10@sha256:89ca9ce0791e18d6e03bf6e2c49ceb27e1c754eae8bd50c989386c1a5963791a
|
||||
image: livekit/livekit-server:v1.13.11@sha256:25cd902de7cacbd5f1f779f6a6ac1eae20ba8f56b0935934ce6b0ab80d1e3d20
|
||||
args:
|
||||
- --config
|
||||
- /config/livekit.yaml
|
||||
|
||||
@@ -179,7 +179,7 @@ spec:
|
||||
# first. The marker on the data PVC makes later starts skip it.
|
||||
# Debug image: the plain one has no shell for the marker check.
|
||||
- name: migrate-to-mas
|
||||
image: ghcr.io/element-hq/matrix-authentication-service:1.26.0-debug@sha256:46f7480f5ea4243ccb6fd2c447ea859e0230c8b3c0e676ee285d4ae6ca344ee3
|
||||
image: ghcr.io/element-hq/matrix-authentication-service:1.27.1-debug@sha256:75f369b206785f1480f4a9f8dd81d7f9f3c6793c40fd184358ecd5d7acae17d4
|
||||
command:
|
||||
- /busybox/sh
|
||||
- -ec
|
||||
@@ -234,7 +234,7 @@ spec:
|
||||
mountPath: /data
|
||||
containers:
|
||||
- name: synapse
|
||||
image: ghcr.io/element-hq/synapse:v1.162.0@sha256:6b84a7bbac36f080b2d2e51e0289cf1b08b349598ea44a558df38d558f2c2311
|
||||
image: ghcr.io/element-hq/synapse:v1.163.2@sha256:c3c21919a322f74d177a43bce8031adc81ef839ddd117a8dd03cfec350e76e02
|
||||
# Bypass /start.py: it expects to run as root and template a config
|
||||
# from env. Default mode is generate-missing-and-run, which creates
|
||||
# the signing key on first start.
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
#
|
||||
# The UDM is queried directly rather than through the cluster resolver:
|
||||
# k8s-gateway caches these with `serve_stale 1h immediate`, which would keep
|
||||
# a deleted record green for an hour.
|
||||
# a deleted record green for an hour. The addresses are the UDM on the segment
|
||||
# pod traffic leaves on, the same upstream k8s-gateway uses (k8s-gateway-site
|
||||
# in talos/patches/site-homelab.yaml).
|
||||
#
|
||||
# `release` is load-bearing — probeSelector matches on it.
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
@@ -25,8 +27,8 @@ spec:
|
||||
targets:
|
||||
staticConfig:
|
||||
static:
|
||||
- "[fd00::1]:53"
|
||||
- "10.2.40.1:53"
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
# instance is the resolver and identical across these Probes, so the
|
||||
# queried name has to come from a label.
|
||||
labels:
|
||||
@@ -49,8 +51,8 @@ spec:
|
||||
targets:
|
||||
staticConfig:
|
||||
static:
|
||||
- "[fd00::1]:53"
|
||||
- "10.2.40.1:53"
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
labels:
|
||||
dns_name: mqtt.renner.uno
|
||||
---
|
||||
@@ -71,8 +73,8 @@ spec:
|
||||
targets:
|
||||
staticConfig:
|
||||
static:
|
||||
- "[fd00::1]:53"
|
||||
- "10.2.40.1:53"
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
labels:
|
||||
dns_name: ldap.renner.uno
|
||||
---
|
||||
@@ -93,8 +95,8 @@ spec:
|
||||
targets:
|
||||
staticConfig:
|
||||
static:
|
||||
- "[fd00::1]:53"
|
||||
- "10.2.40.1:53"
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
labels:
|
||||
dns_name: syslog.renner.uno
|
||||
---
|
||||
@@ -115,8 +117,8 @@ spec:
|
||||
targets:
|
||||
staticConfig:
|
||||
static:
|
||||
- "[fd00::1]:53"
|
||||
- "10.2.40.1:53"
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
labels:
|
||||
dns_name: ftp.paperless.renner.uno
|
||||
---
|
||||
@@ -137,7 +139,7 @@ spec:
|
||||
targets:
|
||||
staticConfig:
|
||||
static:
|
||||
- "[fd00::1]:53"
|
||||
- "10.2.40.1:53"
|
||||
- "[fd02:47::1]:53"
|
||||
- "10.2.47.1:53"
|
||||
labels:
|
||||
dns_name: syncthing.paperless.renner.uno
|
||||
|
||||
@@ -25,8 +25,8 @@ spec:
|
||||
summary: "{{ $labels.dns_name }} no longer resolves to its LAN address"
|
||||
description: >-
|
||||
The UDM stopped answering for a name external-dns publishes from
|
||||
a LoadBalancer Service, or answered with the public
|
||||
*.renner.uno wildcard instead of the record. Clients that use
|
||||
a LoadBalancer Service, or answered without the
|
||||
record (no answer, or a public CNAME). Clients that use
|
||||
the name rather than the address lose the service while
|
||||
everything in the cluster stays healthy — nothing else in the
|
||||
stack reports this. Check the external-dns alerts first, then
|
||||
|
||||
@@ -82,9 +82,9 @@ spec:
|
||||
#
|
||||
# The pod regex is the Deployment's name shape
|
||||
# (mosquitto-<replicaset hash>-<suffix>) and not `mosquitto-.*` on
|
||||
# purpose: mosquitto-bootstrap.yaml runs six Jobs named
|
||||
# mosquitto-*-credentials-bootstrap, and a bootstrap pod that is
|
||||
# briefly Ready must not hide a broker that is down.
|
||||
# purpose: other pods in the namespace start with `mosquitto-`
|
||||
# (mosquitto-dynsec-test), and a Ready one must not hide a broker
|
||||
# that is down.
|
||||
expr: |
|
||||
absent(kube_pod_status_ready{namespace="home-assistant", pod=~"mosquitto-[a-z0-9]{6,10}-[a-z0-9]{5}", condition="true"} == 1)
|
||||
for: 5m
|
||||
|
||||
@@ -31,8 +31,9 @@ spec:
|
||||
{{ $value | humanizePercentage }} of probes in the last 10m got
|
||||
the Ingress address for {{ $labels.dns_name }} ({{ $labels.record }})
|
||||
from k8s-gateway. Pods then resolve Ingress names through the UDM
|
||||
and the public wildcard (CNAME proxy.renner.uno) and leave the
|
||||
cluster. Check the rendered Corefile in the k8s-gateway ConfigMap
|
||||
and the public zone (CNAME proxy.renner.uno for public hosts)
|
||||
and leave the cluster. Check the rendered Corefile in the
|
||||
k8s-gateway ConfigMap
|
||||
(kube-system) for the renner.uno zone, the k8s-gateway logs, and
|
||||
whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml
|
||||
still matches the dns_k8s_gateway_auth_* modules in
|
||||
|
||||
@@ -137,13 +137,13 @@ tests:
|
||||
exp_alerts: []
|
||||
|
||||
# --- MosquittoDown ------------------------------------------------------
|
||||
# The broker goes not-Ready at t=10m while a credentials-bootstrap Job pod is
|
||||
# Ready. The Job pod must not mask the broker.
|
||||
# The broker goes not-Ready at t=10m while the mosquitto-dynsec-test pod is
|
||||
# Ready. That pod must not mask the broker.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="mosquitto-7f8b9c6d5-x2k9q",condition="true"}'
|
||||
values: '1x9 0x20'
|
||||
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="mosquitto-credentials-bootstrap-q7w2e",condition="true"}'
|
||||
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="mosquitto-dynsec-test-6d9f8b7c5-r4t8w",condition="true"}'
|
||||
values: '1x30'
|
||||
alert_rule_test:
|
||||
- eval_time: 14m
|
||||
|
||||
@@ -30,7 +30,7 @@ tests:
|
||||
record: A
|
||||
exp_annotations:
|
||||
summary: "k8s-gateway does not answer auth.renner.uno A with the Traefik VIP"
|
||||
description: "20% of probes in the last 10m got the Ingress address for auth.renner.uno (A) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public wildcard (CNAME proxy.renner.uno) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
|
||||
description: "20% of probes in the last 10m got the Ingress address for auth.renner.uno (A) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public zone (CNAME proxy.renner.uno for public hosts) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
|
||||
- eval_time: 17m
|
||||
alertname: K8sGatewayProbesAbsent
|
||||
exp_alerts: []
|
||||
@@ -53,7 +53,7 @@ tests:
|
||||
record: AAAA
|
||||
exp_annotations:
|
||||
summary: "k8s-gateway does not answer auth.renner.uno AAAA with the Traefik VIP"
|
||||
description: "50% of probes in the last 10m got the Ingress address for auth.renner.uno (AAAA) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public wildcard (CNAME proxy.renner.uno) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
|
||||
description: "50% of probes in the last 10m got the Ingress address for auth.renner.uno (AAAA) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public zone (CNAME proxy.renner.uno for public hosts) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
|
||||
|
||||
# Two consecutive failures in an otherwise healthy hour stay silent
|
||||
# (average exactly 0.8).
|
||||
|
||||
@@ -78,11 +78,10 @@ spec:
|
||||
# passed per scrape. The Probes in monitoring-rules/probes-dns.yaml
|
||||
# point these at the UDM.
|
||||
#
|
||||
# Both answer checks are load-bearing. valid_rcodes alone passes a
|
||||
# deleted record two labels deep (NOERROR, no answer), and the public
|
||||
# *.renner.uno wildcard answers one label deep with a CNAME to
|
||||
# proxy.renner.uno — NOERROR with the WAN proxy behind it. Requiring
|
||||
# an A and rejecting a CNAME separates a real record from both.
|
||||
# Both answer checks are load-bearing: valid_rcodes alone passes a
|
||||
# deleted record (NOERROR, no answer, any depth). Requiring an A
|
||||
# separates a real record from that; a CNAME means the UDM answered with a
|
||||
# public-zone target instead of the LAN address.
|
||||
dns_lan_ha:
|
||||
prober: dns
|
||||
timeout: 5s
|
||||
|
||||
@@ -32,6 +32,9 @@ spec:
|
||||
deploymentMode: SingleBinary
|
||||
loki:
|
||||
auth_enabled: false
|
||||
# No usage reports to stats.grafana.org (egress is not allowed anyway).
|
||||
analytics:
|
||||
reporting_enabled: false
|
||||
commonConfig:
|
||||
replication_factor: 1
|
||||
storage:
|
||||
|
||||
@@ -17,7 +17,7 @@ spec:
|
||||
chart:
|
||||
spec:
|
||||
chart: kube-prometheus-stack
|
||||
version: "92.3.0"
|
||||
version: "92.13.1"
|
||||
sourceRef:
|
||||
kind: HelmRepository
|
||||
name: prometheus-community
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# target of the *.renner.uno wildcard
|
||||
# target of every public CNAME (NetBird proxy load balancer)
|
||||
hostname: proxy.renner.uno
|
||||
resolveType: AAAA
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
@@ -228,7 +228,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# Cloudflare-proxied apex, not the wildcard
|
||||
# Cloudflare-proxied apex
|
||||
hostname: renner.uno
|
||||
resolveType: AAAA
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user