Compare commits

...
Author SHA1 Message Date
k.renner b2b4cd4ab9 Merge pull request 'chore(edge): retire the open3e MQTT bridge from all edge hosts' (#1596) from chore/edge1-retire-mqtt-bridge into main
Reviewed-on: #1596
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-07 13:50:36 +00:00
claude-botandClaude Opus 5.5 6baf3139b9 chore(edge): retire the MQTT bridge from edge-2 too, drop the role
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
edge-2's bridge carried only open3e/# as well, with nothing publishing to it
(owner decision 2026-11-07). The mqtt-bridge role is gone entirely.

- delete the role mqtt-bridge and the play install-mqtt-bridge.yml with its
  site.yml import and the inventory group mqtt_bridge
- install-edge-host.yml pre_tasks remove the bridge from every edge host:
  stop and disable edge-mqtt-cred and mosquitto (if present), delete the unit,
  script, /etc/edge-mqtt-cred and /etc/mosquitto, uninstall mosquitto
- group_vars edge_host: drop the local MQTT contract (edge_mqtt_host/port,
  edge_open3e_mqtt_user/password) and the mosquitto/edge-mqtt-cred sudo units
- register: drop the account edge-2; controller egress: drop
  edge-2.iot.renner.uno:8443
- docs: drop the MQTT bridge section and the vault_edge_open3e_mqtt_password
  entry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:44:08 +01:00
claude-botandClaude Opus 5.5 ff6ad64783 chore(edge): retire the open3e MQTT bridge from edge-1
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
Nothing on edge-1 publishes to its loopback mosquitto any more: the CAN stack is
gone (#1595) and vitocal-can publishes open3e/# to the cluster broker itself.
The bridge carried only open3e/# out and open3e/cmnd in.

- mqtt-bridge leaves the edge base play; the new play install-mqtt-bridge.yml
  applies it to the inventory group mqtt_bridge (edge-2) and removes it from
  every other edge host: stop and disable edge-mqtt-cred and mosquitto, delete
  the unit, script, /etc/edge-mqtt-cred and /etc/mosquitto/bridge.d,
  uninstall mosquitto
- register: drop the account edge-1 (readwrite open3e/#)
- mqtt-account-controller-egress: drop edge-1.iot.renner.uno:8443
- docs: the bridge runs only on [mqtt_bridge] hosts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:40:59 +01:00
k.renner 07e1684bd1 Merge pull request 'chore(edge): retire the heating-can CAN stack from edge-1' (#1595) from chore/retire-heating-can into main
Reviewed-on: #1595
2026-11-07 13:01:27 +00:00
claude-botandClaude Opus 5.5 44cdf84f99 chore(edge): retire the heating-can CAN stack from edge-1
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The Viessmann E3 CAN bus is read by vitocal-can, an ESP32 gateway (open3e-esp32)
at the Vitocal's external CAN port, which publishes open3e/# directly to the
cluster broker (S28-28). edge-1 must no longer read the bus: a second UDS
client on the bus disturbs the gateway's requests.

- remove the roles heating-can and socketcan, the play
  install-heating-can.yml, its site.yml import and the inventory group
  heating_can; edge-1 stays in edge_host through zwave_gateway
- install-edge-host.yml pre_tasks: compose down (containers and image) and
  remove /opt/edge-host/heating-can, remove 80-can0.network and reload
  networkd, take can0 down; each step is a no-op where it is already absent
- group_vars edge_host: drop edge_open3e_mqtt_topic, used only by heating-can
- docs: edge-host.md and the ISO README no longer list the CAN stack

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:59:22 +01:00
k.renner ca20206bbf Merge pull request 'fix(time-sync): keep the internet VPS hosts on NTP' (#1592) from fix/vps-time-sync into main
Reviewed-on: #1592
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-07 12:33:03 +00:00
k.renner 293f11612c Merge pull request 'fix(home-assistant): allow MQTTS to mosquitto from the private LAN' (#1594) from fix/mosquitto-privat-mqtts into main
Reviewed-on: #1594
2026-11-07 12:32:47 +00:00
claude-botandClaude Opus 5.5 7602c755cf fix(home-assistant): allow MQTTS to mosquitto from the private LAN
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Restricting mosquitto to net-iot (#1586) cut off MQTT clients on the
private LAN; policy drops show one on 8883. TLS port only, plaintext
1883 stays IoT-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:31:57 +01:00
k.renner faff22bebe Merge pull request 'feat(mqtt): register vitocal-can, the CAN gateway at the Vitocal' (#1593) from feat/vitocal-can-mqtt-account into main
Reviewed-on: #1593
2026-11-07 12:16:07 +00:00
claude-botandClaude Opus 5.5 7459aa9e59 feat(mqtt): register vitocal-can, the CAN gateway at the Vitocal
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
vitocal-can is a Waveshare ESP32-S3-RS485-CAN running boonkerz/open3e-esp32 on the
Vitocal 250-A's external CAN port. It takes over the open3e/# topics from
the heating-can stack on edge-1 (S28-28).

The controller pushes the password through the firmware's PUT /api/settings
over plain HTTP. The firmware sends its compiled-in DHCP hostname, so the
device is reachable as open3e.iot.renner.uno regardless of its configured
hostname. The controller egress gains that FQDN on port 80.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 12:52:31 +01:00
claude-botandClaude Opus 5.5 41efcd9f91 fix(time-sync): keep the internet VPS hosts on NTP
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
common and plays/configure-vm-time-sync.yml masked systemd-timesyncd on
every KVM guest, assuming a Proxmox host supplies the time. The Proxmox
VMs are gone; the only KVM guests left are the Hetzner/netcup VPS hosts,
which have no such host. netbird.renner.uno ran with no NTP client since
provisioning, drifted behind authentik, and NetBird management rejected
SSO tokens with "token used before issued".

Drop both masking paths and add a timesyncd role to the netbird server
and proxy plays; it unmasks, enables and starts systemd-timesyncd and
waits for NTPSynchronized=yes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 12:43:00 +01:00
k.renner 30704634b3 Merge pull request 'feat(cilium): export policy drops to the agent log' (#1591) from feat/hubble-policy-drop-log into main
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Reviewed-on: #1591
2026-11-07 10:31:25 +00:00
k.renner 43861d34c1 Merge pull request 'feat(coredns): restrict kube-dns-lb ingress to the internal networks' (#1590) from feat/kube-dns-lb-source-cidr into main
Reviewed-on: #1590
2026-11-07 10:31:17 +00:00
k.renner 3252a6f9ee Merge pull request 'feat(paperless): restrict FTP and Syncthing LB ingress to their client networks' (#1589) from feat/paperless-source-cidr into main
Reviewed-on: #1589
2026-11-07 10:31:10 +00:00
k.renner cf80a096a6 Merge pull request 'feat(home-assistant): restrict LB ingress to the client networks' (#1588) from feat/home-assistant-source-cidr into main
Reviewed-on: #1588
2026-11-07 10:31:00 +00:00
k.renner 41e272f188 Merge pull request 'feat(shelly-manager): restrict firmware LB ingress to the IoT network' (#1587) from feat/shelly-firmware-source-cidr into main
Reviewed-on: #1587
2026-11-07 10:30:52 +00:00
k.renner 8dc0986d1d Merge pull request 'feat(mosquitto): restrict LB ingress to the IoT network' (#1586) from feat/mosquitto-source-cidr into main
Reviewed-on: #1586
2026-11-07 10:30:44 +00:00
k.renner edb4aa804c Merge pull request 'feat(monitoring): restrict syslog ingress to the sending networks' (#1585) from feat/syslog-source-cidr into main
Reviewed-on: #1585
2026-11-07 10:30:32 +00:00
claude-botandClaude Opus 5.5 9777e147e8 feat(cilium): export policy drops to the agent log
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Hubble's static exporter writes every POLICY_DENIED drop event to the
cilium-agent stdout, which Alloy already ships to Loki (1 year
retention). That makes denied traffic reviewable after the fact instead
of only for the minutes the Hubble ring buffer holds.

Filter and field mask were checked against the running 1.20.4 agent
with hubble observe --allowlist / --field-mask. The cilium-config change
rolls the agents one at a time via the reloader annotation.

Refs S28-30

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 11:24:23 +01:00
claude-botandClaude Opus 5.5 37dc17065d feat(coredns): restrict kube-dns-lb ingress to the internal networks
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
CoreDNS had no policy, so kube-dns-lb answered any source. The new CNP
allows the whole cluster on every port and the internal client networks
on 53/UDP+TCP. Selecting the pods makes them default-deny for ingress.

Refs S28-19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 11:19:37 +01:00
claude-botandClaude Opus 5.5 570c93559b feat(paperless): restrict FTP and Syncthing LB ingress to their client networks
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ftp-scanner and syncthing-sync accepted any world source.

- ftp-scanner: IoT and Privat. Over the last 7 days the vsftpd log shows
  uploads from the BR5C scanner via fd02:30:: and the Brother via
  10.2.50.170.
- syncthing-sync: Privat and VPN. Over the last 7 days peers connected
  from 10.2.50.0/24 and fd02:50::/64.

host and remote-node stay allowed. Both LBs are ETP=Local.

Refs S28-19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 11:18:11 +01:00
claude-botandClaude Opus 5.5 71ab916ff7 feat(home-assistant): restrict LB ingress to the client networks
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
home-assistant-bgp (:8123) accepted any world source. Allowed now:
Privat, VPN, IoT and Server, per the approved source matrix. Browser and
app access from outside goes through Traefik and is unaffected. The LB is
ETP=Local, so the client address reaches the pod.

Refs S28-19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 11:17:06 +01:00
claude-botandClaude Opus 5.5 9aecaacbb2 feat(shelly-manager): restrict firmware LB ingress to the IoT network
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
shelly-manager-firmware (:8082, /fw/ only) accepted any world source.
Shelly devices live on IoT; the LB is ETP=Local, so the client address
reaches the pod.

Refs S28-19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 11:16:37 +01:00
claude-botandClaude Opus 5.5 744ea79933 feat(mosquitto): restrict LB ingress to the IoT network
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
mosquitto-iot accepted MQTT from any world source. Over the last 7 days
the mosquitto log shows client connections from 10.2.30.0/24 only. The
LB is ETP=Local, so the client address reaches the pod.

Refs S28-19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 11:16:07 +01:00
39 changed files with 255 additions and 1065 deletions
+10 -80
View File
@@ -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,10 +23,6 @@ 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
@@ -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)
@@ -141,17 +126,6 @@ Afterwards:
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).
- 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.
## Logging & eMMC
@@ -215,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
+3 -8
View File
@@ -18,8 +18,8 @@ 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
@@ -37,17 +37,12 @@ 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: 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
-70
View File
@@ -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
+85 -2
View File
@@ -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,6 +30,91 @@
# 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
@@ -38,5 +122,4 @@
- edge-flash-wear
- docker-install
- edge-log-shipper
- mqtt-bridge
- edge-vlan
-18
View File
@@ -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
+1
View File
@@ -22,6 +22,7 @@
become: true
roles:
- common
- timesyncd
- docker-install
- netbird-proxy
- netbird-proxy-watchdog
+1
View File
@@ -16,5 +16,6 @@
become: true
roles:
- common
- timesyncd
- docker-install
- netbird-server
+2 -11
View File
@@ -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
@@ -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
-172
View File
@@ -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: "{{ inventory_hostname_short }}"
# 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
-109
View File
@@ -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 {{ edge_mqtt_bridge_account | replace('-', '') }}-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
@@ -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
-20
View File
@@ -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.
+26
View File
@@ -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
-4
View File
@@ -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 -1
View File
@@ -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.
@@ -49,22 +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).
acl: ["readwrite open3e/#"]
# edge-2's local mosquitto bridge, same role and pinning as edge-1.
- name: edge-2
auth: password
deliver: {esp32: "https://edge-2.iot.renner.uno:8443"}
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> =
@@ -156,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:
@@ -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,12 +524,12 @@ spec:
rules:
dns:
- matchPattern: "*"
# vitocal-can (open3e-esp32), plain HTTP API.
- toFQDNs:
- matchName: "edge-1.iot.renner.uno"
- matchName: "edge-2.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
@@ -41,3 +41,4 @@ resources:
- allow-cilium-health.yaml
- cnpg-barman-s3.yaml
- kube-system-hubble-relay.yaml
- kube-system-coredns.yaml
@@ -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: