From ff6ad64783b30c0ff58e235333117b8e84bcee6b Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Sat, 7 Nov 2026 14:40:59 +0100 Subject: [PATCH 1/2] chore(edge): retire the open3e MQTT bridge from edge-1 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 --- ansible/docs/edge-host.md | 11 ++-- ansible/inventory/pve_hosts.yml | 10 +++- ansible/plays/install-edge-host.yml | 2 +- ansible/plays/install-mqtt-bridge.yml | 54 +++++++++++++++++++ ansible/site.yml | 2 + .../apps/home-assistant/mqtt-accounts.yaml | 18 ++----- .../network-policies/home-assistant-ns.yaml | 1 - 7 files changed, 78 insertions(+), 20 deletions(-) create mode 100644 ansible/plays/install-mqtt-bridge.yml diff --git a/ansible/docs/edge-host.md b/ansible/docs/edge-host.md index 41f532cb..ddf82adf 100644 --- a/ansible/docs/edge-host.md +++ b/ansible/docs/edge-host.md @@ -61,7 +61,7 @@ inventory change. | 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. It depends on -the base play having run: it needs the local MQTT bridge and docker. +the base play having run: it needs docker. A host with nothing attached is simply absent from these groups and needs none of these commands. @@ -69,8 +69,8 @@ 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 +cluster broker has no `open3e` user: a bridge host logs in only as its +register account (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: @@ -196,6 +196,11 @@ journal commit). ## MQTT bridge (TLS to the cluster broker) +Runs only on hosts in `[mqtt_bridge]` (`plays/install-mqtt-bridge.yml`, today +edge-2); that play removes it from every other edge host. edge-1 has none: +the Viessmann CAN bus is read by vitocal-can, which publishes to the cluster +broker itself. + 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** diff --git a/ansible/inventory/pve_hosts.yml b/ansible/inventory/pve_hosts.yml index 4be76925..0b3a6666 100644 --- a/ansible/inventory/pve_hosts.yml +++ b/ansible/inventory/pve_hosts.yml @@ -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 @@ -42,8 +42,14 @@ nas-2.renner.uno ansible_user=root [zwave_gateway] edge-1.iot.renner.uno +# Edge hosts that run the open3e MQTT bridge (plays/install-mqtt-bridge.yml). +# The play removes the bridge from every other edge host. +[mqtt_bridge] +edge-2.iot.renner.uno + [edge_host:children] zwave_gateway +mqtt_bridge # 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 diff --git a/ansible/plays/install-edge-host.yml b/ansible/plays/install-edge-host.yml index ebb12f14..6bfcaeb6 100644 --- a/ansible/plays/install-edge-host.yml +++ b/ansible/plays/install-edge-host.yml @@ -8,6 +8,7 @@ # their own inventory groups: # # plays/install-zwave-gateway.yml [zwave_gateway] ZWA-2 radio +# plays/install-mqtt-bridge.yml [mqtt_bridge] open3e MQTT bridge # # 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. @@ -86,5 +87,4 @@ - edge-flash-wear - docker-install - edge-log-shipper - - mqtt-bridge - edge-vlan diff --git a/ansible/plays/install-mqtt-bridge.yml b/ansible/plays/install-mqtt-bridge.yml new file mode 100644 index 00000000..95fe8708 --- /dev/null +++ b/ansible/plays/install-mqtt-bridge.yml @@ -0,0 +1,54 @@ +--- +# Local MQTT bridge broker (roles/mqtt-bridge): a loopback mosquitto that +# bridges open3e/# to the cluster broker over TLS, with edge-mqtt-cred +# receiving its password from mqtt-account-controller. +# +# Targets [mqtt_bridge]. Edge hosts outside that group have the bridge +# removed: the first play below stops and deletes it, a no-op where it is +# absent. + +- name: Remove the MQTT bridge from edge hosts outside [mqtt_bridge] + hosts: edge_host:!mqtt_bridge + become: true + + tasks: + - name: Read the service list + ansible.builtin.service_facts: + + - name: Stop and disable the 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 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/bridge.d + 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: Install the MQTT bridge + hosts: mqtt_bridge + become: true + + roles: + - mqtt-bridge diff --git a/ansible/site.yml b/ansible/site.yml index 6fedda0e..316d0c4d 100644 --- a/ansible/site.yml +++ b/ansible/site.yml @@ -29,6 +29,8 @@ - name: Edge host base ansible.builtin.import_playbook: plays/install-edge-host.yml +- name: MQTT bridge + ansible.builtin.import_playbook: plays/install-mqtt-bridge.yml - name: Z-Wave gateway ansible.builtin.import_playbook: plays/install-zwave-gateway.yml - name: NTP service diff --git a/kubernetes/apps/home-assistant/mqtt-accounts.yaml b/kubernetes/apps/home-assistant/mqtt-accounts.yaml index b9192166..6c46d4f1 100644 --- a/kubernetes/apps/home-assistant/mqtt-accounts.yaml +++ b/kubernetes/apps/home-assistant/mqtt-accounts.yaml @@ -49,19 +49,11 @@ 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 - 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. +# edge-2's local mosquitto bridge (REN-1307, ansible play +# install-mqtt-bridge.yml). The controller pushes the password to +# edge-mqtt-cred on edge-2 over HTTPS with its own client certificate, and +# pins edge-2's self-generated server certificate on first contact (key +# edge-2.pin in mqtt-account-hashes; delete it after a reinstall of edge-2). - name: edge-2 auth: password deliver: {esp32: "https://edge-2.iot.renner.uno:8443"} diff --git a/kubernetes/infrastructure/network-policies/home-assistant-ns.yaml b/kubernetes/infrastructure/network-policies/home-assistant-ns.yaml index 66492ee5..0d71aea2 100644 --- a/kubernetes/infrastructure/network-policies/home-assistant-ns.yaml +++ b/kubernetes/infrastructure/network-policies/home-assistant-ns.yaml @@ -525,7 +525,6 @@ spec: dns: - matchPattern: "*" - toFQDNs: - - matchName: "edge-1.iot.renner.uno" - matchName: "edge-2.iot.renner.uno" toPorts: - ports: -- 2.54.0 From 6baf3139b9f00df2fdd1a083e6b4a3ebada1d8d9 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Sat, 7 Nov 2026 14:44:08 +0100 Subject: [PATCH 2/2] chore(edge): retire the MQTT bridge from edge-2 too, drop the role 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 --- ansible/docs/edge-host.md | 56 +------ .../inventory/group_vars/edge_host/vars.yml | 11 -- ansible/inventory/pve_hosts.yml | 9 +- ansible/plays/install-edge-host.yml | 41 ++++- ansible/plays/install-mqtt-bridge.yml | 54 ------- ansible/roles/mqtt-bridge/defaults/main.yml | 23 --- .../roles/mqtt-bridge/files/edge-mqtt-cred.py | 141 ------------------ ansible/roles/mqtt-bridge/files/mqtt-ca.crt | 10 -- ansible/roles/mqtt-bridge/handlers/main.yml | 13 -- ansible/roles/mqtt-bridge/tasks/main.yml | 109 -------------- .../templates/edge-1-bridge.conf.in.j2 | 21 --- .../templates/edge-mqtt-bridge-legacy.conf.j2 | 21 --- .../templates/edge-mqtt-bridge.conf.j2 | 30 ---- .../templates/edge-mqtt-cred.json.j2 | 13 -- .../templates/edge-mqtt-cred.service.j2 | 19 --- ansible/site.yml | 2 - .../apps/home-assistant/mqtt-accounts.yaml | 9 -- .../network-policies/home-assistant-ns.yaml | 6 - 18 files changed, 40 insertions(+), 548 deletions(-) delete mode 100644 ansible/plays/install-mqtt-bridge.yml delete mode 100644 ansible/roles/mqtt-bridge/defaults/main.yml delete mode 100644 ansible/roles/mqtt-bridge/files/edge-mqtt-cred.py delete mode 100644 ansible/roles/mqtt-bridge/files/mqtt-ca.crt delete mode 100644 ansible/roles/mqtt-bridge/handlers/main.yml delete mode 100644 ansible/roles/mqtt-bridge/tasks/main.yml delete mode 100644 ansible/roles/mqtt-bridge/templates/edge-1-bridge.conf.in.j2 delete mode 100644 ansible/roles/mqtt-bridge/templates/edge-mqtt-bridge-legacy.conf.j2 delete mode 100644 ansible/roles/mqtt-bridge/templates/edge-mqtt-bridge.conf.j2 delete mode 100644 ansible/roles/mqtt-bridge/templates/edge-mqtt-cred.json.j2 delete mode 100644 ansible/roles/mqtt-bridge/templates/edge-mqtt-cred.service.j2 diff --git a/ansible/docs/edge-host.md b/ansible/docs/edge-host.md index ddf82adf..ecf8784f 100644 --- a/ansible/docs/edge-host.md +++ b/ansible/docs/edge-host.md @@ -69,11 +69,7 @@ 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: a bridge host logs in only as its -register account (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 @@ -82,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") ``` @@ -194,52 +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) - -Runs only on hosts in `[mqtt_bridge]` (`plays/install-mqtt-bridge.yml`, today -edge-2); that play removes it from every other edge host. edge-1 has none: -the Viessmann CAN bus is read by vitocal-can, which publishes to the cluster -broker itself. - -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`. diff --git a/ansible/inventory/group_vars/edge_host/vars.yml b/ansible/inventory/group_vars/edge_host/vars.yml index 0a3d375e..2905794c 100644 --- a/ansible/inventory/group_vars/edge_host/vars.yml +++ b/ansible/inventory/group_vars/edge_host/vars.yml @@ -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,18 +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, and its legacy -# bridge authenticates to the cluster broker as the open3e user. -edge_mqtt_host: 127.0.0.1 -edge_mqtt_port: 1883 -edge_open3e_mqtt_user: 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 diff --git a/ansible/inventory/pve_hosts.yml b/ansible/inventory/pve_hosts.yml index 0b3a6666..88ffe91f 100644 --- a/ansible/inventory/pve_hosts.yml +++ b/ansible/inventory/pve_hosts.yml @@ -37,19 +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 -# Edge hosts that run the open3e MQTT bridge (plays/install-mqtt-bridge.yml). -# The play removes the bridge from every other edge host. -[mqtt_bridge] -edge-2.iot.renner.uno - [edge_host:children] zwave_gateway -mqtt_bridge # 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 diff --git a/ansible/plays/install-edge-host.yml b/ansible/plays/install-edge-host.yml index 6bfcaeb6..f33139d6 100644 --- a/ansible/plays/install-edge-host.yml +++ b/ansible/plays/install-edge-host.yml @@ -8,7 +8,6 @@ # their own inventory groups: # # plays/install-zwave-gateway.yml [zwave_gateway] ZWA-2 radio -# plays/install-mqtt-bridge.yml [mqtt_bridge] open3e MQTT bridge # # 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. @@ -32,9 +31,45 @@ become: true # The Viessmann E3 CAN bus is read by vitocal-can (open3e-esp32 at the - # Vitocal), not by an edge host. These remove the former heating-can stack - # and the can0 configuration; each is a no-op where they are absent. + # 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 diff --git a/ansible/plays/install-mqtt-bridge.yml b/ansible/plays/install-mqtt-bridge.yml deleted file mode 100644 index 95fe8708..00000000 --- a/ansible/plays/install-mqtt-bridge.yml +++ /dev/null @@ -1,54 +0,0 @@ ---- -# Local MQTT bridge broker (roles/mqtt-bridge): a loopback mosquitto that -# bridges open3e/# to the cluster broker over TLS, with edge-mqtt-cred -# receiving its password from mqtt-account-controller. -# -# Targets [mqtt_bridge]. Edge hosts outside that group have the bridge -# removed: the first play below stops and deletes it, a no-op where it is -# absent. - -- name: Remove the MQTT bridge from edge hosts outside [mqtt_bridge] - hosts: edge_host:!mqtt_bridge - become: true - - tasks: - - name: Read the service list - ansible.builtin.service_facts: - - - name: Stop and disable the 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 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/bridge.d - 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: Install the MQTT bridge - hosts: mqtt_bridge - become: true - - roles: - - mqtt-bridge diff --git a/ansible/roles/mqtt-bridge/defaults/main.yml b/ansible/roles/mqtt-bridge/defaults/main.yml deleted file mode 100644 index bc809227..00000000 --- a/ansible/roles/mqtt-bridge/defaults/main.yml +++ /dev/null @@ -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 diff --git a/ansible/roles/mqtt-bridge/files/edge-mqtt-cred.py b/ansible/roles/mqtt-bridge/files/edge-mqtt-cred.py deleted file mode 100644 index b24bbf4a..00000000 --- a/ansible/roles/mqtt-bridge/files/edge-mqtt-cred.py +++ /dev/null @@ -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() diff --git a/ansible/roles/mqtt-bridge/files/mqtt-ca.crt b/ansible/roles/mqtt-bridge/files/mqtt-ca.crt deleted file mode 100644 index 3a44943a..00000000 --- a/ansible/roles/mqtt-bridge/files/mqtt-ca.crt +++ /dev/null @@ -1,10 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIBdTCCARygAwIBAgIUN8ne4k4Wif9O83GDyKiBP0Ma7EIwCgYIKoZIzj0EAwIw -GTEXMBUGA1UEAxMOUmVubmVyIE1RVFQgQ0EwHhcNMjYxMDA2MTUzMDE1WhcNMzYx -MDAzMTUzMDE1WjAZMRcwFQYDVQQDEw5SZW5uZXIgTVFUVCBDQTBZMBMGByqGSM49 -AgEGCCqGSM49AwEHA0IABOZhZF2JDoyvVg2oIXSbJAuAWDcoG22RxPA4oMKRMA/b -XzIY566D2k6cVnWqbil9s/q4O2mj+Epo7plBw8NwuOOjQjBAMA4GA1UdDwEB/wQE -AwICpDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQcqSDDiLZzhrvqq9f16Y08 -CO7QfzAKBggqhkjOPQQDAgNHADBEAiBFkxd9pXnqIcjReEcOTvCBZ3HxKu2jNQGL -/le7gunTGgIgZapWAcu4awa+wmZcR/z2luOjD2GhIT15um14Z+sstJk= ------END CERTIFICATE----- diff --git a/ansible/roles/mqtt-bridge/handlers/main.yml b/ansible/roles/mqtt-bridge/handlers/main.yml deleted file mode 100644 index 60f616a0..00000000 --- a/ansible/roles/mqtt-bridge/handlers/main.yml +++ /dev/null @@ -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 diff --git a/ansible/roles/mqtt-bridge/tasks/main.yml b/ansible/roles/mqtt-bridge/tasks/main.yml deleted file mode 100644 index 5a848e6e..00000000 --- a/ansible/roles/mqtt-bridge/tasks/main.yml +++ /dev/null @@ -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 diff --git a/ansible/roles/mqtt-bridge/templates/edge-1-bridge.conf.in.j2 b/ansible/roles/mqtt-bridge/templates/edge-1-bridge.conf.in.j2 deleted file mode 100644 index 6967adbc..00000000 --- a/ansible/roles/mqtt-bridge/templates/edge-1-bridge.conf.in.j2 +++ /dev/null @@ -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 diff --git a/ansible/roles/mqtt-bridge/templates/edge-mqtt-bridge-legacy.conf.j2 b/ansible/roles/mqtt-bridge/templates/edge-mqtt-bridge-legacy.conf.j2 deleted file mode 100644 index 730220b5..00000000 --- a/ansible/roles/mqtt-bridge/templates/edge-mqtt-bridge-legacy.conf.j2 +++ /dev/null @@ -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 diff --git a/ansible/roles/mqtt-bridge/templates/edge-mqtt-bridge.conf.j2 b/ansible/roles/mqtt-bridge/templates/edge-mqtt-bridge.conf.j2 deleted file mode 100644 index ef9cfa64..00000000 --- a/ansible/roles/mqtt-bridge/templates/edge-mqtt-bridge.conf.j2 +++ /dev/null @@ -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 diff --git a/ansible/roles/mqtt-bridge/templates/edge-mqtt-cred.json.j2 b/ansible/roles/mqtt-bridge/templates/edge-mqtt-cred.json.j2 deleted file mode 100644 index c6c2e0f7..00000000 --- a/ansible/roles/mqtt-bridge/templates/edge-mqtt-cred.json.j2 +++ /dev/null @@ -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 }} diff --git a/ansible/roles/mqtt-bridge/templates/edge-mqtt-cred.service.j2 b/ansible/roles/mqtt-bridge/templates/edge-mqtt-cred.service.j2 deleted file mode 100644 index 392c202f..00000000 --- a/ansible/roles/mqtt-bridge/templates/edge-mqtt-cred.service.j2 +++ /dev/null @@ -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 diff --git a/ansible/site.yml b/ansible/site.yml index 316d0c4d..6fedda0e 100644 --- a/ansible/site.yml +++ b/ansible/site.yml @@ -29,8 +29,6 @@ - name: Edge host base ansible.builtin.import_playbook: plays/install-edge-host.yml -- name: MQTT bridge - ansible.builtin.import_playbook: plays/install-mqtt-bridge.yml - name: Z-Wave gateway ansible.builtin.import_playbook: plays/install-zwave-gateway.yml - name: NTP service diff --git a/kubernetes/apps/home-assistant/mqtt-accounts.yaml b/kubernetes/apps/home-assistant/mqtt-accounts.yaml index 6c46d4f1..9f2383f4 100644 --- a/kubernetes/apps/home-assistant/mqtt-accounts.yaml +++ b/kubernetes/apps/home-assistant/mqtt-accounts.yaml @@ -49,15 +49,6 @@ auth: cert deliver: {namespace: home-assistant} acl: ["readwrite #"] -# edge-2's local mosquitto bridge (REN-1307, ansible play -# install-mqtt-bridge.yml). The controller pushes the password to -# edge-mqtt-cred on edge-2 over HTTPS with its own client certificate, and -# pins edge-2's self-generated server certificate on first contact (key -# edge-2.pin in mqtt-account-hashes; delete it after a reinstall of edge-2). -- name: edge-2 - auth: password - deliver: {esp32: "https://edge-2.iot.renner.uno:8443"} - acl: ["readwrite open3e/#"] # 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. diff --git a/kubernetes/infrastructure/network-policies/home-assistant-ns.yaml b/kubernetes/infrastructure/network-policies/home-assistant-ns.yaml index 0d71aea2..e40dbc08 100644 --- a/kubernetes/infrastructure/network-policies/home-assistant-ns.yaml +++ b/kubernetes/infrastructure/network-policies/home-assistant-ns.yaml @@ -524,12 +524,6 @@ spec: rules: dns: - matchPattern: "*" - - toFQDNs: - - matchName: "edge-2.iot.renner.uno" - toPorts: - - ports: - - port: "8443" - protocol: TCP # vitocal-can (open3e-esp32), plain HTTP API. - toFQDNs: - matchName: "open3e.iot.renner.uno" -- 2.54.0