Compare commits
119
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17839eeeab | ||
|
|
f6fc79fce9 | ||
|
|
583fc044cb | ||
|
|
f7deb40a2b | ||
|
|
b3bf09ebc3 | ||
|
|
5afb29dcb2 | ||
|
|
e4b0b13c3e | ||
|
|
00e443c397 | ||
|
|
b2b4cd4ab9 | ||
|
|
6baf3139b9 | ||
|
|
ff6ad64783 | ||
|
|
07e1684bd1 | ||
|
|
44cdf84f99 | ||
|
|
ca20206bbf | ||
|
|
293f11612c | ||
|
|
7602c755cf | ||
|
|
faff22bebe | ||
|
|
7459aa9e59 | ||
|
|
41efcd9f91 | ||
|
|
30704634b3 | ||
|
|
43861d34c1 | ||
|
|
3252a6f9ee | ||
|
|
cf80a096a6 | ||
|
|
41e272f188 | ||
|
|
8dc0986d1d | ||
|
|
edb4aa804c | ||
|
|
9777e147e8 | ||
|
|
37dc17065d | ||
|
|
570c93559b | ||
|
|
71ab916ff7 | ||
|
|
9aecaacbb2 | ||
|
|
744ea79933 | ||
|
|
c4f32c7a97 | ||
|
|
0ab63f46d4 | ||
|
|
cbc538ec7d | ||
|
|
c541190e8e | ||
|
|
6de87eba4c | ||
|
|
2beb9a4e14 | ||
|
|
767b3cdac4 | ||
|
|
ccb69bb109 | ||
|
|
69f7075c7c | ||
|
|
1b06a922a9 | ||
|
|
d457c7e639 | ||
|
|
a29e10e313 | ||
|
|
15d98d2702 | ||
|
|
4b7960c18c | ||
|
|
bc50d58723 | ||
|
|
f65015a423 | ||
|
|
db0c5a8d96 | ||
|
|
7f78f48ec6 | ||
|
|
73ede3d227 | ||
|
|
9c42c72037 | ||
|
|
5d0e705b30 | ||
|
|
6986f36237 | ||
|
|
4316d503d2 | ||
|
|
1b2684c35b | ||
|
|
ec91122621 | ||
|
|
fc720823db | ||
|
|
8d47e085cb | ||
|
|
54ae8ce463 | ||
|
|
cd2e7864a4 | ||
|
|
fec9f6e0e5 | ||
|
|
55fce76a2d | ||
|
|
5fcd6dc5c4 | ||
|
|
3a35e8481a | ||
|
|
a5be653044 | ||
|
|
1d5a262a84 | ||
|
|
ad6fc66875 | ||
|
|
861e9a3193 | ||
|
|
9a1dc89326 | ||
|
|
d505ca2062 | ||
|
|
28e864cfc4 | ||
|
|
e7d45a5c31 | ||
|
|
7a8791c3b0 | ||
|
|
b6acbc6e79 | ||
|
|
b1b58e397c | ||
|
|
fdaf20aa3f | ||
|
|
4e61d543a8 | ||
|
|
302b177ba2 | ||
|
|
531e192e0c | ||
|
|
3416cd476d | ||
|
|
8a41b80217 | ||
|
|
a05f1431ca | ||
|
|
5718c9bae2 | ||
|
|
2c79edabea | ||
|
|
6e12a608b3 | ||
|
|
5718492e2b | ||
|
|
3bfb38ebd7 | ||
|
|
1de63af07b | ||
|
|
6c2c278f65 | ||
|
|
21625685b4 | ||
|
|
29da21c28f | ||
|
|
c781e263fc | ||
|
|
b1be3181ef | ||
|
|
08b087337e | ||
|
|
8610853a6b | ||
|
|
1b724ef114 | ||
|
|
ee1a421dc0 | ||
|
|
1e66d793d5 | ||
|
|
0996609ce4 | ||
|
|
4b27a5f273 | ||
|
|
ea195d0e4f | ||
|
|
1ae259e474 | ||
|
|
796d8459b6 | ||
|
|
a6be8247eb | ||
|
|
826042f3ea | ||
|
|
aa2543369c | ||
|
|
5add98190f | ||
|
|
1643b305b2 | ||
|
|
657e3d0d92 | ||
|
|
ac2d5d13a1 | ||
|
|
ed3be3156b | ||
|
|
05d2fcac71 | ||
|
|
697856cbf8 | ||
|
|
4962af5ed1 | ||
|
|
bc698ea22d | ||
|
|
73dd6f6362 | ||
|
|
7434894f14 | ||
|
|
335c423ca6 |
@@ -41,6 +41,7 @@ when:
|
||||
- "ci/images/**"
|
||||
- "secret-rotator/**"
|
||||
- "review-bot/**"
|
||||
- "wlan-setup/**"
|
||||
- ".woodpecker/build-ci-images.yml"
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "build-ci-images"'
|
||||
@@ -197,6 +198,31 @@ steps:
|
||||
--destination gitea.renner.uno/gitea_admin/review-bot:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/review-bot:latest
|
||||
|
||||
# wlan-setup: the Go service behind wlan.renner.uno (802.1X/Passpoint setup page with per-device
|
||||
# authentik app passwords). Runs after Gitea is up, so no ghcr mirror.
|
||||
- name: build-wlan-setup
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: wlan-setup
|
||||
when:
|
||||
- event: deployment
|
||||
- event: push
|
||||
path: ["wlan-setup/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
GITEA_USER: gitea_admin
|
||||
GITEA_TOKEN:
|
||||
from_secret: ci_images_token
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
|
||||
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
|
||||
- TAG=$(date +%Y-%m-%d-%H%M)
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context . \
|
||||
--dockerfile Containerfile \
|
||||
--destination gitea.renner.uno/gitea_admin/wlan-setup:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/wlan-setup:latest
|
||||
|
||||
# Runtime image, not CI tooling: a general-purpose alpine + jq base for
|
||||
# workloads that need to read JSON from a shell script. Named for what it IS,
|
||||
# not for who uses it — its first consumer is the zigbee2mqtt seed-data init
|
||||
|
||||
@@ -35,25 +35,25 @@ when:
|
||||
steps:
|
||||
- name: model-selection-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
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.316
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
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.316
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
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.316
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
commands:
|
||||
- sh scripts/ci/test-yaml-citations.sh
|
||||
|
||||
|
||||
+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.316
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
|
||||
@@ -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.316
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
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.316
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Tests for the wlan-setup Go service (wlan-setup/). Offline: the tests run against a fake
|
||||
# authentik server, so the step needs no secret and no network beyond the module cache.
|
||||
when:
|
||||
- event: pull_request
|
||||
path: ["wlan-setup/**", ".woodpecker/wlan-setup-test.yml"]
|
||||
|
||||
steps:
|
||||
- name: go-test
|
||||
image: golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9
|
||||
commands:
|
||||
- test -z "$(gofmt -l wlan-setup)"
|
||||
- go -C wlan-setup vet ./...
|
||||
- go -C wlan-setup test -race ./...
|
||||
+25
-91
@@ -1,13 +1,17 @@
|
||||
# edge-host — bare-metal Arch thin client (replaces the k8s-edge Talos VM)
|
||||
|
||||
Runs the hardware-bound services that used to be pinned to the `k8s-edge`
|
||||
Talos node (opentofu `vm_153_k8s_edge.tf`), plus the new CAN bridge:
|
||||
Talos node (opentofu `vm_153_k8s_edge.tf`):
|
||||
|
||||
| Service | How it runs | Hardware | Talks to |
|
||||
|---|---|---|---|
|
||||
| zwave-js-ui | docker compose | ZWA-2 Z-Wave USB radio | HA `zwave_js` via ws://:3000, UI :8091 |
|
||||
| sml2mqtt | docker compose | FTDI FT230X meter reader | Mosquitto (`sml2mqtt` user) |
|
||||
| open3e | venv + systemd | Innomaker USB2CAN (gs_usb) | Mosquitto (`open3e` user) |
|
||||
|
||||
The Viessmann E3 CAN bus is not read by an edge host: the ESP32 gateway
|
||||
`vitocal-can` (open3e-esp32) at the Vitocal publishes `open3e/#` directly to
|
||||
the cluster broker. `plays/install-edge-host.yml` removes the former
|
||||
heating-can stack and the can0 configuration from edge hosts.
|
||||
|
||||
Everything else that once lived on k8s-edge (HA core, matter-server, OTBR,
|
||||
music-assistant) already moved to the general node pool and stays in the
|
||||
@@ -19,17 +23,13 @@ The deployed unit is a **Dell Wyse 3040** (Atom x5-Z8350, 2 GB RAM) on a
|
||||
flash-wear handling that hardware implies. The installer also supports an
|
||||
N100/NVMe box; it autodetects the target disk, so either works.
|
||||
|
||||
Rationale for leaving Kubernetes for the CAN part: the stock Talos kernel
|
||||
ships with `CONFIG_CAN` unset (siderolabs/talos#7686), so SocketCAN cannot
|
||||
work in any pod without a custom kernel image per Talos release.
|
||||
|
||||
## Base install (once, from the edge-host installer ISO)
|
||||
|
||||
Build and boot the dedicated installer image — see
|
||||
`images/edge-host-iso/README.md`. Short version:
|
||||
|
||||
1. `sudo pacman -S archiso && sudo images/edge-host-iso/build.sh`
|
||||
2. Write ISO to a USB stick, boot the target box from it, run `edge-install`
|
||||
2. Write ISO to a USB stick, boot the target box from it, run `edge-install <hostname>`
|
||||
(partitions the internal disk — NVMe if present, else the eMMC; the
|
||||
installer autodetects — installs the ansible-ready base system with SSH
|
||||
keys, DHCP, docker — asks for confirmation before wiping).
|
||||
@@ -58,24 +58,18 @@ inventory change.
|
||||
|
||||
| device | group | playbook |
|
||||
|---|---|---|
|
||||
| Innomaker USB2CAN (Vitocal/VX3 + E380) | `heating_can` | `plays/install-heating-can.yml` |
|
||||
| Nabu Casa ZWA-2 Z-Wave radio | `zwave_gateway` | `plays/install-zwave-gateway.yml` |
|
||||
|
||||
Add the host to the matching group, then run that playbook. They depend on
|
||||
the base play having run: all three need the local MQTT bridge, and two of
|
||||
them need docker.
|
||||
Add the host to the matching group, then run that playbook. It depends on
|
||||
the base play having run: it needs docker.
|
||||
|
||||
A host with nothing attached is simply absent from all three groups and needs
|
||||
A host with nothing attached is simply absent from these groups and needs
|
||||
none of these commands.
|
||||
|
||||
## Secrets (before first play run)
|
||||
|
||||
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:
|
||||
zwave-js value comes out of the cluster:
|
||||
|
||||
```
|
||||
# zwave-js session secret
|
||||
@@ -84,7 +78,6 @@ kubectl -n home-assistant get secret zwave-js-session -o jsonpath='{.data.sessio
|
||||
|
||||
```yaml
|
||||
# vault.yml
|
||||
vault_edge_open3e_mqtt_password: "..."
|
||||
vault_edge_zwavejs_session_secret: "..."
|
||||
vault_edge_loki_password: "..." # dedicated edge-1 Loki push cred (see "Logging & eMMC")
|
||||
```
|
||||
@@ -102,9 +95,6 @@ The Z-Wave network keys do NOT need separate handling — they live in
|
||||
|
||||
Preparation:
|
||||
- [ ] `vault.yml` populated (above), play runs green against edge-1
|
||||
- [ ] CAN: Innomaker adapter connected to the VX3 external CAN port,
|
||||
`ip -details link show can0` shows `state ERROR-ACTIVE bitrate 250000`,
|
||||
`candump can0 -n 5` shows frames
|
||||
|
||||
zwave-js (order matters — never two servers on one radio):
|
||||
- [ ] Copy the store out of the PVC while the pod still runs:
|
||||
@@ -122,11 +112,6 @@ sml2mqtt:
|
||||
port, Wh stays Wh) → remove sml2mqtt from the home-assistant
|
||||
kustomization in the follow-up PR
|
||||
|
||||
open3e:
|
||||
- [ ] Stop any workstation listener (ONE instance per bus, hard rule)
|
||||
- [ ] Move the USB2CAN adapter, `systemctl start open3e`
|
||||
- [ ] Topics under `open3e/#` update on the broker
|
||||
|
||||
Afterwards:
|
||||
- [ ] Retire VM 153: remove `vm_153_k8s_edge.tf`, `talos/patches/k8s-edge.yaml`,
|
||||
node from the cluster (`kubectl delete node k8s-edge`, tofu apply)
|
||||
@@ -134,22 +119,13 @@ Afterwards:
|
||||
|
||||
## Day-2 notes
|
||||
|
||||
- Image bumps: edit `edge_zwavejs_image` / `edge_sml2mqtt_image` in
|
||||
defaults and re-run the play (no flux image-automation out here —
|
||||
deliberate for sml2mqtt, acceptable for zwave-js). If a zwave-js bump
|
||||
changes its Alpine major, also update `edge_zwave_alpine_base` and refresh
|
||||
the preloaded `.so` (see "Logging & eMMC" → zwave-js).
|
||||
- open3e / E3onCAN updates: both are pinned to commits in
|
||||
`roles/heating-can/defaults/main.yml`, so re-running the play does NOT pick
|
||||
up upstream changes -- that is deliberate, these drive the heat pump. The
|
||||
weekly update-audit reports when either falls behind (see
|
||||
`ci/agents/update-audit/prompt.md`); bump the pin and re-run.
|
||||
Pinning to a tag is not an option for open3e: it publishes none.
|
||||
- Ad-hoc CAN scans: `systemctl stop open3e` first (single-instance rule),
|
||||
restart afterwards.
|
||||
- can-utils (`candump`) is AUR-only on Arch — install manually via
|
||||
`pamac build can-utils` if needed for debugging; the role deliberately
|
||||
doesn't manage AUR packages.
|
||||
- Image bumps: Renovate opens a PR for `edge_*_image` in
|
||||
`roles/*/defaults/main.yml` (tag + digest, `customManagers` in
|
||||
`renovate.json`). No pipeline deploys to edge-1, so after the merge run
|
||||
`ansible-playbook plays/install-zwave-gateway.yml` from `ansible/` on a
|
||||
mesh-connected workstation. The play reads the image's Alpine release
|
||||
itself and rebuilds the preloaded `.so` when it changes (see
|
||||
"Logging & eMMC" → zwave-js).
|
||||
|
||||
## Logging & eMMC
|
||||
|
||||
@@ -162,7 +138,9 @@ role keeps logs off local flash and monitors wear:
|
||||
- **Docker logs to journald** (`docker-daemon.json`, `log-driver: journald`)
|
||||
instead of unbounded json files under `/var/lib/docker/containers`. Both
|
||||
compose services inherit it; `docker logs` still works.
|
||||
- **Grafana Alloy ships the journal to Loki** (`edge-alloy.service`). Its
|
||||
- **Grafana Alloy ships the journal to Loki** (`edge-alloy.service`, which
|
||||
runs the upstream `grafana/alloy` image with `/run/log/journal` mounted
|
||||
read-only; Arch's `grafana-alloy` package has no `loki.source.journal`). Its
|
||||
WAL/positions sit on `/run/alloy` (tmpfs), so the agent writes nothing to
|
||||
the eMMC either. Net effect: no log writes hit local flash — the trade-off
|
||||
is that logs are RAM-only and lost on reboot unless already shipped.
|
||||
@@ -190,7 +168,7 @@ journal commit).
|
||||
|
||||
- **Fix:** the `zwave-js` container preloads **`libeatmydata`**
|
||||
(`LD_PRELOAD=/usr/lib/libeatmydata.so`, mounted read-only from
|
||||
`zwave-js/libeatmydata.so`). This makes `fsync`/`fdatasync` no-ops *for that
|
||||
`zwave-js/libeatmydata-alpine<X.Y>.so`). This makes `fsync`/`fdatasync` no-ops *for that
|
||||
one process*, so the kernel batches the writes via normal writeback (~30 s)
|
||||
instead of forcing each one. The WS/MQTT value stream to Home Assistant and
|
||||
the on-disk cache are **unchanged** — full data resolution, no re-interview
|
||||
@@ -199,9 +177,9 @@ journal commit).
|
||||
batch it); the only mount-level alternative is putting the cache on tmpfs,
|
||||
which makes the *whole* store volatile → re-interview on every restart.
|
||||
- **The `.so` is musl** (the image is Alpine-based), extracted from
|
||||
`edge_zwave_alpine_base` — keep that var in sync with the image's Alpine
|
||||
version; to refresh after a major bump, `rm zwave-js/libeatmydata.so` and
|
||||
re-run.
|
||||
`alpine:<X.Y>`, where `X.Y` is read from the image's `/etc/alpine-release`
|
||||
on every run. A new Alpine release gives a new file name, so the play
|
||||
extracts it, recreates the container and deletes the old file.
|
||||
- **Durability guard:** because `fsync` is neutralized, a hard power-loss
|
||||
during the (rare) settings write could truncate `settings.json`, which holds
|
||||
the **Z-Wave network keys**. `edge-zwave-settings-backup.timer` keeps a
|
||||
@@ -211,47 +189,3 @@ journal commit).
|
||||
- Verify the drop with the eMMC-write sampling described above; roll back by
|
||||
removing the `LD_PRELOAD` env + `.so` mount from the compose file.
|
||||
|
||||
## MQTT bridge (TLS to the cluster broker)
|
||||
|
||||
open3e, e3oncan and sml2mqtt do **not** publish to the cluster broker directly.
|
||||
They connect to a **local mosquitto on loopback** (`127.0.0.1:1883`,
|
||||
`edge_mqtt_host`), which **bridges to the cluster broker over TLS**
|
||||
(`mqtt.renner.uno:8883`). Two reasons:
|
||||
|
||||
- **Security:** open3e and e3oncan have no native MQTTS support (their MQTT
|
||||
code does a plain `connect()`), so they can't use the broker's 8883 TLS
|
||||
listener. Terminating TLS in the local broker keeps their plaintext MQTT on
|
||||
loopback only — off the untrusted IoT VLAN — while the actual network hop is
|
||||
encrypted. The public Let's Encrypt cert is validated against the system CA
|
||||
bundle (`edge_mqtt_bridge_cafile`); no cert distribution needed.
|
||||
- **Availability:** the local broker buffers telemetry (in RAM) if the cluster
|
||||
broker is briefly unreachable, and flushes on reconnect — edge-1 no longer
|
||||
hard-depends on cluster/network uptime for every publish.
|
||||
|
||||
The local broker is `allow_anonymous` on loopback — the trust boundary is the
|
||||
host; nothing off-host can reach it.
|
||||
|
||||
**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
|
||||
its own server key, nothing else. On a push it writes
|
||||
`/etc/mosquitto/bridge.d/edge-1.conf`, deletes `bridge.d/legacy.conf` and
|
||||
restarts the local mosquitto. The controller sets a new password whenever the
|
||||
bridge reports "not connected" (also after a reinstall), so no play run is
|
||||
needed for a rotation. Diagnosis: `journalctl -u
|
||||
edge-mqtt-cred`, and `mosquitto_sub -t '$SYS/broker/connection/edge-1/state'`
|
||||
(retained `1` = connected).
|
||||
|
||||
**eMMC:** the local broker writes **nothing** to flash — `persistence false`
|
||||
(retained/queued messages in RAM) and `log_dest stdout` (to the volatile
|
||||
journal → Loki). Do not add persistence or log-file settings to
|
||||
`edge-mqtt-bridge.conf.j2`.
|
||||
|
||||
Rollback: point `edge_mqtt_host` back to `mqtt.renner.uno` and re-run (clients
|
||||
reconnect directly on 1883). Config: `edge-mqtt-bridge.conf.j2` →
|
||||
`/etc/mosquitto/mosquitto.conf`.
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# Secret indirection — real values live in vault.yml (ansible-vault) next
|
||||
# to this file; see docs/edge-host.md for how to populate it from
|
||||
# the cluster secrets.
|
||||
edge_open3e_mqtt_password: "{{ vault_edge_open3e_mqtt_password }}"
|
||||
edge_zwavejs_session_secret: "{{ vault_edge_zwavejs_session_secret }}"
|
||||
# Loki push credential (dedicated 'edge-1' user, Traefik basic-auth on the
|
||||
# loki.renner.uno ingress; the matching htpasswd line lives in the cluster
|
||||
@@ -36,25 +35,8 @@ keepalived_ntp_sysctl:
|
||||
# Realtek NIC on the Wyse 3040 has no hardware timestamping.
|
||||
chrony_hwtimestamp: false
|
||||
|
||||
# --- Local MQTT contract -------------------------------------------------
|
||||
# Shared between the broker and its clients, so it belongs to the inventory
|
||||
# rather than to either side's role. mqtt-bridge listens here; heating-can
|
||||
# publishes here; and the bridge authenticates to the cluster
|
||||
# broker AS the open3e user, so it needs that identity too.
|
||||
#
|
||||
# Keeping these in one role's defaults only worked while a single play loaded
|
||||
# every role at once. Splitting the plays by capability exposed it: the bridge
|
||||
# could not render without a heating-can default, and the CAN services could
|
||||
# not render without an mqtt-bridge default.
|
||||
edge_mqtt_host: 127.0.0.1
|
||||
edge_mqtt_port: 1883
|
||||
edge_open3e_mqtt_user: open3e
|
||||
edge_open3e_mqtt_topic: open3e
|
||||
|
||||
# Units claude-bot may restart (ssh-ca role); the containers go through docker.
|
||||
ssh_ca_claude_sudo_units:
|
||||
- mosquitto
|
||||
- edge-mqtt-cred
|
||||
- edge-alloy
|
||||
- keepalived
|
||||
- chronyd
|
||||
|
||||
@@ -18,10 +18,11 @@ proxy-1.renner.uno ansible_host=proxy-1.nb.renner.uno ansible_user=root
|
||||
proxy-2.renner.uno ansible_host=proxy-2.nb.renner.uno ansible_user=root
|
||||
|
||||
# Every bare-metal edge thin client. Gets the host-generic roles only:
|
||||
# flash-wear handling, the log shipper, the MQTT bridge, the VLAN
|
||||
# sub-interface. Nothing here assumes any peripheral is attached.
|
||||
# flash-wear handling, the log shipper, the VLAN sub-interface. Nothing here
|
||||
# assumes any peripheral is attached.
|
||||
[edge_host]
|
||||
edge-1.iot.renner.uno ansible_user=root
|
||||
edge-2.iot.renner.uno ansible_user=root
|
||||
|
||||
# Synology NAS (DSM): nas-1 RS1221+ on site, nas-2 DS1522+ off site.
|
||||
[synology_nas]
|
||||
@@ -36,32 +37,18 @@ nas-2.renner.uno ansible_user=root
|
||||
# These are children of [edge_host], declared below: a peripheral is added to an
|
||||
# edge client, it does not replace the base. Making that a :children relation
|
||||
# rather than a comment means a host added only to a capability group still
|
||||
# resolves group_vars/edge_host -- where the local MQTT contract lives -- so it
|
||||
# cannot silently come up without edge_mqtt_host and friends.
|
||||
# resolves group_vars/edge_host.
|
||||
[zwave_gateway]
|
||||
edge-1.iot.renner.uno
|
||||
|
||||
[heating_can]
|
||||
edge-1.iot.renner.uno
|
||||
|
||||
[edge_host:children]
|
||||
zwave_gateway
|
||||
heating_can
|
||||
|
||||
# NTP servers. edge-1 was added here so the role would survive the PVE hosts
|
||||
# being retired for bare-metal Talos, which cannot run chrony or keepalived
|
||||
# itself. That retirement has now happened, so edge-1 is the ONLY member.
|
||||
#
|
||||
# THIS IS NO LONGER HIGHLY AVAILABLE. The group was jupiter (MASTER, prio 100),
|
||||
# saturn (90), neptune (80) and edge-1 (BACKUP, 70); the first three are gone.
|
||||
# A single VRRP member takes the address regardless of its configured state, so
|
||||
# the service address is still served, but by one thin client with
|
||||
# no failover behind it. install-ntp-ha.yml still carries the four-host
|
||||
# ntp_servers priority map, which now describes hosts that do not exist.
|
||||
#
|
||||
# Restoring redundancy needs a second chrony host that is not a Talos node.
|
||||
# See plays/install-ntp-ha.yml for why keepalived is scoped more narrowly than
|
||||
# chrony within this group.
|
||||
# NTP servers: every edge host (edge-1 BACKUP prio 70, edge-2 BACKUP prio 60;
|
||||
# see the ntp_servers map in plays/install-ntp-ha.yml). Talos nodes cannot run
|
||||
# chrony or keepalived. The jupiter/saturn/neptune entries in that map
|
||||
# describe retired PVE hosts. See plays/install-ntp-ha.yml for why keepalived
|
||||
# is scoped more narrowly than chrony within this group.
|
||||
[ntp_cluster:children]
|
||||
edge_host
|
||||
|
||||
@@ -73,6 +60,7 @@ proxy-1.renner.uno
|
||||
proxy-2.renner.uno
|
||||
netbird.renner.uno
|
||||
edge-1.iot.renner.uno
|
||||
edge-2.iot.renner.uno
|
||||
nas-1.renner.uno
|
||||
nas-2.renner.uno
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
---
|
||||
# Configure VMs to use host time sync via kvm-clock
|
||||
# Disables systemd-timesyncd to prevent NTP conflicts
|
||||
#
|
||||
# VMs get time from Proxmox host via kvm-clock paravirtualized clock
|
||||
# This is more accurate and reliable than network NTP for VMs
|
||||
|
||||
- name: Configure VM time synchronization from host
|
||||
hosts: all
|
||||
become: yes
|
||||
gather_facts: yes
|
||||
|
||||
tasks:
|
||||
- name: Check if running as VM (not LXC)
|
||||
stat:
|
||||
path: /sys/devices/system/clocksource/clocksource0/current_clocksource
|
||||
register: clocksource_file
|
||||
|
||||
- name: Get current clock source
|
||||
slurp:
|
||||
src: /sys/devices/system/clocksource/clocksource0/current_clocksource
|
||||
register: clocksource
|
||||
when: clocksource_file.stat.exists
|
||||
|
||||
- name: Display clock source
|
||||
debug:
|
||||
msg: "Clock source: {{ (clocksource.content | b64decode | trim) if clocksource_file.stat.exists else 'N/A (LXC)' }}"
|
||||
|
||||
- name: Check if systemd-timesyncd is installed
|
||||
command: systemctl list-unit-files systemd-timesyncd.service
|
||||
register: timesyncd_installed
|
||||
check_mode: false
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
|
||||
- name: Stop and disable systemd-timesyncd on VMs using kvm-clock
|
||||
systemd:
|
||||
name: systemd-timesyncd
|
||||
state: stopped
|
||||
enabled: no
|
||||
when:
|
||||
- clocksource_file.stat.exists
|
||||
- clocksource.content is defined
|
||||
- "'kvm-clock' in (clocksource.content | b64decode)"
|
||||
- "'systemd-timesyncd.service' in timesyncd_installed.stdout"
|
||||
|
||||
- name: Mask systemd-timesyncd to prevent re-enabling
|
||||
systemd:
|
||||
name: systemd-timesyncd
|
||||
masked: yes
|
||||
when:
|
||||
- clocksource_file.stat.exists
|
||||
- clocksource.content is defined
|
||||
- "'kvm-clock' in (clocksource.content | b64decode)"
|
||||
- "'systemd-timesyncd.service' in timesyncd_installed.stdout"
|
||||
|
||||
- name: Verify time sync status
|
||||
command: timedatectl show --property=NTPSynchronized
|
||||
register: timesync_status
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Display final status
|
||||
debug:
|
||||
msg: |
|
||||
Host: {{ inventory_hostname }}
|
||||
Type: {{ 'VM (kvm-clock)' if (clocksource_file.stat.exists and 'kvm-clock' in (clocksource.content | b64decode | default(''))) else 'LXC/Other' }}
|
||||
Time sync: {{ timesync_status.stdout | default('unknown') }}
|
||||
Note: VMs sync time from Proxmox host, LXCs inherit host time automatically
|
||||
@@ -8,7 +8,6 @@
|
||||
# their own inventory groups:
|
||||
#
|
||||
# plays/install-zwave-gateway.yml [zwave_gateway] ZWA-2 radio
|
||||
# plays/install-heating-can.yml [heating_can] Innomaker USB2CAN
|
||||
#
|
||||
# Splitting the roles without splitting the plays would have kept the coupling:
|
||||
# one play applying every role to one group is the monolith again, a layer up.
|
||||
@@ -31,11 +30,96 @@
|
||||
# do not silently depend on the connection user staying privileged.
|
||||
become: true
|
||||
|
||||
# The Viessmann E3 CAN bus is read by vitocal-can (open3e-esp32 at the
|
||||
# Vitocal), which publishes to the cluster broker itself, not by an edge
|
||||
# host. These remove the former heating-can stack, the can0 configuration
|
||||
# and the open3e MQTT bridge (loopback mosquitto + edge-mqtt-cred); each is
|
||||
# a no-op where they are absent.
|
||||
pre_tasks:
|
||||
- name: Read the service list
|
||||
ansible.builtin.service_facts:
|
||||
|
||||
- name: Stop and disable the retired MQTT bridge services
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
state: stopped
|
||||
enabled: false
|
||||
loop:
|
||||
- edge-mqtt-cred
|
||||
- mosquitto
|
||||
when: (item ~ '.service') in ansible_facts.services
|
||||
|
||||
- name: Remove the retired MQTT bridge files
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- /etc/systemd/system/edge-mqtt-cred.service
|
||||
- /usr/local/sbin/edge-mqtt-cred
|
||||
- /etc/edge-mqtt-cred
|
||||
- /etc/mosquitto
|
||||
register: edge_mqtt_bridge_files
|
||||
|
||||
- name: Reload systemd without the edge-mqtt-cred unit
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
when: edge_mqtt_bridge_files.changed
|
||||
|
||||
- name: Uninstall mosquitto
|
||||
ansible.builtin.package:
|
||||
name: mosquitto
|
||||
state: absent
|
||||
|
||||
- name: Check for the retired heating-can compose project
|
||||
ansible.builtin.stat:
|
||||
path: /opt/edge-host/heating-can/docker-compose.yml
|
||||
register: edge_heating_can_compose
|
||||
|
||||
- name: Stop and remove the retired heating-can containers and image
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose down --remove-orphans --rmi all
|
||||
chdir: /opt/edge-host/heating-can
|
||||
when: edge_heating_can_compose.stat.exists
|
||||
changed_when: true
|
||||
|
||||
- name: Remove the retired heating-can directory
|
||||
ansible.builtin.file:
|
||||
path: /opt/edge-host/heating-can
|
||||
state: absent
|
||||
|
||||
- name: Remove the can0 networkd configuration
|
||||
ansible.builtin.file:
|
||||
path: /etc/systemd/network/80-can0.network
|
||||
state: absent
|
||||
register: edge_can0_network
|
||||
|
||||
- name: Reload networkd without the can0 configuration
|
||||
ansible.builtin.command:
|
||||
cmd: networkctl reload
|
||||
when: edge_can0_network.changed
|
||||
changed_when: true
|
||||
|
||||
- name: Read the can0 link flags
|
||||
ansible.builtin.command:
|
||||
cmd: ip -o link show can0
|
||||
register: edge_can0_link
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Take can0 down
|
||||
ansible.builtin.command:
|
||||
cmd: ip link set can0 down
|
||||
when:
|
||||
- edge_can0_link.rc == 0
|
||||
- edge_can0_link.stdout is search('[<,]UP[,>]')
|
||||
changed_when: true
|
||||
|
||||
roles:
|
||||
- certificates
|
||||
- common
|
||||
- pacman-cache
|
||||
- edge-flash-wear
|
||||
- docker-install
|
||||
- edge-log-shipper
|
||||
- mqtt-bridge
|
||||
- edge-vlan
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
# Heating system over the Viessmann E3 CAN bus, for hosts with the Innomaker
|
||||
# USB2CAN adapter attached.
|
||||
#
|
||||
# Targets [heating_can]: membership means the adapter is physically present.
|
||||
# Two roles because they are two concerns -- the interface is host network
|
||||
# state, the services consume it -- but one group, because both need the same
|
||||
# hardware.
|
||||
#
|
||||
# Requires the base play (plays/install-edge-host.yml) to have run: these
|
||||
# services publish to the local MQTT bridge it sets up.
|
||||
- name: Install heating CAN services
|
||||
hosts: heating_can
|
||||
become: true
|
||||
|
||||
roles:
|
||||
- socketcan
|
||||
- heating-can
|
||||
@@ -22,6 +22,7 @@
|
||||
become: true
|
||||
roles:
|
||||
- common
|
||||
- timesyncd
|
||||
- docker-install
|
||||
- netbird-proxy
|
||||
- netbird-proxy-watchdog
|
||||
|
||||
@@ -16,5 +16,6 @@
|
||||
become: true
|
||||
roles:
|
||||
- common
|
||||
- timesyncd
|
||||
- docker-install
|
||||
- netbird-server
|
||||
|
||||
@@ -83,6 +83,9 @@
|
||||
edge-1.iot.renner.uno:
|
||||
priority: 70
|
||||
state: "BACKUP"
|
||||
edge-2.iot.renner.uno:
|
||||
priority: 60
|
||||
state: "BACKUP"
|
||||
|
||||
roles:
|
||||
- role: chrony-ntp-server
|
||||
|
||||
@@ -54,18 +54,9 @@
|
||||
# via plays/configure-ssh-ca.yml.
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 5. Disable NTP on VMs/LXC (use host time)
|
||||
# 5. Time sync
|
||||
# -------------------------------------------------------
|
||||
- name: Stop and disable systemd-timesyncd on VMs/LXC
|
||||
systemd:
|
||||
name: systemd-timesyncd
|
||||
state: stopped
|
||||
enabled: no
|
||||
masked: yes
|
||||
when:
|
||||
- ansible_facts['virtualization_role'] == 'guest'
|
||||
- ansible_facts['virtualization_type'] in ['kvm', 'lxc']
|
||||
ignore_errors: yes
|
||||
# Moved to separate roles: timesyncd (internet VPS), chrony-ntp-server (edge).
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 6. Certificates
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
|
||||
edge_alloy_config_file: /etc/alloy/edge.alloy
|
||||
edge_alloy_http_port: 12345
|
||||
edge_alloy_bin: /usr/bin/grafana-alloy # Arch `grafana-alloy` pkg names the binary this, not `alloy`
|
||||
# Upstream image: Arch's grafana-alloy 1.21 is built without
|
||||
# loki.source.journal. Renovate-tracked (customManagers in renovate.json).
|
||||
edge_alloy_image: grafana/alloy:v1.21.3@sha256:8ab871206bd2d8f89277f53abffee5f452a293980788d52f85fa183a87a505a4
|
||||
edge_loki_push_url: "https://loki.renner.uno/loki/api/v1/push"
|
||||
edge_loki_username: edge-1
|
||||
edge_alloy_labels:
|
||||
|
||||
@@ -6,15 +6,18 @@
|
||||
# directly, so one journal source covers everything. Alloy s storage/WAL sits
|
||||
# on /run (tmpfs) so it does not write to flash either.
|
||||
|
||||
- name: Install grafana-alloy
|
||||
# Alloy runs from the upstream image (see edge-alloy.service); the Arch package
|
||||
# lacks journal support.
|
||||
- name: Remove the grafana-alloy package
|
||||
ansible.builtin.package:
|
||||
name: grafana-alloy
|
||||
state: present
|
||||
state: absent
|
||||
|
||||
- name: Create alloy system group
|
||||
ansible.builtin.group:
|
||||
name: alloy
|
||||
system: true
|
||||
register: edge_alloy_group
|
||||
|
||||
- name: Create alloy system user
|
||||
ansible.builtin.user:
|
||||
@@ -24,6 +27,25 @@
|
||||
system: true
|
||||
create_home: false
|
||||
shell: /usr/bin/nologin
|
||||
register: edge_alloy_user
|
||||
|
||||
- name: Look up the systemd-journal group
|
||||
ansible.builtin.getent:
|
||||
database: group
|
||||
key: systemd-journal
|
||||
|
||||
- name: Set the container identity
|
||||
ansible.builtin.set_fact:
|
||||
edge_alloy_uid: "{{ edge_alloy_user.uid }}"
|
||||
edge_alloy_gid: "{{ edge_alloy_group.gid }}"
|
||||
edge_alloy_journal_gid: "{{ ansible_facts.getent_group['systemd-journal'][1] }}"
|
||||
|
||||
- name: Pull the alloy image
|
||||
ansible.builtin.command:
|
||||
argv: [docker, pull, "{{ edge_alloy_image }}"]
|
||||
register: edge_alloy_pull
|
||||
changed_when: "'Downloaded newer image' in edge_alloy_pull.stdout"
|
||||
notify: Restart alloy
|
||||
|
||||
- name: Create alloy config directory
|
||||
ansible.builtin.file:
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
[Unit]
|
||||
Description=Grafana Alloy (edge-1 log shipper -> Loki)
|
||||
Description=Grafana Alloy (journal -> Loki)
|
||||
Documentation=https://grafana.com/docs/alloy/
|
||||
After=network-online.target
|
||||
After=docker.service network-online.target
|
||||
Requires=docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=alloy
|
||||
Group=alloy
|
||||
# Read the (volatile) systemd journal.
|
||||
SupplementaryGroups=systemd-journal
|
||||
# WAL + positions on tmpfs so the agent never writes to the eMMC. %t is the
|
||||
# runtime dir root (/run); RuntimeDirectory creates /run/alloy owned by us.
|
||||
RuntimeDirectory=alloy
|
||||
ExecStart={{ edge_alloy_bin }} run \
|
||||
# WAL + positions on tmpfs so the agent never writes to the eMMC.
|
||||
ExecStartPre=/usr/bin/install -d -o alloy -g alloy -m 0750 /run/alloy
|
||||
ExecStartPre=-/usr/bin/docker rm -f edge-alloy
|
||||
# Attached run with --log-driver none: the container's output reaches the
|
||||
# journal once, through this unit.
|
||||
ExecStart=/usr/bin/docker run --rm --name edge-alloy \
|
||||
--log-driver none \
|
||||
--network host \
|
||||
--user {{ edge_alloy_uid }}:{{ edge_alloy_gid }} \
|
||||
--group-add {{ edge_alloy_journal_gid }} \
|
||||
--read-only --tmpfs /tmp \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
-v /run/log/journal:/run/log/journal:ro \
|
||||
-v /etc/machine-id:/etc/machine-id:ro \
|
||||
-v {{ edge_alloy_config_file }}:/etc/alloy/config.alloy:ro \
|
||||
-v /run/alloy:/var/lib/alloy \
|
||||
{{ edge_alloy_image }} run \
|
||||
--server.http.listen-addr=127.0.0.1:{{ edge_alloy_http_port }} \
|
||||
--storage.path=%t/alloy \
|
||||
{{ edge_alloy_config_file }}
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=on-failure
|
||||
--storage.path=/var/lib/alloy \
|
||||
/etc/alloy/config.alloy
|
||||
ExecStop=/usr/bin/docker stop edge-alloy
|
||||
Restart=always
|
||||
RestartSec=10s
|
||||
LimitNOFILE=65536
|
||||
|
||||
# Hardening (bare metal, no LXC namespace limits here).
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
# Heating system over the Viessmann E3 CAN bus: open3e (heat pump) and
|
||||
# e3oncan (E380 energy meter). The can0 interface itself is the socketcan
|
||||
# role.
|
||||
|
||||
# Service root. Kept per-role so the role is usable on its own.
|
||||
edge_base_dir: /opt/edge-host
|
||||
|
||||
edge_open3e_mqtt_user: open3e
|
||||
edge_open3e_mqtt_topic: open3e
|
||||
edge_open3e_mqtt_format: "{device}_{didNumber:04d}_{didName}"
|
||||
edge_open3e_poll_seconds: 30
|
||||
# Read-only datapoint set (Phase 1 extended set, established 2026-07-02 on
|
||||
# the commissioning workstation; see vican repo CLAUDE.md for the rationale).
|
||||
# NO -w / write DIDs here — live heating + battery, writes are Phase 3.
|
||||
# vx3.266 = ErrorDtcHistory (O3EList) — the VX3 fault log; polled to catch
|
||||
# recurrences of FailureCommunicationEnergyMeter (the E380 energy-meter link
|
||||
# the internal PV-surplus EMS depends on). Decodes via the master O3EList
|
||||
# codec inherited through the vx3plus overlay.
|
||||
# vitocal.543/2350/2543/2560: SmartGrid-/EMS-Status-Beobachtung (2026-07-06)
|
||||
# — klärt, ob die Eigenstromoptimierung (EMS) bei PV-Überschuss von selbst
|
||||
# den WW-Boost auslöst (2543.2 steht laut Scan bereits auf +10 K), bevor
|
||||
# SG-Ready-Verdrahtung oder andere Trigger gebaut werden. Alle vier ro.
|
||||
# vitocal.259/260 = InfoDtcList/InfoDtcHistory (O3EList) — the HPMU event log,
|
||||
# the Vitocal-side counterpart to vx3.266. Both are capped lists (2 and 9
|
||||
# entries when added), so the multi-frame read is cheap at a 30 s poll.
|
||||
# Records faults/info events only (NormalOperation, LagDeviceDtcReported,
|
||||
# LegionellaProtectionTemperatureReached) — NOT setpoint or program changes.
|
||||
# onebase.*: the 29 paired ZigBee slots on the One Base gateway (ECU 0x6c3) --
|
||||
# 8 radiator valves, 9 climate sensors, 9 underfloor actuators, 3 floor
|
||||
# thermostats. Per-device room temperature, active setpoint, humidity, valve
|
||||
# opening/demand, battery and RSSI; the local replacement for the ViCare cloud
|
||||
# room sensors. Needs the onebase overlay (files/open3e-onebase.py) -- the stock
|
||||
# codec declares 57 bytes and a paired slot answers with 68.
|
||||
# Only CurrentValues are polled. The room and device registries (1884.., 2084..)
|
||||
# are static configuration and are read on demand, not every 30 s.
|
||||
edge_open3e_datapoints: >-
|
||||
vitocal.259,vitocal.260,vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.286,vitocal.288,vitocal.318,vitocal.321,vitocal.324,vitocal.335,vitocal.336,vitocal.396,vitocal.402,vitocal.403,vitocal.476,vitocal.477,vitocal.543,vitocal.548,vitocal.565,vitocal.1007,vitocal.1043,vitocal.1190,vitocal.1211,vitocal.1391,vitocal.1416,vitocal.1417,vitocal.2320,vitocal.2346,vitocal.2350,vitocal.2351,vitocal.2352,vitocal.2369,vitocal.2370,vitocal.2486,vitocal.2487,vitocal.2488,vitocal.2543,vitocal.2560,vitocal.2622,vitocal.2624,vitocal.2625,vitocal.2735,vitocal.3016,vx3.266,vx3.378,vx3.379,vx3.380,vx3.535,vx3.1603,vx3.1664,vx3.1690,vx3.1801,vx3.1802,vx3.1810,vx3.1828,vx3.1830,vx3.1577,vx3.1578,vx3.1579,vx3.1580,vx3.1581,vx3.1582,vx3.1834,vx3.1836,vx3.2214,vx3.2240,vx3.3085,vx3.3086,vx3.3087,vx3.3088,vx3.3089,vx3.3090,vcmu.320,vcmu.322,vcmu.325,vcmu.355,vcmu.389,vcmu.391,vcmu.1772,vcmu.1773,vcmu.1775,vcmu.1776,vcmu.2256,vcmu.2259,vcmu.2333,vcmu.2334,vcmu.2569,vcmu.2571,vitocal.2791,onebase.2086,onebase.2089,onebase.2092,onebase.2095,onebase.2098,onebase.2101,onebase.2104,onebase.2107,onebase.2110,onebase.2113,onebase.2116,onebase.2119,onebase.2122,onebase.2125,onebase.2128,onebase.2131,onebase.2134,onebase.2137,onebase.2140,onebase.2143,onebase.2262,onebase.2265,onebase.2268,onebase.2271,onebase.2274,onebase.2277,onebase.2280,onebase.2283,onebase.2286
|
||||
# ECU addresses; -dev selects codec set, tx selects the UDS address.
|
||||
# vcmu (refrigerant-circuit controller) has no dedicated codec set —
|
||||
# omitting dpList makes open3e fall back to the generic datapoint list,
|
||||
# which decodes all polled VCMU DIDs.
|
||||
edge_open3e_devices:
|
||||
vitocal: { tx: "0x680", dpList: "vcal" }
|
||||
# dpList als Pfad: Overlay-Datei mit den Batteriemodul-DIDs (siehe
|
||||
# files/open3e-vx3plus.py) — die Stock-vx3-Liste filtert sie sonst weg.
|
||||
vx3: { tx: "0x6a1", dpList: "/config/vx3plus.py" }
|
||||
vcmu: { tx: "0x68c" }
|
||||
# One Base ZigBee gateway. Overlay re-declares the paired ZigBee slots at
|
||||
# their real 68-byte length (see files/open3e-onebase.py).
|
||||
onebase: { tx: "0x6c3", dpList: "/config/onebase.py" }
|
||||
|
||||
edge_e3oncan_gap_seconds: 10
|
||||
edge_e3oncan_mqtt_format: "{device}_{didNumber:04d}_{didName}"
|
||||
|
||||
# Secrets come from inventory/group_vars/edge_host/vault.yml:
|
||||
# edge_open3e_mqtt_password
|
||||
|
||||
# The image carrying both readers, built and pinned in CI
|
||||
# (ci/images/heating-can/Containerfile). The source pins live in the
|
||||
# Containerfile, in the artifact -- not here -- so what is deployed is a tag,
|
||||
# and `docker compose up -d` on an unchanged tag is a genuine no-op.
|
||||
#
|
||||
# Tracked by the weekly update-audit as an ordinary image, which is why this is
|
||||
# simpler to keep current than the two git refs it replaced: one publishes no
|
||||
# tags at all, the other was pinned to an untagged commit between releases.
|
||||
heating_can_image: gitea.renner.uno/gitea_admin/heating-can:latest
|
||||
|
||||
# Compose project directory. Separate from the old native layout under
|
||||
# {{ edge_base_dir }}/open3e, which this role removes.
|
||||
heating_can_base_dir: "{{ edge_base_dir }}/heating-can"
|
||||
@@ -1,54 +0,0 @@
|
||||
# Managed by ansible (roles/heating-can). Datapoint overlay for the One Base
|
||||
# ZigBee gateway on ECU 0x6c3 (BACKENDGATEWAY).
|
||||
#
|
||||
# Upstream declares ZigBee<N>DeviceCurrentValues as 57 bytes. A PAIRED device
|
||||
# slot on this gateway answers with 68: the 57 known bytes plus the parent
|
||||
# device serial and three trailing bytes. udsoncan reads only the declared
|
||||
# length and then treats the next two bytes -- the first half of that serial --
|
||||
# as a foreign data identifier, so every paired slot fails to decode with
|
||||
# "Server returned values for data identifier 0x.... that was not requested".
|
||||
# The bogus identifier differs per device because the serial does.
|
||||
#
|
||||
# This overlay re-declares the 29 paired slots at 68 bytes, reusing upstream's
|
||||
# sub-type list so the per-device-type O3ESwitch (climate sensor / TRV / floor
|
||||
# thermostat / actuator, incl. DeviceTemperatureSetpoint) is inherited rather
|
||||
# than copied.
|
||||
#
|
||||
# EMPTY slots answer with 57 and decode against the stock codec, so they are
|
||||
# left alone and are not polled. A slot that is later unpaired therefore starts
|
||||
# reporting a decode error on its topic instead of a value.
|
||||
#
|
||||
# Verified on ECU 0x6c3, 2026-09-17: 29 paired slots, all 68 bytes.
|
||||
from open3e.Open3Ecodecs import O3EComplexType, RawCodec
|
||||
from open3e.Open3Edatapoints import dataIdentifiers as _master
|
||||
|
||||
# ZigBee<N>DeviceCurrentValues of the paired slots: devices 1-20 run 2086..2143
|
||||
# in steps of 3, devices 21-29 continue at 2262..2286.
|
||||
_PAIRED_CURRENT_VALUES = [2086 + 3 * n for n in range(20)] + [
|
||||
2262 + 3 * n for n in range(9)
|
||||
]
|
||||
|
||||
|
||||
def _with_parent_serial(did):
|
||||
stock = _master["dids"][did]
|
||||
return O3EComplexType(
|
||||
68,
|
||||
stock.id,
|
||||
list(stock.subTypes)
|
||||
+ [
|
||||
RawCodec(8, "ParentDeviceSerialnumber"),
|
||||
RawCodec(2, "Unknown_65_66"),
|
||||
RawCodec(1, "Unknown_67"),
|
||||
],
|
||||
desc=stock.desc,
|
||||
info=stock.info,
|
||||
acc=stock.acc,
|
||||
)
|
||||
|
||||
|
||||
dataIdentifiers = {
|
||||
"dids": {
|
||||
**_master["dids"],
|
||||
**{did: _with_parent_serial(did) for did in _PAIRED_CURRENT_VALUES},
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
# Managed by ansible (roles/heating-can). Datapoint overlay for the VX3:
|
||||
# the stock vx3 list acts as a FILTER (DIDs missing from it are popped
|
||||
# from the master table, Open3Eclass.py), so the per-battery-module DIDs
|
||||
# were never pollable with dpList "vx3". This overlay re-adds them with
|
||||
# codec None (= keep the master/general codec, RawCodec hex strings).
|
||||
#
|
||||
# Module DIDs (verified answering on ECU 0x6a1, 2026-07-03):
|
||||
# 1577-1582 ElectricalEnergyStorageModule{One..Six}OperatingData
|
||||
# (139 B: 8x temperature 0.1 °C + 15x cell voltage mV)
|
||||
# 3085-3090 ElectricalEnergyStorageModule{One..Six}Information
|
||||
# (18 B: module voltage 0.01 V, SoH?, counters)
|
||||
from open3e.Open3EdatapointsVx3 import dataIdentifiers as _vx3
|
||||
|
||||
dataIdentifiers = {
|
||||
"dids": {
|
||||
**_vx3["dids"],
|
||||
1577: None, 1578: None, 1579: None, 1580: None, 1581: None, 1582: None,
|
||||
3085: None, 3086: None, 3087: None, 3088: None, 3089: None, 3090: None,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
|
||||
- name: Restart heating-can
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d --force-recreate
|
||||
chdir: "{{ heating_can_base_dir }}"
|
||||
changed_when: true
|
||||
@@ -1,172 +0,0 @@
|
||||
---
|
||||
# Heating system over the Viessmann E3 CAN bus.
|
||||
#
|
||||
# Two readers of the same heating installation, supervised inside one container
|
||||
# from one image (ci/images/heating-can):
|
||||
# open3e UDS request/response to the Vitocal heat pump and Vitocharge VX3
|
||||
# e3oncan passive CANopen listener for the E380 grid energy meter, which
|
||||
# feeds the heat pump's own energy management
|
||||
#
|
||||
# They coexist because e3oncan issues no bus writes.
|
||||
#
|
||||
# Nothing is installed here. The image is built in CI with both sources pinned,
|
||||
# published to the registry, and this host only pulls and runs it. That is what
|
||||
# removes the churn: these were pip-installed into venvs on the node, and pip
|
||||
# re-cloned and rebuilt a VCS requirement on every converge -- restarting the
|
||||
# heating bridge each time -- which pinning the SHA did not fix.
|
||||
#
|
||||
# network_mode: host is required, not incidental: SocketCAN interfaces live in
|
||||
# the host network namespace, so can0 is only reachable that way. It is also how
|
||||
# the local MQTT broker on 127.0.0.1 is reached. CAN_RAW needs no capabilities,
|
||||
# so both run unprivileged.
|
||||
#
|
||||
# The can0 interface itself belongs to the socketcan role; this role consumes it.
|
||||
|
||||
- name: Create heating-can directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ heating_can_base_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
# --- Migration off the native install ---------------------------------------
|
||||
# Must happen before the containers start. Two open3e instances issuing UDS
|
||||
# requests on one CAN bus is the failure mode to avoid, so the old units are
|
||||
# stopped and masked rather than merely disabled -- masked so a stray
|
||||
# `systemctl start` or a leftover dependency cannot bring them back alongside
|
||||
# the containers.
|
||||
- name: Find which native CAN units still exist
|
||||
ansible.builtin.stat:
|
||||
path: "/etc/systemd/system/{{ item }}.service"
|
||||
loop:
|
||||
- open3e
|
||||
- e3oncan
|
||||
register: heating_can_native_units
|
||||
|
||||
# Stop and disable BEFORE anything else, and do NOT swallow the result.
|
||||
#
|
||||
# The first version did stop + disable + mask in one call with
|
||||
# failed_when: false, then deleted the unit files. Masking a unit whose file
|
||||
# still exists conflicts, so the call failed, failed_when hid it, nothing was
|
||||
# stopped -- and the next task deleted the unit files out from under two
|
||||
# running processes. systemctl is-enabled then reported "not-found" and looked
|
||||
# clean while is-active still said "active".
|
||||
#
|
||||
# The result on 2026-08-10 was two open3e instances issuing UDS requests on the
|
||||
# same CAN bus for nine minutes, fighting over the open3e-edge client ID and
|
||||
# kicking each other off the broker.
|
||||
#
|
||||
# Only units that actually exist are touched, so a fresh host does not need an
|
||||
# error swallowed to get through this.
|
||||
- name: Stop and disable the native CAN services
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item.item }}"
|
||||
state: stopped
|
||||
enabled: false
|
||||
loop: "{{ heating_can_native_units.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item }}"
|
||||
when: item.stat.exists
|
||||
|
||||
# Masking is deliberately NOT used. It writes a symlink at the same path the
|
||||
# next task removes, so the two cancel out -- which is half of how the original
|
||||
# failed. With the unit files gone there is nothing left to start.
|
||||
- name: Remove the native unit files
|
||||
ansible.builtin.file:
|
||||
path: "/etc/systemd/system/{{ item }}.service"
|
||||
state: absent
|
||||
loop:
|
||||
- open3e
|
||||
- e3oncan
|
||||
notify: Reload systemd
|
||||
|
||||
# The invariant that actually matters is "no native process is on the CAN bus",
|
||||
# and systemd reporting success is not the same claim. Two open3e instances
|
||||
# polling one bus is the failure this whole block exists to prevent, so it is
|
||||
# asserted directly rather than inferred from the calls above.
|
||||
- name: Check for surviving native CAN processes
|
||||
ansible.builtin.command:
|
||||
argv: [pgrep, -af, "edge-host/(open3e|e3oncan)"]
|
||||
register: heating_can_survivors
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Assert no native CAN process survived
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- heating_can_survivors.stdout | default('') | length == 0
|
||||
fail_msg: >-
|
||||
A native open3e/e3oncan process is still running after the units were
|
||||
stopped and removed:
|
||||
{{ heating_can_survivors.stdout_lines | default([]) | join('; ') }}.
|
||||
Starting the container now would put two readers on the same CAN bus --
|
||||
two open3e instances issue competing UDS requests and take over each
|
||||
other's MQTT session. Kill the survivors, then re-run.
|
||||
success_msg: No native CAN processes remain; safe to start the container.
|
||||
|
||||
- name: Remove the native venvs
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- "{{ edge_base_dir }}/open3e/venv"
|
||||
- "{{ edge_base_dir }}/e3oncan/venv"
|
||||
- "{{ edge_base_dir }}/e3oncan/src"
|
||||
- "{{ edge_base_dir }}/open3e"
|
||||
- "{{ edge_base_dir }}/e3oncan"
|
||||
|
||||
# --- Configuration (bind-mounted into the containers) ------------------------
|
||||
- name: Deploy open3e devices.json
|
||||
ansible.builtin.copy:
|
||||
content: "{{ edge_open3e_devices | to_nice_json }}\n"
|
||||
dest: "{{ heating_can_base_dir }}/devices.json"
|
||||
mode: "0644"
|
||||
notify: Restart heating-can
|
||||
|
||||
- name: Deploy open3e vx3 datapoint overlay (battery-module DIDs)
|
||||
ansible.builtin.copy:
|
||||
src: open3e-vx3plus.py
|
||||
dest: "{{ heating_can_base_dir }}/vx3plus.py"
|
||||
mode: "0644"
|
||||
notify: Restart heating-can
|
||||
|
||||
- name: Deploy open3e One Base datapoint overlay (68-byte ZigBee slots)
|
||||
ansible.builtin.copy:
|
||||
src: open3e-onebase.py
|
||||
dest: "{{ heating_can_base_dir }}/onebase.py"
|
||||
mode: "0644"
|
||||
notify: Restart heating-can
|
||||
|
||||
# --- Compose project ---------------------------------------------------------
|
||||
- name: Deploy the broker credential env file
|
||||
ansible.builtin.template:
|
||||
src: heating-can.env.j2
|
||||
dest: "{{ heating_can_base_dir }}/heating-can.env"
|
||||
mode: "0600"
|
||||
notify: Restart heating-can
|
||||
|
||||
- name: Deploy heating-can compose project
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ heating_can_base_dir }}/docker-compose.yml"
|
||||
mode: "0644"
|
||||
notify: Restart heating-can
|
||||
|
||||
- name: Pull the heating-can image
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose pull
|
||||
chdir: "{{ heating_can_base_dir }}"
|
||||
changed_when: false
|
||||
|
||||
# Unlike the zwave and meter services there is no missing-device tolerance here:
|
||||
# can0 is created by the socketcan role from a USB adapter that is part of this
|
||||
# host's purpose. If it is absent the heating telemetry is down and the run
|
||||
# should say so rather than pass quietly.
|
||||
- name: Start heating-can services
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d
|
||||
chdir: "{{ heating_can_base_dir }}"
|
||||
register: heating_can_up
|
||||
changed_when: >-
|
||||
'Started' in heating_can_up.stderr or 'Created' in heating_can_up.stderr
|
||||
or 'Started' in heating_can_up.stdout or 'Creating' in heating_can_up.stdout
|
||||
@@ -1,36 +0,0 @@
|
||||
# Managed by ansible (roles/heating-can).
|
||||
#
|
||||
# One container. Both CAN readers live in the image and are supervised inside
|
||||
# it -- this file supplies configuration and nothing else. It does not know
|
||||
# open3e's or E3onCAN's command line.
|
||||
#
|
||||
# network_mode: host is required: SocketCAN interfaces live in the host network
|
||||
# namespace, so {{ edge_can_interface }} is only reachable this way, and it is
|
||||
# also how the local MQTT broker is reached. CAN_RAW needs no capabilities, so
|
||||
# the image runs unprivileged.
|
||||
services:
|
||||
heating-can:
|
||||
image: {{ heating_can_image }}
|
||||
container_name: heating-can
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
volumes:
|
||||
- ./devices.json:/config/devices.json:ro
|
||||
- ./vx3plus.py:/config/vx3plus.py:ro
|
||||
- ./onebase.py:/config/onebase.py:ro
|
||||
env_file:
|
||||
# Broker credential, 0600 -- see heating-can.env.j2.
|
||||
- heating-can.env
|
||||
environment:
|
||||
CAN_INTERFACE: "{{ edge_can_interface }}"
|
||||
MQTT_HOST: "{{ edge_mqtt_host }}"
|
||||
MQTT_PORT: "{{ edge_mqtt_port }}"
|
||||
MQTT_TOPIC: "{{ edge_open3e_mqtt_topic }}"
|
||||
MQTT_FORMAT: "{{ edge_open3e_mqtt_format }}"
|
||||
E3ONCAN_MQTT_FORMAT: "{{ edge_e3oncan_mqtt_format }}"
|
||||
OPEN3E_CONFIG: /config/devices.json
|
||||
OPEN3E_CLIENT_ID: open3e-edge
|
||||
OPEN3E_POLL_SECONDS: "{{ edge_open3e_poll_seconds }}"
|
||||
OPEN3E_DATAPOINTS: "{{ edge_open3e_datapoints }}"
|
||||
E3ONCAN_DEVICE: e380
|
||||
E3ONCAN_GAP_SECONDS: "{{ edge_e3oncan_gap_seconds }}"
|
||||
@@ -1,8 +0,0 @@
|
||||
# Managed by ansible (roles/heating-can). 0600 -- carries the broker credential.
|
||||
#
|
||||
# Kept out of docker-compose.yml, which is 0644: the sibling roles on this host
|
||||
# do the same (power-meter-sml renders its config at 0600 while its compose file
|
||||
# stays world-readable), and the native unit this replaced used a 0600 env file
|
||||
# for exactly this value.
|
||||
MQTT_USER={{ edge_open3e_mqtt_user }}
|
||||
MQTT_PASSWORD={{ edge_open3e_mqtt_password }}
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
# --- MQTT: edge clients (open3e, e3oncan, sml2mqtt) publish to a LOCAL
|
||||
# mosquitto on loopback, which bridges to the central cluster broker over TLS
|
||||
# (open3e/e3oncan have no native MQTTS). This keeps plaintext on loopback only
|
||||
# — off the untrusted IoT VLAN — and buffers telemetry in RAM during cluster
|
||||
# blips. The local broker writes nothing to the eMMC (persistence off, logs to
|
||||
# the volatile journal). See edge-mqtt-bridge.conf.j2 and README "MQTT bridge".
|
||||
# Upstream cluster broker the local bridge connects to (TLS; the public
|
||||
# Let's Encrypt cert is validated against the system CA bundle).
|
||||
edge_mqtt_cluster_host: mqtt.renner.uno
|
||||
edge_mqtt_cluster_tls_port: 8883
|
||||
edge_mqtt_bridge_cafile: /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
# --- Bridge credential from mqtt-account-controller (REN-1307) ------------
|
||||
# The cluster account the bridge logs in as (register entry in
|
||||
# kubernetes/apps/home-assistant/mqtt-accounts.yaml). The controller pushes
|
||||
# its password to edge-mqtt-cred and rotates it when the bridge reports
|
||||
# "not connected"; there is no copy in ansible-vault.
|
||||
edge_mqtt_bridge_account: edge-1
|
||||
# HTTPS port of edge-mqtt-cred (esp32 device API, mTLS). Only a client
|
||||
# certificate from the MQTT CA with this CN is accepted.
|
||||
edge_mqtt_cred_port: 8443
|
||||
edge_mqtt_cred_allowed_cn: mqtt-account-controller
|
||||
@@ -1,141 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# Managed by ansible (roles/mqtt-bridge). Receives the cluster bridge password
|
||||
# from mqtt-account-controller (REN-1307) so no plaintext copy lives in
|
||||
# ansible-vault and no play run is needed to rotate it.
|
||||
#
|
||||
# Speaks the controller's esp32 device API over HTTPS:
|
||||
# GET /api/status -> {"mqtt": {"connected": bool}}
|
||||
# PUT /api/settings <- {"mqtt": {"user": "...", "pass": "..."}}
|
||||
# A client certificate from the MQTT CA with CN mqtt-account-controller is
|
||||
# required; nothing else may call it. The server key is generated on this
|
||||
# host and never leaves it (the controller pins it on first contact).
|
||||
#
|
||||
# PUT writes the bridge file (template + credentials), removes the legacy
|
||||
# vault-password bridges and restarts the local mosquitto. The password is
|
||||
# kept only in that 0640 root:mosquitto file.
|
||||
import grp
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
CONF = json.load(open(sys.argv[1]))
|
||||
WRITE = threading.Lock()
|
||||
|
||||
|
||||
def connected():
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["mosquitto_sub", "-h", "127.0.0.1", "-p", str(CONF["local_port"]),
|
||||
"-t", CONF["state_topic"], "-C", "1", "-W", "3"],
|
||||
capture_output=True, text=True, timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return out.strip() == "1"
|
||||
|
||||
|
||||
def write_bridge(user, password):
|
||||
with open(CONF["template"]) as f:
|
||||
body = f.read()
|
||||
body += f"remote_username {user}\nremote_password {password}\n"
|
||||
d = os.path.dirname(CONF["bridge_file"])
|
||||
fd, tmp = tempfile.mkstemp(dir=d, prefix=".edge-mqtt-cred.")
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(body)
|
||||
os.chown(tmp, 0, grp.getgrnam("mosquitto").gr_gid)
|
||||
os.chmod(tmp, 0o640)
|
||||
os.replace(tmp, CONF["bridge_file"])
|
||||
for legacy in CONF["legacy_files"]:
|
||||
if os.path.exists(legacy):
|
||||
os.remove(legacy)
|
||||
subprocess.run(["systemctl", "restart", "mosquitto"], check=True, timeout=60)
|
||||
|
||||
|
||||
def valid(s):
|
||||
# Controller passwords are random URL-safe tokens; anything with
|
||||
# whitespace or a control character would break the config line.
|
||||
return isinstance(s, str) and 0 < len(s) <= 256 and s.isprintable() and not any(c.isspace() for c in s)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def authorized(self):
|
||||
cert = self.connection.getpeercert() or {}
|
||||
cns = [v for rdn in cert.get("subject", ()) for k, v in rdn if k == "commonName"]
|
||||
if cns != [CONF["allowed_cn"]]:
|
||||
self.send_error(403)
|
||||
return False
|
||||
return True
|
||||
|
||||
def reply(self, code, obj=None):
|
||||
data = b"" if obj is None else json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
if data:
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_GET(self):
|
||||
if not self.authorized():
|
||||
return
|
||||
if self.path != "/api/status":
|
||||
return self.send_error(404)
|
||||
self.reply(200, {"mqtt": {"connected": connected()}})
|
||||
|
||||
def do_PUT(self):
|
||||
if not self.authorized():
|
||||
return
|
||||
if self.path != "/api/settings":
|
||||
return self.send_error(404)
|
||||
try:
|
||||
n = int(self.headers.get("Content-Length", "0"))
|
||||
if not 0 < n <= 4096:
|
||||
raise ValueError("body size")
|
||||
m = json.loads(self.rfile.read(n))["mqtt"]
|
||||
user, password = m["user"], m["pass"]
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return self.send_error(400)
|
||||
if user != CONF["user"] or not valid(password):
|
||||
return self.send_error(400)
|
||||
with WRITE:
|
||||
write_bridge(user, password)
|
||||
self.log_message("bridge credentials for %s replaced, mosquitto restarted", user)
|
||||
self.reply(204)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
sys.stderr.write("%s %s\n" % (self.address_string(), fmt % args))
|
||||
|
||||
|
||||
class Server(ThreadingHTTPServer):
|
||||
address_family = socket.AF_INET6
|
||||
|
||||
def server_bind(self):
|
||||
self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
|
||||
super().server_bind()
|
||||
|
||||
def get_request(self):
|
||||
# The TLS handshake runs in the request thread, with a timeout, so a
|
||||
# stalled peer cannot block the accept loop.
|
||||
sock, addr = self.socket.accept()
|
||||
sock.settimeout(10)
|
||||
return self.ctx.wrap_socket(sock, server_side=True, do_handshake_on_connect=False), addr
|
||||
|
||||
|
||||
def main():
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
ctx.load_cert_chain(CONF["cert"], CONF["key"])
|
||||
ctx.load_verify_locations(CONF["ca"])
|
||||
ctx.verify_mode = ssl.CERT_REQUIRED
|
||||
srv = Server(("::", CONF["port"]), Handler)
|
||||
srv.ctx = ctx
|
||||
srv.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,10 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBdTCCARygAwIBAgIUN8ne4k4Wif9O83GDyKiBP0Ma7EIwCgYIKoZIzj0EAwIw
|
||||
GTEXMBUGA1UEAxMOUmVubmVyIE1RVFQgQ0EwHhcNMjYxMDA2MTUzMDE1WhcNMzYx
|
||||
MDAzMTUzMDE1WjAZMRcwFQYDVQQDEw5SZW5uZXIgTVFUVCBDQTBZMBMGByqGSM49
|
||||
AgEGCCqGSM49AwEHA0IABOZhZF2JDoyvVg2oIXSbJAuAWDcoG22RxPA4oMKRMA/b
|
||||
XzIY566D2k6cVnWqbil9s/q4O2mj+Epo7plBw8NwuOOjQjBAMA4GA1UdDwEB/wQE
|
||||
AwICpDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQcqSDDiLZzhrvqq9f16Y08
|
||||
CO7QfzAKBggqhkjOPQQDAgNHADBEAiBFkxd9pXnqIcjReEcOTvCBZ3HxKu2jNQGL
|
||||
/le7gunTGgIgZapWAcu4awa+wmZcR/z2luOjD2GhIT15um14Z+sstJk=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
# The MQTT clients (zwave-js, sml2mqtt, open3e, e3oncan) reconnect on their
|
||||
# own once the local broker is back.
|
||||
- name: Restart mosquitto
|
||||
ansible.builtin.service:
|
||||
name: mosquitto
|
||||
state: restarted
|
||||
|
||||
- name: Restart edge-mqtt-cred
|
||||
ansible.builtin.systemd:
|
||||
name: edge-mqtt-cred
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
@@ -1,109 +0,0 @@
|
||||
---
|
||||
# Local MQTT bridge broker.
|
||||
#
|
||||
# Edge clients (open3e, e3oncan, sml2mqtt) publish here over loopback; this
|
||||
# broker bridges to the cluster broker over TLS, because those clients have no
|
||||
# native MQTTS. Persistence is off and logging goes to stdout so it writes
|
||||
# nothing to local flash.
|
||||
|
||||
- name: Install mosquitto
|
||||
ansible.builtin.package:
|
||||
name: mosquitto
|
||||
state: present
|
||||
|
||||
- name: Create the bridge directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/mosquitto/bridge.d
|
||||
state: directory
|
||||
owner: root
|
||||
group: mosquitto
|
||||
mode: "0750"
|
||||
|
||||
- name: Check for the controller-managed bridge file
|
||||
ansible.builtin.stat:
|
||||
path: "/etc/mosquitto/bridge.d/{{ edge_mqtt_bridge_account }}.conf"
|
||||
register: edge_mqtt_bridge_managed
|
||||
|
||||
# Until mqtt-account-controller has pushed the first password, keep the
|
||||
# vault-password bridges so the cluster never loses edge-1's data. Once the
|
||||
# managed file exists, never render them again (edge-mqtt-cred removed them).
|
||||
- name: Deploy legacy vault-password bridges
|
||||
ansible.builtin.template:
|
||||
src: edge-mqtt-bridge-legacy.conf.j2
|
||||
dest: /etc/mosquitto/bridge.d/legacy.conf
|
||||
owner: root
|
||||
group: mosquitto
|
||||
mode: "0640" # carries the legacy cluster bridge credentials
|
||||
when: not edge_mqtt_bridge_managed.stat.exists
|
||||
notify: Restart mosquitto
|
||||
|
||||
- name: Deploy local mosquitto config
|
||||
ansible.builtin.template:
|
||||
src: edge-mqtt-bridge.conf.j2
|
||||
dest: /etc/mosquitto/mosquitto.conf
|
||||
owner: root
|
||||
group: mosquitto
|
||||
mode: "0640"
|
||||
notify: Restart mosquitto
|
||||
|
||||
- name: Enable local mosquitto bridge
|
||||
ansible.builtin.systemd:
|
||||
name: mosquitto
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
# --- edge-mqtt-cred (REN-1307): receives the bridge password from
|
||||
# mqtt-account-controller. edge-1 holds only the public MQTT CA and its own
|
||||
# server key, generated here and never copied off the host.
|
||||
- name: Create edge-mqtt-cred config directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/edge-mqtt-cred
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
|
||||
- name: Generate edge-mqtt-cred server key and certificate
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1
|
||||
-nodes -days 36500 -subj "/CN={{ inventory_hostname }}"
|
||||
-keyout /etc/edge-mqtt-cred/tls.key -out /etc/edge-mqtt-cred/tls.crt
|
||||
creates: /etc/edge-mqtt-cred/tls.key
|
||||
notify: Restart edge-mqtt-cred
|
||||
|
||||
- name: Deploy edge-mqtt-cred files
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ item.dest }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- {src: edge-mqtt-cred.py, dest: /usr/local/sbin/edge-mqtt-cred, mode: "0755"}
|
||||
# Public CA of the cluster's MQTT client certificates
|
||||
# (kubernetes/infrastructure/post-crds/mqtt-ca.yaml), valid until 2036-10-03.
|
||||
- {src: mqtt-ca.crt, dest: /etc/edge-mqtt-cred/mqtt-ca.crt, mode: "0644"}
|
||||
notify: Restart edge-mqtt-cred
|
||||
|
||||
- name: Deploy edge-mqtt-cred config and bridge template
|
||||
ansible.builtin.template:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ item.dest }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- {src: edge-mqtt-cred.json.j2, dest: /etc/edge-mqtt-cred/config.json, mode: "0600"}
|
||||
# Read only on the controller's next push: a change here reaches the
|
||||
# running bridge when the password is next set.
|
||||
- {src: edge-1-bridge.conf.in.j2, dest: /etc/edge-mqtt-cred/bridge.conf.in, mode: "0600"}
|
||||
- {src: edge-mqtt-cred.service.j2, dest: /etc/systemd/system/edge-mqtt-cred.service, mode: "0644"}
|
||||
notify: Restart edge-mqtt-cred
|
||||
|
||||
- name: Enable edge-mqtt-cred
|
||||
ansible.builtin.systemd:
|
||||
name: edge-mqtt-cred
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
@@ -1,21 +0,0 @@
|
||||
# Managed by roles/mqtt-bridge (REN-1307). edge-mqtt-cred appends
|
||||
# remote_username/remote_password to this and writes the result to
|
||||
# /etc/mosquitto/bridge.d/{{ edge_mqtt_bridge_account }}.conf. One bridge for
|
||||
# open3e, as the register account `{{ edge_mqtt_bridge_account }}`
|
||||
# (ACL readwrite open3e/#). sml2mqtt is retired (SML reader, 2026-09-19).
|
||||
connection {{ edge_mqtt_bridge_account }}
|
||||
address {{ edge_mqtt_cluster_host }}:{{ edge_mqtt_cluster_tls_port }}
|
||||
bridge_cafile {{ edge_mqtt_bridge_cafile }}
|
||||
bridge_insecure false
|
||||
remote_clientid edge1-bridge
|
||||
cleansession false
|
||||
try_private true
|
||||
restart_timeout 5 30
|
||||
queue_qos0_messages true
|
||||
# The bridge state goes to the LOCAL broker only (retained 1/0); edge-mqtt-cred
|
||||
# reads it to answer the controller's "connected?".
|
||||
notifications true
|
||||
notifications_local_only true
|
||||
notification_topic $SYS/broker/connection/{{ edge_mqtt_bridge_account }}/state
|
||||
topic open3e/# out 0
|
||||
topic open3e/cmnd in 0
|
||||
@@ -1,21 +0,0 @@
|
||||
# Managed by roles/mqtt-bridge. Legacy vault-password bridges, rendered only
|
||||
# while /etc/mosquitto/bridge.d/{{ edge_mqtt_bridge_account }}.conf does not
|
||||
# exist; edge-mqtt-cred deletes this file on the controller's first push
|
||||
# (REN-1307). Remove with the vault keys once edge-1 is on the register account.
|
||||
# --- Bridge 1: open3e datapoints + command topic, as the cluster `open3e`
|
||||
# user (its ACL is `readwrite open3e/#`, which covers publishing datapoints
|
||||
# and subscribing to open3e/cmnd). e3oncan also publishes under open3e/#.
|
||||
connection edge1-open3e
|
||||
address {{ edge_mqtt_cluster_host }}:{{ edge_mqtt_cluster_tls_port }}
|
||||
bridge_cafile {{ edge_mqtt_bridge_cafile }}
|
||||
bridge_insecure false
|
||||
remote_username {{ edge_open3e_mqtt_user }}
|
||||
remote_password {{ edge_open3e_mqtt_password }}
|
||||
remote_clientid edge1-bridge-open3e
|
||||
cleansession false
|
||||
try_private true
|
||||
notifications false
|
||||
restart_timeout 5 30
|
||||
queue_qos0_messages true
|
||||
topic open3e/# out 0
|
||||
topic open3e/cmnd in 0
|
||||
@@ -1,30 +0,0 @@
|
||||
# Managed by roles/mqtt-bridge. Local mosquitto that the edge clients (open3e,
|
||||
# e3oncan, sml2mqtt) publish to over loopback; it bridges to the central
|
||||
# cluster broker over TLS. open3e/e3oncan have no native MQTTS, so this is how
|
||||
# edge-1's MQTT gets off plaintext on the untrusted IoT VLAN, and it buffers
|
||||
# telemetry in RAM if the cluster broker is briefly unreachable.
|
||||
#
|
||||
# eMMC: this host runs on a small internal eMMC, so the local broker writes
|
||||
# NOTHING to flash — `persistence false` keeps retained/queued messages in RAM
|
||||
# only, and `log_dest stdout` goes to the (volatile, RAM) journal that Alloy
|
||||
# ships to Loki. Do not add persistence/log-file settings here.
|
||||
persistence false
|
||||
log_dest stdout
|
||||
log_type error
|
||||
log_type warning
|
||||
log_type notice
|
||||
|
||||
# Loopback only — the trust boundary is the host itself. Any process on edge-1
|
||||
# may publish here (same trust as the bridged services); nothing off-host can
|
||||
# reach it. Real authentication to the cluster happens on the bridge below.
|
||||
# Single v4-loopback listener: all edge clients dial 127.0.0.1 (edge_mqtt_host),
|
||||
# and a second same-port listener can make some builds refuse to start.
|
||||
listener 1883 127.0.0.1
|
||||
allow_anonymous true
|
||||
|
||||
# --- Bridges to the cluster broker (REN-1307). bridge.d holds either
|
||||
# {{ edge_mqtt_bridge_account }}.conf, written by edge-mqtt-cred with the password
|
||||
# mqtt-account-controller pushed, or, until that first push, legacy.conf with
|
||||
# the two vault-password bridges. edge-mqtt-cred deletes legacy.conf when it
|
||||
# writes the new file.
|
||||
include_dir /etc/mosquitto/bridge.d
|
||||
@@ -1,13 +0,0 @@
|
||||
{{ {
|
||||
"port": edge_mqtt_cred_port,
|
||||
"allowed_cn": edge_mqtt_cred_allowed_cn,
|
||||
"user": edge_mqtt_bridge_account,
|
||||
"cert": "/etc/edge-mqtt-cred/tls.crt",
|
||||
"key": "/etc/edge-mqtt-cred/tls.key",
|
||||
"ca": "/etc/edge-mqtt-cred/mqtt-ca.crt",
|
||||
"template": "/etc/edge-mqtt-cred/bridge.conf.in",
|
||||
"bridge_file": "/etc/mosquitto/bridge.d/" ~ edge_mqtt_bridge_account ~ ".conf",
|
||||
"legacy_files": ["/etc/mosquitto/bridge.d/legacy.conf"],
|
||||
"local_port": edge_mqtt_port,
|
||||
"state_topic": "$SYS/broker/connection/" ~ edge_mqtt_bridge_account ~ "/state",
|
||||
} | to_nice_json }}
|
||||
@@ -1,19 +0,0 @@
|
||||
# Managed by roles/mqtt-bridge (REN-1307). See /usr/local/sbin/edge-mqtt-cred.
|
||||
[Unit]
|
||||
Description=Receives the MQTT bridge credential from mqtt-account-controller
|
||||
After=network-online.target mosquitto.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /usr/local/sbin/edge-mqtt-cred /etc/edge-mqtt-cred/config.json
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# Root only to write the bridge file and restart mosquitto.
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/etc/mosquitto/bridge.d
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,14 @@
|
||||
# Managed by ansible (roles/pacman-cache).
|
||||
# Downloaded packages are not kept; older versions come from the Arch Linux
|
||||
# Archive.
|
||||
[Trigger]
|
||||
Operation = Install
|
||||
Operation = Upgrade
|
||||
Operation = Remove
|
||||
Type = Package
|
||||
Target = *
|
||||
|
||||
[Action]
|
||||
Description = Removing downloaded packages from the cache...
|
||||
When = PostTransaction
|
||||
Exec = /usr/bin/find /var/cache/pacman/pkg -mindepth 1 -delete
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
# Keep the pacman package cache empty. pacman keeps every downloaded package;
|
||||
# on a 7 GB eMMC root the cache grows to gigabytes. Arch hosts only.
|
||||
|
||||
- name: Ensure the pacman hook directory exists
|
||||
ansible.builtin.file:
|
||||
path: /etc/pacman.d/hooks
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Empty the package cache after every transaction
|
||||
ansible.builtin.copy:
|
||||
src: pacman-cache.hook
|
||||
dest: /etc/pacman.d/hooks/pacman-cache.hook
|
||||
mode: "0644"
|
||||
|
||||
- name: Empty the package cache
|
||||
ansible.builtin.command:
|
||||
argv: [find, /var/cache/pacman/pkg, -mindepth, "1", -delete, -print]
|
||||
register: pacman_cache_clean
|
||||
changed_when: pacman_cache_clean.stdout | length > 0
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
# SocketCAN interface. Consumed by heating-can.
|
||||
# --- CAN / open3e (Viessmann E3 external bus) ---
|
||||
edge_can_interface: can0
|
||||
edge_can_bitrate: 250000
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
# reload alone only re-reads config files; existing links keep their old state
|
||||
# until reconfigured.
|
||||
- name: Reload networkd
|
||||
ansible.builtin.shell:
|
||||
cmd: networkctl reload && networkctl reconfigure {{ edge_can_interface }}
|
||||
changed_when: true
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
# SocketCAN interface, owned by systemd-networkd.
|
||||
#
|
||||
# gs_usb is an in-tree module and autoloads when the adapter enumerates;
|
||||
# networkd then matches the interface and applies bitrate + up. Separate from
|
||||
# the services that consume the bus (heating-can, e380-can-meter) because the
|
||||
# interface is host network state shared by both.
|
||||
|
||||
- name: Configure can0 via systemd-networkd
|
||||
ansible.builtin.template:
|
||||
src: 80-can0.network.j2
|
||||
dest: /etc/systemd/network/80-can0.network
|
||||
mode: "0644"
|
||||
notify: Reload networkd
|
||||
|
||||
- name: Enable systemd-networkd
|
||||
ansible.builtin.service:
|
||||
name: systemd-networkd
|
||||
state: started
|
||||
enabled: true
|
||||
@@ -1,17 +0,0 @@
|
||||
# Managed by ansible (roles/socketcan).
|
||||
# Viessmann E3 external CAN bus via Innomaker USB2CAN (gs_usb).
|
||||
# The adapter is a stub tap on an already-terminated bus (120R jumper OFF).
|
||||
[Match]
|
||||
Name={{ edge_can_interface }}
|
||||
|
||||
[Link]
|
||||
# networkd applies [CAN] settings but does not raise a CAN link on its
|
||||
# own (observed on edge-1); force it up, and back up after any down.
|
||||
ActivationPolicy=always-up
|
||||
|
||||
[CAN]
|
||||
BitRate={{ edge_can_bitrate }}
|
||||
# NO RestartSec here: gs_usb does not support hardware bus-off restart,
|
||||
# and networkd fails the ENTIRE link configuration on the unsupported
|
||||
# option ("off (failed)", link never raised — found by reboot test).
|
||||
# Bus-off recovery falls to the open3e service restart loop.
|
||||
@@ -143,6 +143,12 @@
|
||||
group: root
|
||||
mode: "0644"
|
||||
|
||||
- name: Install sudo
|
||||
ansible.builtin.package:
|
||||
name: sudo
|
||||
state: present
|
||||
when: not ssh_ca_synouser.stat.exists
|
||||
|
||||
- name: Install the Claude sudo rules
|
||||
ansible.builtin.template:
|
||||
src: sudoers-claude.j2
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
# Keep the system clock on NTP with systemd-timesyncd, on hosts that run no
|
||||
# chrony. A KVM guest's clocksource does not steer wall-clock time; without an
|
||||
# NTP client the clock drifts until JWT iat/exp checks fail.
|
||||
|
||||
- name: Install systemd-timesyncd
|
||||
ansible.builtin.apt:
|
||||
name: systemd-timesyncd
|
||||
state: present
|
||||
when: ansible_facts['os_family'] == 'Debian'
|
||||
|
||||
- name: Run systemd-timesyncd
|
||||
ansible.builtin.systemd_service:
|
||||
name: systemd-timesyncd
|
||||
masked: false
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Wait for the clock to synchronize
|
||||
ansible.builtin.command:
|
||||
argv: [timedatectl, show, --property=NTPSynchronized, --value]
|
||||
register: timesyncd_synchronized
|
||||
changed_when: false
|
||||
until: timesyncd_synchronized.stdout == "yes"
|
||||
retries: 12
|
||||
delay: 5
|
||||
@@ -13,12 +13,8 @@ zwave_base_dir: "{{ edge_base_dir }}/zwave-js"
|
||||
# survives moving the stick between machines.
|
||||
edge_zwave_device: /dev/serial/by-id/usb-Nabu_Casa_ZWA-2_80B54EE15720-if00
|
||||
|
||||
edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.21.1
|
||||
|
||||
# Alpine version the zwave-js-ui image is built on -- the source for the musl
|
||||
# libeatmydata.so preloaded into that container. Keep in sync if the image
|
||||
# bumps its Alpine major (check: docker exec zwave-js cat /etc/os-release).
|
||||
edge_zwave_alpine_base: alpine:3.22
|
||||
# Renovate-tracked (customManagers in renovate.json).
|
||||
edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.25.2@sha256:ac4874a3cbea61c0c60002a8fd8ae05104cc8eb2eae91aa05bd184972aa06853
|
||||
|
||||
# Secret from inventory/group_vars/edge_host/vault.yml:
|
||||
# edge_zwavejs_session_secret
|
||||
|
||||
@@ -15,17 +15,31 @@
|
||||
- "{{ zwave_base_dir }}"
|
||||
- "{{ zwave_base_dir }}/store"
|
||||
|
||||
# The preloaded .so is musl and must come from the image's own Alpine release.
|
||||
- name: Read the Alpine release of the zwave-js-ui image
|
||||
ansible.builtin.command:
|
||||
cmd: docker run --rm --entrypoint cat {{ edge_zwavejs_image }} /etc/alpine-release
|
||||
register: zwave_alpine_release
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Name libeatmydata.so after that Alpine release
|
||||
ansible.builtin.set_fact:
|
||||
zwave_alpine_minor: "{{ zwave_alpine_release.stdout.split('.')[:2] | join('.') }}"
|
||||
zwave_libeatmydata: "libeatmydata-alpine{{ zwave_alpine_release.stdout.split('.')[:2] | join('.') }}.so"
|
||||
|
||||
- name: Extract musl libeatmydata.so (matches the zwave-js-ui Alpine base)
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -euo pipefail
|
||||
tmp="$(mktemp)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
docker run --rm --network=host {{ edge_zwave_alpine_base }} \
|
||||
docker run --rm --network=host alpine:{{ zwave_alpine_minor }} \
|
||||
sh -c 'apk add --no-cache libeatmydata >/dev/null 2>&1 && cat /usr/lib/libeatmydata.so' > "$tmp"
|
||||
test "$(stat -c%s "$tmp")" -ge 8000 # sanity: real .so is ~14 KB, guards against a failed/empty pull
|
||||
install -m 0644 "$tmp" {{ zwave_base_dir }}/libeatmydata.so
|
||||
creates: "{{ zwave_base_dir }}/libeatmydata.so"
|
||||
install -m 0644 "$tmp" {{ zwave_base_dir }}/{{ zwave_libeatmydata }}
|
||||
creates: "{{ zwave_base_dir }}/{{ zwave_libeatmydata }}"
|
||||
executable: /bin/bash
|
||||
notify: Restart zwave-js
|
||||
|
||||
# Because fsync is neutralized, a hard power-loss during the (rare) settings
|
||||
@@ -93,3 +107,18 @@
|
||||
zwave-js could not start -- the ZWA-2 radio is not present:
|
||||
{{ zwave_compose_up.stderr_lines | select('search', 'device') | list }}
|
||||
when: zwave_compose_up.rc != 0
|
||||
|
||||
- name: Find libeatmydata.so builds of other Alpine releases
|
||||
ansible.builtin.find:
|
||||
paths: "{{ zwave_base_dir }}"
|
||||
patterns: "libeatmydata*.so"
|
||||
excludes: "{{ zwave_libeatmydata }}"
|
||||
register: zwave_stale_libeatmydata
|
||||
|
||||
- name: Remove libeatmydata.so builds of other Alpine releases
|
||||
ansible.builtin.file:
|
||||
path: "{{ item.path }}"
|
||||
state: absent
|
||||
loop: "{{ zwave_stale_libeatmydata.files }}"
|
||||
loop_control:
|
||||
label: "{{ item.path }}"
|
||||
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
- "{{ edge_zwave_device }}:/dev/zwave"
|
||||
volumes:
|
||||
# musl libeatmydata.so extracted from the image's Alpine base (ro)
|
||||
- ./libeatmydata.so:/usr/lib/libeatmydata.so:ro
|
||||
- ./{{ zwave_libeatmydata }}:/usr/lib/libeatmydata.so:ro
|
||||
# Persistent store: settings.json, device DB, network keys. Path is
|
||||
# unchanged from the previous shared project, so nothing is migrated.
|
||||
- ./store:/usr/src/app/store
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
ansible.builtin.import_playbook: plays/configure-synology-python.yml
|
||||
- name: Base system
|
||||
ansible.builtin.import_playbook: plays/common.yml
|
||||
- name: VM time sync
|
||||
ansible.builtin.import_playbook: plays/configure-vm-time-sync.yml
|
||||
|
||||
- name: SSH user CA
|
||||
ansible.builtin.import_playbook: plays/configure-ssh-ca.yml
|
||||
@@ -33,8 +31,6 @@
|
||||
ansible.builtin.import_playbook: plays/install-edge-host.yml
|
||||
- name: Z-Wave gateway
|
||||
ansible.builtin.import_playbook: plays/install-zwave-gateway.yml
|
||||
- name: Heating CAN
|
||||
ansible.builtin.import_playbook: plays/install-heating-can.yml
|
||||
- name: NTP service
|
||||
ansible.builtin.import_playbook: plays/install-ntp-ha.yml
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24-alpine@sha256:8a32c8cf649a49915a66e81e7a609223c708d0adabf288ee3f4077d765542f85
|
||||
FROM node:26-alpine@sha256:c6ad9e9c1d80f6eb607c26b4b0a132ff7a2344fad1c78ea3baa2a7d4c8de6d94
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.wifi.managed</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>uno.renner.wifi.renner-test.network</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>c7814579-fc2b-4f38-bcc8-77e1123ab710</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Renner Test Wi-Fi</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string>Renner</string>
|
||||
<key>SSID_STR</key>
|
||||
<string>Renner Test</string>
|
||||
<key>HIDDEN_NETWORK</key>
|
||||
<false/>
|
||||
<key>AutoJoin</key>
|
||||
<true/>
|
||||
<key>EncryptionType</key>
|
||||
<string>Any</string>
|
||||
<key>EAPClientConfiguration</key>
|
||||
<dict>
|
||||
<key>OuterIdentity</key>
|
||||
<string>anonymous</string>
|
||||
<key>AcceptEAPTypes</key>
|
||||
<array>
|
||||
<integer>21</integer>
|
||||
</array>
|
||||
<key>TTLSInnerAuthentication</key>
|
||||
<string>PAP</string>
|
||||
<key>TLSTrustedServerNames</key>
|
||||
<array>
|
||||
<string>radius.renner.uno</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>uno.renner.wifi.renner-test</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>b35513d4-97e5-4804-a77b-5ca1f723a408</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Renner Test Wi-Fi (802.1X)</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string>Renner</string>
|
||||
</dict>
|
||||
</plist>
|
||||
+113
-22
@@ -4,6 +4,54 @@ FreeRADIUS terminates EAP-TTLS and proxies the inner PAP request to the
|
||||
authentik RADIUS outpost `802.1X Radius`. authentik checks the password and
|
||||
the access policy and returns the VLAN.
|
||||
|
||||
## Routing by outer identity
|
||||
|
||||
FreeRADIUS decides on the outer EAP identity (`User-Name`) of each request,
|
||||
before any local EAP processing:
|
||||
|
||||
| Outer identity | Path |
|
||||
|---|---|
|
||||
| `anonymous` or `anonymous@<anything>` (any case) | EAP-TTLS terminated in FreeRADIUS, inner PAP to authentik |
|
||||
| anything else | whole EAP conversation (all rounds, `State` included) proxied to the UDM RADIUS for UniFi Endpoint accounts |
|
||||
|
||||
UniFi Endpoint clients send their real account name as outer identity; their
|
||||
names look like ordinary usernames, so the rule cannot match on name patterns.
|
||||
The UDM's Access-Accept/Reject/Challenge goes to the NAS unchanged, including
|
||||
its Tunnel-* VLAN and MS-MPPE keys. The "Accept without VLAN is rejected" rule
|
||||
applies to the authentik path only. Accounting is answered locally on both
|
||||
paths.
|
||||
|
||||
The anonymous outer identity is mandatory for authentik clients: without it
|
||||
the login goes to the UDM and fails.
|
||||
|
||||
UDM target: home server `udm`, `10.2.11.1` UDP 1812 (the UDM on VLAN 11,
|
||||
UniFi network "(11) RADIUS"), shared secret `UDM_RADIUS_SECRET` from OpenBao
|
||||
`apps/radius`. The UDM RADIUS accepts clients from `10.2.11.0/24` only, not
|
||||
from the pod CIDR.
|
||||
|
||||
### Path to the UDM
|
||||
|
||||
Each FreeRADIUS pod has a second interface `net1` on VLAN 11
|
||||
(NetworkAttachmentDefinition `freeradius-udm`,
|
||||
`kubernetes/apps/authentik/freeradius-network-attachment.yaml`):
|
||||
|
||||
- macvlan on the node link `radius0` (VLAN 11 on `uplink`, no host addresses;
|
||||
VLANConfig in `kubernetes/talos/patches/k8s-{1,2,3}.yaml`). VLAN 11 is in
|
||||
Cilium's `bpf.vlanBypass`, otherwise the tagged frames are dropped on
|
||||
`uplink`.
|
||||
- IPv4 leased from the UDM DHCP server (cni-dhcp-daemon), requested without
|
||||
the routers option: `net1` holds only the connected `10.2.11.0/24`, no
|
||||
default route. The proxy socket is not bound to an address; the kernel's
|
||||
route lookup for `10.2.11.1` picks `net1` and its leased address as source.
|
||||
- IPv6 disabled on `net1` (VLAN 11 sends router advertisements).
|
||||
- The NAS listeners are bound to `eth0` (`interface = eth0`); nothing listens
|
||||
on `net1`.
|
||||
- Traffic on `net1` bypasses Cilium; there is no CNP for the UDM hop.
|
||||
|
||||
`radius0` must exist on every node before FreeRADIUS pods are scheduled
|
||||
there: without it the macvlan ADD fails and the pod stays in
|
||||
`ContainerCreating`.
|
||||
|
||||
## authentik objects
|
||||
|
||||
Defined in `kubernetes/apps/authentik/blueprints-configmap.yaml`, key
|
||||
@@ -77,7 +125,15 @@ Created through the UniFi Network API (site `default`):
|
||||
|---|---|---|
|
||||
| Firewall policy "Allow APs RADIUS" | `62c21e63-8c15-4720-adf8-b2333c090e2f` | zone Isolated `10.2.10.0/24` → zone External `10.2.99.91`, UDP `1812-1813`, ALLOW, IPv4 |
|
||||
| RADIUS profile "Cluster 802.1X" | `3e5fcfed-63dc-4e7d-ad51-17cbc074e4bb` | auth `10.2.99.91:1812`, acct `10.2.99.91:1813`, secret `DOT1X_NAS_SECRET`, VLAN assignment on (`vlan_wlan_mode: optional`) |
|
||||
| WLAN "Renner Test" | `3eba27e7-ddc2-41fb-ad3f-82add317847a` | copy of "Renner UniFi" (WPA2/WPA3 Enterprise transition, fast roaming, BSS transition, same AP group and bands), network (90) Guest as fallback, RADIUS profile "Cluster 802.1X" |
|
||||
| WLAN "Renner UniFi" | `ef4122f2-4a83-4dca-9ef3-468d29f39a6b` | WPA3 Enterprise (no transition), PMF required, fast roaming, BSS transition, client isolation off, RADIUS profile "Cluster 802.1X"; Application Hotspot, type Passpoint (set in the UniFi UI: the API format differs from the UI's) |
|
||||
|
||||
Passpoint on "Renner UniFi" (UniFi offers Passpoint only with WPA3 Enterprise):
|
||||
venue "Renner", Residential / Private Residence, network type Private with
|
||||
Guest Access, IPv4 single NATed private, IPv6 available, NAI realm
|
||||
`renner.uno` with EAP-TTLS, sub-methods PAP and username/password, domain
|
||||
`renner.uno`, operator friendly name "Renner", connection capability ICMP
|
||||
allow. The AKMs on air are `FT/802.1X` and `802.1X/SHA-256`; plain `802.1X`
|
||||
is not offered.
|
||||
|
||||
## Live login check
|
||||
|
||||
@@ -95,22 +151,54 @@ For IPv6 set `LIVE_SOURCE` to the workstation's `fd02:50::` address and use
|
||||
`fd99::91`. `group-id=[3530]` is VLAN `50`, `tunnel-type=[0000000d]` VLAN,
|
||||
`medium=[00000006]` IEEE-802.
|
||||
|
||||
## Client setup (SSID "Renner Test")
|
||||
## Client setup (SSID "Renner UniFi")
|
||||
|
||||
Setup page: `https://wlan.renner.uno` (behind the Authentik gate, any
|
||||
authentik user), served by the Go service `wlan-setup/`
|
||||
(`kubernetes/apps/authentik/wlan-setup*.yaml`). Android, Apple and Windows get
|
||||
Passpoint profiles with their own app password; Linux an `nmcli` command
|
||||
(NetworkManager has no Passpoint support) that carries no password.
|
||||
|
||||
Passpoint: `/<platform>/install` (`android`, `apple` or `windows`) signs the
|
||||
user in through the authentik OAuth2 application `wlan-setup` (scope
|
||||
`goauthentik.io/api`). On `/<platform>/callback`, the service exchanges the
|
||||
code, creates an app password (`wlan-<platform>-<user>-<timestamp>-<random>`)
|
||||
with the user's own access token, and hands out the profile:
|
||||
|
||||
- Android: `application/x-wifi-config` in the response to that GET, as its
|
||||
installer requires.
|
||||
- Apple: a `.mobileconfig` download.
|
||||
- Windows: a CarrierProvisioning XML signed with XML-DSig (enveloped, C14N
|
||||
1.0, rsa-sha1, chain in KeyInfo) by the Let's Encrypt certificate
|
||||
`wlan-setup-signing`; kept for 5 minutes under a single-use token. The
|
||||
browser is sent to `ms-settings:wifi-provisioning?uri=https://wlan.renner.uno/windows/profile/<token>`;
|
||||
Windows Settings fetches it (Ingress `wlan-setup-provisioning`, without the
|
||||
gate) and offers "Add".
|
||||
|
||||
The service stores nothing else. Deleting the app password in authentik removes
|
||||
that device from the WLAN. The profiles match WLANs that advertise Hotspot 2.0
|
||||
with domain and NAI realm `renner.uno` (EAP-TTLS/PAP), i.e. "Renner UniFi".
|
||||
Creating the app password needs no admin rights.
|
||||
|
||||
App passwords expire when unused. The page creates them with 29 days (the API
|
||||
caps new tokens at 30 days unless the group attribute
|
||||
`goauthentik.io/user/token-maximum-lifetime` allows more). Every 802.1X login
|
||||
with an app password renews it to 60 days: the policy
|
||||
`dot1x-app-password-renew` on the `dot1x-login` binding (at most one write per
|
||||
day). authentik rejects expired app passwords and deletes them in its
|
||||
`clean_expired_models` task. A device that has not connected for 60 days, or a
|
||||
profile that was never installed, loses its app password and needs a new
|
||||
profile from the page.
|
||||
|
||||
All clients use EAP-TTLS with inner PAP. The server certificate is a public
|
||||
Let's Encrypt certificate for `radius.renner.uno`, so no CA needs to be
|
||||
installed. Username and password are the authentik credentials. PAP runs
|
||||
inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air.
|
||||
installed. The 802.1X password stage (`dot1x-password`) accepts the authentik
|
||||
password and app passwords. PAP runs inside the TLS tunnel to FreeRADIUS and
|
||||
is never sent in clear over the air.
|
||||
|
||||
### Android
|
||||
|
||||
- EAP method: TTLS
|
||||
- Phase 2 authentication: PAP
|
||||
- CA certificate: Use system certificates
|
||||
- Domain: `radius.renner.uno`
|
||||
- Identity: authentik username
|
||||
- Anonymous identity: `anonymous`
|
||||
- Password: authentik password
|
||||
Passpoint profile from the setup page ("Auf Android installieren").
|
||||
|
||||
### Linux (NetworkManager)
|
||||
|
||||
@@ -119,14 +207,17 @@ inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air.
|
||||
- Inner authentication: PAP
|
||||
- CA certificate: system bundle (e.g. `/etc/ssl/certs/ca-certificates.crt`)
|
||||
- Domain: `radius.renner.uno`
|
||||
- Anonymous identity: `anonymous` (mandatory)
|
||||
- Username / password: authentik credentials
|
||||
|
||||
### Windows 11
|
||||
|
||||
Settings > Network & Internet > Wi-Fi > Manage known networks > Add network:
|
||||
Passpoint profile from the setup page ("Auf Windows installieren"). Manual
|
||||
setup without the page: Settings > Network & Internet > Wi-Fi > Manage known
|
||||
networks > Add network:
|
||||
|
||||
- Network name: `Renner Test`
|
||||
- Security type: WPA2-Enterprise AES
|
||||
- Network name: `Renner UniFi`
|
||||
- Security type: WPA3-Enterprise AES
|
||||
- EAP method: EAP-TTLS
|
||||
- Authentication method: Unencrypted password (PAP)
|
||||
- Server validation: where offered ("Verify server identity" / trusted
|
||||
@@ -141,14 +232,14 @@ wireless network" instead.
|
||||
|
||||
### Apple (iOS, iPadOS, macOS)
|
||||
|
||||
Apple devices need the configuration profile
|
||||
`docs/radius/clients/renner-test.mobileconfig`. Install it via AirDrop, Files
|
||||
or Settings (Profile Downloaded). The device asks for username and password on
|
||||
the first join. The profile sets security type Any (WPA2/WPA3), outer identity
|
||||
`anonymous` and trusted server name `radius.renner.uno`; it contains no
|
||||
password and no certificate. Apple
|
||||
devices may show a one-time "trust certificate" prompt for `radius.renner.uno`
|
||||
on the first join.
|
||||
Passpoint configuration profile from the setup page ("Profil laden"),
|
||||
installed via Settings (Profile Downloaded) or, on a Mac, System Settings >
|
||||
General > Device Management. It sets `IsHotspot` with domain and NAI realm
|
||||
`renner.uno` (no SSID), security type Any, outer identity `anonymous`,
|
||||
trusted server name `radius.renner.uno`, username and app password; it
|
||||
contains no certificate. Its PayloadIdentifier is fixed, so installing it
|
||||
replaces an earlier profile. Apple devices may show a one-time "trust
|
||||
certificate" prompt for `radius.renner.uno` on the first join.
|
||||
|
||||
## Measurement: authentication latency per SSID
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ The image is the official archiso **releng** profile plus:
|
||||
- the extra disk tools the installer needs
|
||||
|
||||
Division of labour: the ISO produces an **ansible-ready base system**;
|
||||
all service configuration (zwave-js-ui, sml2mqtt, open3e/CAN) stays in
|
||||
all service configuration (zwave-js-ui, sml2mqtt) stays in
|
||||
`ansible/docs/edge-host.md` as the single source of truth. Rebuilding the
|
||||
box is: boot ISO → `edge-install` → run the play.
|
||||
|
||||
@@ -42,10 +42,11 @@ for the CA key; rebuild the ISO after a CA rotation.
|
||||
2. Boot the target from it (UEFI, Secure Boot off; one-time F12/setup
|
||||
visit if the box doesn't already boot USB first)
|
||||
3. Either on the console (auto-login as root) or headless —
|
||||
`ssh root@<dhcp-ip>` with an owner certificate — run `edge-install`. It auto-detects the internal disk
|
||||
(NVMe, else eMMC — covers the Wyse 3040 — else first non-USB disk),
|
||||
shows the target and requires typing the hostname before wiping
|
||||
anything. Override with `edge-install /dev/sdY`.
|
||||
`ssh root@<dhcp-ip>` with an owner certificate — run
|
||||
`edge-install <hostname>` (e.g. `edge-install edge-2`). It auto-detects
|
||||
the internal disk (NVMe, else eMMC — covers the Wyse 3040 — else first
|
||||
non-USB disk), shows the target and requires typing the hostname before
|
||||
wiping anything. Override with `edge-install <hostname> /dev/sdY`.
|
||||
4. Reboot. Confirm the box is reachable by name — `ssh root@<host>` — before
|
||||
going further; it is identified by name everywhere and its address is
|
||||
never recorded. Then add it to `[edge_host]` in
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installer for edge-1 (bare-metal edge host, replaces the k8s-edge VM).
|
||||
# Installer for the bare-metal edge hosts (edge-1, edge-2, ...).
|
||||
# Runs from the edge-host ISO live environment. WIPES the target disk.
|
||||
# Usage: edge-install [disk]
|
||||
# Default: the internal disk — first NVMe, else eMMC (Wyse 3040), else
|
||||
# Usage: edge-install <hostname> [disk]
|
||||
# Default disk: the internal disk — first NVMe, else eMMC (Wyse 3040), else
|
||||
# the first non-USB, non-optical disk. The live USB stick itself is
|
||||
# excluded via the ISO9660 label mount.
|
||||
set -euo pipefail
|
||||
|
||||
HOST=edge-1
|
||||
usage() { echo "usage: edge-install <hostname> [disk]"; exit 1; }
|
||||
[ $# -ge 1 ] && [ $# -le 2 ] || usage
|
||||
HOST=$1
|
||||
# RFC 1123 label: what systemd-networkd sends as the DHCP hostname.
|
||||
[[ "$HOST" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] \
|
||||
|| { echo "invalid hostname: $HOST"; exit 1; }
|
||||
shift
|
||||
|
||||
detect_disk() {
|
||||
# eMMC needs mmc_block before any /dev/mmcblk* exists (see
|
||||
@@ -51,7 +57,7 @@ mount --mkdir "$(part 1)" /mnt/boot
|
||||
|
||||
# Everything the edge-host ansible role expects to find; the role itself
|
||||
# is applied over SSH after first boot (see ansible/docs/edge-host.md).
|
||||
pacstrap -K /mnt base linux linux-firmware intel-ucode openssh python git \
|
||||
pacstrap -K /mnt base linux linux-firmware intel-ucode openssh sudo python git \
|
||||
docker docker-compose
|
||||
|
||||
genfstab -U /mnt >> /mnt/etc/fstab
|
||||
|
||||
@@ -1483,8 +1483,11 @@ data:
|
||||
name: dot1x-password
|
||||
state: present
|
||||
attrs:
|
||||
# App passwords: the WLAN credential that devices store (Android
|
||||
# Passpoint profiles carry it), created by wlan.renner.uno.
|
||||
backends:
|
||||
- authentik.core.auth.InbuiltBackend
|
||||
- authentik.core.auth.TokenBackend
|
||||
|
||||
- id: dot1x-login
|
||||
model: authentik_stages_user_login.userloginstage
|
||||
@@ -1506,12 +1509,54 @@ data:
|
||||
order: 20
|
||||
state: present
|
||||
|
||||
- model: authentik_flows.flowstagebinding
|
||||
- id: binding-dot1x-login
|
||||
model: authentik_flows.flowstagebinding
|
||||
identifiers:
|
||||
target: !KeyOf dot1x-flow
|
||||
stage: !KeyOf dot1x-login
|
||||
order: 100
|
||||
state: present
|
||||
attrs:
|
||||
evaluate_on_plan: false
|
||||
re_evaluate_policies: true
|
||||
|
||||
# Sliding expiry: an app password used for 802.1X expires 60 days
|
||||
# after its last use; authentik rejects and deletes expired app
|
||||
# passwords. auth_method_args.token is set by TokenBackend. Always
|
||||
# passes; failure_result true keeps an error from blocking the login.
|
||||
- id: dot1x-app-password-renew
|
||||
model: authentik_policies_expression.expressionpolicy
|
||||
identifiers:
|
||||
name: dot1x-app-password-renew
|
||||
state: present
|
||||
attrs:
|
||||
expression: |
|
||||
from datetime import timedelta
|
||||
from django.utils.timezone import now
|
||||
from authentik.core.models import Token, TokenIntents
|
||||
ref = (request.context.get("auth_method_args") or {}).get("token")
|
||||
if request.context.get("auth_method") == "token" and isinstance(ref, dict):
|
||||
renewed = now() + timedelta(days=60)
|
||||
count = Token.objects.filter(
|
||||
pk=ref.get("pk"),
|
||||
user=request.user,
|
||||
intent=TokenIntents.INTENT_APP_PASSWORD,
|
||||
expiring=True,
|
||||
expires__lt=renewed - timedelta(days=1),
|
||||
).update(expires=renewed)
|
||||
ak_logger.info("dot1x app password", token=ref.get("name"), renewed=count)
|
||||
return True
|
||||
|
||||
- model: authentik_policies.policybinding
|
||||
identifiers:
|
||||
policy: !KeyOf dot1x-app-password-renew
|
||||
target: !KeyOf binding-dot1x-login
|
||||
order: 0
|
||||
state: present
|
||||
attrs:
|
||||
enabled: true
|
||||
negate: false
|
||||
failure_result: true
|
||||
|
||||
- id: dot1x-has-vlan
|
||||
model: authentik_policies_expression.expressionpolicy
|
||||
@@ -1624,3 +1669,47 @@ data:
|
||||
kubernetes_httproute_annotations: {}
|
||||
kubernetes_httproute_parent_refs: []
|
||||
kubernetes_json_patches: null
|
||||
|
||||
# wlan.renner.uno (wlan-setup.yaml): signs the user in and, with the
|
||||
# user's own access token (scope goauthentik.io/api), creates the WLAN
|
||||
# app password for the Android Passpoint profile. Confidential client;
|
||||
# the secret comes from wlan-setup-oauth (wlan-setup-oauth.yaml). No
|
||||
# policy binding: every authentik user may set up the WLAN.
|
||||
- id: wlan-setup-provider
|
||||
model: authentik_providers_oauth2.oauth2provider
|
||||
identifiers:
|
||||
name: WLAN setup
|
||||
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: wlan-setup
|
||||
client_secret: !Env WLAN_SETUP_CLIENT_SECRET
|
||||
grant_types:
|
||||
- authorization_code
|
||||
access_token_validity: minutes=5
|
||||
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-authentik_api]]
|
||||
redirect_uris:
|
||||
- url: https://wlan.renner.uno/android/callback
|
||||
matching_mode: strict
|
||||
- url: https://wlan.renner.uno/apple/callback
|
||||
matching_mode: strict
|
||||
- url: https://wlan.renner.uno/windows/callback
|
||||
matching_mode: strict
|
||||
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: wlan-setup
|
||||
state: present
|
||||
attrs:
|
||||
name: WLAN setup
|
||||
slug: wlan-setup
|
||||
provider: !KeyOf wlan-setup-provider
|
||||
meta_launch_url: https://wlan.renner.uno
|
||||
meta_description: "WLAN setup page; creates WLAN app passwords"
|
||||
|
||||
@@ -73,6 +73,10 @@ spec:
|
||||
# on an unclean shutdown is not an acceptable trade.
|
||||
checkpoint_timeout: "15min"
|
||||
max_wal_size: "2GB"
|
||||
# authentik's subclass joins over empty tables get planner costs far above
|
||||
# jit_above_cost; LLVM compilation then takes ~420 ms for a query that
|
||||
# executes in 0.1 ms (every flow start, every RADIUS/LDAP login).
|
||||
jit: "off"
|
||||
# zstd, not "on": the boolean spelling resolves to pglz (2016-era, weaker
|
||||
# and slower than the lz4/zstd this build also accepts). Compresses the
|
||||
# full-page images that dominate WAL volume after each checkpoint.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# FreeRADIUS 3.2 config for 802.1X: EAP-TTLS terminated here, inner PAP
|
||||
# proxied to the authentik outpost ak-outpost-8021x-radius. Replaces the
|
||||
# image's raddb completely. Functional test: tests/radius/run.sh.
|
||||
# FreeRADIUS 3.2 config for 802.1X: outer identity anonymous[@...] -> EAP-TTLS
|
||||
# terminated here, inner PAP proxied to the authentik outpost
|
||||
# ak-outpost-8021x-radius; any other outer identity -> whole EAP conversation
|
||||
# proxied to the UDM RADIUS (UniFi Endpoint). Replaces the image's raddb
|
||||
# completely. Functional test: tests/radius/run.sh.
|
||||
# VLAN contract: docs/radius/dot1x.md.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
@@ -32,9 +34,10 @@ data:
|
||||
|
||||
max_request_time = 30
|
||||
cleanup_delay = 5
|
||||
# Greater than response_window (proxy.conf): never forward a NAS
|
||||
# retransmit to the outpost. A TTLS inner request sent twice to the home
|
||||
# server leaves a freed request in the proxy hash; radiusd segfaults.
|
||||
# Greater than every home server's response_window (proxy.conf): never
|
||||
# forward a NAS retransmit to a home server. A TTLS inner request sent
|
||||
# twice to the home server leaves a freed request in the proxy hash;
|
||||
# radiusd segfaults.
|
||||
proxy_dedup_window = 10
|
||||
max_requests = 16384
|
||||
hostname_lookups = no
|
||||
@@ -152,6 +155,42 @@ data:
|
||||
nostrip
|
||||
}
|
||||
|
||||
# UDM RADIUS (FreeRADIUS) for UniFi Endpoint accounts; gets the whole
|
||||
# outer EAP conversation. Address: UDM on VLAN 11 "(11) RADIUS", reached
|
||||
# over net1 (freeradius-network-attachment.yaml); the UDM accepts
|
||||
# RADIUS clients from 10.2.11.0/24 only.
|
||||
home_server udm {
|
||||
type = auth
|
||||
ipaddr = 10.2.11.1
|
||||
port = 1812
|
||||
proto = udp
|
||||
secret = $ENV{UDM_RADIUS_SECRET}
|
||||
# RFC 3579: every reply to an EAP request carries Message-Authenticator.
|
||||
require_message_authenticator = yes
|
||||
# Less than proxy_dedup_window (radiusd.conf).
|
||||
response_window = 5
|
||||
zombie_period = 20
|
||||
# Probe with an Access-Request for a user that cannot exist (no
|
||||
# EAP-Message, so no Endpoint login); the reject counts as an answer.
|
||||
# Sent only while zombie or dead.
|
||||
status_check = request
|
||||
username = "udm-status-probe.invalid"
|
||||
password = "not-a-secret"
|
||||
check_interval = 6
|
||||
check_timeout = 4
|
||||
num_answers_to_alive = 3
|
||||
}
|
||||
|
||||
home_server_pool udm {
|
||||
type = fail-over
|
||||
home_server = udm
|
||||
}
|
||||
|
||||
realm udm {
|
||||
auth_pool = udm
|
||||
nostrip
|
||||
}
|
||||
|
||||
mods-enabled_always: |
|
||||
always ok {
|
||||
rcode = ok
|
||||
@@ -194,35 +233,50 @@ data:
|
||||
|
||||
sites-enabled_default: |
|
||||
server default {
|
||||
# NAS listeners on eth0 only; net1 (VLAN 11) carries the UDM proxy
|
||||
# traffic and nothing else.
|
||||
listen {
|
||||
type = auth
|
||||
ipaddr = *
|
||||
port = 1812
|
||||
interface = eth0
|
||||
}
|
||||
|
||||
listen {
|
||||
type = acct
|
||||
ipaddr = *
|
||||
port = 1813
|
||||
interface = eth0
|
||||
}
|
||||
|
||||
listen {
|
||||
type = auth
|
||||
ipv6addr = ::
|
||||
port = 1812
|
||||
interface = eth0
|
||||
}
|
||||
|
||||
listen {
|
||||
type = acct
|
||||
ipv6addr = ::
|
||||
port = 1813
|
||||
interface = eth0
|
||||
}
|
||||
|
||||
authorize {
|
||||
# EAP-TTLS only.
|
||||
# EAP only.
|
||||
if (!&EAP-Message) {
|
||||
reject
|
||||
}
|
||||
# Outer identity anonymous[@realm]: authentik clients, EAP-TTLS here.
|
||||
# Any other: UniFi Endpoint account (real name as outer identity);
|
||||
# every round goes to the UDM unprocessed.
|
||||
if (&User-Name && &User-Name !~ /^anonymous(@.*)?$/i) {
|
||||
update control {
|
||||
&Proxy-To-Realm := udm
|
||||
}
|
||||
return
|
||||
}
|
||||
eap {
|
||||
ok = return
|
||||
updated = return
|
||||
@@ -245,12 +299,23 @@ data:
|
||||
ok
|
||||
}
|
||||
|
||||
# UDM replies pass unchanged.
|
||||
post-auth {
|
||||
update reply {
|
||||
&Cisco-AVPair !* ANY
|
||||
if (&control:Proxy-To-Realm == "udm") {
|
||||
noop
|
||||
}
|
||||
else {
|
||||
update reply {
|
||||
&Cisco-AVPair !* ANY
|
||||
}
|
||||
}
|
||||
Post-Auth-Type REJECT {
|
||||
eap
|
||||
if (&control:Proxy-To-Realm == "udm") {
|
||||
noop
|
||||
}
|
||||
else {
|
||||
eap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +324,12 @@ data:
|
||||
|
||||
# Runs the TTLS callback that hands the outpost reply back into the tunnel.
|
||||
post-proxy {
|
||||
eap
|
||||
if (&control:Proxy-To-Realm == "udm") {
|
||||
noop
|
||||
}
|
||||
else {
|
||||
eap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
# FreeRADIUS net1: macvlan on radius0, VLAN 11 "(11) RADIUS" (VLANConfig in
|
||||
# kubernetes/talos/patches/k8s-{1,2,3}.yaml). Carries only the proxy traffic
|
||||
# to home server `udm` (freeradius-config.yaml): the UDM RADIUS accepts
|
||||
# clients from 10.2.11.0/24, not from the pod CIDR. Bypasses Cilium policy.
|
||||
# Address leased from the UDM; nothing references it.
|
||||
#
|
||||
# net1 holds only its connected /24, so the unbound proxy socket reaches the
|
||||
# UDM via net1 with the leased source. No sbr: it moves that route into a
|
||||
# source-based table, and the proxy would leave via eth0 from the pod IP.
|
||||
# skipDefault + subnet-mask: the lease is requested without routers, so no
|
||||
# default route lands on net1 (dnsmasq sends only requested options).
|
||||
# IPv6 off on net1: VLAN 11 sends RAs (default route, SLAAC); the UDM
|
||||
# RADIUS clients are IPv4 only.
|
||||
#
|
||||
# Requires cni-dhcp-daemon (infrastructure/multus) and VLAN 11 in Cilium's
|
||||
# bpf.vlanBypass (infrastructure/cilium/helmrelease.yaml).
|
||||
apiVersion: k8s.cni.cncf.io/v1
|
||||
kind: NetworkAttachmentDefinition
|
||||
metadata:
|
||||
name: freeradius-udm
|
||||
namespace: authentik
|
||||
spec:
|
||||
config: |
|
||||
{
|
||||
"cniVersion": "0.3.1",
|
||||
"name": "freeradius-udm",
|
||||
"plugins": [
|
||||
{
|
||||
"type": "macvlan",
|
||||
"master": "radius0",
|
||||
"mode": "bridge",
|
||||
"ipam": {
|
||||
"type": "dhcp",
|
||||
"request": [
|
||||
{ "skipDefault": true },
|
||||
{ "option": "subnet-mask" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tuning",
|
||||
"sysctl": {
|
||||
"net.ipv6.conf.IFNAME.disable_ipv6": "1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -18,6 +18,9 @@ spec:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: freeradius
|
||||
annotations:
|
||||
# net1 on VLAN 11: the path to the UDM RADIUS.
|
||||
k8s.v1.cni.cncf.io/networks: freeradius-udm
|
||||
spec:
|
||||
enableServiceLinks: false
|
||||
affinity:
|
||||
@@ -89,6 +92,11 @@ spec:
|
||||
secretKeyRef:
|
||||
name: radius-secrets
|
||||
key: DOT1X_OUTPOST_SECRET
|
||||
- name: UDM_RADIUS_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: radius-secrets
|
||||
key: UDM_RADIUS_SECRET
|
||||
ports:
|
||||
- name: radius
|
||||
containerPort: 1812
|
||||
|
||||
@@ -105,7 +105,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-1059 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
|
||||
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-05-1355 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
|
||||
@@ -69,6 +69,11 @@ spec:
|
||||
secretKeyRef:
|
||||
name: ak-pg-app
|
||||
key: password
|
||||
# Default 0 opens a new Postgres connection per request.
|
||||
- name: AUTHENTIK_POSTGRESQL__CONN_MAX_AGE
|
||||
value: "60"
|
||||
- name: AUTHENTIK_POSTGRESQL__CONN_HEALTH_CHECKS
|
||||
value: "true"
|
||||
- name: AUTHENTIK_EMAIL__PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
@@ -166,10 +171,11 @@ spec:
|
||||
worker:
|
||||
# Overrides the global key; keep authentik-secrets in the list.
|
||||
podAnnotations:
|
||||
secret.reloader.stakater.com/reload: authentik-secrets,radius-secrets
|
||||
# Shared secret of the 802.1X Radius provider, set via `!Env` in
|
||||
# dot1x-radius.yaml. Worker only (blueprint applier); optional so a
|
||||
# missing Secret fails only the dot1x blueprint entry.
|
||||
secret.reloader.stakater.com/reload: authentik-secrets,radius-secrets,wlan-setup-oauth
|
||||
# Shared secret of the 802.1X Radius provider and client secret of the
|
||||
# WLAN setup provider, set via `!Env` in dot1x-radius.yaml. Worker only
|
||||
# (blueprint applier); optional so a missing Secret fails only the
|
||||
# blueprint entries that use it.
|
||||
env:
|
||||
- name: DOT1X_OUTPOST_SECRET
|
||||
valueFrom:
|
||||
@@ -177,6 +183,12 @@ spec:
|
||||
name: radius-secrets
|
||||
key: DOT1X_OUTPOST_SECRET
|
||||
optional: true
|
||||
- name: WLAN_SETUP_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wlan-setup-oauth
|
||||
key: client_secret
|
||||
optional: true
|
||||
resources:
|
||||
requests:
|
||||
# Same reasoning as server above, and the worker is burstier still:
|
||||
|
||||
@@ -13,8 +13,12 @@ resources:
|
||||
- radius-cert.yaml
|
||||
- radius-externalsecret.yaml
|
||||
- freeradius-config.yaml
|
||||
- freeradius-network-attachment.yaml
|
||||
- freeradius.yaml
|
||||
- freeradius-cnp.yaml
|
||||
- wlan-setup-oauth.yaml
|
||||
- wlan-setup-signing-cert.yaml
|
||||
- wlan-setup.yaml
|
||||
# ak-pg CNPG Postgres backend (moved here from the retired seed-authentik KS;
|
||||
# each carries prune: disabled so ownership transferred without a teardown).
|
||||
# authentik has no data PVCs — all state lives in ak-pg. This KS depends on
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Client secret of the "WLAN setup" OAuth2 provider (dot1x-radius blueprint).
|
||||
# Generated once; the authentik worker reads it as WLAN_SETUP_CLIENT_SECRET
|
||||
# for the blueprint, wlan-setup reads it for the code exchange.
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
metadata:
|
||||
name: wlan-setup-oauth
|
||||
namespace: authentik
|
||||
spec:
|
||||
length: 64
|
||||
digits: 16
|
||||
symbols: 0
|
||||
allowRepeat: true
|
||||
noUpper: false
|
||||
secretKeys:
|
||||
- client_secret
|
||||
---
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: wlan-setup-oauth
|
||||
namespace: authentik
|
||||
spec:
|
||||
refreshPolicy: CreatedOnce
|
||||
target:
|
||||
name: wlan-setup-oauth
|
||||
dataFrom:
|
||||
- sourceRef:
|
||||
generatorRef:
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
name: wlan-setup-oauth
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
# Signs the Windows Wi-Fi provisioning XML of wlan.renner.uno (XML-DSig in the
|
||||
# wlan-setup service). Issued via the cluster-wide Cloudflare DNS-01
|
||||
# ClusterIssuer.
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: wlan-setup-signing
|
||||
namespace: authentik
|
||||
spec:
|
||||
secretName: wlan-setup-signing
|
||||
issuerRef:
|
||||
name: letsencrypt-cluster
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- wlan.renner.uno
|
||||
privateKey:
|
||||
algorithm: RSA
|
||||
size: 2048
|
||||
encoding: PKCS8
|
||||
rotationPolicy: Always
|
||||
@@ -0,0 +1,157 @@
|
||||
# wlan.renner.uno: setup page for the 802.1X WLAN "Renner UniFi", served by the
|
||||
# wlan-setup Go service (wlan-setup/). Android, Apple and Windows get Passpoint
|
||||
# profiles with a per-device authentik app password; Linux an nmcli command.
|
||||
# Public, so a device can reach it before it is in the WLAN (mobile data,
|
||||
# Renner Gast). Any Authentik user passes the gate (no group); the service
|
||||
# stores no user credentials beyond the 5-minute single-use Windows profile.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: wlan-setup
|
||||
namespace: authentik
|
||||
labels:
|
||||
app.kubernetes.io/name: wlan-setup
|
||||
annotations:
|
||||
secret.reloader.stakater.com/reload: wlan-setup-oauth
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: wlan-setup
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: wlan-setup
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
# Group of the signing key files (defaultMode 0440).
|
||||
fsGroup: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: wlan-setup
|
||||
image: gitea.renner.uno/gitea_admin/wlan-setup:2026-11-05-1517 # {"$imagepolicy": "flux-system:wlan-setup"}
|
||||
env:
|
||||
# Client secret of the "WLAN setup" OAuth2 provider.
|
||||
- name: WLAN_SETUP_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wlan-setup-oauth
|
||||
key: client_secret
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
resources:
|
||||
requests:
|
||||
cpu: 5m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
volumeMounts:
|
||||
# Key and chain that sign the Windows provisioning XML; read per
|
||||
# request, so cert-manager renewals need no restart.
|
||||
- name: signing
|
||||
mountPath: /etc/wlan-signing
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: signing
|
||||
secret:
|
||||
secretName: wlan-setup-signing
|
||||
defaultMode: 0440
|
||||
items:
|
||||
- key: tls.key
|
||||
path: tls.key
|
||||
- key: tls.crt
|
||||
path: tls.crt
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: wlan-setup
|
||||
namespace: authentik
|
||||
labels:
|
||||
app.kubernetes.io/name: wlan-setup
|
||||
spec:
|
||||
ipFamilyPolicy: PreferDualStack
|
||||
selector:
|
||||
app.kubernetes.io/name: wlan-setup
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: wlan-setup
|
||||
namespace: authentik
|
||||
labels:
|
||||
# Public: Cloudflare CNAME → NetBird proxy → Traefik.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- wlan.renner.uno
|
||||
secretName: wlan-setup-tls
|
||||
rules:
|
||||
- host: wlan.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: wlan-setup
|
||||
port:
|
||||
number: 80
|
||||
---
|
||||
# Without the gate: Windows Settings fetches the signed provisioning XML
|
||||
# without the browser's session; the single-use token in the path is the only
|
||||
# gate. Traefik reaches the pods through the CNP wlan-setup-gate that
|
||||
# gate-sync creates for the Ingress above.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: wlan-setup-provisioning
|
||||
namespace: authentik
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- wlan.renner.uno
|
||||
secretName: wlan-setup-tls
|
||||
rules:
|
||||
- host: wlan.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /windows/profile/
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: wlan-setup
|
||||
port:
|
||||
number: 80
|
||||
@@ -30,7 +30,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-29-1355 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
|
||||
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-11-05-1401 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
|
||||
env:
|
||||
- name: CLOUDFLARE_API_TOKEN
|
||||
valueFrom:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gitea-mcp
|
||||
image: docker.gitea.com/gitea-mcp-server:1.12.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
|
||||
image: docker.gitea.com/gitea-mcp-server:1.13.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
|
||||
# Image has no ENTRYPOINT (binary is in CMD), so args alone would replace it.
|
||||
command:
|
||||
- /app/gitea-mcp
|
||||
|
||||
@@ -44,7 +44,7 @@ spec:
|
||||
annotations:
|
||||
# Bump to restart the pod and re-attach net1 (DHCP via multus).
|
||||
# 2026-10-06: first attach on containernetworking/plugins v1.9.1.
|
||||
homelab.renner.uno/restarted-for: "2026-10-06 cni-plugins v1.9.1 attach check"
|
||||
homelab.renner.uno/restarted-for: "2026-11-07 net1 without RA (S28-36)"
|
||||
k8s.v1.cni.cncf.io/networks: mdns-relay-iot
|
||||
spec:
|
||||
# Runs where the node patch declares the IoT VLAN (ens19).
|
||||
|
||||
@@ -31,6 +31,11 @@ spec:
|
||||
# dropped. The relay's forward path to Home Assistant runs over eth0, so
|
||||
# that route has to stay intact.
|
||||
#
|
||||
# sbr covers IPv4 only. With Router Advertisements net1 gets a global IPv6
|
||||
# address and default route in the main table, and the IPv6 forward to the
|
||||
# Service leaves via net1 with an IoT source, which HA's CNP drops. tuning
|
||||
# turns RA processing off; the link-local address ff02::fb needs stays.
|
||||
#
|
||||
# Requires the cni-dhcp-daemon DaemonSet in infrastructure/multus.
|
||||
config: |
|
||||
{
|
||||
@@ -45,6 +50,13 @@ spec:
|
||||
"type": "dhcp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tuning",
|
||||
"sysctl": {
|
||||
"net.ipv6.conf.IFNAME.accept_ra": "0",
|
||||
"net.ipv6.conf.IFNAME.autoconf": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "sbr"
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: shelly-manager
|
||||
image: gitea.renner.uno/konairius/shelly-manager:2026-11-02-1412 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
image: gitea.renner.uno/konairius/shelly-manager:2026-11-07-1918 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
|
||||
@@ -128,7 +128,7 @@ spec:
|
||||
memory: 32Mi
|
||||
containers:
|
||||
- name: home-assistant
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.10.4 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.11.0 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
env:
|
||||
- name: TZ
|
||||
value: Europe/Berlin
|
||||
|
||||
@@ -49,17 +49,12 @@
|
||||
auth: cert
|
||||
deliver: {namespace: home-assistant}
|
||||
acl: ["readwrite #"]
|
||||
# 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;
|
||||
# delete it after a reinstall of edge-1).
|
||||
- name: edge-1
|
||||
# vitocal-can: open3e-esp32 CAN gateway at the Vitocal 250-A (S28-28).
|
||||
# The firmware registers its compiled-in DHCP name `open3e`, not its
|
||||
# configured hostname.
|
||||
- name: vitocal-can
|
||||
auth: password
|
||||
deliver: {esp32: "https://edge-1.iot.renner.uno:8443"}
|
||||
# No sml2mqtt/#: the SML meter reader is retired for good (owner,
|
||||
# 2026-09-19; grid data comes from the E380 on the CAN bus).
|
||||
deliver: {esp32: "http://open3e.iot.renner.uno"}
|
||||
acl: ["readwrite open3e/#"]
|
||||
# Shellies (REN-1017): one password account per device, pushed by the
|
||||
# controller through shelly-manager's per-device API. Name shelly-<id> =
|
||||
@@ -106,6 +101,10 @@
|
||||
auth: password
|
||||
deliver: {shelly-manager: 25} # Plug S Gen1 1c1a8f
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-41
|
||||
auth: password
|
||||
deliver: {shelly-manager: 41} # Plug Gen1 f4cfa26d9cb2
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-15
|
||||
auth: password
|
||||
deliver: {shelly-manager: 15} # Plug S G3 8cbfea910578
|
||||
@@ -188,6 +187,34 @@
|
||||
auth: password
|
||||
deliver: {shelly-manager: 30} # Plus 2PM b48a0a226c28 (reachable again, REN-1108)
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-7
|
||||
auth: password
|
||||
deliver: {shelly-manager: 7} # Plus 1PM Mini 348518df3500
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-23
|
||||
auth: password
|
||||
deliver: {shelly-manager: 23} # Plus 1PM Mini 348518e05b88
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-11
|
||||
auth: password
|
||||
deliver: {shelly-manager: 11} # Plus 2PM ccdba7d0b804
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-28
|
||||
auth: password
|
||||
deliver: {shelly-manager: 28} # Plus 2PM 0cdc7efa8dc0
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-17
|
||||
auth: password
|
||||
deliver: {shelly-manager: 17} # Plus Plug S d4d4daed2054
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-18
|
||||
auth: password
|
||||
deliver: {shelly-manager: 18} # Plus Plug S d4d4daecc47c
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-20
|
||||
auth: password
|
||||
deliver: {shelly-manager: 20} # Plus Plug S d4d4daecb690
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
# Sleeping H&Ts (REN-1107): awake only 8-13 s every 1-2 h, so the
|
||||
# controller pushes when the device publishes `online` on its wake topic
|
||||
# (shelly-manager topic prefix `shellies/<hostname>`), not in the 2-min loop.
|
||||
@@ -211,3 +238,8 @@
|
||||
deliver: {shelly-manager: 38} # H&T G3 ecda3bc0b6a0
|
||||
wake: shellies/shellyhtg3-ecda3bc0b6a0/online
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
- name: shelly-40
|
||||
auth: password
|
||||
deliver: {shelly-manager: 40} # Flood G4 7c2c67732e08
|
||||
wake: shellies/shellyfloodg4-7c2c67732e08/online
|
||||
acl: ["readwrite shellies/#", "write homeassistant/#"]
|
||||
|
||||
@@ -66,7 +66,7 @@ spec:
|
||||
# (machine.registries.mirrors, applied by bootstrap.sh), and containerd
|
||||
# keeps the repository path when it falls through — so the path has to
|
||||
# exist on both registries. Steady state still pulls from Gitea.
|
||||
image: gitea.renner.uno/konairius/alpine-jq:2026-10-29-1348 # {"$imagepolicy": "flux-system:alpine-jq"}
|
||||
image: gitea.renner.uno/konairius/alpine-jq:2026-11-05-1353 # {"$imagepolicy": "flux-system:alpine-jq"}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -32,7 +32,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: immich-machine-learning
|
||||
image: ghcr.io/immich-app/immich-machine-learning:v3.3.5 # {"$imagepolicy": "flux-system:immich-machine-learning"}
|
||||
image: ghcr.io/immich-app/immich-machine-learning:v3.3.6 # {"$imagepolicy": "flux-system:immich-machine-learning"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3003
|
||||
|
||||
@@ -87,7 +87,7 @@ spec:
|
||||
mountPath: /ca
|
||||
containers:
|
||||
- name: immich-server
|
||||
image: ghcr.io/immich-app/immich-server:v3.3.5 # {"$imagepolicy": "flux-system:immich-server"}
|
||||
image: ghcr.io/immich-app/immich-server:v3.3.6 # {"$imagepolicy": "flux-system:immich-server"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3001
|
||||
|
||||
@@ -30,7 +30,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-30-1314 # {"$imagepolicy": "flux-system:kuma-sync"}
|
||||
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-11-05-1355 # {"$imagepolicy": "flux-system:kuma-sync"}
|
||||
env:
|
||||
- name: KUMA_URL
|
||||
value: https://kuma.renner.uno
|
||||
|
||||
@@ -22,7 +22,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gotenberg
|
||||
image: gotenberg/gotenberg:8.39.0 # {"$imagepolicy": "flux-system:gotenberg"}
|
||||
image: gotenberg/gotenberg:8.40.0 # {"$imagepolicy": "flux-system:gotenberg"}
|
||||
args:
|
||||
- "gotenberg"
|
||||
- "--chromium-disable-javascript=true"
|
||||
|
||||
@@ -155,7 +155,7 @@ spec:
|
||||
# exist on both registries — hence konairius/, which matches the ghcr
|
||||
# namespace. Steady state still pulls from Gitea and never leaves the
|
||||
# network.
|
||||
image: gitea.renner.uno/konairius/alpine-openssl:2026-10-29-1348 # {"$imagepolicy": "flux-system:alpine-openssl"}
|
||||
image: gitea.renner.uno/konairius/alpine-openssl:2026-11-05-1353 # {"$imagepolicy": "flux-system:alpine-openssl"}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -32,7 +32,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: review-bot
|
||||
image: gitea.renner.uno/gitea_admin/review-bot:2026-11-02-1054 # {"$imagepolicy": "flux-system:review-bot"}
|
||||
image: gitea.renner.uno/gitea_admin/review-bot:2026-11-05-1350 # {"$imagepolicy": "flux-system:review-bot"}
|
||||
env:
|
||||
- name: GITEA_URL
|
||||
value: https://gitea.renner.uno
|
||||
|
||||
@@ -184,7 +184,7 @@ spec:
|
||||
# (machine.registries.mirrors, applied by bootstrap.sh), and containerd
|
||||
# keeps the repository path, so it has to exist on both registries.
|
||||
# Steady state still pulls from Gitea.
|
||||
image: gitea.renner.uno/konairius/alpine-jq:2026-10-29-1348 # {"$imagepolicy": "flux-system:alpine-jq"}
|
||||
image: gitea.renner.uno/konairius/alpine-jq:2026-11-05-1353 # {"$imagepolicy": "flux-system:alpine-jq"}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -134,7 +134,7 @@ spec:
|
||||
# is a device-bricking class of risk. Bumps are a reviewed, manual
|
||||
# act — see kubernetes/infrastructure/image-automation/README.md.
|
||||
- name: terminus
|
||||
image: ghcr.io/usetrmnl/terminus:0.79.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
image: ghcr.io/usetrmnl/terminus:0.80.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 2345
|
||||
@@ -197,7 +197,7 @@ spec:
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
- name: sidekiq
|
||||
image: ghcr.io/usetrmnl/terminus:0.79.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
image: ghcr.io/usetrmnl/terminus:0.80.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
command: ["bundle", "exec", "sidekiq", "-r", "./config/sidekiq.rb"]
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
|
||||
@@ -95,19 +95,20 @@ spec:
|
||||
# VLAN-tagged frames, accounted as "VLAN traffic disallowed by VLAN
|
||||
# filter" in cilium_drop_count_total.
|
||||
#
|
||||
# Only VLAN 30 (v30iot) needs listing. Cilium attaches to devices that
|
||||
# carry an address, so it sees mgmt0's frames already untagged by the
|
||||
# 8021q layer — nothing for a VLAN filter to match. ens19 is deliberately
|
||||
# address-less, so its frames only meet the filter on the shared parent,
|
||||
# still tagged. Adding 40 would change nothing and would touch the
|
||||
# datapath carrying etcd and the API.
|
||||
# Only the address-less macvlan parents need listing: VLAN 30 (ens19,
|
||||
# v30iot) and VLAN 11 (radius0, freeradius-udm). Cilium attaches to
|
||||
# devices that carry an address, so it sees mgmt0's frames already
|
||||
# untagged by the 8021q layer — nothing for a VLAN filter to match.
|
||||
# ens19 and radius0 are deliberately address-less, so their frames only
|
||||
# meet the filter on the shared parent, still tagged. Adding 40 would
|
||||
# change nothing and would touch the datapath carrying etcd and the API.
|
||||
#
|
||||
# The symptom when this is wrong is not a DHCP timeout: a tc-layer drop
|
||||
# reaches an AF_PACKET sender as ENOBUFS, so the CNI dhcp plugin reports
|
||||
# "no buffer space available" (#592).
|
||||
#
|
||||
# Same three-copy problem as policyMapMax above. Takes an agent restart.
|
||||
vlanBypass: [30]
|
||||
vlanBypass: [30, 11]
|
||||
# Let pods reach the cluster's own LoadBalancer external IPs; Cilium
|
||||
# redirects the socket directly to the backend pod instead of sending
|
||||
# traffic out to the VIP and back (which fails with L2 announcements).
|
||||
@@ -155,13 +156,39 @@ spec:
|
||||
k8sServicePort: 7445
|
||||
# Hubble itself is on in every agent (chart default); the relay aggregates
|
||||
# the three nodes' flow buffers behind one gRPC endpoint so `hubble observe`
|
||||
# works without exec into kube-system (REN-487). Renders no change to
|
||||
# cilium-config or the agent DaemonSet, so the reloader annotation above
|
||||
# does not roll the CNI. The relay has no client auth; who may reach it is
|
||||
# network-policies/kube-system-hubble-relay.yaml.
|
||||
# works without exec into kube-system (REN-487). The relay has no client
|
||||
# auth; who may reach it is network-policies/kube-system-hubble-relay.yaml.
|
||||
hubble:
|
||||
relay:
|
||||
enabled: true
|
||||
# Every policy drop goes to the agent's stdout and from there to Loki
|
||||
# ({namespace="kube-system", container="cilium-agent"} |= "POLICY_DENIED"),
|
||||
# so denied traffic can be reviewed after the fact. Changes cilium-config,
|
||||
# so the reloader rolls the agents.
|
||||
export:
|
||||
static:
|
||||
enabled: true
|
||||
filePath: stdout
|
||||
allowList:
|
||||
- '{"verdict":["DROPPED"],"drop_reason_desc":["POLICY_DENIED"],"event_type":[{"type":1}]}'
|
||||
fieldMask:
|
||||
- time
|
||||
- node_name
|
||||
- verdict
|
||||
- drop_reason_desc
|
||||
- traffic_direction
|
||||
- IP
|
||||
- l4
|
||||
- source.namespace
|
||||
- source.pod_name
|
||||
- source.identity
|
||||
- source.workloads
|
||||
- source_names
|
||||
- destination.namespace
|
||||
- destination.pod_name
|
||||
- destination.identity
|
||||
- destination.workloads
|
||||
- destination_names
|
||||
l2announcements:
|
||||
enabled: true
|
||||
externalIPs:
|
||||
|
||||
@@ -46,6 +46,7 @@ existing `flux-system` GitRepository whose Secret was created by
|
||||
| authentik-gate-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
|
||||
| kuma-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
|
||||
| cloudflare-lb-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
|
||||
| wlan-setup | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
|
||||
| secret-rotator | `YYYY-MM-DD` | In-house (build-ci-images.yml); newest tag wins; staged: only the Home Assistant rotation CronJobs carry the marker so far |
|
||||
| gatus | `5.x` | v-prefix filter |
|
||||
| velero-plugin-for-aws | `1.13.x` | Tight: must match velero server major.minor |
|
||||
|
||||
@@ -42,5 +42,6 @@ resources:
|
||||
- velero-plugin-for-aws.yaml
|
||||
- vsftpd.yaml
|
||||
- wiki.yaml
|
||||
- wlan-setup.yaml
|
||||
- woodpecker.yaml
|
||||
- wyoming.yaml
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: wlan-setup
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: gitea.renner.uno/gitea_admin/wlan-setup
|
||||
interval: 30m
|
||||
---
|
||||
# build-ci-images.yml tags each build YYYY-MM-DD-HHMM; newest wins.
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: wlan-setup
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: wlan-setup
|
||||
filterTags:
|
||||
pattern: '^\d{4}-\d{2}-\d{2}-\d{4}$'
|
||||
policy:
|
||||
alphabetical:
|
||||
order: asc
|
||||
@@ -73,7 +73,7 @@ spec:
|
||||
# No imagePullSecret: the ghcr namespace is private and its credential
|
||||
# is in machine.registries too, because a Secret would have to come
|
||||
# from OpenBao, which needs Ceph, which needs these binaries.
|
||||
image: gitea.renner.uno/konairius/cni-plugins:2026-10-29-1348 # {"$imagepolicy": "flux-system:cni-plugins"}
|
||||
image: gitea.renner.uno/konairius/cni-plugins:2026-11-05-1353 # {"$imagepolicy": "flux-system:cni-plugins"}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -76,6 +76,10 @@ spec:
|
||||
value: "netbird-ingress-$(NODE_NAME)"
|
||||
- name: NB_LOG_LEVEL
|
||||
value: "info"
|
||||
# stdout only; the image default also writes client.log into the
|
||||
# 16Mi state tmpfs.
|
||||
- name: NB_LOG_FILE
|
||||
value: "console"
|
||||
# rootless image needs a writable HOME for its state.
|
||||
- name: USER
|
||||
value: "netbird"
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Client networks as Cilium CIDR groups, referenced from CNPs via
|
||||
# fromCIDRSet.cidrGroupRef. ULA + IPv4 only: the ISP-delegated GUA prefixes
|
||||
# change and are not listed.
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumCIDRGroup
|
||||
metadata:
|
||||
name: net-default
|
||||
spec:
|
||||
externalCIDRs:
|
||||
- fd00::/64
|
||||
- 192.168.8.0/24
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumCIDRGroup
|
||||
metadata:
|
||||
name: net-management
|
||||
spec:
|
||||
externalCIDRs:
|
||||
- fd02:10::/64
|
||||
- 10.2.10.0/24
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumCIDRGroup
|
||||
metadata:
|
||||
name: net-radius
|
||||
spec:
|
||||
externalCIDRs:
|
||||
- fd02:11::/64
|
||||
- 10.2.11.0/24
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumCIDRGroup
|
||||
metadata:
|
||||
name: net-video
|
||||
spec:
|
||||
externalCIDRs:
|
||||
- 10.2.20.0/24
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumCIDRGroup
|
||||
metadata:
|
||||
name: net-iot
|
||||
spec:
|
||||
externalCIDRs:
|
||||
- fd02:30::/64
|
||||
- 10.2.30.0/24
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumCIDRGroup
|
||||
metadata:
|
||||
name: net-server
|
||||
spec:
|
||||
externalCIDRs:
|
||||
- fd02:40::/64
|
||||
- fd40::/64
|
||||
- 10.2.40.0/24
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumCIDRGroup
|
||||
metadata:
|
||||
name: net-k8s-node
|
||||
spec:
|
||||
externalCIDRs:
|
||||
- fd02:46::/64
|
||||
- 10.2.46.0/24
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumCIDRGroup
|
||||
metadata:
|
||||
name: net-k8s-ingress
|
||||
spec:
|
||||
externalCIDRs:
|
||||
- fd02:47::/64
|
||||
- 10.2.47.0/24
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumCIDRGroup
|
||||
metadata:
|
||||
name: net-k8s-pod
|
||||
spec:
|
||||
externalCIDRs:
|
||||
- fd02:48::/64
|
||||
- 10.2.48.0/24
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumCIDRGroup
|
||||
metadata:
|
||||
name: net-privat
|
||||
spec:
|
||||
externalCIDRs:
|
||||
- fd02:50::/64
|
||||
- 10.2.50.0/24
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumCIDRGroup
|
||||
metadata:
|
||||
name: net-phone
|
||||
spec:
|
||||
externalCIDRs:
|
||||
- 10.2.60.0/24
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumCIDRGroup
|
||||
metadata:
|
||||
name: net-access
|
||||
spec:
|
||||
externalCIDRs:
|
||||
- 10.2.70.0/24
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumCIDRGroup
|
||||
metadata:
|
||||
name: net-vpn
|
||||
spec:
|
||||
externalCIDRs:
|
||||
- 10.2.80.0/24
|
||||
- 192.168.32.0/24
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumCIDRGroup
|
||||
metadata:
|
||||
name: net-guest
|
||||
spec:
|
||||
externalCIDRs:
|
||||
- 10.2.90.0/24
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumCIDRGroup
|
||||
metadata:
|
||||
name: net-multi-transfer
|
||||
spec:
|
||||
externalCIDRs:
|
||||
- 10.2.122.0/24
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumCIDRGroup
|
||||
metadata:
|
||||
name: net-multi-lan
|
||||
spec:
|
||||
externalCIDRs:
|
||||
- 10.2.123.0/24
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumCIDRGroup
|
||||
metadata:
|
||||
name: net-mutti
|
||||
spec:
|
||||
externalCIDRs:
|
||||
- 10.2.200.0/24
|
||||
@@ -186,14 +186,21 @@ spec:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: mosquitto
|
||||
ingress:
|
||||
- fromEntities:
|
||||
- world
|
||||
- fromCIDRSet:
|
||||
- cidrGroupRef: net-iot
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "1883"
|
||||
protocol: TCP
|
||||
- port: "8883"
|
||||
protocol: TCP
|
||||
# MQTT clients on the private LAN, TLS only.
|
||||
- fromCIDRSet:
|
||||
- cidrGroupRef: net-privat
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8883"
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
@@ -205,8 +212,11 @@ spec:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: home-assistant
|
||||
ingress:
|
||||
- fromEntities:
|
||||
- world
|
||||
- fromCIDRSet:
|
||||
- cidrGroupRef: net-privat
|
||||
- cidrGroupRef: net-vpn
|
||||
- cidrGroupRef: net-iot
|
||||
- cidrGroupRef: net-server
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8123"
|
||||
@@ -225,8 +235,8 @@ spec:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: shelly-manager
|
||||
ingress:
|
||||
- fromEntities:
|
||||
- world
|
||||
- fromCIDRSet:
|
||||
- cidrGroupRef: net-iot
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8082"
|
||||
@@ -489,7 +499,7 @@ spec:
|
||||
- port: "6638"
|
||||
protocol: TCP
|
||||
---
|
||||
# mqtt-account-controller: credential delivery to edge-1 (deliver.esp32 in mqtt-accounts.yaml).
|
||||
# mqtt-account-controller: credential delivery to deliver.esp32 targets in mqtt-accounts.yaml.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
@@ -514,11 +524,12 @@ spec:
|
||||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
# vitocal-can (open3e-esp32), plain HTTP API.
|
||||
- toFQDNs:
|
||||
- matchName: "edge-1.iot.renner.uno"
|
||||
- matchName: "open3e.iot.renner.uno"
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8443"
|
||||
- port: "80"
|
||||
protocol: TCP
|
||||
---
|
||||
# whisper and speech-to-phrase: model downloads over HTTPS.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# CoreDNS is reachable from the LAN through the kube-dns-lb LoadBalancer
|
||||
# (ETP=Local, client address reaches the pod). Selecting the pods makes
|
||||
# them default-deny for ingress, so the whole cluster (pods, nodes, kubelet
|
||||
# probes, Prometheus) is allowed on every port; LAN clients only on 53.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: coredns-ingress
|
||||
namespace: kube-system
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
k8s-app: kube-dns
|
||||
ingress:
|
||||
- fromEntities:
|
||||
- cluster
|
||||
- fromCIDRSet:
|
||||
- cidrGroupRef: net-default
|
||||
- cidrGroupRef: net-management
|
||||
- cidrGroupRef: net-radius
|
||||
- cidrGroupRef: net-video
|
||||
- cidrGroupRef: net-iot
|
||||
- cidrGroupRef: net-server
|
||||
- cidrGroupRef: net-k8s-node
|
||||
- cidrGroupRef: net-k8s-ingress
|
||||
- cidrGroupRef: net-k8s-pod
|
||||
- cidrGroupRef: net-privat
|
||||
- cidrGroupRef: net-phone
|
||||
- cidrGroupRef: net-access
|
||||
- cidrGroupRef: net-vpn
|
||||
- cidrGroupRef: net-guest
|
||||
- cidrGroupRef: net-multi-transfer
|
||||
- cidrGroupRef: net-multi-lan
|
||||
- cidrGroupRef: net-mutti
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
@@ -1,6 +1,7 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- cidr-groups.yaml
|
||||
- default-deny.yaml
|
||||
- allow-ingress.yaml
|
||||
- ingress-isolation.yaml
|
||||
@@ -40,3 +41,4 @@ resources:
|
||||
- allow-cilium-health.yaml
|
||||
- cnpg-barman-s3.yaml
|
||||
- kube-system-hubble-relay.yaml
|
||||
- kube-system-coredns.yaml
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
# Targeted carve-out: alloy-syslog needs world ingress on the syslog
|
||||
# ports specifically. The Service is ETP=Cluster, so external syslog
|
||||
# senders (Proxmox, LXC hosts, network gear) hit the LB IP, get DNAT'd
|
||||
# to an alloy pod, and get SNAT'd to the receiving node's cilium_host
|
||||
# address. Cilium then sees the source as `world-ipv4` identity (the
|
||||
# cilium_host IP isn't a known endpoint), NOT `host` — so the broad
|
||||
# monitoring CNP's `fromEntities: host` rule doesn't catch it.
|
||||
#
|
||||
# Scoped via endpointSelector so this only widens ingress on the
|
||||
# alloy-syslog pod, not the whole namespace.
|
||||
# Syslog senders reaching the alloy-syslog LB (ETP=Local, client address
|
||||
# reaches the pod): network gear on Management, the SLZB coordinators on
|
||||
# IoT, and the UDM itself, which sources from its k8s-ingress address.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
@@ -19,8 +12,10 @@ spec:
|
||||
app.kubernetes.io/instance: alloy-syslog
|
||||
app.kubernetes.io/name: alloy
|
||||
ingress:
|
||||
- fromEntities:
|
||||
- world
|
||||
- fromCIDRSet:
|
||||
- cidrGroupRef: net-management
|
||||
- cidrGroupRef: net-iot
|
||||
- cidrGroupRef: net-k8s-ingress
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "514"
|
||||
|
||||
@@ -112,7 +112,7 @@ spec:
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
---
|
||||
# Allow FTP access (from scanners on the LAN) to the ftp-scanner pod.
|
||||
# FTP from the scanners (IoT and Privat) to the ftp-scanner pod.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
@@ -123,8 +123,24 @@ spec:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ftp-scanner
|
||||
ingress:
|
||||
- fromCIDRSet:
|
||||
- cidrGroupRef: net-iot
|
||||
- cidrGroupRef: net-privat
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "21"
|
||||
protocol: TCP
|
||||
- port: "21100"
|
||||
protocol: TCP
|
||||
- port: "21101"
|
||||
protocol: TCP
|
||||
- port: "21102"
|
||||
protocol: TCP
|
||||
- port: "21103"
|
||||
protocol: TCP
|
||||
- port: "21104"
|
||||
protocol: TCP
|
||||
- fromEntities:
|
||||
- world
|
||||
- host
|
||||
- remote-node
|
||||
toPorts:
|
||||
@@ -142,7 +158,7 @@ spec:
|
||||
- port: "21104"
|
||||
protocol: TCP
|
||||
---
|
||||
# Allow Syncthing peer-to-peer sync from the LAN/world.
|
||||
# Allow Syncthing peer-to-peer sync from Privat and VPN.
|
||||
# - 22000 TCP+QUIC: actual sync traffic between peers.
|
||||
# - 21027 UDP: local-discovery broadcast packets from peers on the LAN.
|
||||
# UI (8384) is intentionally NOT listed — authentik-gate-sync generates the
|
||||
@@ -157,8 +173,18 @@ spec:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: syncthing
|
||||
ingress:
|
||||
- fromCIDRSet:
|
||||
- cidrGroupRef: net-privat
|
||||
- cidrGroupRef: net-vpn
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "22000"
|
||||
protocol: TCP
|
||||
- port: "22000"
|
||||
protocol: UDP
|
||||
- port: "21027"
|
||||
protocol: UDP
|
||||
- fromEntities:
|
||||
- world
|
||||
- host
|
||||
- remote-node
|
||||
toPorts:
|
||||
|
||||
@@ -62,9 +62,10 @@ spec:
|
||||
# CA, so this bypasses Traefik entirely and the namespace default-deny has
|
||||
# to be opened for it — same shape as paperless's allow-ftp-scanner.
|
||||
#
|
||||
# Scoped to the terminus pods and port 2345 only. Requests still have to
|
||||
# carry a known device MAC in the `ID` header plus its api_key; this rule
|
||||
# opens the port, not the application.
|
||||
# Scoped to the terminus pods, port 2345 and the IoT network (the panel's
|
||||
# network; the LB is ETP=Local, so the client address reaches the pod).
|
||||
# Requests still have to carry a known device MAC in the `ID` header plus
|
||||
# its api_key; this rule opens the port, not the application.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
@@ -75,8 +76,13 @@ spec:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: terminus
|
||||
ingress:
|
||||
- fromCIDRSet:
|
||||
- cidrGroupRef: net-iot
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "2345"
|
||||
protocol: TCP
|
||||
- fromEntities:
|
||||
- world
|
||||
- host
|
||||
- remote-node
|
||||
toPorts:
|
||||
@@ -154,6 +160,12 @@ spec:
|
||||
---
|
||||
# In-cluster + DNS for every pod except terminus (terminus-egress is its
|
||||
# allowlist; selecting it here would widen it to all of `cluster`).
|
||||
#
|
||||
# trmnl-renderer's Chromium calls clients2.google.com:80,
|
||||
# update.googleapis.com:443 and accounts.google.com:443; these drops are
|
||||
# expected and harmless. The launch flags are fixed in upstream
|
||||
# trmnl-ha/ha-trmnl/screenshot.ts (PUPPETEER_ARGS, no override) and lack
|
||||
# --disable-background-networking.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
|
||||
@@ -71,7 +71,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: rotate
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-02 # {"$imagepolicy": "flux-system:secret-rotator"}
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-05 # {"$imagepolicy": "flux-system:secret-rotator"}
|
||||
args: ["ha-token"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
@@ -133,7 +133,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: rotate
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-02 # {"$imagepolicy": "flux-system:secret-rotator"}
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-05 # {"$imagepolicy": "flux-system:secret-rotator"}
|
||||
args: ["ha-token"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
@@ -195,7 +195,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: rotate
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-02 # {"$imagepolicy": "flux-system:secret-rotator"}
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-05 # {"$imagepolicy": "flux-system:secret-rotator"}
|
||||
args: ["ha-token"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
@@ -32,7 +32,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: backup-verify
|
||||
image: gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-29 # {"$imagepolicy": "flux-system:backup-verify"}
|
||||
image: gitea.renner.uno/gitea_admin/ci-backup-verify:2026-11-05 # {"$imagepolicy": "flux-system:backup-verify"}
|
||||
command: ["bash", "/script/backup-verify.sh"]
|
||||
env:
|
||||
- name: HOME
|
||||
|
||||
@@ -8,6 +8,10 @@ machine:
|
||||
# 10.2.30.0/24 route makes node replies bypass the UDM and breaks every
|
||||
# DNATed flow.
|
||||
net.ipv6.conf.ens19.disable_ipv6: "1"
|
||||
# radius0 (VLAN 11) is LINK ONLY as well: the macvlan parent for the
|
||||
# FreeRADIUS attachment. VLAN 11 announces RAs; the node must not
|
||||
# autoconfigure from them.
|
||||
net.ipv6.conf.radius0.disable_ipv6: "1"
|
||||
# These two sit on the untagged fallback network, where nothing may run.
|
||||
# Left enabled they autoconfigure a ULA and an IPv6 default route from its
|
||||
# RAs, which ties with mgmt0's and costs the cluster IPv6 egress.
|
||||
@@ -168,6 +172,17 @@ vlanID: 30
|
||||
parent: uplink
|
||||
up: true
|
||||
---
|
||||
# VLAN 11, UniFi "(11) RADIUS": link only, no addresses, no DHCP. Macvlan
|
||||
# parent of the freeradius-udm attachment
|
||||
# (kubernetes/apps/authentik/freeradius-network-attachment.yaml), which names
|
||||
# it as `master`; renaming this link means editing that too.
|
||||
apiVersion: v1alpha1
|
||||
kind: VLANConfig
|
||||
name: radius0
|
||||
vlanID: 11
|
||||
parent: uplink
|
||||
up: true
|
||||
---
|
||||
# Pod network, VLAN 48. Node-to-node only: the VXLAN overlay rides it once the
|
||||
# kubelet node IP moves here, and nothing on it is routed off the switch.
|
||||
#
|
||||
|
||||
@@ -7,6 +7,8 @@ machine:
|
||||
# macvlan parent for the v30iot pod attachments; host addresses on that L2
|
||||
# make node replies bypass the UDM and break every UDM-DNATed flow.
|
||||
net.ipv6.conf.ens19.disable_ipv6: "1"
|
||||
# radius0 (VLAN 11): link only — see k8s-1.yaml.
|
||||
net.ipv6.conf.radius0.disable_ipv6: "1"
|
||||
# Same for the physical ports on the untagged fallback network: left
|
||||
# enabled they take an IPv6 default route from its RAs and cost the
|
||||
# cluster IPv6 egress.
|
||||
@@ -115,6 +117,14 @@ vlanID: 30
|
||||
parent: uplink
|
||||
up: true
|
||||
---
|
||||
# VLAN 11, "(11) RADIUS": link only, no host addressing — see k8s-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
kind: VLANConfig
|
||||
name: radius0
|
||||
vlanID: 11
|
||||
parent: uplink
|
||||
up: true
|
||||
---
|
||||
# Pod network, VLAN 48. Node-to-node only, statically addressed, no gateway
|
||||
# and no DHCP — see k8s-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
|
||||
@@ -4,6 +4,8 @@ machine:
|
||||
sysctls:
|
||||
# v30iot NIC carries no host addressing — see k8s-1.yaml.
|
||||
net.ipv6.conf.ens19.disable_ipv6: "1"
|
||||
# radius0 (VLAN 11): link only — see k8s-1.yaml.
|
||||
net.ipv6.conf.radius0.disable_ipv6: "1"
|
||||
# Same for the physical ports on the untagged fallback network — see
|
||||
# k8s-1.yaml.
|
||||
net.ipv6.conf.uplink.disable_ipv6: "1"
|
||||
@@ -102,6 +104,14 @@ vlanID: 30
|
||||
parent: uplink
|
||||
up: true
|
||||
---
|
||||
# VLAN 11, "(11) RADIUS": link only, no host addressing — see k8s-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
kind: VLANConfig
|
||||
name: radius0
|
||||
vlanID: 11
|
||||
parent: uplink
|
||||
up: true
|
||||
---
|
||||
# Pod network, VLAN 48. Node-to-node only, statically addressed, no gateway
|
||||
# and no DHCP — see k8s-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
|
||||
@@ -68,13 +68,14 @@ locals {
|
||||
# idx is a static per-network slot for rule_index derivation — never
|
||||
# reuse a freed idx for a new network.
|
||||
#
|
||||
# Deliberately absent: (40) Server idx=4, (42) Ingress idx=5, and
|
||||
# (49) k8s Ceph idx=6. The cluster nodes have interfaces on those VLANs,
|
||||
# so a DNATed flow's reply would go directly over the shared L2 and
|
||||
# bypass the UDM's conntrack (asymmetric, connection drops) — and
|
||||
# clients there are the nodes themselves, whose DNS chain (CoreDNS ->
|
||||
# k8s_gateway) already resolves the public names in-cluster. Their idx
|
||||
# slots stay reserved.
|
||||
# Deliberately absent: (40) Server idx=4, (49) k8s Ceph idx=6,
|
||||
# (46) k8s Node, (47) k8s Ingress and (48) k8s Pod. The cluster nodes
|
||||
# have interfaces on those VLANs (mgmt0, node0, ingress0, pod0), so a
|
||||
# DNATed flow's reply would go directly over the shared L2 and bypass
|
||||
# the UDM's conntrack (asymmetric, connection drops) — and clients there
|
||||
# are the nodes themselves, whose DNS chain (CoreDNS -> k8s_gateway)
|
||||
# already resolves the public names in-cluster. idx=5 stays reserved
|
||||
# (its network, (42) Ingress, no longer exists).
|
||||
hairpin_networks = {
|
||||
"Default" = { id = "5d4fba77ce63ee614841d53d", idx = 0 }
|
||||
"(10) Management" = { id = "600334abf9ee0c059da6be8f", idx = 1 }
|
||||
|
||||
+30
-1
@@ -7,7 +7,8 @@
|
||||
"flux",
|
||||
"kubernetes",
|
||||
"woodpecker",
|
||||
"dockerfile"
|
||||
"dockerfile",
|
||||
"custom.regex"
|
||||
],
|
||||
"ignorePaths": [
|
||||
"kubernetes/clusters/homelab/flux-system/**",
|
||||
@@ -23,6 +24,20 @@
|
||||
"/^kubernetes/.+\\.ya?ml$/"
|
||||
]
|
||||
},
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Container images of the Ansible roles: `edge_<name>_image: <image>:<tag>@<digest>` in ansible/roles/*/defaults/main.yml. No other manager reads these files.",
|
||||
"managerFilePatterns": [
|
||||
"/^ansible/roles/[^/]+/defaults/main\\.ya?ml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"(?:^|\\n)edge_[a-z0-9_]+_image:[ \\t]*[\"']?(?<depName>[^\\s:@\"']+):(?<currentValue>[^\\s@\"']+)(?:@(?<currentDigest>sha256:[a-f0-9]{64}))?"
|
||||
],
|
||||
"datasourceTemplate": "docker",
|
||||
"versioningTemplate": "docker"
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Flux image automation (kubernetes/infrastructure/image-automation) already bumps these images. Renovate must not open a second, competing update. Keep this list equal to the ImageRepository images there.",
|
||||
@@ -134,6 +149,20 @@
|
||||
"groupName": "CI non-major",
|
||||
"groupSlug": "ci-minor-patch"
|
||||
},
|
||||
{
|
||||
"description": "Edge-host images (ansible/): no pipeline deploys them, the play runs from a workstation after the merge. One PR of its own, so the merge and the play run go together.",
|
||||
"matchFileNames": [
|
||||
"ansible/**"
|
||||
],
|
||||
"matchUpdateTypes": [
|
||||
"patch",
|
||||
"minor",
|
||||
"digest",
|
||||
"pin"
|
||||
],
|
||||
"groupName": "edge hosts (ansible)",
|
||||
"groupSlug": "edge-hosts-ansible"
|
||||
},
|
||||
{
|
||||
"description": "Stateful apps and the CNI: one PR each, never in a group (REN-315). A git revert does not undo a DB migration or a CNI change, so each needs its own review and, where it can disrupt the cluster, a window.",
|
||||
"matchUpdateTypes": [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user