Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5.5 4589ef3747 fix(monitoring): turn off Alloy and InfluxDB usage telemetry
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Both phone home on a timer: every alloy pod to stats.grafana.org (~135
a day each) and influxd to telemetry.influxdata.com (56 a day). Egress
policy denies it, so this only removed log noise and wasted SYNs; switch
it off at the source like Loki's reporting_enabled: false.
--disable-reporting per the "alloy run" reference (chart alloy.extraArgs);
INFLUXD_REPORTING_DISABLED per the InfluxDB v2 config options.

S28-41

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 22:10:52 +01:00
k.renner 979e3910ca Merge pull request 'fix(authentik): admit the UDM's LDAPS sync to the LDAP outpost' (#1600) from fix/ldap-outpost-udm-ldaps into main
Reviewed-on: #1600
2026-11-07 21:03:33 +00:00
claude-botandClaude Opus 5.5 6a0dbf9e26 fix(authentik): admit only the UDM's own address, not all of k8s-ingress
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
New CIDR group udm-k8s-ingress (10.2.47.1/32, fd02:47::1/128); the LDAPS
rule references it instead of the whole net-k8s-ingress network.

S28-39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 22:02:30 +01:00
claude-botandClaude Opus 5.5 e25f1a43f8 fix(authentik): admit the UDM's LDAPS sync to the LDAP outpost
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
The UDM authenticates admins against ldap.renner.uno:636 and syncs
hourly. It sources from its k8s-ingress address (10.2.47.1 /
fd02:47::1), which ldap-outpost-lan-ingress did not admit: 200 SYNs a
day dropped at :26 every hour. Same group the alloy-syslog and CoreDNS
policies use for the UDM; LDAPS only.

S28-39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 22:00:21 +01:00
k.renner a4e5f99ca1 Merge pull request 'docs(trmnl): note the expected Google drops from the renderer's Chromium' (#1599) from docs/trmnl-renderer-google-drops into main
Reviewed-on: #1599
2026-11-07 20:02:17 +00:00
claude-botandClaude Opus 5.5 17839eeeab docs(trmnl): note the expected Google drops from the renderer's Chromium
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
About 43k POLICY_DENIED a day from trmnl-renderer to Google update and
account hosts. Source (OCI label): usetrmnl/trmnl-home-assistant dd556e6;
the Chromium flags are a fixed list in screenshot.ts:78 with no env or
option override, and --disable-background-networking is not among them.
Nothing on our side can switch it off; the denies are correct.

S28-4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 21:00:44 +01:00
k.renner f6fc79fce9 Merge pull request 'fix(mdns-relay): turn off RA on net1 so the IPv6 forward uses eth0' (#1598) from fix/mdns-relay-no-ra into main
Reviewed-on: #1598
2026-11-07 19:43:47 +00:00
claude-botandClaude Opus 5.5 583fc044cb fix(mdns-relay): turn off RA on net1 so the IPv6 forward uses eth0
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
sbr confines only the DHCP (IPv4) routes. net1 still takes a SLAAC address
and an RA default route, so socat-ipv6 sent every relayed datagram out the
IoT VLAN with source fd02:30::…; it came back through the UDM and HA's
CNP dropped it (37k POLICY_DENIED to home-assistant:5353 in 24 h). No IPv6
mDNS announcement reached HA. Same tuning as shelly-manager-iot. The
annotation bump re-attaches net1 with the new NAD.

S28-36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:41:46 +01:00
k.renner f7deb40a2b Merge pull request 'feat(mqtt): register shelly-41 and seven Gen2 Shellies without an account' (#1597) from feat/mqtt-shelly-41 into main
Reviewed-on: #1597
2026-11-07 19:29:05 +00:00
claude-botandClaude Opus 5.5 b3bf09ebc3 feat(mqtt): register shelly-40, the Flood G4
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Battery device, entered like the sleeping H&Ts.

S28-35

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:28:18 +01:00
claude-botandClaude Opus 5.5 5afb29dcb2 feat(mqtt): register the seven Gen2 Shellies refused as not authorised
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
Devices 7, 11, 17, 18, 20, 23 and 28 have MQTT on and reconnect about
once a minute, each refused for lack of an account. Each reboots once
when its password is pushed.

S28-35

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:25:25 +01:00
claude-botandClaude Opus 5.5 e4b0b13c3e feat(mqtt): register shelly-41, the Gen1 plug in the laundry room
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
shelly-manager has MQTT enabled on the device (gen1 mqtt.enable,
mqtt.server mqtt.renner.uno:1883) and the plug reaches the broker, but it
is refused as not authorised: it has no account. With MQTT on, the
controller's push lands and the device reports connected, so it does not
loop on ROTATE_GRACE.

S28-35

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:23:41 +01:00
security-bot 00e443c397 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-11-07-1918
Updates managed by image-automation-controller.
2026-11-07 19:21:33 +00:00
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
claude-botandClaude Opus 5.5 c4f32c7a97 feat(monitoring): restrict syslog ingress to the sending networks
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
alloy-syslog accepted syslog from any world source. Senders seen in
Hubble and Loki over the last 7 days: switches and APs on Management,
SLZB coordinators and edge hosts on IoT, and the UDM, which sources from
its k8s-ingress address 10.2.47.1. The LB is ETP=Local, so those client
addresses reach the pod.

Refs S28-19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 11:14:54 +01:00
k.renner 0ab63f46d4 Merge pull request 'feat(trmnl): restrict device HTTP ingress to the IoT network' (#1584) from feat/trmnl-device-cidr into main
Reviewed-on: #1584
2026-11-07 07:00:20 +00:00
claude-botandClaude Opus 5.5 cbc538ec7d feat(trmnl): restrict device HTTP ingress to the IoT network
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
allow-device-http admitted world on 2345/TCP. The TRMNL panel lives on
IoT (terminus log: GET /api/display from 10.2.30.244 every ~5 min) and
terminus-device is ETP=Local, so the client address reaches the pod.
Source is now net-iot; host/remote-node stay for probes.

Depends on #1583 (CiliumCIDRGroup net-iot).

Refs S28-19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:54:41 +01:00
k.renner c541190e8e Merge pull request 'feat(network-policies): CiliumCIDRGroup per client network' (#1583) from feat/cidr-groups-per-vlan into main
Reviewed-on: #1583
2026-11-06 14:47:46 +00:00
claude-botandClaude Opus 5.5 6de87eba4c feat(network-policies): CiliumCIDRGroup per client network
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
One group per UDM client network (ULA + IPv4; ISP-delegated GUA
prefixes are not stable and are left out), so LoadBalancer CNPs can
restrict sources via fromCIDRSet.cidrGroupRef instead of repeating
subnets. Adds no policy; no traffic changes.

Refs S28-19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:45:15 +01:00
k.renner 2beb9a4e14 Merge pull request 'docs(inventory): ntp_cluster has two members, edge-1 and edge-2' (#1582) from docs/ntp-cluster-edge2 into main
Reviewed-on: #1582
2026-11-06 13:51:17 +00:00
k.renner 767b3cdac4 Merge pull request 'docs(hairpin): list k8s VLANs 46/47/48 as deliberately absent' (#1581) from docs/hairpin-absent-vlans into main
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
Reviewed-on: #1581
2026-11-06 13:50:58 +00:00
claude-botandClaude Opus 5.5 ccb69bb109 docs(inventory): ntp_cluster has two members, edge-1 and edge-2
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
edge-2 is in [edge_host] and in the ntp_servers map of install-ntp-ha.yml
(BACKUP, prio 60) and answers NTP (stratum 2) on 10.2.30.184. The comment
still called edge-1 the only member.

Refs S28-14

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:46:43 +01:00
claude-botandClaude Opus 5.5 69f7075c7c docs(hairpin): list k8s VLANs 46/47/48 as deliberately absent
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The nodes hold addresses on 46 (node0), 47 (ingress0) and 48 (pod0),
the same asymmetric-reply reason that excludes 40 and 49. (42) Ingress
no longer exists; its idx slot stays reserved.

Refs S28-13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:38:37 +01:00
k.renner 1b06a922a9 Merge pull request 'fix(pacman-cache): empty the pacman package cache before the edge image pulls' (#1580) from fix/common-pacman-cache into main
Reviewed-on: #1580
2026-11-06 13:01:23 +00:00
claude-botandClaude Opus 5.5 d457c7e639 refactor(pacman-cache): own role instead of common
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The pacman cache policy is its own concern, not base configuration of
every host. The new role pacman-cache runs in install-edge-host after
common and before docker-install; common is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:45:30 +01:00
claude-botandClaude Opus 5.5 a29e10e313 fix(common): empty the pacman package cache in the base play
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The cache cleanup from #1579 sat in os-update, which site.yml does not
run, so site.yml still failed on edge-1 with "no space left on device"
when pulling the Alloy image. The hook and the cleanup move to common,
which site.yml runs on every host before install-edge-host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:26:34 +01:00
k.renner 15d98d2702 Merge pull request 'fix(os-update): keep the pacman package cache empty' (#1579) from fix/edge-pacman-cache into main
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Reviewed-on: #1579
2026-11-06 12:03:23 +00:00
claude-botandClaude Opus 5.5 4b7960c18c fix(os-update): keep the pacman package cache empty
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
pacman keeps every package an upgrade downloads. On edge-1's 6.6 GB eMMC
root that was 1.7 GB, leaving 197 MB free. The role now installs a pacman
hook that empties the cache after every transaction, and empties it
before the 1 GiB free-space check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:58:26 +01:00
claude-botandClaude Opus 5.5 bc50d58723 Revert "fix(edge-flash-wear): keep the pacman package cache empty"
The package cache is a disk-space concern of the package upgrades, not
flash wear; it moves to the os-update role.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:58:01 +01:00
claude-botandClaude Opus 5.5 f65015a423 fix(edge-flash-wear): keep the pacman package cache empty
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
edge-1's 6.6 GB eMMC root had 1.7 GB of cached packages and 197 MB free,
so pulling the Alloy image failed with "no space left on device". A
pacman hook now empties the cache after every transaction, and the role
empties it on each run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:13:39 +01:00
k.renner db0c5a8d96 Merge pull request 'fix(edge-log-shipper): run Alloy from the upstream image' (#1578) from fix/edge-alloy-upstream-image into main
Reviewed-on: #1578
2026-11-06 11:06:06 +00:00
claude-botandClaude Opus 5.5 7f78f48ec6 fix(edge-log-shipper): run Alloy from the upstream image
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Arch's grafana-alloy 1.21.1 is built without loki.source.journal
("loki.source.journal is not enabled"), so since the 2026-10-22 upgrade
edge-1 has shipped no logs and edge-2 never did. edge-alloy.service now
runs grafana/alloy (Renovate-tracked) as the alloy user with the journal
group, read-only root, no capabilities, host network, /run/log/journal
mounted read-only and the WAL on /run/alloy. The package is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:42:55 +01:00
k.renner 73ede3d227 Merge pull request 'fix(ssh-ca): install sudo before the Claude sudo rules' (#1577) from fix/ssh-ca-install-sudo into main
Reviewed-on: #1577
2026-11-06 10:20:43 +00:00
claude-botandClaude Opus 5.5 9c42c72037 fix(edge-host-iso): include sudo in the base system
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The ssh-ca role installs the claude-bot sudo rules into /etc/sudoers.d,
which a base system without sudo does not have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:19:44 +01:00
claude-botandClaude Opus 5.5 5d0e705b30 fix(ssh-ca): install sudo before the Claude sudo rules
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The edge-host base install has no sudo, so /etc/sudoers.d is missing and
the sudoers template fails. DSM keeps its own sudo and is skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:18:11 +01:00
k.renner 6986f36237 Merge pull request 'feat(edge-host): add edge-2 as second edge host' (#1576) from feat/edge-2-host into main
Reviewed-on: #1576
2026-11-06 10:01:03 +00:00
claude-botandClaude Opus 5.5 4316d503d2 feat(edge-host): add edge-2 as second edge host
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
edge-2.iot.renner.uno joins [edge_host] (base roles only, no peripherals)
and [ssh_ca], and the NTP VRRP group as BACKUP with priority 60, below
edge-1.

The MQTT bridge account follows the host's short name instead of a fixed
edge-1, and the bridge client id is derived from it (edge-1 keeps
edge1-bridge). The register gets the account edge-2, and the controller's
egress policy allows edge-2's credential endpoint on 8443.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:59:25 +01:00
k.renner 1b2684c35b Merge pull request 'fix(edge-host-iso): take the hostname as an argument in edge-install' (#1575) from fix/edge-install-hostname into main
Reviewed-on: #1575
2026-11-06 09:00:02 +00:00
claude-botandClaude Opus 5.5 ec91122621 fix(edge-host-iso): take the hostname as an argument in edge-install
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
edge-install wrote a hard-coded edge-1 into /etc/hostname, so every further
edge host came up with the production host's name and DHCP hostname.
The hostname is now a required argument, validated as an RFC 1123 label
and confirmed before the disk is wiped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:56:55 +01:00
k.renner fc720823db Merge pull request 'fix(netbird-ingress): log to console only' (#1574) from fix/netbird-ingress-log-console into main
Reviewed-on: #1574
2026-11-06 07:29:04 +00:00
claude-botandClaude Opus 5.5 8d47e085cb fix(netbird-ingress): log to console only
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The rootless image defaults NB_LOG_FILE to
console,/var/lib/netbird/client.log. The state volume is a 16Mi
memory emptyDir, so a single 15MB rotated log fills it (ENOSPC on
log writes and rotation compression) and the tmpfs is charged
against the 256Mi memory limit. Console output already reaches Loki.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:26:15 +01:00
security-bot 54ae8ce463 chore(image-automation): bump docker.gitea.com/gitea-mcp-server:1.13.0
Updates managed by image-automation-controller.
2026-11-06 06:31:21 +00:00
security-bot cd2e7864a4 chore(image-automation): bump gitea.renner.uno/gitea_admin/wlan-setup:2026-11-05-1517
Updates managed by image-automation-controller.
2026-11-05 15:19:21 +00:00
k.renner fec9f6e0e5 Merge pull request 'fix(wlan-setup): Windows profile valid against the provisioning schemas' (#1573) from fix/wlan-setup-windows-xsd into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1573
2026-11-05 15:17:16 +00:00
claude-botandClaude Opus 5.5 55fce76a2d fix(wlan-setup): Windows profile valid against the provisioning schemas
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/wlan-setup-test Pipeline was successful
Windows Settings rejected the provisioning XML: Global/SubscriberId is
base:SubscriberType (\w+, at most 20 characters) in the schemas Windows
ships in %SYSTEMROOT%\schemas\provisioning, and "renner-unifi" contains a
hyphen. It is now "rennerunifi".

Those schemas also allow WPA3ENT and Hotspot2/NAIRealm, which the online
schema reference lacks: the profile uses WPA3ENT again (the network is
WPA3-Enterprise) and carries NAI realm renner.uno next to the domain.

TestWindowsProfileSchema validates the signed document with xmllint against
a copy of that schema folder (WLAN_SETUP_PROVISIONING_XSD); without it the
test is skipped, the schemas are Microsoft's files and not in the repo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:16:06 +01:00
security-bot 5fcd6dc5c4 chore(image-automation): bump gitea.renner.uno/gitea_admin/wlan-setup:2026-11-05-1429
Updates managed by image-automation-controller.
2026-11-05 14:53:19 +00:00
security-bot 3a35e8481a chore(image-automation): bump gotenberg/gotenberg:8.40.0
Updates managed by image-automation-controller.
2026-11-05 14:53:04 +00:00
k.renner a5be653044 Merge pull request 'fix(wlan-setup): schema-valid Windows profile, profiles built with serializers' (#1572) from fix/wlan-setup-windows-schema into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1572
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-05 14:29:39 +00:00
security-bot 1d5a262a84 chore(image-automation): bump gitea.renner.uno/gitea_admin/ci-backup-verify:2026-11-05
Updates managed by image-automation-controller.
2026-11-05 14:24:19 +00:00
security-bot ad6fc66875 chore(image-automation): bump gitea.renner.uno/gitea_admin/kuma-sync:2026-11-05-1355
Updates managed by image-automation-controller.
2026-11-05 14:24:14 +00:00
security-bot 861e9a3193 chore(image-automation): bump gitea.renner.uno/konairius/alpine-openssl:2026-11-05-1353
Updates managed by image-automation-controller.
2026-11-05 14:23:48 +00:00
claude-bot 9a1dc89326 Merge remote-tracking branch 'origin/main' into fix/wlan-setup-windows-schema
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/wlan-setup-test Pipeline was successful
2026-11-05 15:18:39 +01:00
claude-botandClaude Opus 5.5 d505ca2062 chore(wlan-setup): drop the build output from the tree
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:18:29 +01:00
claude-botandClaude Opus 5.5 28e864cfc4 refactor(wlan-setup): build profiles with XML and plist serializers
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/wlan-setup-test Pipeline was canceled
All three profiles were assembled from strings. They are now typed
documents:

- Android: PPS MO from encoding/xml structs; MIME multipart with
  mime/multipart (boundary renner-passpoint-profile; Android's ConfigParser
  reads part headers into a map, so their order does not matter).
- Apple: .mobileconfig marshalled with howett.net/plist.
- Windows: CarrierProvisioning built as an etree document; the signature is
  an etree subtree, digest and SignedInfo canonicalized with goxmldsig's
  C14N 1.0 canonicalizer (SignedInfo in place, so it carries the inherited
  xmldsig namespace). Same form as Microsoft's samples: one transform,
  rsa-sha1, sha1 digest, chain in KeyInfo.

Tests parse the outputs (multipart reader, encoding/xml, plist, etree) and
verify the Windows signature with goxmldsig.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:18:15 +01:00
security-bot e7d45a5c31 chore(image-automation): bump gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-11-05-1401
Updates managed by image-automation-controller.
2026-11-05 14:17:39 +00:00
security-bot 7a8791c3b0 chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-11-05-1350
Updates managed by image-automation-controller.
2026-11-05 14:14:29 +00:00
claude-botandClaude Opus 5.5 b6acbc6e79 fix(wlan-setup): Windows provisioning XML within the CarrierControl schema
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/wlan-setup-test Pipeline was successful
Windows Settings rejected the profile as invalid provisioning XML. The
CarrierControl WLAN schema allows authentication only up to WPA2; WPA3ENT is
not in it. The profile now uses WPA2 (Enterprise; Windows adds the PMF the
WPA3-Enterprise network requires) and follows Microsoft's signed Passpoint
samples otherwise: Hotspot2 with DomainName only, XML declaration in front
(outside the root, so not part of the digest).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:07:23 +01:00
security-bot b1b58e397c chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-05-1355
Updates managed by image-automation-controller.
2026-11-05 14:07:18 +00:00
k.renner fdaf20aa3f Merge pull request 'feat(wlan-setup): run wlan.renner.uno on the Go service' (#1571) from feat/wlan-setup-go-deploy into main
Reviewed-on: #1571
2026-11-05 13:59:54 +00:00
claude-botandClaude Opus 5.5 4e61d543a8 feat(wlan-setup): run wlan.renner.uno on the Go service
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The wlan-setup Deployment runs gitea.renner.uno/gitea_admin/wlan-setup
(Flux image policy wlan-setup) instead of nginx + njs; the ConfigMap
wlan-setup-config.yaml with nginx.conf, njs and the Windows .cmd is removed.

- Certificate wlan-setup-signing for wlan.renner.uno (PKCS#8), mounted at
  /etc/wlan-signing (fsGroup 65534, mode 0440); the service reads it per
  request, so renewals need no restart.
- Ingress wlan-setup-provisioning: /windows/profile/ without the authentik
  gate, for Windows Settings fetching the signed provisioning XML.
- OAuth provider wlan-setup: redirect URI
  https://wlan.renner.uno/windows/callback.
- docs/radius/dot1x.md: Go service and the Windows Passpoint flow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:56:15 +01:00
security-bot 302b177ba2 chore(image-automation): bump gitea.renner.uno/gitea_admin/secret-rotator:2026-11-05
Updates managed by image-automation-controller.
2026-11-05 13:54:15 +00:00
security-bot 531e192e0c chore(image-automation): bump gitea.renner.uno/konairius/cni-plugins:2026-11-05-1353
Updates managed by image-automation-controller.
2026-11-05 13:53:49 +00:00
security-bot 3416cd476d chore(image-automation): bump gitea.renner.uno/konairius/alpine-jq:2026-11-05-1353
Updates managed by image-automation-controller.
2026-11-05 13:53:48 +00:00
k.renner 8a41b80217 Merge pull request 'feat(wlan-setup): Go service for the WLAN setup page' (#1570) from feat/wlan-setup-go into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1570
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-05 13:46:29 +00:00
claude-botandClaude Opus 5.5 a05f1431ca feat(wlan-setup): Go service for the WLAN setup page
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/wlan-setup-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
wlan-setup/ is a Go service that will replace the nginx + njs backend of
wlan.renner.uno (switched over in a follow-up once the first image exists).

- Page, /whoami, /healthz; /{android,apple,windows}/install and /callback:
  authentik OAuth code flow (client wlan-setup, client_secret_basic), user
  check against X-authentik-username, per-device app password
  wlan-<platform>-... (29 days), profile per platform: Android Passpoint
  (application/x-wifi-config), Apple .mobileconfig with Passpoint, Windows
  CarrierProvisioning with Passpoint.
- Windows: the profile is signed with XML-DSig in the form of Microsoft's
  samples (enveloped, C14N 1.0, rsa-sha1, chain in KeyInfo) by the key in
  /etc/wlan-signing, kept for 5 minutes under a single-use token, and the
  browser is sent to ms-settings:wifi-provisioning?uri=.../windows/profile/<token>.
- Offline tests against a fake authentik; goxmldsig verifies the signature
  in the tests only.
- CI: wlan-setup-test (gofmt, vet, test -race) on PRs; build-ci-images
  builds gitea.renner.uno/gitea_admin/wlan-setup:YYYY-MM-DD-HHMM on main;
  Flux image automation follows the newest tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:39:07 +01:00
k.renner 5718c9bae2 Merge pull request 'feat(radius): Apple Passpoint profile with a per-device app password' (#1568) from feat/wlan-setup-apple-passpoint into main
Reviewed-on: #1568
2026-11-05 13:03:12 +00:00
k.renner 2c79edabea Merge pull request 'docs(radius): Renner UniFi is WPA3 Enterprise with Passpoint' (#1567) from docs/dot1x-passpoint-renner-unifi into main
Reviewed-on: #1567
2026-11-05 13:02:23 +00:00
claude-botandClaude Opus 5.5 6e12a608b3 docs(radius): Passpoint setup for Android and Apple
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Android and Apple get Passpoint profiles with their own app password from
wlan.renner.uno; the manual Android steps with the account password are
removed. Windows and Linux keep their setup files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:00:55 +01:00
claude-botandClaude Opus 5.5 5718492e2b feat(radius): Apple Passpoint profile with a per-device app password
wlan.renner.uno serves Apple devices the same way as Android: /apple/install
signs in through the wlan-setup OAuth application, /apple/callback creates
an app password (wlan-apple-...) with the user's own access token and returns
a .mobileconfig with Passpoint (IsHotspot, domain and NAI realm renner.uno),
EAP-TTLS/PAP, outer identity anonymous, trusted server name
radius.renner.uno, username and app password. The static profile without a
password is removed; its PayloadIdentifier/UUIDs are kept so the new profile
replaces it on install. The OAuth provider gets the redirect URI
https://wlan.renner.uno/apple/callback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:00:16 +01:00
claude-botandClaude Opus 5.5 3bfb38ebd7 docs(radius): Renner UniFi is WPA3 Enterprise with Passpoint
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
"Renner UniFi" now runs WPA3 Enterprise without transition mode and
advertises Passpoint (realm and domain renner.uno, EAP-TTLS/PAP), so the
Android profiles from wlan.renner.uno join it directly. The table row for
the deleted "Renner Test" WLAN is replaced; the Windows manual setup uses
WPA3-Enterprise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:53:12 +01:00
k.renner 1de63af07b Merge pull request 'feat(radius): Android WLAN profile with a per-device app password' (#1566) from feat/dot1x-app-passwords into main
Reviewed-on: #1566
2026-11-05 12:19:11 +00:00
claude-botandClaude Opus 5.5 6c2c278f65 feat(radius): log WLAN app password renewals
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:16:27 +01:00
claude-botandClaude Opus 5.5 21625685b4 feat(radius): WLAN app passwords expire 60 days after last use
ci/woodpecker/pr/ci-scripts-test Pipeline failed
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
New app passwords start with 29 days (below the 30-day API cap, so no
group attribute is needed). The expression policy dot1x-app-password-renew,
re-evaluated on the dot1x-login binding, renews the app password that
TokenBackend matched to 60 days on each 802.1X login (at most one write
per day; always passes, failure_result true). authentik rejects expired
app passwords and deletes them in clean_expired_models (every 5 min).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:11:12 +01:00
claude-botandClaude Opus 5.5 29da21c28f docs(radius): app password lifetime cap is 30 days without the attribute
ci/woodpecker/pr/ci-scripts-test Pipeline is pending
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:05:27 +01:00
claude-botandClaude Opus 5.5 c781e263fc fix(radius): wlan-setup authentik subrequests take large replies
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
- subrequest_output_buffer_size 256k: users/me with groups, avatar and
  permissions exceeds the one-page default and was cut off.
- proxy_pass_request_headers off: browser headers (Accept-Encoding,
  User-Agent, X-authentik-*) no longer reach authentik.
- Random suffix on the app password identifier, so two installs in the
  same second do not collide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:02:10 +01:00
claude-botandClaude Opus 5.5 b1be3181ef feat(radius): Android WLAN profile with a per-device app password
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
wlan.renner.uno signs the user in through the new authentik OAuth2
application wlan-setup (confidential, scope goauthentik.io/api). The
callback exchanges the code (client_secret_basic), creates an app password
with the user's own access token (10 years) and returns the Passpoint
profile carrying it in the response to the user's GET. The page asks for
no password and stores nothing; deleting the app password in authentik
removes the device.

- dot1x-password stage also accepts app passwords (TokenBackend).
- Client secret: ESO Password generator wlan-setup-oauth, read by the
  authentik worker (blueprint) and by wlan-setup.
- authentik calls go through an internal nginx location; headers come
  from $arg_* because subrequests share the parent's variable cache.
- Reloader annotations on the wlan-setup Deployment metadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:52:09 +01:00
k.renner 08b087337e Merge pull request 'feat(radius): Android Passpoint profile on the WLAN setup page' (#1565) from feat/wlan-setup-passpoint into main
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Reviewed-on: #1565
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-05 10:37:22 +00:00
97 changed files with 3046 additions and 1655 deletions
+26
View File
@@ -41,6 +41,7 @@ when:
- "ci/images/**"
- "secret-rotator/**"
- "review-bot/**"
- "wlan-setup/**"
- ".woodpecker/build-ci-images.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "build-ci-images"'
@@ -197,6 +198,31 @@ steps:
--destination gitea.renner.uno/gitea_admin/review-bot:$TAG \
--destination gitea.renner.uno/gitea_admin/review-bot:latest
# wlan-setup: the Go service behind wlan.renner.uno (802.1X/Passpoint setup page with per-device
# authentik app passwords). Runs after Gitea is up, so no ghcr mirror.
- name: build-wlan-setup
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: wlan-setup
when:
- event: deployment
- event: push
path: ["wlan-setup/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d-%H%M)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/wlan-setup:$TAG \
--destination gitea.renner.uno/gitea_admin/wlan-setup:latest
# Runtime image, not CI tooling: a general-purpose alpine + jq base for
# workloads that need to read JSON from a shell script. Named for what it IS,
# not for who uses it — its first consumer is the zigbee2mqtt seed-data init
+13
View File
@@ -0,0 +1,13 @@
# Tests for the wlan-setup Go service (wlan-setup/). Offline: the tests run against a fake
# authentik server, so the step needs no secret and no network beyond the module cache.
when:
- event: pull_request
path: ["wlan-setup/**", ".woodpecker/wlan-setup-test.yml"]
steps:
- name: go-test
image: golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9
commands:
- test -z "$(gofmt -l wlan-setup)"
- go -C wlan-setup vet ./...
- go -C wlan-setup test -race ./...
+14 -82
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,17 +23,13 @@ The deployed unit is a **Dell Wyse 3040** (Atom x5-Z8350, 2 GB RAM) on a
flash-wear handling that hardware implies. The installer also supports an
N100/NVMe box; it autodetects the target disk, so either works.
Rationale for leaving Kubernetes for the CAN part: the stock Talos kernel
ships with `CONFIG_CAN` unset (siderolabs/talos#7686), so SocketCAN cannot
work in any pod without a custom kernel image per Talos release.
## Base install (once, from the edge-host installer ISO)
Build and boot the dedicated installer image — see
`images/edge-host-iso/README.md`. Short version:
1. `sudo pacman -S archiso && sudo images/edge-host-iso/build.sh`
2. Write ISO to a USB stick, boot the target box from it, run `edge-install`
2. Write ISO to a USB stick, boot the target box from it, run `edge-install <hostname>`
(partitions the internal disk — NVMe if present, else the eMMC; the
installer autodetects — installs the ansible-ready base system with SSH
keys, DHCP, docker — asks for confirmation before wiping).
@@ -58,24 +58,18 @@ inventory change.
| device | group | playbook |
|---|---|---|
| Innomaker USB2CAN (Vitocal/VX3 + E380) | `heating_can` | `plays/install-heating-can.yml` |
| Nabu Casa ZWA-2 Z-Wave radio | `zwave_gateway` | `plays/install-zwave-gateway.yml` |
Add the host to the matching group, then run that playbook. They depend on
the base play having run: all three need the local MQTT bridge, and two of
them need docker.
Add the host to the matching group, then run that playbook. It depends on
the base play having run: it needs docker.
A host with nothing attached is simply absent from all three groups and needs
A host with nothing attached is simply absent from these groups and needs
none of these commands.
## Secrets (before first play run)
Create `inventory/group_vars/edge_host/vault.yml` with ansible-vault. The
cluster broker has no `open3e` user: edge-1 logs in only as the register
account `edge-1` (see "MQTT bridge" below), so
`vault_edge_open3e_mqtt_password` reaches only the loopback broker, which
allows anonymous clients; any random value works (`openssl rand -hex 24`).
The zwave-js value comes out of the cluster:
zwave-js value comes out of the cluster:
```
# zwave-js session secret
@@ -84,7 +78,6 @@ kubectl -n home-assistant get secret zwave-js-session -o jsonpath='{.data.sessio
```yaml
# vault.yml
vault_edge_open3e_mqtt_password: "..."
vault_edge_zwavejs_session_secret: "..."
vault_edge_loki_password: "..." # dedicated edge-1 Loki push cred (see "Logging & eMMC")
```
@@ -102,9 +95,6 @@ The Z-Wave network keys do NOT need separate handling — they live in
Preparation:
- [ ] `vault.yml` populated (above), play runs green against edge-1
- [ ] CAN: Innomaker adapter connected to the VX3 external CAN port,
`ip -details link show can0` shows `state ERROR-ACTIVE bitrate 250000`,
`candump can0 -n 5` shows frames
zwave-js (order matters — never two servers on one radio):
- [ ] Copy the store out of the PVC while the pod still runs:
@@ -122,11 +112,6 @@ sml2mqtt:
port, Wh stays Wh) → remove sml2mqtt from the home-assistant
kustomization in the follow-up PR
open3e:
- [ ] Stop any workstation listener (ONE instance per bus, hard rule)
- [ ] Move the USB2CAN adapter, `systemctl start open3e`
- [ ] Topics under `open3e/#` update on the broker
Afterwards:
- [ ] Retire VM 153: remove `vm_153_k8s_edge.tf`, `talos/patches/k8s-edge.yaml`,
node from the cluster (`kubectl delete node k8s-edge`, tofu apply)
@@ -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
@@ -164,7 +138,9 @@ role keeps logs off local flash and monitors wear:
- **Docker logs to journald** (`docker-daemon.json`, `log-driver: journald`)
instead of unbounded json files under `/var/lib/docker/containers`. Both
compose services inherit it; `docker logs` still works.
- **Grafana Alloy ships the journal to Loki** (`edge-alloy.service`). Its
- **Grafana Alloy ships the journal to Loki** (`edge-alloy.service`, which
runs the upstream `grafana/alloy` image with `/run/log/journal` mounted
read-only; Arch's `grafana-alloy` package has no `loki.source.journal`). Its
WAL/positions sit on `/run/alloy` (tmpfs), so the agent writes nothing to
the eMMC either. Net effect: no log writes hit local flash — the trade-off
is that logs are RAM-only and lost on reboot unless already shipped.
@@ -213,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
+10 -22
View File
@@ -18,10 +18,11 @@ proxy-1.renner.uno ansible_host=proxy-1.nb.renner.uno ansible_user=root
proxy-2.renner.uno ansible_host=proxy-2.nb.renner.uno ansible_user=root
# Every bare-metal edge thin client. Gets the host-generic roles only:
# flash-wear handling, the log shipper, the MQTT bridge, the VLAN
# sub-interface. Nothing here assumes any peripheral is attached.
# flash-wear handling, the log shipper, the VLAN sub-interface. Nothing here
# assumes any peripheral is attached.
[edge_host]
edge-1.iot.renner.uno ansible_user=root
edge-2.iot.renner.uno ansible_user=root
# Synology NAS (DSM): nas-1 RS1221+ on site, nas-2 DS1522+ off site.
[synology_nas]
@@ -36,32 +37,18 @@ nas-2.renner.uno ansible_user=root
# These are children of [edge_host], declared below: a peripheral is added to an
# edge client, it does not replace the base. Making that a :children relation
# rather than a comment means a host added only to a capability group still
# resolves group_vars/edge_host -- where the local MQTT contract lives -- so it
# cannot silently come up without edge_mqtt_host and friends.
# resolves group_vars/edge_host.
[zwave_gateway]
edge-1.iot.renner.uno
[heating_can]
edge-1.iot.renner.uno
[edge_host:children]
zwave_gateway
heating_can
# NTP servers. edge-1 was added here so the role would survive the PVE hosts
# being retired for bare-metal Talos, which cannot run chrony or keepalived
# itself. That retirement has now happened, so edge-1 is the ONLY member.
#
# THIS IS NO LONGER HIGHLY AVAILABLE. The group was jupiter (MASTER, prio 100),
# saturn (90), neptune (80) and edge-1 (BACKUP, 70); the first three are gone.
# A single VRRP member takes the address regardless of its configured state, so
# the service address is still served, but by one thin client with
# no failover behind it. install-ntp-ha.yml still carries the four-host
# ntp_servers priority map, which now describes hosts that do not exist.
#
# Restoring redundancy needs a second chrony host that is not a Talos node.
# See plays/install-ntp-ha.yml for why keepalived is scoped more narrowly than
# chrony within this group.
# NTP servers: every edge host (edge-1 BACKUP prio 70, edge-2 BACKUP prio 60;
# see the ntp_servers map in plays/install-ntp-ha.yml). Talos nodes cannot run
# chrony or keepalived. The jupiter/saturn/neptune entries in that map
# describe retired PVE hosts. See plays/install-ntp-ha.yml for why keepalived
# is scoped more narrowly than chrony within this group.
[ntp_cluster:children]
edge_host
@@ -73,6 +60,7 @@ proxy-1.renner.uno
proxy-2.renner.uno
netbird.renner.uno
edge-1.iot.renner.uno
edge-2.iot.renner.uno
nas-1.renner.uno
nas-2.renner.uno
-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
+86 -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,11 +30,96 @@
# do not silently depend on the connection user staying privileged.
become: true
# The Viessmann E3 CAN bus is read by vitocal-can (open3e-esp32 at the
# Vitocal), which publishes to the cluster broker itself, not by an edge
# host. These remove the former heating-can stack, the can0 configuration
# and the open3e MQTT bridge (loopback mosquitto + edge-mqtt-cred); each is
# a no-op where they are absent.
pre_tasks:
- name: Read the service list
ansible.builtin.service_facts:
- name: Stop and disable the retired MQTT bridge services
ansible.builtin.systemd:
name: "{{ item }}"
state: stopped
enabled: false
loop:
- edge-mqtt-cred
- mosquitto
when: (item ~ '.service') in ansible_facts.services
- name: Remove the retired MQTT bridge files
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/systemd/system/edge-mqtt-cred.service
- /usr/local/sbin/edge-mqtt-cred
- /etc/edge-mqtt-cred
- /etc/mosquitto
register: edge_mqtt_bridge_files
- name: Reload systemd without the edge-mqtt-cred unit
ansible.builtin.systemd:
daemon_reload: true
when: edge_mqtt_bridge_files.changed
- name: Uninstall mosquitto
ansible.builtin.package:
name: mosquitto
state: absent
- name: Check for the retired heating-can compose project
ansible.builtin.stat:
path: /opt/edge-host/heating-can/docker-compose.yml
register: edge_heating_can_compose
- name: Stop and remove the retired heating-can containers and image
ansible.builtin.command:
cmd: docker compose down --remove-orphans --rmi all
chdir: /opt/edge-host/heating-can
when: edge_heating_can_compose.stat.exists
changed_when: true
- name: Remove the retired heating-can directory
ansible.builtin.file:
path: /opt/edge-host/heating-can
state: absent
- name: Remove the can0 networkd configuration
ansible.builtin.file:
path: /etc/systemd/network/80-can0.network
state: absent
register: edge_can0_network
- name: Reload networkd without the can0 configuration
ansible.builtin.command:
cmd: networkctl reload
when: edge_can0_network.changed
changed_when: true
- name: Read the can0 link flags
ansible.builtin.command:
cmd: ip -o link show can0
register: edge_can0_link
check_mode: false
changed_when: false
failed_when: false
- name: Take can0 down
ansible.builtin.command:
cmd: ip link set can0 down
when:
- edge_can0_link.rc == 0
- edge_can0_link.stdout is search('[<,]UP[,>]')
changed_when: true
roles:
- certificates
- common
- pacman-cache
- edge-flash-wear
- docker-install
- edge-log-shipper
- mqtt-bridge
- edge-vlan
-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
+3
View File
@@ -83,6 +83,9 @@
edge-1.iot.renner.uno:
priority: 70
state: "BACKUP"
edge-2.iot.renner.uno:
priority: 60
state: "BACKUP"
roles:
- role: chrony-ntp-server
+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
@@ -7,7 +7,9 @@
edge_alloy_config_file: /etc/alloy/edge.alloy
edge_alloy_http_port: 12345
edge_alloy_bin: /usr/bin/grafana-alloy # Arch `grafana-alloy` pkg names the binary this, not `alloy`
# Upstream image: Arch's grafana-alloy 1.21 is built without
# loki.source.journal. Renovate-tracked (customManagers in renovate.json).
edge_alloy_image: grafana/alloy:v1.21.3@sha256:8ab871206bd2d8f89277f53abffee5f452a293980788d52f85fa183a87a505a4
edge_loki_push_url: "https://loki.renner.uno/loki/api/v1/push"
edge_loki_username: edge-1
edge_alloy_labels:
+24 -2
View File
@@ -6,15 +6,18 @@
# directly, so one journal source covers everything. Alloy s storage/WAL sits
# on /run (tmpfs) so it does not write to flash either.
- name: Install grafana-alloy
# Alloy runs from the upstream image (see edge-alloy.service); the Arch package
# lacks journal support.
- name: Remove the grafana-alloy package
ansible.builtin.package:
name: grafana-alloy
state: present
state: absent
- name: Create alloy system group
ansible.builtin.group:
name: alloy
system: true
register: edge_alloy_group
- name: Create alloy system user
ansible.builtin.user:
@@ -24,6 +27,25 @@
system: true
create_home: false
shell: /usr/bin/nologin
register: edge_alloy_user
- name: Look up the systemd-journal group
ansible.builtin.getent:
database: group
key: systemd-journal
- name: Set the container identity
ansible.builtin.set_fact:
edge_alloy_uid: "{{ edge_alloy_user.uid }}"
edge_alloy_gid: "{{ edge_alloy_group.gid }}"
edge_alloy_journal_gid: "{{ ansible_facts.getent_group['systemd-journal'][1] }}"
- name: Pull the alloy image
ansible.builtin.command:
argv: [docker, pull, "{{ edge_alloy_image }}"]
register: edge_alloy_pull
changed_when: "'Downloaded newer image' in edge_alloy_pull.stdout"
notify: Restart alloy
- name: Create alloy config directory
ansible.builtin.file:
@@ -1,32 +1,35 @@
[Unit]
Description=Grafana Alloy (edge-1 log shipper -> Loki)
Description=Grafana Alloy (journal -> Loki)
Documentation=https://grafana.com/docs/alloy/
After=network-online.target
After=docker.service network-online.target
Requires=docker.service
Wants=network-online.target
[Service]
Type=simple
User=alloy
Group=alloy
# Read the (volatile) systemd journal.
SupplementaryGroups=systemd-journal
# WAL + positions on tmpfs so the agent never writes to the eMMC. %t is the
# runtime dir root (/run); RuntimeDirectory creates /run/alloy owned by us.
RuntimeDirectory=alloy
ExecStart={{ edge_alloy_bin }} run \
# WAL + positions on tmpfs so the agent never writes to the eMMC.
ExecStartPre=/usr/bin/install -d -o alloy -g alloy -m 0750 /run/alloy
ExecStartPre=-/usr/bin/docker rm -f edge-alloy
# Attached run with --log-driver none: the container's output reaches the
# journal once, through this unit.
ExecStart=/usr/bin/docker run --rm --name edge-alloy \
--log-driver none \
--network host \
--user {{ edge_alloy_uid }}:{{ edge_alloy_gid }} \
--group-add {{ edge_alloy_journal_gid }} \
--read-only --tmpfs /tmp \
--cap-drop ALL --security-opt no-new-privileges \
-v /run/log/journal:/run/log/journal:ro \
-v /etc/machine-id:/etc/machine-id:ro \
-v {{ edge_alloy_config_file }}:/etc/alloy/config.alloy:ro \
-v /run/alloy:/var/lib/alloy \
{{ edge_alloy_image }} run \
--server.http.listen-addr=127.0.0.1:{{ edge_alloy_http_port }} \
--storage.path=%t/alloy \
{{ edge_alloy_config_file }}
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
--storage.path=/var/lib/alloy \
/etc/alloy/config.alloy
ExecStop=/usr/bin/docker stop edge-alloy
Restart=always
RestartSec=10s
LimitNOFILE=65536
# Hardening (bare metal, no LXC namespace limits here).
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
@@ -1,71 +0,0 @@
---
# Heating system over the Viessmann E3 CAN bus: open3e (heat pump) and
# e3oncan (E380 energy meter). The can0 interface itself is the socketcan
# role.
# Service root. Kept per-role so the role is usable on its own.
edge_base_dir: /opt/edge-host
edge_open3e_mqtt_user: open3e
edge_open3e_mqtt_topic: open3e
edge_open3e_mqtt_format: "{device}_{didNumber:04d}_{didName}"
edge_open3e_poll_seconds: 30
# Read-only datapoint set (Phase 1 extended set, established 2026-07-02 on
# the commissioning workstation; see vican repo CLAUDE.md for the rationale).
# NO -w / write DIDs here — live heating + battery, writes are Phase 3.
# vx3.266 = ErrorDtcHistory (O3EList) — the VX3 fault log; polled to catch
# recurrences of FailureCommunicationEnergyMeter (the E380 energy-meter link
# the internal PV-surplus EMS depends on). Decodes via the master O3EList
# codec inherited through the vx3plus overlay.
# vitocal.543/2350/2543/2560: SmartGrid-/EMS-Status-Beobachtung (2026-07-06)
# — klärt, ob die Eigenstromoptimierung (EMS) bei PV-Überschuss von selbst
# den WW-Boost auslöst (2543.2 steht laut Scan bereits auf +10 K), bevor
# SG-Ready-Verdrahtung oder andere Trigger gebaut werden. Alle vier ro.
# vitocal.259/260 = InfoDtcList/InfoDtcHistory (O3EList) — the HPMU event log,
# the Vitocal-side counterpart to vx3.266. Both are capped lists (2 and 9
# entries when added), so the multi-frame read is cheap at a 30 s poll.
# Records faults/info events only (NormalOperation, LagDeviceDtcReported,
# LegionellaProtectionTemperatureReached) — NOT setpoint or program changes.
# onebase.*: the 29 paired ZigBee slots on the One Base gateway (ECU 0x6c3) --
# 8 radiator valves, 9 climate sensors, 9 underfloor actuators, 3 floor
# thermostats. Per-device room temperature, active setpoint, humidity, valve
# opening/demand, battery and RSSI; the local replacement for the ViCare cloud
# room sensors. Needs the onebase overlay (files/open3e-onebase.py) -- the stock
# codec declares 57 bytes and a paired slot answers with 68.
# Only CurrentValues are polled. The room and device registries (1884.., 2084..)
# are static configuration and are read on demand, not every 30 s.
edge_open3e_datapoints: >-
vitocal.259,vitocal.260,vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.286,vitocal.288,vitocal.318,vitocal.321,vitocal.324,vitocal.335,vitocal.336,vitocal.396,vitocal.402,vitocal.403,vitocal.476,vitocal.477,vitocal.543,vitocal.548,vitocal.565,vitocal.1007,vitocal.1043,vitocal.1190,vitocal.1211,vitocal.1391,vitocal.1416,vitocal.1417,vitocal.2320,vitocal.2346,vitocal.2350,vitocal.2351,vitocal.2352,vitocal.2369,vitocal.2370,vitocal.2486,vitocal.2487,vitocal.2488,vitocal.2543,vitocal.2560,vitocal.2622,vitocal.2624,vitocal.2625,vitocal.2735,vitocal.3016,vx3.266,vx3.378,vx3.379,vx3.380,vx3.535,vx3.1603,vx3.1664,vx3.1690,vx3.1801,vx3.1802,vx3.1810,vx3.1828,vx3.1830,vx3.1577,vx3.1578,vx3.1579,vx3.1580,vx3.1581,vx3.1582,vx3.1834,vx3.1836,vx3.2214,vx3.2240,vx3.3085,vx3.3086,vx3.3087,vx3.3088,vx3.3089,vx3.3090,vcmu.320,vcmu.322,vcmu.325,vcmu.355,vcmu.389,vcmu.391,vcmu.1772,vcmu.1773,vcmu.1775,vcmu.1776,vcmu.2256,vcmu.2259,vcmu.2333,vcmu.2334,vcmu.2569,vcmu.2571,vitocal.2791,onebase.2086,onebase.2089,onebase.2092,onebase.2095,onebase.2098,onebase.2101,onebase.2104,onebase.2107,onebase.2110,onebase.2113,onebase.2116,onebase.2119,onebase.2122,onebase.2125,onebase.2128,onebase.2131,onebase.2134,onebase.2137,onebase.2140,onebase.2143,onebase.2262,onebase.2265,onebase.2268,onebase.2271,onebase.2274,onebase.2277,onebase.2280,onebase.2283,onebase.2286
# ECU addresses; -dev selects codec set, tx selects the UDS address.
# vcmu (refrigerant-circuit controller) has no dedicated codec set —
# omitting dpList makes open3e fall back to the generic datapoint list,
# which decodes all polled VCMU DIDs.
edge_open3e_devices:
vitocal: { tx: "0x680", dpList: "vcal" }
# dpList als Pfad: Overlay-Datei mit den Batteriemodul-DIDs (siehe
# files/open3e-vx3plus.py) — die Stock-vx3-Liste filtert sie sonst weg.
vx3: { tx: "0x6a1", dpList: "/config/vx3plus.py" }
vcmu: { tx: "0x68c" }
# One Base ZigBee gateway. Overlay re-declares the paired ZigBee slots at
# their real 68-byte length (see files/open3e-onebase.py).
onebase: { tx: "0x6c3", dpList: "/config/onebase.py" }
edge_e3oncan_gap_seconds: 10
edge_e3oncan_mqtt_format: "{device}_{didNumber:04d}_{didName}"
# Secrets come from inventory/group_vars/edge_host/vault.yml:
# edge_open3e_mqtt_password
# The image carrying both readers, built and pinned in CI
# (ci/images/heating-can/Containerfile). The source pins live in the
# Containerfile, in the artifact -- not here -- so what is deployed is a tag,
# and `docker compose up -d` on an unchanged tag is a genuine no-op.
#
# Tracked by the weekly update-audit as an ordinary image, which is why this is
# simpler to keep current than the two git refs it replaced: one publishes no
# tags at all, the other was pinned to an untagged commit between releases.
heating_can_image: gitea.renner.uno/gitea_admin/heating-can:latest
# Compose project directory. Separate from the old native layout under
# {{ edge_base_dir }}/open3e, which this role removes.
heating_can_base_dir: "{{ edge_base_dir }}/heating-can"
@@ -1,54 +0,0 @@
# Managed by ansible (roles/heating-can). Datapoint overlay for the One Base
# ZigBee gateway on ECU 0x6c3 (BACKENDGATEWAY).
#
# Upstream declares ZigBee<N>DeviceCurrentValues as 57 bytes. A PAIRED device
# slot on this gateway answers with 68: the 57 known bytes plus the parent
# device serial and three trailing bytes. udsoncan reads only the declared
# length and then treats the next two bytes -- the first half of that serial --
# as a foreign data identifier, so every paired slot fails to decode with
# "Server returned values for data identifier 0x.... that was not requested".
# The bogus identifier differs per device because the serial does.
#
# This overlay re-declares the 29 paired slots at 68 bytes, reusing upstream's
# sub-type list so the per-device-type O3ESwitch (climate sensor / TRV / floor
# thermostat / actuator, incl. DeviceTemperatureSetpoint) is inherited rather
# than copied.
#
# EMPTY slots answer with 57 and decode against the stock codec, so they are
# left alone and are not polled. A slot that is later unpaired therefore starts
# reporting a decode error on its topic instead of a value.
#
# Verified on ECU 0x6c3, 2026-09-17: 29 paired slots, all 68 bytes.
from open3e.Open3Ecodecs import O3EComplexType, RawCodec
from open3e.Open3Edatapoints import dataIdentifiers as _master
# ZigBee<N>DeviceCurrentValues of the paired slots: devices 1-20 run 2086..2143
# in steps of 3, devices 21-29 continue at 2262..2286.
_PAIRED_CURRENT_VALUES = [2086 + 3 * n for n in range(20)] + [
2262 + 3 * n for n in range(9)
]
def _with_parent_serial(did):
stock = _master["dids"][did]
return O3EComplexType(
68,
stock.id,
list(stock.subTypes)
+ [
RawCodec(8, "ParentDeviceSerialnumber"),
RawCodec(2, "Unknown_65_66"),
RawCodec(1, "Unknown_67"),
],
desc=stock.desc,
info=stock.info,
acc=stock.acc,
)
dataIdentifiers = {
"dids": {
**_master["dids"],
**{did: _with_parent_serial(did) for did in _PAIRED_CURRENT_VALUES},
}
}
@@ -1,20 +0,0 @@
# Managed by ansible (roles/heating-can). Datapoint overlay for the VX3:
# the stock vx3 list acts as a FILTER (DIDs missing from it are popped
# from the master table, Open3Eclass.py), so the per-battery-module DIDs
# were never pollable with dpList "vx3". This overlay re-adds them with
# codec None (= keep the master/general codec, RawCodec hex strings).
#
# Module DIDs (verified answering on ECU 0x6a1, 2026-07-03):
# 1577-1582 ElectricalEnergyStorageModule{One..Six}OperatingData
# (139 B: 8x temperature 0.1 °C + 15x cell voltage mV)
# 3085-3090 ElectricalEnergyStorageModule{One..Six}Information
# (18 B: module voltage 0.01 V, SoH?, counters)
from open3e.Open3EdatapointsVx3 import dataIdentifiers as _vx3
dataIdentifiers = {
"dids": {
**_vx3["dids"],
1577: None, 1578: None, 1579: None, 1580: None, 1581: None, 1582: None,
3085: None, 3086: None, 3087: None, 3088: None, 3089: None, 3090: None,
}
}
@@ -1,10 +0,0 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Restart heating-can
ansible.builtin.command:
cmd: docker compose up -d --force-recreate
chdir: "{{ heating_can_base_dir }}"
changed_when: true
-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: edge-1
# HTTPS port of edge-mqtt-cred (esp32 device API, mTLS). Only a client
# certificate from the MQTT CA with this CN is accepted.
edge_mqtt_cred_port: 8443
edge_mqtt_cred_allowed_cn: mqtt-account-controller
@@ -1,141 +0,0 @@
#!/usr/bin/env python3
# Managed by ansible (roles/mqtt-bridge). Receives the cluster bridge password
# from mqtt-account-controller (REN-1307) so no plaintext copy lives in
# ansible-vault and no play run is needed to rotate it.
#
# Speaks the controller's esp32 device API over HTTPS:
# GET /api/status -> {"mqtt": {"connected": bool}}
# PUT /api/settings <- {"mqtt": {"user": "...", "pass": "..."}}
# A client certificate from the MQTT CA with CN mqtt-account-controller is
# required; nothing else may call it. The server key is generated on this
# host and never leaves it (the controller pins it on first contact).
#
# PUT writes the bridge file (template + credentials), removes the legacy
# vault-password bridges and restarts the local mosquitto. The password is
# kept only in that 0640 root:mosquitto file.
import grp
import json
import os
import socket
import ssl
import subprocess
import sys
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
CONF = json.load(open(sys.argv[1]))
WRITE = threading.Lock()
def connected():
try:
out = subprocess.run(
["mosquitto_sub", "-h", "127.0.0.1", "-p", str(CONF["local_port"]),
"-t", CONF["state_topic"], "-C", "1", "-W", "3"],
capture_output=True, text=True, timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
return False
return out.strip() == "1"
def write_bridge(user, password):
with open(CONF["template"]) as f:
body = f.read()
body += f"remote_username {user}\nremote_password {password}\n"
d = os.path.dirname(CONF["bridge_file"])
fd, tmp = tempfile.mkstemp(dir=d, prefix=".edge-mqtt-cred.")
with os.fdopen(fd, "w") as f:
f.write(body)
os.chown(tmp, 0, grp.getgrnam("mosquitto").gr_gid)
os.chmod(tmp, 0o640)
os.replace(tmp, CONF["bridge_file"])
for legacy in CONF["legacy_files"]:
if os.path.exists(legacy):
os.remove(legacy)
subprocess.run(["systemctl", "restart", "mosquitto"], check=True, timeout=60)
def valid(s):
# Controller passwords are random URL-safe tokens; anything with
# whitespace or a control character would break the config line.
return isinstance(s, str) and 0 < len(s) <= 256 and s.isprintable() and not any(c.isspace() for c in s)
class Handler(BaseHTTPRequestHandler):
def authorized(self):
cert = self.connection.getpeercert() or {}
cns = [v for rdn in cert.get("subject", ()) for k, v in rdn if k == "commonName"]
if cns != [CONF["allowed_cn"]]:
self.send_error(403)
return False
return True
def reply(self, code, obj=None):
data = b"" if obj is None else json.dumps(obj).encode()
self.send_response(code)
if data:
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
if not self.authorized():
return
if self.path != "/api/status":
return self.send_error(404)
self.reply(200, {"mqtt": {"connected": connected()}})
def do_PUT(self):
if not self.authorized():
return
if self.path != "/api/settings":
return self.send_error(404)
try:
n = int(self.headers.get("Content-Length", "0"))
if not 0 < n <= 4096:
raise ValueError("body size")
m = json.loads(self.rfile.read(n))["mqtt"]
user, password = m["user"], m["pass"]
except (ValueError, KeyError, TypeError):
return self.send_error(400)
if user != CONF["user"] or not valid(password):
return self.send_error(400)
with WRITE:
write_bridge(user, password)
self.log_message("bridge credentials for %s replaced, mosquitto restarted", user)
self.reply(204)
def log_message(self, fmt, *args):
sys.stderr.write("%s %s\n" % (self.address_string(), fmt % args))
class Server(ThreadingHTTPServer):
address_family = socket.AF_INET6
def server_bind(self):
self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
super().server_bind()
def get_request(self):
# The TLS handshake runs in the request thread, with a timeout, so a
# stalled peer cannot block the accept loop.
sock, addr = self.socket.accept()
sock.settimeout(10)
return self.ctx.wrap_socket(sock, server_side=True, do_handshake_on_connect=False), addr
def main():
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.load_cert_chain(CONF["cert"], CONF["key"])
ctx.load_verify_locations(CONF["ca"])
ctx.verify_mode = ssl.CERT_REQUIRED
srv = Server(("::", CONF["port"]), Handler)
srv.ctx = ctx
srv.serve_forever()
if __name__ == "__main__":
main()
@@ -1,10 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIBdTCCARygAwIBAgIUN8ne4k4Wif9O83GDyKiBP0Ma7EIwCgYIKoZIzj0EAwIw
GTEXMBUGA1UEAxMOUmVubmVyIE1RVFQgQ0EwHhcNMjYxMDA2MTUzMDE1WhcNMzYx
MDAzMTUzMDE1WjAZMRcwFQYDVQQDEw5SZW5uZXIgTVFUVCBDQTBZMBMGByqGSM49
AgEGCCqGSM49AwEHA0IABOZhZF2JDoyvVg2oIXSbJAuAWDcoG22RxPA4oMKRMA/b
XzIY566D2k6cVnWqbil9s/q4O2mj+Epo7plBw8NwuOOjQjBAMA4GA1UdDwEB/wQE
AwICpDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQcqSDDiLZzhrvqq9f16Y08
CO7QfzAKBggqhkjOPQQDAgNHADBEAiBFkxd9pXnqIcjReEcOTvCBZ3HxKu2jNQGL
/le7gunTGgIgZapWAcu4awa+wmZcR/z2luOjD2GhIT15um14Z+sstJk=
-----END CERTIFICATE-----
@@ -1,13 +0,0 @@
---
# The MQTT clients (zwave-js, sml2mqtt, open3e, e3oncan) reconnect on their
# own once the local broker is back.
- name: Restart mosquitto
ansible.builtin.service:
name: mosquitto
state: restarted
- name: Restart edge-mqtt-cred
ansible.builtin.systemd:
name: edge-mqtt-cred
state: restarted
daemon_reload: true
-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 edge1-bridge
cleansession false
try_private true
restart_timeout 5 30
queue_qos0_messages true
# The bridge state goes to the LOCAL broker only (retained 1/0); edge-mqtt-cred
# reads it to answer the controller's "connected?".
notifications true
notifications_local_only true
notification_topic $SYS/broker/connection/{{ edge_mqtt_bridge_account }}/state
topic open3e/# out 0
topic open3e/cmnd in 0
@@ -1,21 +0,0 @@
# Managed by roles/mqtt-bridge. Legacy vault-password bridges, rendered only
# while /etc/mosquitto/bridge.d/{{ edge_mqtt_bridge_account }}.conf does not
# exist; edge-mqtt-cred deletes this file on the controller's first push
# (REN-1307). Remove with the vault keys once edge-1 is on the register account.
# --- Bridge 1: open3e datapoints + command topic, as the cluster `open3e`
# user (its ACL is `readwrite open3e/#`, which covers publishing datapoints
# and subscribing to open3e/cmnd). e3oncan also publishes under open3e/#.
connection edge1-open3e
address {{ edge_mqtt_cluster_host }}:{{ edge_mqtt_cluster_tls_port }}
bridge_cafile {{ edge_mqtt_bridge_cafile }}
bridge_insecure false
remote_username {{ edge_open3e_mqtt_user }}
remote_password {{ edge_open3e_mqtt_password }}
remote_clientid edge1-bridge-open3e
cleansession false
try_private true
notifications false
restart_timeout 5 30
queue_qos0_messages true
topic open3e/# out 0
topic open3e/cmnd in 0
@@ -1,30 +0,0 @@
# Managed by roles/mqtt-bridge. Local mosquitto that the edge clients (open3e,
# e3oncan, sml2mqtt) publish to over loopback; it bridges to the central
# cluster broker over TLS. open3e/e3oncan have no native MQTTS, so this is how
# edge-1's MQTT gets off plaintext on the untrusted IoT VLAN, and it buffers
# telemetry in RAM if the cluster broker is briefly unreachable.
#
# eMMC: this host runs on a small internal eMMC, so the local broker writes
# NOTHING to flash — `persistence false` keeps retained/queued messages in RAM
# only, and `log_dest stdout` goes to the (volatile, RAM) journal that Alloy
# ships to Loki. Do not add persistence/log-file settings here.
persistence false
log_dest stdout
log_type error
log_type warning
log_type notice
# Loopback only — the trust boundary is the host itself. Any process on edge-1
# may publish here (same trust as the bridged services); nothing off-host can
# reach it. Real authentication to the cluster happens on the bridge below.
# Single v4-loopback listener: all edge clients dial 127.0.0.1 (edge_mqtt_host),
# and a second same-port listener can make some builds refuse to start.
listener 1883 127.0.0.1
allow_anonymous true
# --- Bridges to the cluster broker (REN-1307). bridge.d holds either
# {{ edge_mqtt_bridge_account }}.conf, written by edge-mqtt-cred with the password
# mqtt-account-controller pushed, or, until that first push, legacy.conf with
# the two vault-password bridges. edge-mqtt-cred deletes legacy.conf when it
# writes the new file.
include_dir /etc/mosquitto/bridge.d
@@ -1,13 +0,0 @@
{{ {
"port": edge_mqtt_cred_port,
"allowed_cn": edge_mqtt_cred_allowed_cn,
"user": edge_mqtt_bridge_account,
"cert": "/etc/edge-mqtt-cred/tls.crt",
"key": "/etc/edge-mqtt-cred/tls.key",
"ca": "/etc/edge-mqtt-cred/mqtt-ca.crt",
"template": "/etc/edge-mqtt-cred/bridge.conf.in",
"bridge_file": "/etc/mosquitto/bridge.d/" ~ edge_mqtt_bridge_account ~ ".conf",
"legacy_files": ["/etc/mosquitto/bridge.d/legacy.conf"],
"local_port": edge_mqtt_port,
"state_topic": "$SYS/broker/connection/" ~ edge_mqtt_bridge_account ~ "/state",
} | to_nice_json }}
@@ -1,19 +0,0 @@
# Managed by roles/mqtt-bridge (REN-1307). See /usr/local/sbin/edge-mqtt-cred.
[Unit]
Description=Receives the MQTT bridge credential from mqtt-account-controller
After=network-online.target mosquitto.service
Wants=network-online.target
[Service]
ExecStart=/usr/bin/python3 /usr/local/sbin/edge-mqtt-cred /etc/edge-mqtt-cred/config.json
Restart=always
RestartSec=5
# Root only to write the bridge file and restart mosquitto.
ProtectSystem=strict
ReadWritePaths=/etc/mosquitto/bridge.d
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,14 @@
# Managed by ansible (roles/pacman-cache).
# Downloaded packages are not kept; older versions come from the Arch Linux
# Archive.
[Trigger]
Operation = Install
Operation = Upgrade
Operation = Remove
Type = Package
Target = *
[Action]
Description = Removing downloaded packages from the cache...
When = PostTransaction
Exec = /usr/bin/find /var/cache/pacman/pkg -mindepth 1 -delete
+21
View File
@@ -0,0 +1,21 @@
---
# Keep the pacman package cache empty. pacman keeps every downloaded package;
# on a 7 GB eMMC root the cache grows to gigabytes. Arch hosts only.
- name: Ensure the pacman hook directory exists
ansible.builtin.file:
path: /etc/pacman.d/hooks
state: directory
mode: "0755"
- name: Empty the package cache after every transaction
ansible.builtin.copy:
src: pacman-cache.hook
dest: /etc/pacman.d/hooks/pacman-cache.hook
mode: "0644"
- name: Empty the package cache
ansible.builtin.command:
argv: [find, /var/cache/pacman/pkg, -mindepth, "1", -delete, -print]
register: pacman_cache_clean
changed_when: pacman_cache_clean.stdout | length > 0
@@ -1,5 +0,0 @@
---
# SocketCAN interface. Consumed by heating-can.
# --- CAN / open3e (Viessmann E3 external bus) ---
edge_can_interface: can0
edge_can_bitrate: 250000
@@ -1,7 +0,0 @@
---
# reload alone only re-reads config files; existing links keep their old state
# until reconfigured.
- name: Reload networkd
ansible.builtin.shell:
cmd: networkctl reload && networkctl reconfigure {{ edge_can_interface }}
changed_when: true
-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.
+6
View File
@@ -143,6 +143,12 @@
group: root
mode: "0644"
- name: Install sudo
ansible.builtin.package:
name: sudo
state: present
when: not ssh_ca_synouser.stat.exists
- name: Install the Claude sudo rules
ansible.builtin.template:
src: sudoers-claude.j2
+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
+60 -29
View File
@@ -125,7 +125,15 @@ Created through the UniFi Network API (site `default`):
|---|---|---|
| Firewall policy "Allow APs RADIUS" | `62c21e63-8c15-4720-adf8-b2333c090e2f` | zone Isolated `10.2.10.0/24` → zone External `10.2.99.91`, UDP `1812-1813`, ALLOW, IPv4 |
| RADIUS profile "Cluster 802.1X" | `3e5fcfed-63dc-4e7d-ad51-17cbc074e4bb` | auth `10.2.99.91:1812`, acct `10.2.99.91:1813`, secret `DOT1X_NAS_SECRET`, VLAN assignment on (`vlan_wlan_mode: optional`) |
| WLAN "Renner Test" | `3eba27e7-ddc2-41fb-ad3f-82add317847a` | copy of "Renner UniFi" (WPA2/WPA3 Enterprise transition, fast roaming, BSS transition, same AP group and bands), network (90) Guest as fallback, RADIUS profile "Cluster 802.1X" |
| WLAN "Renner UniFi" | `ef4122f2-4a83-4dca-9ef3-468d29f39a6b` | WPA3 Enterprise (no transition), PMF required, fast roaming, BSS transition, client isolation off, RADIUS profile "Cluster 802.1X"; Application Hotspot, type Passpoint (set in the UniFi UI: the API format differs from the UI's) |
Passpoint on "Renner UniFi" (UniFi offers Passpoint only with WPA3 Enterprise):
venue "Renner", Residential / Private Residence, network type Private with
Guest Access, IPv4 single NATed private, IPv6 available, NAI realm
`renner.uno` with EAP-TTLS, sub-methods PAP and username/password, domain
`renner.uno`, operator friendly name "Renner", connection capability ICMP
allow. The AKMs on air are `FT/802.1X` and `802.1X/SHA-256`; plain `802.1X`
is not offered.
## Live login check
@@ -146,31 +154,51 @@ For IPv6 set `LIVE_SOURCE` to the workstation's `fd02:50::` address and use
## Client setup (SSID "Renner UniFi")
Setup page: `https://wlan.renner.uno` (behind the Authentik gate, any
authentik user; `kubernetes/apps/authentik/wlan-setup*.yaml`). It serves the
Apple profile (caller's username filled in), a Windows file that imports the
WLAN profile with `netsh`, and the Linux `nmcli` command. None of these carries a password. Android
gets a Passpoint profile (`application/x-wifi-config`): the page POSTs the
password the user types there, nginx (njs) builds the profile and keeps it in
memory for at most 120 s under a one-time token bound to the user, and a tap on
the resulting link GETs it, as the Android installer requires. It matches WLANs
that advertise Hotspot 2.0 with domain and NAI realm `renner.uno`
(EAP-TTLS/PAP).
authentik user), served by the Go service `wlan-setup/`
(`kubernetes/apps/authentik/wlan-setup*.yaml`). Android, Apple and Windows get
Passpoint profiles with their own app password; Linux an `nmcli` command
(NetworkManager has no Passpoint support) that carries no password.
Passpoint: `/<platform>/install` (`android`, `apple` or `windows`) signs the
user in through the authentik OAuth2 application `wlan-setup` (scope
`goauthentik.io/api`). On `/<platform>/callback`, the service exchanges the
code, creates an app password (`wlan-<platform>-<user>-<timestamp>-<random>`)
with the user's own access token, and hands out the profile:
- Android: `application/x-wifi-config` in the response to that GET, as its
installer requires.
- Apple: a `.mobileconfig` download.
- Windows: a CarrierProvisioning XML signed with XML-DSig (enveloped, C14N
1.0, rsa-sha1, chain in KeyInfo) by the Let's Encrypt certificate
`wlan-setup-signing`; kept for 5 minutes under a single-use token. The
browser is sent to `ms-settings:wifi-provisioning?uri=https://wlan.renner.uno/windows/profile/<token>`;
Windows Settings fetches it (Ingress `wlan-setup-provisioning`, without the
gate) and offers "Add".
The service stores nothing else. Deleting the app password in authentik removes
that device from the WLAN. The profiles match WLANs that advertise Hotspot 2.0
with domain and NAI realm `renner.uno` (EAP-TTLS/PAP), i.e. "Renner UniFi".
Creating the app password needs no admin rights.
App passwords expire when unused. The page creates them with 29 days (the API
caps new tokens at 30 days unless the group attribute
`goauthentik.io/user/token-maximum-lifetime` allows more). Every 802.1X login
with an app password renews it to 60 days: the policy
`dot1x-app-password-renew` on the `dot1x-login` binding (at most one write per
day). authentik rejects expired app passwords and deletes them in its
`clean_expired_models` task. A device that has not connected for 60 days, or a
profile that was never installed, loses its app password and needs a new
profile from the page.
All clients use EAP-TTLS with inner PAP. The server certificate is a public
Let's Encrypt certificate for `radius.renner.uno`, so no CA needs to be
installed. Username and password are the authentik credentials. PAP runs
inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air.
installed. The 802.1X password stage (`dot1x-password`) accepts the authentik
password and app passwords. PAP runs inside the TLS tunnel to FreeRADIUS and
is never sent in clear over the air.
### Android
- EAP method: TTLS
- Phase 2 authentication: PAP
- CA certificate: Use system certificates
- Domain: `radius.renner.uno`
- Identity: authentik username
- Anonymous identity: `anonymous` (mandatory; empty sends the login to the
UDM, where it fails)
- Password: authentik password
Passpoint profile from the setup page ("Auf Android installieren").
### Linux (NetworkManager)
@@ -184,10 +212,12 @@ inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air.
### Windows 11
Settings > Network & Internet > Wi-Fi > Manage known networks > Add network:
Passpoint profile from the setup page ("Auf Windows installieren"). Manual
setup without the page: Settings > Network & Internet > Wi-Fi > Manage known
networks > Add network:
- Network name: `Renner UniFi`
- Security type: WPA2-Enterprise AES
- Security type: WPA3-Enterprise AES
- EAP method: EAP-TTLS
- Authentication method: Unencrypted password (PAP)
- Server validation: where offered ("Verify server identity" / trusted
@@ -202,13 +232,14 @@ wireless network" instead.
### Apple (iOS, iPadOS, macOS)
Apple devices need a configuration profile; the setup page serves it with the
username filled in. Install it via Settings (Profile Downloaded). The device
asks for the password on the first join. The profile sets security type Any (WPA2/WPA3), outer identity
`anonymous` and trusted server name `radius.renner.uno`; it contains no
password and no certificate. Apple
devices may show a one-time "trust certificate" prompt for `radius.renner.uno`
on the first join.
Passpoint configuration profile from the setup page ("Profil laden"),
installed via Settings (Profile Downloaded) or, on a Mac, System Settings >
General > Device Management. It sets `IsHotspot` with domain and NAI realm
`renner.uno` (no SSID), security type Any, outer identity `anonymous`,
trusted server name `radius.renner.uno`, username and app password; it
contains no certificate. Its PayloadIdentifier is fixed, so installing it
replaces an earlier profile. Apple devices may show a one-time "trust
certificate" prompt for `radius.renner.uno` on the first join.
## Measurement: authentication latency per SSID
+6 -5
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.
@@ -42,10 +42,11 @@ for the CA key; rebuild the ISO after a CA rotation.
2. Boot the target from it (UEFI, Secure Boot off; one-time F12/setup
visit if the box doesn't already boot USB first)
3. Either on the console (auto-login as root) or headless —
`ssh root@<dhcp-ip>` with an owner certificate — run `edge-install`. It auto-detects the internal disk
(NVMe, else eMMC — covers the Wyse 3040 — else first non-USB disk),
shows the target and requires typing the hostname before wiping
anything. Override with `edge-install /dev/sdY`.
`ssh root@<dhcp-ip>` with an owner certificate — run
`edge-install <hostname>` (e.g. `edge-install edge-2`). It auto-detects
the internal disk (NVMe, else eMMC — covers the Wyse 3040 — else first
non-USB disk), shows the target and requires typing the hostname before
wiping anything. Override with `edge-install <hostname> /dev/sdY`.
4. Reboot. Confirm the box is reachable by name — `ssh root@<host>` — before
going further; it is identified by name everywhere and its address is
never recorded. Then add it to `[edge_host]` in
@@ -1,13 +1,19 @@
#!/usr/bin/env bash
# Installer for edge-1 (bare-metal edge host, replaces the k8s-edge VM).
# Installer for the bare-metal edge hosts (edge-1, edge-2, ...).
# Runs from the edge-host ISO live environment. WIPES the target disk.
# Usage: edge-install [disk]
# Default: the internal disk — first NVMe, else eMMC (Wyse 3040), else
# Usage: edge-install <hostname> [disk]
# Default disk: the internal disk — first NVMe, else eMMC (Wyse 3040), else
# the first non-USB, non-optical disk. The live USB stick itself is
# excluded via the ISO9660 label mount.
set -euo pipefail
HOST=edge-1
usage() { echo "usage: edge-install <hostname> [disk]"; exit 1; }
[ $# -ge 1 ] && [ $# -le 2 ] || usage
HOST=$1
# RFC 1123 label: what systemd-networkd sends as the DHCP hostname.
[[ "$HOST" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] \
|| { echo "invalid hostname: $HOST"; exit 1; }
shift
detect_disk() {
# eMMC needs mmc_block before any /dev/mmcblk* exists (see
@@ -51,7 +57,7 @@ mount --mkdir "$(part 1)" /mnt/boot
# Everything the edge-host ansible role expects to find; the role itself
# is applied over SSH after first boot (see ansible/docs/edge-host.md).
pacstrap -K /mnt base linux linux-firmware intel-ucode openssh python git \
pacstrap -K /mnt base linux linux-firmware intel-ucode openssh sudo python git \
docker docker-compose
genfstab -U /mnt >> /mnt/etc/fstab
@@ -1483,8 +1483,11 @@ data:
name: dot1x-password
state: present
attrs:
# App passwords: the WLAN credential that devices store (Android
# Passpoint profiles carry it), created by wlan.renner.uno.
backends:
- authentik.core.auth.InbuiltBackend
- authentik.core.auth.TokenBackend
- id: dot1x-login
model: authentik_stages_user_login.userloginstage
@@ -1506,12 +1509,54 @@ data:
order: 20
state: present
- model: authentik_flows.flowstagebinding
- id: binding-dot1x-login
model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf dot1x-flow
stage: !KeyOf dot1x-login
order: 100
state: present
attrs:
evaluate_on_plan: false
re_evaluate_policies: true
# Sliding expiry: an app password used for 802.1X expires 60 days
# after its last use; authentik rejects and deletes expired app
# passwords. auth_method_args.token is set by TokenBackend. Always
# passes; failure_result true keeps an error from blocking the login.
- id: dot1x-app-password-renew
model: authentik_policies_expression.expressionpolicy
identifiers:
name: dot1x-app-password-renew
state: present
attrs:
expression: |
from datetime import timedelta
from django.utils.timezone import now
from authentik.core.models import Token, TokenIntents
ref = (request.context.get("auth_method_args") or {}).get("token")
if request.context.get("auth_method") == "token" and isinstance(ref, dict):
renewed = now() + timedelta(days=60)
count = Token.objects.filter(
pk=ref.get("pk"),
user=request.user,
intent=TokenIntents.INTENT_APP_PASSWORD,
expiring=True,
expires__lt=renewed - timedelta(days=1),
).update(expires=renewed)
ak_logger.info("dot1x app password", token=ref.get("name"), renewed=count)
return True
- model: authentik_policies.policybinding
identifiers:
policy: !KeyOf dot1x-app-password-renew
target: !KeyOf binding-dot1x-login
order: 0
state: present
attrs:
enabled: true
negate: false
failure_result: true
- id: dot1x-has-vlan
model: authentik_policies_expression.expressionpolicy
@@ -1624,3 +1669,47 @@ data:
kubernetes_httproute_annotations: {}
kubernetes_httproute_parent_refs: []
kubernetes_json_patches: null
# wlan.renner.uno (wlan-setup.yaml): signs the user in and, with the
# user's own access token (scope goauthentik.io/api), creates the WLAN
# app password for the Android Passpoint profile. Confidential client;
# the secret comes from wlan-setup-oauth (wlan-setup-oauth.yaml). No
# policy binding: every authentik user may set up the WLAN.
- id: wlan-setup-provider
model: authentik_providers_oauth2.oauth2provider
identifiers:
name: WLAN setup
state: present
attrs:
authentication_flow: null
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
client_type: confidential
client_id: wlan-setup
client_secret: !Env WLAN_SETUP_CLIENT_SECRET
grant_types:
- authorization_code
access_token_validity: minutes=5
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-authentik_api]]
redirect_uris:
- url: https://wlan.renner.uno/android/callback
matching_mode: strict
- url: https://wlan.renner.uno/apple/callback
matching_mode: strict
- url: https://wlan.renner.uno/windows/callback
matching_mode: strict
- model: authentik_core.application
identifiers:
slug: wlan-setup
state: present
attrs:
name: WLAN setup
slug: wlan-setup
provider: !KeyOf wlan-setup-provider
meta_launch_url: https://wlan.renner.uno
meta_description: "WLAN setup page; creates WLAN app passwords"
+1 -1
View File
@@ -105,7 +105,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-1059 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-05-1355 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
+11 -4
View File
@@ -171,10 +171,11 @@ spec:
worker:
# Overrides the global key; keep authentik-secrets in the list.
podAnnotations:
secret.reloader.stakater.com/reload: authentik-secrets,radius-secrets
# Shared secret of the 802.1X Radius provider, set via `!Env` in
# dot1x-radius.yaml. Worker only (blueprint applier); optional so a
# missing Secret fails only the dot1x blueprint entry.
secret.reloader.stakater.com/reload: authentik-secrets,radius-secrets,wlan-setup-oauth
# Shared secret of the 802.1X Radius provider and client secret of the
# WLAN setup provider, set via `!Env` in dot1x-radius.yaml. Worker only
# (blueprint applier); optional so a missing Secret fails only the
# blueprint entries that use it.
env:
- name: DOT1X_OUTPOST_SECRET
valueFrom:
@@ -182,6 +183,12 @@ spec:
name: radius-secrets
key: DOT1X_OUTPOST_SECRET
optional: true
- name: WLAN_SETUP_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: wlan-setup-oauth
key: client_secret
optional: true
resources:
requests:
# Same reasoning as server above, and the worker is burstier still:
+2 -1
View File
@@ -16,7 +16,8 @@ resources:
- freeradius-network-attachment.yaml
- freeradius.yaml
- freeradius-cnp.yaml
- wlan-setup-config.yaml
- wlan-setup-oauth.yaml
- wlan-setup-signing-cert.yaml
- wlan-setup.yaml
# ak-pg CNPG Postgres backend (moved here from the retired seed-authentik KS;
# each carries prune: disabled so ownership transferred without a teardown).
@@ -34,3 +34,10 @@ spec:
protocol: TCP
- port: "6636"
protocol: TCP
# The UDM's LDAPS admin-auth sync.
- fromCIDRSet:
- cidrGroupRef: udm-k8s-ingress
toPorts:
- ports:
- port: "6636"
protocol: TCP
@@ -1,416 +0,0 @@
# wlan.renner.uno: setup files for "Renner UniFi" (802.1X, EAP-TTLS/PAP
# against freeradius, outer identity anonymous, server name radius.renner.uno).
# Served by wlan-setup (wlan-setup.yaml) behind the Authentik gate; the
# username comes from X-authentik-username. Only the Android Passpoint profile
# carries the password (the format requires it); it exists in nginx memory for
# at most 120 s and is handed out once.
apiVersion: v1
kind: ConfigMap
metadata:
name: wlan-setup
namespace: authentik
data:
# The image's nginx.conf plus the njs module and a single worker, so the
# one-time tokens of passpoint.js live in one process.
nginx.conf: |
load_module modules/ngx_http_js_module.so;
worker_processes 1;
error_log /var/log/nginx/error.log notice;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
proxy_temp_path /tmp/proxy_temp;
client_body_temp_path /tmp/client_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - [$time_local] "$request" $status $body_bytes_sent "$http_user_agent"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
include /etc/nginx/conf.d/*.conf;
}
default.conf: |
# Only plain usernames reach a generated file; anything else leaves the
# field empty and the device asks for it.
map $http_x_authentik_username $wifi_user {
"~^[A-Za-z0-9._@+-]{1,64}$" $http_x_authentik_username;
default "";
}
js_path /etc/nginx/njs/;
js_import passpoint from passpoint.js;
# token -> {user, profile}; entries expire after 120 s.
js_shared_dict_zone zone=passpoint:1m timeout=120s evict;
server {
listen 8080;
listen [::]:8080;
server_tokens off;
root /usr/share/nginx/html;
add_header Cache-Control "no-store" always;
location = / {
try_files /index.html =404;
}
location = /whoami {
default_type text/plain;
return 200 $wifi_user;
}
# Apple: com.apple.wifi.managed, TTLS (21) with inner PAP. Fixed
# PayloadIdentifier/UUIDs so a reinstall replaces the old profile.
location = /renner-unifi.mobileconfig {
default_type application/x-apple-aspen-config;
add_header Cache-Control "no-store" always;
add_header Content-Disposition 'attachment; filename="renner-unifi.mobileconfig"' always;
return 200 '<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>com.apple.wifi.managed</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>uno.renner.wifi.renner-unifi.network</string>
<key>PayloadUUID</key>
<string>824959bf-c55d-4ed2-b569-c33f3759c2b6</string>
<key>PayloadDisplayName</key>
<string>Renner UniFi</string>
<key>PayloadOrganization</key>
<string>Renner</string>
<key>SSID_STR</key>
<string>Renner UniFi</string>
<key>HIDDEN_NETWORK</key>
<false/>
<key>AutoJoin</key>
<true/>
<key>EncryptionType</key>
<string>Any</string>
<key>EAPClientConfiguration</key>
<dict>
<key>UserName</key>
<string>$wifi_user</string>
<key>OuterIdentity</key>
<string>anonymous</string>
<key>AcceptEAPTypes</key>
<array>
<integer>21</integer>
</array>
<key>TTLSInnerAuthentication</key>
<string>PAP</string>
<key>TLSTrustedServerNames</key>
<array>
<string>radius.renner.uno</string>
</array>
</dict>
</dict>
</array>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>uno.renner.wifi.renner-unifi</string>
<key>PayloadUUID</key>
<string>7175a2a3-ddd3-42ea-b4ad-b7e5501892f3</string>
<key>PayloadDisplayName</key>
<string>WLAN Renner UniFi</string>
<key>PayloadOrganization</key>
<string>Renner</string>
</dict>
</plist>
';
}
location = /renner-unifi-wlan.cmd {
default_type application/octet-stream;
add_header Cache-Control "no-store" always;
add_header Content-Disposition 'attachment; filename="renner-unifi-wlan.cmd"' always;
try_files /renner-unifi-wlan.cmd =404;
}
# Android: the page POSTs the password and gets a one-time token; a tap
# on /android/profile/<token> then GETs the Passpoint profile, as the
# Android installer requires (application/x-wifi-config, base64,
# no Content-Disposition).
location = /android/prepare {
limit_except POST { deny all; }
client_max_body_size 1k;
client_body_buffer_size 1k;
js_content passpoint.prepare;
}
location ~ "^/android/profile/[A-Za-z0-9_-]{32}$" {
js_content passpoint.deliver;
}
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location / {
return 404;
}
}
# Windows: extracts the WLAN profile (everything after line 8 of itself)
# into %TEMP% and imports it for the current user with netsh. EAP-TTLS with
# PAPAuthentication, outer identity anonymous, server name
# radius.renner.uno, trust anchor ISRG Root X1 (SHA-1 thumbprint; the
# Let's Encrypt chain of radius-tls ends there). Windows asks for username
# and password on connect. CRLF line endings: cmd.exe needs them.
renner-unifi-wlan.cmd: "@echo off\r\nrem WLAN Renner UniFi einrichten (802.1X, Anmeldung mit dem authentik-Konto)\r\nset \"WLANXML=%TEMP%\\renner-unifi-wlan.xml\"\r\nmore +8 \"%~f0\" > \"%WLANXML%\"\r\nnetsh wlan add profile filename=\"%WLANXML%\" user=current\r\ndel \"%WLANXML%\"\r\nnetsh wlan connect name=\"Renner UniFi\"\r\npause & exit /b\r\n<?xml version=\"1.0\"?><WLANProfile xmlns=\"http://www.microsoft.com/networking/WLAN/profile/v1\"><name>Renner UniFi</name><SSIDConfig><SSID><hex>52656E6E657220556E694669</hex><name>Renner UniFi</name></SSID><nonBroadcast>false</nonBroadcast></SSIDConfig><connectionType>ESS</connectionType><connectionMode>auto</connectionMode><MSM><security><authEncryption><authentication>WPA2</authentication><encryption>AES</encryption><useOneX>true</useOneX></authEncryption><OneX xmlns=\"http://www.microsoft.com/networking/OneX/v1\"><cacheUserData>true</cacheUserData><authMode>user</authMode><EAPConfig><EapHostConfig xmlns=\"http://www.microsoft.com/provisioning/EapHostConfig\"><EapMethod><Type xmlns=\"http://www.microsoft.com/provisioning/EapCommon\">21</Type><VendorId xmlns=\"http://www.microsoft.com/provisioning/EapCommon\">0</VendorId><VendorType xmlns=\"http://www.microsoft.com/provisioning/EapCommon\">0</VendorType><AuthorId xmlns=\"http://www.microsoft.com/provisioning/EapCommon\">311</AuthorId></EapMethod><Config xmlns=\"http://www.microsoft.com/provisioning/EapHostConfig\"><EapTtls xmlns=\"http://www.microsoft.com/provisioning/EapTtlsConnectionPropertiesV1\"><ServerValidation><ServerNames>radius.renner.uno</ServerNames><TrustedRootCAHash>ca bd 2a 79 a1 07 6a 31 f2 1d 25 36 35 cb 03 9d 43 29 a5 e8</TrustedRootCAHash><DisablePrompt>false</DisablePrompt></ServerValidation><Phase2Authentication><PAPAuthentication/></Phase2Authentication><Phase1Identity><IdentityPrivacy>true</IdentityPrivacy><AnonymousIdentity>anonymous</AnonymousIdentity></Phase1Identity></EapTtls></Config></EapHostConfig></EAPConfig></OneX></security></MSM></WLANProfile>\r\n"
# Android Passpoint R1 provisioning (source.android.com, Passpoint): the
# body is base64 of a MIME multipart with the PPS MO and the trust anchor.
# HomeSP FQDN and realm renner.uno match the Hotspot 2.0 domain and NAI realm
# the Passpoint WLAN announces; EAP-TTLS (21) with inner PAP. Trust anchor
# ISRG Root X1 (the radius-tls chain ends there), AAA server name
# radius.renner.uno (Extension/Android/AAAServerTrustedNames, as Android's
# PpsMoParser expects it). A token is bound to the user who created it and
# can be fetched once.
passpoint.js: |
const ISRG_ROOT_X1 = "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygch77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZLubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVcoyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=";
const USER = /^[A-Za-z0-9._@+-]{1,64}$/;
function b64(s) { return Buffer.from(s).toString("base64"); }
function node(name, inner) { return "<Node><NodeName>" + name + "</NodeName>" + inner + "</Node>"; }
function leaf(name, value) { return node(name, "<Value>" + value + "</Value>"); }
function profile(user, password) {
const pps = '<MgmtTree xmlns="syncml:dmddf1.2"><VerDTD>1.2</VerDTD>' +
node("PerProviderSubscription",
"<RTProperties><Type><DDFName>urn:wfa:mo:hotspot2dot0-perprovidersubscription:1.0</DDFName></Type></RTProperties>" +
node("i001",
node("HomeSP", leaf("FriendlyName", "Renner") + leaf("FQDN", "renner.uno")) +
node("Credential",
leaf("Realm", "renner.uno") +
node("UsernamePassword",
leaf("Username", user) +
leaf("Password", b64(password)) +
node("EAPMethod", leaf("EAPType", "21") + leaf("InnerMethod", "PAP")))) +
node("Extension", node("Android",
node("AAAServerTrustedNames", leaf("FQDN", "radius.renner.uno")))))) +
"</MgmtTree>";
const mime = "Content-Type: multipart/mixed; boundary={boundary}\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
"--{boundary}\r\n" +
"Content-Type: application/x-passpoint-profile\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
b64(pps) + "\r\n" +
"--{boundary}\r\n" +
"Content-Type: application/x-x509-ca-cert\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
ISRG_ROOT_X1 + "\r\n" +
"--{boundary}--\r\n";
return b64(mime);
}
function caller(r) {
const user = r.headersIn["X-authentik-username"] || "";
return USER.test(user) ? user : "";
}
function prepare(r) {
const user = caller(r);
const password = r.requestText || "";
if (!user) { r.return(403); return; }
if (!password || password.length > 256) { r.return(400); return; }
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
const token = Buffer.from(bytes).toString("base64url");
ngx.shared.passpoint.set(token, JSON.stringify({ user: user, body: profile(user, password) }));
r.headersOut["Content-Type"] = "text/plain";
r.return(200, token);
}
function deliver(r) {
const token = r.uri.substring(r.uri.lastIndexOf("/") + 1);
const dict = ngx.shared.passpoint;
const entry = dict.get(token);
if (!entry) { r.return(404); return; }
const data = JSON.parse(entry);
if (data.user !== caller(r)) { r.return(404); return; }
dict.delete(token);
r.headersOut["Content-Type"] = "application/x-wifi-config";
r.headersOut["Content-Transfer-Encoding"] = "base64";
r.return(200, data.body);
}
export default { prepare, deliver };
index.html: |
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WLAN einrichten</title>
<style>
:root { --bg: #ffffff; --fg: #1d1d1f; --muted: #5f6368; --card: #f4f5f7; --accent: #0b63ce; --code: #e9ebef; }
@media (prefers-color-scheme: dark) {
:root { --bg: #121314; --fg: #e8eaed; --muted: #9aa0a6; --card: #1e1f22; --accent: #7cacf8; --code: #2a2b2f; }
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 42rem; margin: 0 auto; padding: 1.5rem 1rem 3rem; }
h1 { font-size: 1.6rem; margin: 0 0 .25rem; }
h2 { font-size: 1.15rem; margin: 0 0 .5rem; }
p { margin: .5rem 0; }
.muted { color: var(--muted); }
section { background: var(--card); border-radius: 12px; padding: 1rem 1.1rem; margin: 1rem 0; }
section.current { outline: 2px solid var(--accent); }
a.button, button { display: inline-block; background: var(--accent); color: #fff; border: 0; border-radius: 8px; padding: .6rem 1rem; font: inherit; text-decoration: none; cursor: pointer; }
@media (prefers-color-scheme: dark) { a.button, button { color: #121314; } }
ol { padding-left: 1.3rem; margin: .5rem 0; }
pre { background: var(--code); border-radius: 8px; padding: .75rem; overflow-x: auto; white-space: pre-wrap; word-break: break-all; font-size: .85rem; margin: .5rem 0; }
details > summary { cursor: pointer; color: var(--accent); margin: 1.5rem 0 .5rem; }
</style>
</head>
<body>
<main>
<h1>WLAN einrichten</h1>
<p>WLAN <strong>Renner UniFi</strong>. Anmeldung mit deinem Konto <strong id="user">…</strong> und deinem authentik-Passwort.</p>
<div id="detected"></div>
<details id="others">
<summary>Andere Geräte</summary>
<div id="rest"></div>
</details>
<template id="t-android">
<section data-os="android">
<h2>Android</h2>
<form id="android-form">
<p><label>Passwort für <strong class="u">…</strong><br>
<input type="password" id="android-password" autocomplete="current-password" required></label></p>
<p><button type="submit">Profil vorbereiten</button></p>
</form>
<p><a class="button" id="android-install" href="#" hidden>Auf Android installieren</a></p>
<p class="muted" id="android-status"></p>
<p class="muted">Ändert sich dein Passwort, hier einfach neu installieren.</p>
</section>
</template>
<template id="t-apple">
<section data-os="apple">
<h2>iPhone, iPad, Mac</h2>
<p><a class="button" href="/renner-unifi.mobileconfig">Profil laden</a></p>
<ol>
<li>iPhone/iPad: Einstellungen → „Profil geladen“ → Installieren. Mac: Systemeinstellungen → Allgemein → Geräteverwaltung → Profil doppelklicken.</li>
<li>Beim ersten Verbinden mit „Renner UniFi“ dein Passwort eingeben.</li>
</ol>
</section>
</template>
<template id="t-windows">
<section data-os="windows">
<h2>Windows</h2>
<p><a class="button" href="/renner-unifi-wlan.cmd">Einrichtungsdatei laden</a></p>
<ol>
<li>Die geladene Datei doppelklicken. Fragt Windows nach („Der Computer wurde durch Windows geschützt“): „Weitere Informationen“ → „Trotzdem ausführen“.</li>
<li>Beim Verbinden Benutzername <strong class="u">…</strong> und dein Passwort eingeben.</li>
</ol>
</section>
</template>
<template id="t-linux">
<section data-os="linux">
<h2>Linux (NetworkManager)</h2>
<p>Im Terminal ausführen, danach fragt NetworkManager nach dem Passwort:</p>
<pre id="nmcli"></pre>
<p><button type="button" id="copy-nmcli">Befehl kopieren</button></p>
</section>
</template>
</main>
<script>
(function () {
var ua = navigator.userAgent;
var os = "other";
if (/Android/i.test(ua)) os = "android";
else if (/iPhone|iPad|iPod|Macintosh/i.test(ua)) os = "apple";
else if (/Windows/i.test(ua)) os = "windows";
else if (/Linux|X11/i.test(ua)) os = "linux";
var detected = document.getElementById("detected");
var rest = document.getElementById("rest");
["android", "apple", "windows", "linux"].forEach(function (name) {
var node = document.getElementById("t-" + name).content.cloneNode(true);
if (name === os) { node.querySelector("section").classList.add("current"); detected.appendChild(node); }
else rest.appendChild(node);
});
if (os === "other") document.getElementById("others").open = true;
var currentUser = "";
function quote(s) { return "'" + s.replace(/'/g, "'\\''") + "'"; }
function render(user) {
currentUser = user;
var shown = user || "(dein Benutzername)";
document.getElementById("user").textContent = shown;
document.querySelectorAll(".u").forEach(function (el) { el.textContent = shown; });
document.getElementById("nmcli").textContent =
"nmcli connection add type wifi con-name 'Renner UniFi' ssid 'Renner UniFi' " +
"wifi-sec.key-mgmt wpa-eap 802-1x.eap ttls 802-1x.phase2-auth pap " +
"802-1x.anonymous-identity anonymous 802-1x.identity " + quote(user || "BENUTZERNAME") + " " +
"802-1x.domain-suffix-match radius.renner.uno 802-1x.system-ca-certs yes " +
"802-1x.password-flags 2 && nmcli --ask connection up 'Renner UniFi'";
}
render("");
fetch("/whoami", { credentials: "same-origin" })
.then(function (r) { return r.ok ? r.text() : ""; })
.then(function (t) { render(t.trim()); })
.catch(function () {});
// Android: the server builds the Passpoint profile from the password and
// keeps it under a one-time token; the installer only starts on a GET
// that the user triggers by tapping, so the token becomes a link.
document.getElementById("android-form").addEventListener("submit", function (ev) {
ev.preventDefault();
var field = document.getElementById("android-password");
var install = document.getElementById("android-install");
var status = document.getElementById("android-status");
if (!field.value) return;
fetch("/android/prepare", { method: "POST", credentials: "same-origin", body: field.value })
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.text(); })
.then(function (token) {
field.value = "";
install.href = "/android/profile/" + token.trim();
install.hidden = false;
status.textContent = "Bereit. Gültig für 2 Minuten.";
})
.catch(function () { status.textContent = "Das hat nicht geklappt. Bitte neu laden und erneut versuchen."; });
});
document.getElementById("copy-nmcli").addEventListener("click", function () {
var text = document.getElementById("nmcli").textContent;
var btn = this;
navigator.clipboard.writeText(text).then(function () {
btn.textContent = "Kopiert";
setTimeout(function () { btn.textContent = "Befehl kopieren"; }, 2000);
});
});
})();
</script>
</body>
</html>
@@ -0,0 +1,32 @@
# Client secret of the "WLAN setup" OAuth2 provider (dot1x-radius blueprint).
# Generated once; the authentik worker reads it as WLAN_SETUP_CLIENT_SECRET
# for the blueprint, wlan-setup reads it for the code exchange.
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
metadata:
name: wlan-setup-oauth
namespace: authentik
spec:
length: 64
digits: 16
symbols: 0
allowRepeat: true
noUpper: false
secretKeys:
- client_secret
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: wlan-setup-oauth
namespace: authentik
spec:
refreshPolicy: CreatedOnce
target:
name: wlan-setup-oauth
dataFrom:
- sourceRef:
generatorRef:
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
name: wlan-setup-oauth
@@ -0,0 +1,21 @@
---
# Signs the Windows Wi-Fi provisioning XML of wlan.renner.uno (XML-DSig in the
# wlan-setup service). Issued via the cluster-wide Cloudflare DNS-01
# ClusterIssuer.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wlan-setup-signing
namespace: authentik
spec:
secretName: wlan-setup-signing
issuerRef:
name: letsencrypt-cluster
kind: ClusterIssuer
dnsNames:
- wlan.renner.uno
privateKey:
algorithm: RSA
size: 2048
encoding: PKCS8
rotationPolicy: Always
+60 -57
View File
@@ -1,8 +1,9 @@
# wlan.renner.uno: setup page for the 802.1X WLAN "Renner UniFi" (Apple
# profile, Windows import file, Linux nmcli command; config and files in
# wlan-setup-config.yaml). Public, so a device can reach it before it is in the
# WLAN (mobile data, Renner Gast). Any Authentik user passes the gate (no
# group): the page holds no secrets, only the caller's own username.
# wlan.renner.uno: setup page for the 802.1X WLAN "Renner UniFi", served by the
# wlan-setup Go service (wlan-setup/). Android, Apple and Windows get Passpoint
# profiles with a per-device authentik app password; Linux an nmcli command.
# Public, so a device can reach it before it is in the WLAN (mobile data,
# Renner Gast). Any Authentik user passes the gate (no group); the service
# stores no user credentials beyond the 5-minute single-use Windows profile.
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -10,6 +11,8 @@ metadata:
namespace: authentik
labels:
app.kubernetes.io/name: wlan-setup
annotations:
secret.reloader.stakater.com/reload: wlan-setup-oauth
spec:
replicas: 1
selector:
@@ -19,19 +22,26 @@ spec:
metadata:
labels:
app.kubernetes.io/name: wlan-setup
annotations:
configmap.reloader.stakater.com/reload: wlan-setup
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
runAsUser: 65534
runAsGroup: 65534
# Group of the signing key files (defaultMode 0440).
fsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: nginx
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b5047b9b76845eac124358373c6852377c52e4b05834a47647e955dd91cb8f1e
- name: wlan-setup
image: gitea.renner.uno/gitea_admin/wlan-setup:2026-11-05-1517 # {"$imagepolicy": "flux-system:wlan-setup"}
env:
# Client secret of the "WLAN setup" OAuth2 provider.
- name: WLAN_SETUP_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: wlan-setup-oauth
key: client_secret
ports:
- name: http
containerPort: 8080
@@ -56,55 +66,21 @@ spec:
limits:
memory: 64Mi
volumeMounts:
# subPath: replaced on rollout, which the reloader annotation
# triggers on every ConfigMap change.
- name: main
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
# Key and chain that sign the Windows provisioning XML; read per
# request, so cert-manager renewals need no restart.
- name: signing
mountPath: /etc/wlan-signing
readOnly: true
- name: config
mountPath: /etc/nginx/conf.d
readOnly: true
- name: njs
mountPath: /etc/nginx/njs
readOnly: true
- name: html
mountPath: /usr/share/nginx/html
readOnly: true
# pid file and temp paths of the unprivileged image.
- name: tmp
mountPath: /tmp
volumes:
- name: main
configMap:
name: wlan-setup
- name: signing
secret:
secretName: wlan-setup-signing
defaultMode: 0440
items:
- key: nginx.conf
path: nginx.conf
- name: config
configMap:
name: wlan-setup
items:
- key: default.conf
path: default.conf
- name: njs
configMap:
name: wlan-setup
items:
- key: passpoint.js
path: passpoint.js
- name: html
configMap:
name: wlan-setup
items:
- key: index.html
path: index.html
- key: renner-unifi-wlan.cmd
path: renner-unifi-wlan.cmd
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 16Mi
- key: tls.key
path: tls.key
- key: tls.crt
path: tls.crt
---
apiVersion: v1
kind: Service
@@ -152,3 +128,30 @@ spec:
name: wlan-setup
port:
number: 80
---
# Without the gate: Windows Settings fetches the signed provisioning XML
# without the browser's session; the single-use token in the path is the only
# gate. Traefik reaches the pods through the CNP wlan-setup-gate that
# gate-sync creates for the Ingress above.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wlan-setup-provisioning
namespace: authentik
spec:
ingressClassName: traefik
tls:
- hosts:
- wlan.renner.uno
secretName: wlan-setup-tls
rules:
- host: wlan.renner.uno
http:
paths:
- path: /windows/profile/
pathType: Prefix
backend:
service:
name: wlan-setup
port:
number: 80
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-29-1355 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-11-05-1401 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
env:
- name: CLOUDFLARE_API_TOKEN
valueFrom:
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
type: RuntimeDefault
containers:
- name: gitea-mcp
image: docker.gitea.com/gitea-mcp-server:1.12.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
image: docker.gitea.com/gitea-mcp-server:1.13.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
# Image has no ENTRYPOINT (binary is in CMD), so args alone would replace it.
command:
- /app/gitea-mcp
@@ -44,7 +44,7 @@ spec:
annotations:
# Bump to restart the pod and re-attach net1 (DHCP via multus).
# 2026-10-06: first attach on containernetworking/plugins v1.9.1.
homelab.renner.uno/restarted-for: "2026-10-06 cni-plugins v1.9.1 attach check"
homelab.renner.uno/restarted-for: "2026-11-07 net1 without RA (S28-36)"
k8s.v1.cni.cncf.io/networks: mdns-relay-iot
spec:
# Runs where the node patch declares the IoT VLAN (ens19).
@@ -31,6 +31,11 @@ spec:
# dropped. The relay's forward path to Home Assistant runs over eth0, so
# that route has to stay intact.
#
# sbr covers IPv4 only. With Router Advertisements net1 gets a global IPv6
# address and default route in the main table, and the IPv6 forward to the
# Service leaves via net1 with an IoT source, which HA's CNP drops. tuning
# turns RA processing off; the link-local address ff02::fb needs stays.
#
# Requires the cni-dhcp-daemon DaemonSet in infrastructure/multus.
config: |
{
@@ -45,6 +50,13 @@ spec:
"type": "dhcp"
}
},
{
"type": "tuning",
"sysctl": {
"net.ipv6.conf.IFNAME.accept_ra": "0",
"net.ipv6.conf.IFNAME.autoconf": "0"
}
},
{
"type": "sbr"
}
@@ -64,7 +64,7 @@ spec:
type: RuntimeDefault
containers:
- name: shelly-manager
image: gitea.renner.uno/konairius/shelly-manager:2026-11-05-0732 # {"$imagepolicy": "flux-system:shelly-manager"}
image: gitea.renner.uno/konairius/shelly-manager:2026-11-07-1918 # {"$imagepolicy": "flux-system:shelly-manager"}
imagePullPolicy: IfNotPresent
ports:
- name: http
@@ -49,17 +49,12 @@
auth: cert
deliver: {namespace: home-assistant}
acl: ["readwrite #"]
# edge-1's local mosquitto bridge (REN-1307): one bridge for open3e/e3oncan.
# The controller pushes
# the password to edge-mqtt-cred on edge-1 (ansible role mqtt-bridge) over
# HTTPS with its own client certificate, and pins edge-1's self-generated
# server certificate on first contact (key edge-1.pin in mqtt-account-hashes;
# delete it after a reinstall of edge-1).
- name: edge-1
# vitocal-can: open3e-esp32 CAN gateway at the Vitocal 250-A (S28-28).
# The firmware registers its compiled-in DHCP name `open3e`, not its
# configured hostname.
- name: vitocal-can
auth: password
deliver: {esp32: "https://edge-1.iot.renner.uno:8443"}
# No sml2mqtt/#: the SML meter reader is retired for good (owner,
# 2026-09-19; grid data comes from the E380 on the CAN bus).
deliver: {esp32: "http://open3e.iot.renner.uno"}
acl: ["readwrite open3e/#"]
# Shellies (REN-1017): one password account per device, pushed by the
# controller through shelly-manager's per-device API. Name shelly-<id> =
@@ -106,6 +101,10 @@
auth: password
deliver: {shelly-manager: 25} # Plug S Gen1 1c1a8f
acl: ["readwrite shellies/#", "write homeassistant/#"]
- name: shelly-41
auth: password
deliver: {shelly-manager: 41} # Plug Gen1 f4cfa26d9cb2
acl: ["readwrite shellies/#", "write homeassistant/#"]
- name: shelly-15
auth: password
deliver: {shelly-manager: 15} # Plug S G3 8cbfea910578
@@ -188,6 +187,34 @@
auth: password
deliver: {shelly-manager: 30} # Plus 2PM b48a0a226c28 (reachable again, REN-1108)
acl: ["readwrite shellies/#", "write homeassistant/#"]
- name: shelly-7
auth: password
deliver: {shelly-manager: 7} # Plus 1PM Mini 348518df3500
acl: ["readwrite shellies/#", "write homeassistant/#"]
- name: shelly-23
auth: password
deliver: {shelly-manager: 23} # Plus 1PM Mini 348518e05b88
acl: ["readwrite shellies/#", "write homeassistant/#"]
- name: shelly-11
auth: password
deliver: {shelly-manager: 11} # Plus 2PM ccdba7d0b804
acl: ["readwrite shellies/#", "write homeassistant/#"]
- name: shelly-28
auth: password
deliver: {shelly-manager: 28} # Plus 2PM 0cdc7efa8dc0
acl: ["readwrite shellies/#", "write homeassistant/#"]
- name: shelly-17
auth: password
deliver: {shelly-manager: 17} # Plus Plug S d4d4daed2054
acl: ["readwrite shellies/#", "write homeassistant/#"]
- name: shelly-18
auth: password
deliver: {shelly-manager: 18} # Plus Plug S d4d4daecc47c
acl: ["readwrite shellies/#", "write homeassistant/#"]
- name: shelly-20
auth: password
deliver: {shelly-manager: 20} # Plus Plug S d4d4daecb690
acl: ["readwrite shellies/#", "write homeassistant/#"]
# Sleeping H&Ts (REN-1107): awake only 8-13 s every 1-2 h, so the
# controller pushes when the device publishes `online` on its wake topic
# (shelly-manager topic prefix `shellies/<hostname>`), not in the 2-min loop.
@@ -211,3 +238,8 @@
deliver: {shelly-manager: 38} # H&T G3 ecda3bc0b6a0
wake: shellies/shellyhtg3-ecda3bc0b6a0/online
acl: ["readwrite shellies/#", "write homeassistant/#"]
- name: shelly-40
auth: password
deliver: {shelly-manager: 40} # Flood G4 7c2c67732e08
wake: shellies/shellyfloodg4-7c2c67732e08/online
acl: ["readwrite shellies/#", "write homeassistant/#"]
@@ -66,7 +66,7 @@ spec:
# (machine.registries.mirrors, applied by bootstrap.sh), and containerd
# keeps the repository path when it falls through — so the path has to
# exist on both registries. Steady state still pulls from Gitea.
image: gitea.renner.uno/konairius/alpine-jq:2026-10-29-1348 # {"$imagepolicy": "flux-system:alpine-jq"}
image: gitea.renner.uno/konairius/alpine-jq:2026-11-05-1353 # {"$imagepolicy": "flux-system:alpine-jq"}
command:
- sh
- -c
@@ -100,6 +100,9 @@ spec:
# Keep tokens unhashed; enable hashing later as its own change.
- name: INFLUXD_USE_HASHED_TOKENS
value: "false"
# No usage telemetry to telemetry.influxdata.com (egress is denied).
- name: INFLUXD_REPORTING_DISABLED
value: "true"
# The chart defaults to liveness/readiness with initialDelaySeconds=0,
# so after a crash restart the kubelet kills the pod before shards
# finish opening. Give it 10 minutes to come up cleanly.
+1 -1
View File
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-30-1314 # {"$imagepolicy": "flux-system:kuma-sync"}
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-11-05-1355 # {"$imagepolicy": "flux-system:kuma-sync"}
env:
- name: KUMA_URL
value: https://kuma.renner.uno
@@ -54,6 +54,9 @@ spec:
# non-RFC-framed payload). Scoped to this single-purpose receiver
# only — the pod-log Alloy DaemonSet stays at default stability.
stabilityLevel: experimental
# No usage reporting to stats.grafana.org (egress is denied anyway).
extraArgs:
- --disable-reporting
resources:
requests:
cpu: 20m
@@ -116,6 +116,9 @@ spec:
retries: 3
values:
alloy:
# No usage reporting to stats.grafana.org (egress is denied anyway).
extraArgs:
- --disable-reporting
configMap:
content: |
// Discover and collect K8s pod logs
+1 -1
View File
@@ -22,7 +22,7 @@ spec:
type: RuntimeDefault
containers:
- name: gotenberg
image: gotenberg/gotenberg:8.39.0 # {"$imagepolicy": "flux-system:gotenberg"}
image: gotenberg/gotenberg:8.40.0 # {"$imagepolicy": "flux-system:gotenberg"}
args:
- "gotenberg"
- "--chromium-disable-javascript=true"
+1 -1
View File
@@ -155,7 +155,7 @@ spec:
# exist on both registries — hence konairius/, which matches the ghcr
# namespace. Steady state still pulls from Gitea and never leaves the
# network.
image: gitea.renner.uno/konairius/alpine-openssl:2026-10-29-1348 # {"$imagepolicy": "flux-system:alpine-openssl"}
image: gitea.renner.uno/konairius/alpine-openssl:2026-11-05-1353 # {"$imagepolicy": "flux-system:alpine-openssl"}
command:
- sh
- -c
+1 -1
View File
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: review-bot
image: gitea.renner.uno/gitea_admin/review-bot:2026-11-02-1054 # {"$imagepolicy": "flux-system:review-bot"}
image: gitea.renner.uno/gitea_admin/review-bot:2026-11-05-1350 # {"$imagepolicy": "flux-system:review-bot"}
env:
- name: GITEA_URL
value: https://gitea.renner.uno
+1 -1
View File
@@ -184,7 +184,7 @@ spec:
# (machine.registries.mirrors, applied by bootstrap.sh), and containerd
# keeps the repository path, so it has to exist on both registries.
# Steady state still pulls from Gitea.
image: gitea.renner.uno/konairius/alpine-jq:2026-10-29-1348 # {"$imagepolicy": "flux-system:alpine-jq"}
image: gitea.renner.uno/konairius/alpine-jq:2026-11-05-1353 # {"$imagepolicy": "flux-system:alpine-jq"}
command:
- sh
- -c
@@ -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:
@@ -46,6 +46,7 @@ existing `flux-system` GitRepository whose Secret was created by
| authentik-gate-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
| kuma-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
| cloudflare-lb-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
| wlan-setup | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
| secret-rotator | `YYYY-MM-DD` | In-house (build-ci-images.yml); newest tag wins; staged: only the Home Assistant rotation CronJobs carry the marker so far |
| gatus | `5.x` | v-prefix filter |
| velero-plugin-for-aws | `1.13.x` | Tight: must match velero server major.minor |
@@ -42,5 +42,6 @@ resources:
- velero-plugin-for-aws.yaml
- vsftpd.yaml
- wiki.yaml
- wlan-setup.yaml
- woodpecker.yaml
- wyoming.yaml
@@ -0,0 +1,23 @@
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: wlan-setup
namespace: flux-system
spec:
image: gitea.renner.uno/gitea_admin/wlan-setup
interval: 30m
---
# build-ci-images.yml tags each build YYYY-MM-DD-HHMM; newest wins.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: wlan-setup
namespace: flux-system
spec:
imageRepositoryRef:
name: wlan-setup
filterTags:
pattern: '^\d{4}-\d{2}-\d{2}-\d{4}$'
policy:
alphabetical:
order: asc
@@ -73,7 +73,7 @@ spec:
# No imagePullSecret: the ghcr namespace is private and its credential
# is in machine.registries too, because a Secret would have to come
# from OpenBao, which needs Ceph, which needs these binaries.
image: gitea.renner.uno/konairius/cni-plugins:2026-10-29-1348 # {"$imagepolicy": "flux-system:cni-plugins"}
image: gitea.renner.uno/konairius/cni-plugins:2026-11-05-1353 # {"$imagepolicy": "flux-system:cni-plugins"}
command:
- sh
- -c
@@ -76,6 +76,10 @@ spec:
value: "netbird-ingress-$(NODE_NAME)"
- name: NB_LOG_LEVEL
value: "info"
# stdout only; the image default also writes client.log into the
# 16Mi state tmpfs.
- name: NB_LOG_FILE
value: "console"
# rootless image needs a writable HOME for its state.
- name: USER
value: "netbird"
@@ -0,0 +1,161 @@
# Client networks as Cilium CIDR groups, referenced from CNPs via
# fromCIDRSet.cidrGroupRef. ULA + IPv4 only: the ISP-delegated GUA prefixes
# change and are not listed.
---
apiVersion: cilium.io/v2
kind: CiliumCIDRGroup
metadata:
name: net-default
spec:
externalCIDRs:
- fd00::/64
- 192.168.8.0/24
---
apiVersion: cilium.io/v2
kind: CiliumCIDRGroup
metadata:
name: net-management
spec:
externalCIDRs:
- fd02:10::/64
- 10.2.10.0/24
---
apiVersion: cilium.io/v2
kind: CiliumCIDRGroup
metadata:
name: net-radius
spec:
externalCIDRs:
- fd02:11::/64
- 10.2.11.0/24
---
apiVersion: cilium.io/v2
kind: CiliumCIDRGroup
metadata:
name: net-video
spec:
externalCIDRs:
- 10.2.20.0/24
---
apiVersion: cilium.io/v2
kind: CiliumCIDRGroup
metadata:
name: net-iot
spec:
externalCIDRs:
- fd02:30::/64
- 10.2.30.0/24
---
apiVersion: cilium.io/v2
kind: CiliumCIDRGroup
metadata:
name: net-server
spec:
externalCIDRs:
- fd02:40::/64
- fd40::/64
- 10.2.40.0/24
---
apiVersion: cilium.io/v2
kind: CiliumCIDRGroup
metadata:
name: net-k8s-node
spec:
externalCIDRs:
- fd02:46::/64
- 10.2.46.0/24
---
apiVersion: cilium.io/v2
kind: CiliumCIDRGroup
metadata:
name: net-k8s-ingress
spec:
externalCIDRs:
- fd02:47::/64
- 10.2.47.0/24
---
# The UDM's own address on k8s-ingress, which it sources from towards the
# cluster's LoadBalancer IPs.
apiVersion: cilium.io/v2
kind: CiliumCIDRGroup
metadata:
name: udm-k8s-ingress
spec:
externalCIDRs:
- fd02:47::1/128
- 10.2.47.1/32
---
apiVersion: cilium.io/v2
kind: CiliumCIDRGroup
metadata:
name: net-k8s-pod
spec:
externalCIDRs:
- fd02:48::/64
- 10.2.48.0/24
---
apiVersion: cilium.io/v2
kind: CiliumCIDRGroup
metadata:
name: net-privat
spec:
externalCIDRs:
- fd02:50::/64
- 10.2.50.0/24
---
apiVersion: cilium.io/v2
kind: CiliumCIDRGroup
metadata:
name: net-phone
spec:
externalCIDRs:
- 10.2.60.0/24
---
apiVersion: cilium.io/v2
kind: CiliumCIDRGroup
metadata:
name: net-access
spec:
externalCIDRs:
- 10.2.70.0/24
---
apiVersion: cilium.io/v2
kind: CiliumCIDRGroup
metadata:
name: net-vpn
spec:
externalCIDRs:
- 10.2.80.0/24
- 192.168.32.0/24
---
apiVersion: cilium.io/v2
kind: CiliumCIDRGroup
metadata:
name: net-guest
spec:
externalCIDRs:
- 10.2.90.0/24
---
apiVersion: cilium.io/v2
kind: CiliumCIDRGroup
metadata:
name: net-multi-transfer
spec:
externalCIDRs:
- 10.2.122.0/24
---
apiVersion: cilium.io/v2
kind: CiliumCIDRGroup
metadata:
name: net-multi-lan
spec:
externalCIDRs:
- 10.2.123.0/24
---
apiVersion: cilium.io/v2
kind: CiliumCIDRGroup
metadata:
name: net-mutti
spec:
externalCIDRs:
- 10.2.200.0/24
@@ -186,14 +186,21 @@ spec:
matchLabels:
app.kubernetes.io/name: mosquitto
ingress:
- fromEntities:
- world
- fromCIDRSet:
- cidrGroupRef: net-iot
toPorts:
- ports:
- port: "1883"
protocol: TCP
- port: "8883"
protocol: TCP
# MQTT clients on the private LAN, TLS only.
- fromCIDRSet:
- cidrGroupRef: net-privat
toPorts:
- ports:
- port: "8883"
protocol: TCP
---
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
@@ -205,8 +212,11 @@ spec:
matchLabels:
app.kubernetes.io/name: home-assistant
ingress:
- fromEntities:
- world
- fromCIDRSet:
- cidrGroupRef: net-privat
- cidrGroupRef: net-vpn
- cidrGroupRef: net-iot
- cidrGroupRef: net-server
toPorts:
- ports:
- port: "8123"
@@ -225,8 +235,8 @@ spec:
matchLabels:
app.kubernetes.io/name: shelly-manager
ingress:
- fromEntities:
- world
- fromCIDRSet:
- cidrGroupRef: net-iot
toPorts:
- ports:
- port: "8082"
@@ -489,7 +499,7 @@ spec:
- port: "6638"
protocol: TCP
---
# mqtt-account-controller: credential delivery to edge-1 (deliver.esp32 in mqtt-accounts.yaml).
# mqtt-account-controller: credential delivery to deliver.esp32 targets in mqtt-accounts.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
@@ -514,11 +524,12 @@ spec:
rules:
dns:
- matchPattern: "*"
# vitocal-can (open3e-esp32), plain HTTP API.
- toFQDNs:
- matchName: "edge-1.iot.renner.uno"
- matchName: "open3e.iot.renner.uno"
toPorts:
- ports:
- port: "8443"
- port: "80"
protocol: TCP
---
# whisper and speech-to-phrase: model downloads over HTTPS.
@@ -0,0 +1,40 @@
# CoreDNS is reachable from the LAN through the kube-dns-lb LoadBalancer
# (ETP=Local, client address reaches the pod). Selecting the pods makes
# them default-deny for ingress, so the whole cluster (pods, nodes, kubelet
# probes, Prometheus) is allowed on every port; LAN clients only on 53.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: coredns-ingress
namespace: kube-system
spec:
endpointSelector:
matchLabels:
k8s-app: kube-dns
ingress:
- fromEntities:
- cluster
- fromCIDRSet:
- cidrGroupRef: net-default
- cidrGroupRef: net-management
- cidrGroupRef: net-radius
- cidrGroupRef: net-video
- cidrGroupRef: net-iot
- cidrGroupRef: net-server
- cidrGroupRef: net-k8s-node
- cidrGroupRef: net-k8s-ingress
- cidrGroupRef: net-k8s-pod
- cidrGroupRef: net-privat
- cidrGroupRef: net-phone
- cidrGroupRef: net-access
- cidrGroupRef: net-vpn
- cidrGroupRef: net-guest
- cidrGroupRef: net-multi-transfer
- cidrGroupRef: net-multi-lan
- cidrGroupRef: net-mutti
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
@@ -1,6 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- cidr-groups.yaml
- default-deny.yaml
- allow-ingress.yaml
- ingress-isolation.yaml
@@ -40,3 +41,4 @@ resources:
- allow-cilium-health.yaml
- cnpg-barman-s3.yaml
- kube-system-hubble-relay.yaml
- kube-system-coredns.yaml
@@ -1,13 +1,6 @@
# Targeted carve-out: alloy-syslog needs world ingress on the syslog
# ports specifically. The Service is ETP=Cluster, so external syslog
# senders (Proxmox, LXC hosts, network gear) hit the LB IP, get DNAT'd
# to an alloy pod, and get SNAT'd to the receiving node's cilium_host
# address. Cilium then sees the source as `world-ipv4` identity (the
# cilium_host IP isn't a known endpoint), NOT `host` — so the broad
# monitoring CNP's `fromEntities: host` rule doesn't catch it.
#
# Scoped via endpointSelector so this only widens ingress on the
# alloy-syslog pod, not the whole namespace.
# Syslog senders reaching the alloy-syslog LB (ETP=Local, client address
# reaches the pod): network gear on Management, the SLZB coordinators on
# IoT, and the UDM itself, which sources from its k8s-ingress address.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
@@ -19,8 +12,10 @@ spec:
app.kubernetes.io/instance: alloy-syslog
app.kubernetes.io/name: alloy
ingress:
- fromEntities:
- world
- fromCIDRSet:
- cidrGroupRef: net-management
- cidrGroupRef: net-iot
- cidrGroupRef: net-k8s-ingress
toPorts:
- ports:
- port: "514"
@@ -112,7 +112,7 @@ spec:
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
---
# Allow FTP access (from scanners on the LAN) to the ftp-scanner pod.
# FTP from the scanners (IoT and Privat) to the ftp-scanner pod.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
@@ -123,8 +123,24 @@ spec:
matchLabels:
app.kubernetes.io/name: ftp-scanner
ingress:
- fromCIDRSet:
- cidrGroupRef: net-iot
- cidrGroupRef: net-privat
toPorts:
- ports:
- port: "21"
protocol: TCP
- port: "21100"
protocol: TCP
- port: "21101"
protocol: TCP
- port: "21102"
protocol: TCP
- port: "21103"
protocol: TCP
- port: "21104"
protocol: TCP
- fromEntities:
- world
- host
- remote-node
toPorts:
@@ -142,7 +158,7 @@ spec:
- port: "21104"
protocol: TCP
---
# Allow Syncthing peer-to-peer sync from the LAN/world.
# Allow Syncthing peer-to-peer sync from Privat and VPN.
# - 22000 TCP+QUIC: actual sync traffic between peers.
# - 21027 UDP: local-discovery broadcast packets from peers on the LAN.
# UI (8384) is intentionally NOT listed — authentik-gate-sync generates the
@@ -157,8 +173,18 @@ spec:
matchLabels:
app.kubernetes.io/name: syncthing
ingress:
- fromCIDRSet:
- cidrGroupRef: net-privat
- cidrGroupRef: net-vpn
toPorts:
- ports:
- port: "22000"
protocol: TCP
- port: "22000"
protocol: UDP
- port: "21027"
protocol: UDP
- fromEntities:
- world
- host
- remote-node
toPorts:
@@ -62,9 +62,10 @@ spec:
# CA, so this bypasses Traefik entirely and the namespace default-deny has
# to be opened for it — same shape as paperless's allow-ftp-scanner.
#
# Scoped to the terminus pods and port 2345 only. Requests still have to
# carry a known device MAC in the `ID` header plus its api_key; this rule
# opens the port, not the application.
# Scoped to the terminus pods, port 2345 and the IoT network (the panel's
# network; the LB is ETP=Local, so the client address reaches the pod).
# Requests still have to carry a known device MAC in the `ID` header plus
# its api_key; this rule opens the port, not the application.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
@@ -75,8 +76,13 @@ spec:
matchLabels:
app.kubernetes.io/name: terminus
ingress:
- fromCIDRSet:
- cidrGroupRef: net-iot
toPorts:
- ports:
- port: "2345"
protocol: TCP
- fromEntities:
- world
- host
- remote-node
toPorts:
@@ -154,6 +160,12 @@ spec:
---
# In-cluster + DNS for every pod except terminus (terminus-egress is its
# allowlist; selecting it here would widen it to all of `cluster`).
#
# trmnl-renderer's Chromium calls clients2.google.com:80,
# update.googleapis.com:443 and accounts.google.com:443; these drops are
# expected and harmless. The launch flags are fixed in upstream
# trmnl-ha/ha-trmnl/screenshot.ts (PUPPETEER_ARGS, no override) and lack
# --disable-background-networking.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
@@ -71,7 +71,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-02 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-05 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -133,7 +133,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-02 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-05 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -195,7 +195,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-02 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-05 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: backup-verify
image: gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-29 # {"$imagepolicy": "flux-system:backup-verify"}
image: gitea.renner.uno/gitea_admin/ci-backup-verify:2026-11-05 # {"$imagepolicy": "flux-system:backup-verify"}
command: ["bash", "/script/backup-verify.sh"]
env:
- name: HOME
+8 -7
View File
@@ -68,13 +68,14 @@ locals {
# idx is a static per-network slot for rule_index derivation — never
# reuse a freed idx for a new network.
#
# Deliberately absent: (40) Server idx=4, (42) Ingress idx=5, and
# (49) k8s Ceph idx=6. The cluster nodes have interfaces on those VLANs,
# so a DNATed flow's reply would go directly over the shared L2 and
# bypass the UDM's conntrack (asymmetric, connection drops) — and
# clients there are the nodes themselves, whose DNS chain (CoreDNS ->
# k8s_gateway) already resolves the public names in-cluster. Their idx
# slots stay reserved.
# Deliberately absent: (40) Server idx=4, (49) k8s Ceph idx=6,
# (46) k8s Node, (47) k8s Ingress and (48) k8s Pod. The cluster nodes
# have interfaces on those VLANs (mgmt0, node0, ingress0, pod0), so a
# DNATed flow's reply would go directly over the shared L2 and bypass
# the UDM's conntrack (asymmetric, connection drops) — and clients there
# are the nodes themselves, whose DNS chain (CoreDNS -> k8s_gateway)
# already resolves the public names in-cluster. idx=5 stays reserved
# (its network, (42) Ingress, no longer exists).
hairpin_networks = {
"Default" = { id = "5d4fba77ce63ee614841d53d", idx = 0 }
"(10) Management" = { id = "600334abf9ee0c059da6be8f", idx = 1 }
+1
View File
@@ -0,0 +1 @@
/wlan-setup
+23
View File
@@ -0,0 +1,23 @@
# wlan-setup — static Go binary in a FROM-scratch image.
#
# Nothing in the final image but the binary and the CA bundle (the binary has no shell and no
# package manager to patch or to exec).
FROM golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9 AS build
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY *.go index.html ./
RUN go vet ./... \
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /wlan-setup .
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /wlan-setup /wlan-setup
# Matches runAsUser in the Deployment. There is no /etc/passwd in a scratch image, so this is a
# bare uid by necessity.
USER 65534:65534
EXPOSE 8080
ENTRYPOINT ["/wlan-setup"]
+169
View File
@@ -0,0 +1,169 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
)
// authentikHost is the Host header of every call: authentik routes by it.
const authentikHost = "auth.renner.uno"
// maxReply bounds what is read of an authentik reply.
const maxReply = 1 << 20
var credential = regexp.MustCompile(`^[A-Za-z0-9._~+/=-]+$`)
// Authentik calls authentik's OAuth and API endpoints at its internal address.
type Authentik struct {
base string
secret string
http *http.Client
}
// call sends one request and decodes the JSON reply into out. Errors name the method, path and
// status, never the credential or the request body.
func (a *Authentik) call(ctx context.Context, method, path, auth, contentType string, body []byte, out any) error {
var rd io.Reader
if body != nil {
rd = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, a.base+path, rd)
if err != nil {
return fmt.Errorf("%s %s: %w", method, path, err)
}
req.Host = authentikHost
req.Header.Set("Authorization", auth)
req.Header.Set("Accept", "application/json")
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
resp, err := a.http.Do(req)
if err != nil {
return fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, maxReply+1))
if err != nil {
return fmt.Errorf("%s %s: reading the reply: %w", method, path, err)
}
if len(data) > maxReply {
return fmt.Errorf("%s %s -> %d: reply larger than %d bytes", method, path, resp.StatusCode, maxReply)
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return fmt.Errorf("%s %s -> %d %s", method, path, resp.StatusCode, snippet(data))
}
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("%s %s: decoding the reply: %w", method, path, err)
}
return nil
}
func snippet(b []byte) string {
s := strings.ToValidUTF8(string(b), "?")
if len(s) > 300 {
s = s[:300]
}
return s
}
func bearer(token string) (string, error) {
if !credential.MatchString(token) {
return "", fmt.Errorf("no access token")
}
return "Bearer " + token, nil
}
// Exchange trades the authorization code for an access token.
func (a *Authentik) Exchange(ctx context.Context, code, redirectURI string) (string, error) {
basic := "Basic " + base64.StdEncoding.EncodeToString([]byte(clientID+":"+a.secret))
body := form("grant_type", "authorization_code", "code", code, "redirect_uri", redirectURI)
var grant struct {
AccessToken string `json:"access_token"`
}
if err := a.call(ctx, http.MethodPost, "/application/o/token/", basic, "application/x-www-form-urlencoded",
[]byte(body), &grant); err != nil {
return "", err
}
if !credential.MatchString(grant.AccessToken) {
return "", fmt.Errorf("POST /application/o/token/: no access token in the reply")
}
return grant.AccessToken, nil
}
// Username returns the username of the access token's user.
func (a *Authentik) Username(ctx context.Context, token string) (string, error) {
auth, err := bearer(token)
if err != nil {
return "", err
}
var me struct {
User struct {
Username string `json:"username"`
} `json:"user"`
}
if err := a.call(ctx, http.MethodGet, "/api/v3/core/users/me/", auth, "", nil, &me); err != nil {
return "", err
}
return me.User.Username, nil
}
type tokenRequest struct {
Identifier string `json:"identifier"`
Intent string `json:"intent"`
Description string `json:"description"`
Expiring bool `json:"expiring"`
Expires string `json:"expires"`
}
// AppPassword creates an app password as the token's user and returns its key.
func (a *Authentik) AppPassword(ctx context.Context, token string, t tokenRequest) (string, error) {
auth, err := bearer(token)
if err != nil {
return "", err
}
body, err := json.Marshal(t)
if err != nil {
return "", err
}
var created json.RawMessage
if err := a.call(ctx, http.MethodPost, "/api/v3/core/tokens/", auth, "application/json", body, &created); err != nil {
return "", err
}
var view struct {
Key string `json:"key"`
}
path := "/api/v3/core/tokens/" + url.PathEscape(t.Identifier) + "/view_key/"
if err := a.call(ctx, http.MethodGet, path, auth, "", nil, &view); err != nil {
return "", err
}
if view.Key == "" {
return "", fmt.Errorf("GET %s: no key in the reply", path)
}
return view.Key, nil
}
// form encodes key/value pairs in the given order, like encodeURIComponent.
func form(pairs ...string) string {
var b strings.Builder
for i := 0; i+1 < len(pairs); i += 2 {
if i > 0 {
b.WriteByte('&')
}
b.WriteString(escape(pairs[i]))
b.WriteByte('=')
b.WriteString(escape(pairs[i+1]))
}
return b.String()
}
func escape(s string) string {
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
}
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"errors"
"net/url"
"strings"
)
// Config is the service configuration, read from the environment.
type Config struct {
ClientSecret string
// AuthentikURL is where the calls go; they carry Host auth.renner.uno.
AuthentikURL string
AuthorizeURL string
PublicURL string
SigningKey string
SigningCert string
Listen string
}
func loadConfig(getenv func(string) string) (Config, error) {
get := func(key, def string) string {
if v := strings.TrimSpace(getenv(key)); v != "" {
return v
}
return def
}
c := Config{
ClientSecret: strings.TrimSpace(getenv("WLAN_SETUP_CLIENT_SECRET")),
AuthentikURL: strings.TrimRight(get("AUTHENTIK_INTERNAL_URL", "http://authentik-server.authentik.svc.cluster.local"), "/"),
AuthorizeURL: get("AUTHENTIK_AUTHORIZE_URL", "https://auth.renner.uno/application/o/authorize/"),
PublicURL: strings.TrimRight(get("PUBLIC_URL", "https://wlan.renner.uno"), "/"),
SigningKey: get("SIGNING_KEY", "/etc/wlan-signing/tls.key"),
SigningCert: get("SIGNING_CERT", "/etc/wlan-signing/tls.crt"),
Listen: get("LISTEN", ":8080"),
}
var errs []error
if c.ClientSecret == "" {
errs = append(errs, errors.New("WLAN_SETUP_CLIENT_SECRET is not set"))
}
for key, v := range map[string]string{
"AUTHENTIK_INTERNAL_URL": c.AuthentikURL,
"AUTHENTIK_AUTHORIZE_URL": c.AuthorizeURL,
"PUBLIC_URL": c.PublicURL,
} {
if u, err := url.Parse(v); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
errs = append(errs, errors.New(key+" must be an absolute http(s) URL"))
}
}
return c, errors.Join(errs...)
}
+11
View File
@@ -0,0 +1,11 @@
module gitea.renner.uno/konairius/homelab/wlan-setup
go 1.27
require (
github.com/beevik/etree v1.7.0
github.com/russellhaering/goxmldsig v1.6.1
howett.net/plist v1.0.1
)
require github.com/jonboulle/clockwork v0.5.0 // indirect
+19
View File
@@ -0,0 +1,19 @@
github.com/beevik/etree v1.7.0 h1:xjBk9O4p4x7D1YajePjfLzdaFC4/uYUENA7P0pv6gXA=
github.com/beevik/etree v1.7.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russellhaering/goxmldsig v1.6.1 h1:SB7R5ttvrGIDB2juJAK/i7DQ2Ivr7agG+ohfNJjwyYU=
github.com/russellhaering/goxmldsig v1.6.1/go.mod h1:haZkRcLs9W/Xp989fIjP3BrTdbFQveRF0QNZSYoH09w=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
+123
View File
@@ -0,0 +1,123 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WLAN einrichten</title>
<style>
:root { --bg: #ffffff; --fg: #1d1d1f; --muted: #5f6368; --card: #f4f5f7; --accent: #0b63ce; --code: #e9ebef; }
@media (prefers-color-scheme: dark) {
:root { --bg: #121314; --fg: #e8eaed; --muted: #9aa0a6; --card: #1e1f22; --accent: #7cacf8; --code: #2a2b2f; }
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 42rem; margin: 0 auto; padding: 1.5rem 1rem 3rem; }
h1 { font-size: 1.6rem; margin: 0 0 .25rem; }
h2 { font-size: 1.15rem; margin: 0 0 .5rem; }
p { margin: .5rem 0; }
.muted { color: var(--muted); }
section { background: var(--card); border-radius: 12px; padding: 1rem 1.1rem; margin: 1rem 0; }
section.current { outline: 2px solid var(--accent); }
a.button, button { display: inline-block; background: var(--accent); color: #fff; border: 0; border-radius: 8px; padding: .6rem 1rem; font: inherit; text-decoration: none; cursor: pointer; }
@media (prefers-color-scheme: dark) { a.button, button { color: #121314; } }
ol { padding-left: 1.3rem; margin: .5rem 0; }
pre { background: var(--code); border-radius: 8px; padding: .75rem; overflow-x: auto; white-space: pre-wrap; word-break: break-all; font-size: .85rem; margin: .5rem 0; }
details > summary { cursor: pointer; color: var(--accent); margin: 1.5rem 0 .5rem; }
</style>
</head>
<body>
<main>
<h1>WLAN einrichten</h1>
<p>WLAN <strong>Renner UniFi</strong> für dein Konto <strong id="user">…</strong>. Android, iPhone, iPad, Mac und Windows bekommen ein eigenes WLAN-App-Passwort; Linux meldet sich mit deinem authentik-Passwort an.</p>
<div id="detected"></div>
<details id="others">
<summary>Andere Geräte</summary>
<div id="rest"></div>
</details>
<template id="t-android">
<section data-os="android">
<h2>Android</h2>
<p><a class="button" href="/android/install">Auf Android installieren</a></p>
<p class="muted">Das Gerät bekommt ein eigenes WLAN-App-Passwort in deinem authentik-Konto; dort gelöscht, ist das Gerät aus dem WLAN.</p>
</section>
</template>
<template id="t-apple">
<section data-os="apple">
<h2>iPhone, iPad, Mac</h2>
<p><a class="button" href="/apple/install">Profil laden</a></p>
<ol>
<li>iPhone/iPad: Einstellungen → „Profil geladen“ → Installieren. Mac: Systemeinstellungen → Allgemein → Geräteverwaltung → Profil doppelklicken.</li>
<li>Das Gerät verbindet sich danach selbst mit „Renner UniFi“.</li>
</ol>
<p class="muted">Das Gerät bekommt ein eigenes WLAN-App-Passwort in deinem authentik-Konto; dort gelöscht, ist das Gerät aus dem WLAN.</p>
</section>
</template>
<template id="t-windows">
<section data-os="windows">
<h2>Windows</h2>
<p><a class="button" href="/windows/install">Auf Windows installieren</a></p>
<p>Windows öffnet die Einstellungen und fragt, ob das WLAN-Profil hinzugefügt werden soll: „Hinzufügen“ wählen.</p>
<p class="muted">Das Gerät bekommt ein eigenes WLAN-App-Passwort in deinem authentik-Konto; dort gelöscht, ist das Gerät aus dem WLAN.</p>
</section>
</template>
<template id="t-linux">
<section data-os="linux">
<h2>Linux (NetworkManager)</h2>
<p>Im Terminal ausführen, danach fragt NetworkManager nach dem Passwort:</p>
<pre id="nmcli"></pre>
<p><button type="button" id="copy-nmcli">Befehl kopieren</button></p>
</section>
</template>
</main>
<script>
(function () {
var ua = navigator.userAgent;
var os = "other";
if (/Android/i.test(ua)) os = "android";
else if (/iPhone|iPad|iPod|Macintosh/i.test(ua)) os = "apple";
else if (/Windows/i.test(ua)) os = "windows";
else if (/Linux|X11/i.test(ua)) os = "linux";
var detected = document.getElementById("detected");
var rest = document.getElementById("rest");
["android", "apple", "windows", "linux"].forEach(function (name) {
var node = document.getElementById("t-" + name).content.cloneNode(true);
if (name === os) { node.querySelector("section").classList.add("current"); detected.appendChild(node); }
else rest.appendChild(node);
});
if (os === "other") document.getElementById("others").open = true;
function quote(s) { return "'" + s.replace(/'/g, "'\\''") + "'"; }
function render(user) {
document.getElementById("user").textContent = user || "(dein Benutzername)";
document.getElementById("nmcli").textContent =
"nmcli connection add type wifi con-name 'Renner UniFi' ssid 'Renner UniFi' " +
"wifi-sec.key-mgmt wpa-eap 802-1x.eap ttls 802-1x.phase2-auth pap " +
"802-1x.anonymous-identity anonymous 802-1x.identity " + quote(user || "BENUTZERNAME") + " " +
"802-1x.domain-suffix-match radius.renner.uno 802-1x.system-ca-certs yes " +
"802-1x.password-flags 2 && nmcli --ask connection up 'Renner UniFi'";
}
render("");
fetch("/whoami", { credentials: "same-origin" })
.then(function (r) { return r.ok ? r.text() : ""; })
.then(function (t) { render(t.trim()); })
.catch(function () {});
document.getElementById("copy-nmcli").addEventListener("click", function () {
var text = document.getElementById("nmcli").textContent;
var btn = this;
navigator.clipboard.writeText(text).then(function () {
btn.textContent = "Kopiert";
setTimeout(function () { btn.textContent = "Befehl kopieren"; }, 2000);
});
});
})();
</script>
</body>
</html>
+59
View File
@@ -0,0 +1,59 @@
// wlan-setup serves wlan.renner.uno behind the authentik gate: the setup page for the WLAN
// "Renner UniFi" and, per device, an authorization-code sign-in that creates an authentik app
// password as the user and answers with a Passpoint profile (Android, Apple) or a signed
// CarrierProvisioning document that Windows Settings fetches once.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
cfg, err := loadConfig(os.Getenv)
if err != nil {
log.Error("invalid configuration", "err", err)
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := run(ctx, cfg, log); err != nil {
log.Error("stopped", "err", err)
os.Exit(1)
}
}
func run(ctx context.Context, cfg Config, log *slog.Logger) error {
srv := &http.Server{
Addr: cfg.Listen,
Handler: newServer(cfg, log).routes(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
// A callback makes up to four authentik calls of at most 10 s each.
WriteTimeout: 60 * time.Second,
IdleTimeout: 2 * time.Minute,
MaxHeaderBytes: 64 << 10,
}
failed := make(chan error, 1)
go func() { failed <- srv.ListenAndServe() }()
log.Info("listening", "addr", cfg.Listen)
select {
case <-ctx.Done():
case err := <-failed:
if !errors.Is(err, http.ErrServerClosed) {
return err
}
}
log.Info("shutting down")
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdown)
}
+258
View File
@@ -0,0 +1,258 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/xml"
"fmt"
"mime"
"mime/multipart"
"net/textproto"
"github.com/beevik/etree"
"howett.net/plist"
)
// isrgRootX1 is the trust anchor of the radius-tls chain (DER, base64).
const isrgRootX1 = "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygch77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZLubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVcoyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc="
// carrierID identifies the Windows provisioning document; a reinstall replaces the profile.
const carrierID = "{5423c37a-7530-4ce9-8f87-a2574ea006bc}"
// mimeBoundary separates the parts of the Android profile.
const mimeBoundary = "renner-passpoint-profile"
// ppsNode is a node of the PPS MO management tree: a leaf carries a Value, an interior node
// further nodes.
type ppsNode struct {
XMLName xml.Name `xml:"Node"`
NodeName string `xml:"NodeName"`
RTProperties *ppsRTProperties `xml:"RTProperties,omitempty"`
Value *string `xml:"Value,omitempty"`
Nodes []ppsNode `xml:"Node"`
}
type ppsRTProperties struct {
DDFName string `xml:"Type>DDFName"`
}
type ppsTree struct {
XMLName xml.Name `xml:"syncml:dmddf1.2 MgmtTree"`
VerDTD string `xml:"VerDTD"`
Nodes []ppsNode `xml:"Node"`
}
func ppsLeaf(name, value string) ppsNode { return ppsNode{NodeName: name, Value: &value} }
func ppsInterior(name string, nodes ...ppsNode) ppsNode {
return ppsNode{NodeName: name, Nodes: nodes}
}
// androidProfile is the application/x-wifi-config body: base64 of a MIME multipart with the
// Passpoint R1 PPS MO and the trust anchor, each part base64.
func androidProfile(user, password string) ([]byte, error) {
subscription := ppsInterior("PerProviderSubscription",
ppsInterior("i001",
ppsInterior("HomeSP", ppsLeaf("FriendlyName", "Renner"), ppsLeaf("FQDN", "renner.uno")),
ppsInterior("Credential",
ppsLeaf("Realm", "renner.uno"),
ppsInterior("UsernamePassword",
ppsLeaf("Username", user),
ppsLeaf("Password", base64.StdEncoding.EncodeToString([]byte(password))),
ppsInterior("EAPMethod", ppsLeaf("EAPType", "21"), ppsLeaf("InnerMethod", "PAP")))),
ppsInterior("Extension", ppsInterior("Android",
ppsInterior("AAAServerTrustedNames", ppsLeaf("FQDN", "radius.renner.uno"))))))
subscription.RTProperties = &ppsRTProperties{DDFName: "urn:wfa:mo:hotspot2dot0-perprovidersubscription:1.0"}
pps, err := xml.Marshal(ppsTree{VerDTD: "1.2", Nodes: []ppsNode{subscription}})
if err != nil {
return nil, fmt.Errorf("pps: %w", err)
}
var parts bytes.Buffer
mw := multipart.NewWriter(&parts)
if err := mw.SetBoundary(mimeBoundary); err != nil {
return nil, err
}
for _, p := range []struct{ contentType, body string }{
{"application/x-passpoint-profile", base64.StdEncoding.EncodeToString(pps)},
{"application/x-x509-ca-cert", isrgRootX1},
} {
w, err := mw.CreatePart(textproto.MIMEHeader{
"Content-Type": {p.contentType},
"Content-Transfer-Encoding": {"base64"},
})
if err != nil {
return nil, err
}
if _, err := w.Write([]byte(p.body)); err != nil {
return nil, err
}
}
if err := mw.Close(); err != nil {
return nil, err
}
var message bytes.Buffer
fmt.Fprintf(&message, "Content-Type: %s\r\nContent-Transfer-Encoding: base64\r\n\r\n",
mime.FormatMediaType("multipart/mixed", map[string]string{"boundary": mw.Boundary()}))
message.Write(parts.Bytes())
return []byte(base64.StdEncoding.EncodeToString(message.Bytes())), nil
}
type appleEAP struct {
UserName string
UserPassword string
OuterIdentity string
AcceptEAPTypes []int
TTLSInnerAuthentication string
TLSTrustedServerNames []string
}
type appleWiFi struct {
PayloadType string
PayloadVersion int
PayloadIdentifier string
PayloadUUID string
PayloadDisplayName string
PayloadOrganization string
IsHotspot bool
DomainName string
NAIRealmNames []string
DisplayedOperatorName string
ServiceProviderRoamingEnabled bool
AutoJoin bool
EncryptionType string
EAPClientConfiguration appleEAP
}
type appleConfiguration struct {
PayloadContent []appleWiFi
PayloadType string
PayloadVersion int
PayloadIdentifier string
PayloadUUID string
PayloadDisplayName string
PayloadOrganization string
}
// appleProfile is the .mobileconfig: com.apple.wifi.managed with Passpoint matching. Fixed
// identifiers and UUIDs, so a reinstall replaces the previous profile.
func appleProfile(user, password string) ([]byte, error) {
return plist.MarshalIndent(appleConfiguration{
PayloadContent: []appleWiFi{{
PayloadType: "com.apple.wifi.managed",
PayloadVersion: 1,
PayloadIdentifier: "uno.renner.wifi.renner-unifi.network",
PayloadUUID: "824959bf-c55d-4ed2-b569-c33f3759c2b6",
PayloadDisplayName: "Renner UniFi",
PayloadOrganization: "Renner",
IsHotspot: true,
DomainName: "renner.uno",
NAIRealmNames: []string{"renner.uno"},
DisplayedOperatorName: "Renner",
ServiceProviderRoamingEnabled: false,
AutoJoin: true,
EncryptionType: "Any",
EAPClientConfiguration: appleEAP{
UserName: user,
UserPassword: password,
OuterIdentity: "anonymous",
AcceptEAPTypes: []int{21},
TTLSInnerAuthentication: "PAP",
TLSTrustedServerNames: []string{"radius.renner.uno"},
},
}},
PayloadType: "Configuration",
PayloadVersion: 1,
PayloadIdentifier: "uno.renner.wifi.renner-unifi",
PayloadUUID: "7175a2a3-ddd3-42ea-b4ad-b7e5501892f3",
PayloadDisplayName: "WLAN Renner UniFi",
PayloadOrganization: "Renner",
}, plist.XMLFormat, "\t")
}
// Namespaces of the CarrierProvisioning document.
const (
nsCarrierControl = "http://www.microsoft.com/networking/CarrierControl/v1"
nsWLAN = "http://www.microsoft.com/networking/CarrierControl/WLAN/v1"
nsOneX = "http://www.microsoft.com/networking/OneX/v1"
nsEapHostConfig = "http://www.microsoft.com/provisioning/EapHostConfig"
nsEapCommon = "http://www.microsoft.com/provisioning/EapCommon"
nsEapTtlsConn = "http://www.microsoft.com/provisioning/EapTtlsConnectionPropertiesV1"
nsEapHostUserCred = "http://www.microsoft.com/provisioning/EapHostUserCredentials"
nsEapTtlsUser = "http://www.microsoft.com/provisioning/EapTtlsUserPropertiesV1"
)
// add appends an element named tag to parent. ns, when set, becomes its default namespace.
func add(parent *etree.Element, tag, ns string) *etree.Element {
el := parent.CreateElement(tag)
if ns != "" {
el.CreateAttr("xmlns", ns)
}
return el
}
// addText appends an element named tag with text content to parent.
func addText(parent *etree.Element, tag, ns, text string) *etree.Element {
el := add(parent, tag, ns)
el.SetText(text)
return el
}
// windowsProfile is the unsigned CarrierProvisioning document: one Hotspot 2.0 WLAN profile
// (domain and NAI realm renner.uno), WPA3-Enterprise, EAP-TTLS with inner PAP, and the user's
// credentials. Valid against the schemas Windows ships in %SYSTEMROOT%\schemas\provisioning
// (TestWindowsProfileSchema); SubscriberId is base:SubscriberType, i.e. \w+ and at most 20
// characters.
func windowsProfile(user, password string) *etree.Document {
doc := etree.NewDocument()
doc.WriteSettings = etree.WriteSettings{CanonicalEndTags: true, CanonicalText: true, CanonicalAttrVal: true}
doc.CreateProcInst("xml", `version="1.0"`)
doc.CreateText("\n")
root := add(&doc.Element, "CarrierProvisioning", nsCarrierControl)
global := add(root, "Global", "")
addText(global, "CarrierId", "", carrierID)
addText(global, "SubscriberId", "", "rennerunifi")
wlan := add(add(root, "WLANProfiles", ""), "WLANProfile", nsWLAN)
addText(wlan, "name", "", "Renner UniFi")
addText(add(add(wlan, "SSIDConfig", ""), "SSID", ""), "name", "", "Renner UniFi")
hotspot2 := add(wlan, "Hotspot2", "")
addText(hotspot2, "DomainName", "", "renner.uno")
addText(add(hotspot2, "NAIRealm", ""), "name", "", "renner.uno")
security := add(add(wlan, "MSM", ""), "security", "")
auth := add(security, "authEncryption", "")
addText(auth, "authentication", "", "WPA3ENT")
addText(auth, "encryption", "", "AES")
addText(auth, "useOneX", "", "true")
oneX := add(security, "OneX", nsOneX)
addText(oneX, "authMode", "", "user")
eapHost := add(add(oneX, "EAPConfig", ""), "EapHostConfig", nsEapHostConfig)
method := add(eapHost, "EapMethod", "")
addText(method, "Type", nsEapCommon, "21")
addText(method, "VendorId", nsEapCommon, "0")
addText(method, "VendorType", nsEapCommon, "0")
addText(method, "AuthorId", nsEapCommon, "311")
ttls := add(add(eapHost, "Config", ""), "EapTtls", nsEapTtlsConn)
validation := add(ttls, "ServerValidation", "")
addText(validation, "ServerNames", "", "radius.renner.uno")
addText(validation, "TrustedRootCAHash", "", "ca bd 2a 79 a1 07 6a 31 f2 1d 25 36 35 cb 03 9d 43 29 a5 e8")
addText(validation, "DisablePrompt", "", "true")
add(add(ttls, "Phase2Authentication", ""), "PAPAuthentication", "")
identity := add(ttls, "Phase1Identity", "")
addText(identity, "IdentityPrivacy", "", "true")
addText(identity, "AnonymousIdentity", "", "anonymous")
userCred := add(security, "EapHostUserCredentials", nsEapHostUserCred)
userCred.CreateAttr("xmlns:eapCommon", nsEapCommon)
userMethod := add(userCred, "EapMethod", "")
addText(userMethod, "eapCommon:Type", "", "21")
addText(userMethod, "eapCommon:AuthorId", "", "311")
credentials := add(add(userCred, "Credentials", ""), "EapTtls", nsEapTtlsUser)
addText(credentials, "Username", "", user)
addText(credentials, "Password", "", password)
return doc
}
+249
View File
@@ -0,0 +1,249 @@
package main
import (
"crypto/rand"
"crypto/subtle"
_ "embed"
"encoding/base64"
"io"
"log/slog"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
//go:embed index.html
var indexHTML []byte
const (
clientID = "wlan-setup"
stateCookie = "wlan_oauth_state"
lifetimeDays = 29
restart = "Bitte die Einrichtung neu starten.\n"
failed = "Die Einrichtung ist fehlgeschlagen.\n"
)
// userPattern admits only plain usernames into a generated file.
var userPattern = regexp.MustCompile(`^[A-Za-z0-9._@+-]{1,64}$`)
var identifierUnsafe = regexp.MustCompile(`[^a-z0-9-]`)
var platformLabels = map[string]string{"android": "Android", "apple": "Apple", "windows": "Windows"}
// Server serves the setup page and the per-platform flows.
type Server struct {
cfg Config
authentik *Authentik
profiles *ProfileStore
now func() time.Time
log *slog.Logger
}
func newServer(cfg Config, log *slog.Logger) *Server {
return &Server{
cfg: cfg,
authentik: &Authentik{base: cfg.AuthentikURL, secret: cfg.ClientSecret, http: &http.Client{Timeout: 10 * time.Second}},
profiles: newProfileStore(time.Now),
now: time.Now,
log: log,
}
}
func (s *Server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(indexHTML)
})
mux.HandleFunc("GET /whoami", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
if u := r.Header.Get("X-authentik-username"); userPattern.MatchString(u) {
_, _ = io.WriteString(w, u)
}
})
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = io.WriteString(w, "ok\n")
})
for name := range platformLabels {
mux.HandleFunc("GET /"+name+"/install", func(w http.ResponseWriter, r *http.Request) { s.install(w, r, name) })
mux.HandleFunc("GET /"+name+"/callback", func(w http.ResponseWriter, r *http.Request) { s.callback(w, r, name) })
}
mux.HandleFunc("GET /windows/profile/{token}", s.windowsProfile)
mux.HandleFunc("/", http.NotFound)
return noStore(mux)
}
func noStore(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
h.ServeHTTP(w, r)
})
}
func random(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func (s *Server) redirectURI(name string) string { return s.cfg.PublicURL + "/" + name + "/callback" }
func setStateCookie(w http.ResponseWriter, name, value string, maxAge int) {
w.Header().Set("Set-Cookie", stateCookie+"="+value+"; Path=/"+name+"/; Max-Age="+strconv.Itoa(maxAge)+
"; HttpOnly; Secure; SameSite=Lax")
}
func redirect(w http.ResponseWriter, location string) {
w.Header().Set("Location", location)
w.WriteHeader(http.StatusFound)
}
func text(w http.ResponseWriter, status int, body string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(status)
_, _ = io.WriteString(w, body)
}
// install starts an authorization-code sign-in at authentik; the user is already signed in there.
func (s *Server) install(w http.ResponseWriter, _ *http.Request, name string) {
state := random(24)
setStateCookie(w, name, state, 300)
redirect(w, s.cfg.AuthorizeURL+"?"+form(
"response_type", "code",
"client_id", clientID,
"redirect_uri", s.redirectURI(name),
"scope", "openid profile goauthentik.io/api",
"state", state,
))
}
func stateFrom(r *http.Request) string {
c, err := r.Cookie(stateCookie)
if err != nil {
return ""
}
return c.Value
}
// callback creates a new app password as the user and answers with the platform's profile.
func (s *Server) callback(w http.ResponseWriter, r *http.Request, name string) {
q := r.URL.Query()
state, code := stateFrom(r), q.Get("code")
if state == "" || code == "" || subtle.ConstantTimeCompare([]byte(state), []byte(q.Get("state"))) != 1 {
text(w, http.StatusBadRequest, restart)
return
}
fail := func(reason string, args ...any) {
s.log.Error("wlan-setup "+name+": "+reason, args...)
text(w, http.StatusBadGateway, failed)
}
// Windows signs the profile: without key and chain there is nothing to hand out, so no app
// password is created.
var signer *Signer
if name == "windows" {
var err error
if signer, err = loadSigner(s.cfg.SigningKey, s.cfg.SigningCert); err != nil {
fail("cannot load the signing key", "err", err)
return
}
}
ctx := r.Context()
access, err := s.authentik.Exchange(ctx, code, s.redirectURI(name))
if err != nil {
fail("token exchange", "err", err)
return
}
user, err := s.authentik.Username(ctx, access)
if err != nil {
fail("user lookup", "err", err)
return
}
if !userPattern.MatchString(user) || user != r.Header.Get("X-authentik-username") {
s.log.Warn("wlan-setup "+name+": the token's user is not the signed-in user", "user", user)
w.WriteHeader(http.StatusForbidden)
return
}
now := s.now().UTC()
suffix := strings.Map(func(c rune) rune {
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
return c
}
return 'x'
}, random(24)[:6])
identifier := "wlan-" + name + "-" + identifierUnsafe.ReplaceAllString(strings.ToLower(user), "-") + "-" +
now.Format("20060102T150405") + "-" + suffix
key, err := s.authentik.AppPassword(ctx, access, tokenRequest{
Identifier: identifier,
Intent: "app_password",
Description: "WLAN " + platformLabels[name] + ", " + now.Format("2006-01-02") + " (wlan.renner.uno)",
Expiring: true,
Expires: now.Add(lifetimeDays * 24 * time.Hour).Format("2006-01-02T15:04:05.000Z07:00"),
})
if err != nil {
fail("app password", "err", err)
return
}
switch name {
case "android":
body, err := androidProfile(user, key)
if err != nil {
fail("building the profile", "err", err)
return
}
setStateCookie(w, name, "", 0)
w.Header().Set("Content-Type", "application/x-wifi-config")
w.Header().Set("Content-Transfer-Encoding", "base64")
_, _ = w.Write(body)
case "apple":
body, err := appleProfile(user, key)
if err != nil {
fail("building the profile", "err", err)
return
}
setStateCookie(w, name, "", 0)
w.Header().Set("Content-Type", "application/x-apple-aspen-config")
w.Header().Set("Content-Disposition", `attachment; filename="renner-unifi.mobileconfig"`)
_, _ = w.Write(body)
case "windows":
body, err := signer.Sign(windowsProfile(user, key))
if err != nil {
fail("signing the profile", "err", err)
return
}
token := random(32)
if !s.profiles.Put(token, body) {
fail("too many profiles waiting to be fetched")
return
}
setStateCookie(w, name, "", 0)
redirect(w, "ms-settings:wifi-provisioning?uri="+s.cfg.PublicURL+"/windows/profile/"+token)
}
}
// windowsProfile hands out a signed profile once. Windows Settings fetches it without the
// authentik session: the unguessable single-use token is the only gate.
func (s *Server) windowsProfile(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
if r.Method == http.MethodHead {
if !s.profiles.Has(token) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/xml")
return
}
body, ok := s.profiles.Take(token)
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/xml")
_, _ = w.Write(body)
}
+880
View File
@@ -0,0 +1,880 @@
package main
import (
"bufio"
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"encoding/xml"
"io"
"log/slog"
"math/big"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"net/url"
"os"
"os/exec"
"path/filepath"
"reflect"
"regexp"
"strings"
"sync"
"testing"
"time"
"github.com/beevik/etree"
dsig "github.com/russellhaering/goxmldsig"
"howett.net/plist"
)
const (
testSecret = "client-secret"
testAccess = "access-token-123"
testKey = "app-password-<&>-456"
testCode = "code-789"
)
// fakeAuthentik answers the four calls of a callback and records what it received.
type fakeAuthentik struct {
t *testing.T
username string
fail string // path that answers 500
mu sync.Mutex
requests []string
created tokenRequest
}
func (f *fakeAuthentik) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
f.mu.Unlock()
t := f.t
if r.Host != "auth.renner.uno" {
t.Errorf("%s: Host %q", r.URL.Path, r.Host)
}
if r.Header.Get("Cookie") != "" {
t.Errorf("%s: a cookie reached authentik", r.URL.Path)
}
if r.URL.Path == f.fail {
http.Error(w, `{"detail":"broken"}`, http.StatusInternalServerError)
return
}
bearer := func() bool {
if got := r.Header.Get("Authorization"); got != "Bearer "+testAccess {
t.Errorf("%s: Authorization %q", r.URL.Path, got)
return false
}
return true
}
switch {
case r.Method == http.MethodPost && r.URL.Path == "/application/o/token/":
user, pass, ok := r.BasicAuth()
if !ok || user != "wlan-setup" || pass != testSecret {
t.Errorf("token: Basic auth %q %v", user, ok)
}
if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
t.Errorf("token: Content-Type %q", ct)
}
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
if r.PostForm.Get("grant_type") != "authorization_code" || r.PostForm.Get("code") != testCode {
t.Errorf("token: form %v", r.PostForm)
}
platform := strings.Split(r.PostForm.Get("redirect_uri"), "/")[3]
if want := "https://wlan.renner.uno/" + platform + "/callback"; r.PostForm.Get("redirect_uri") != want {
t.Errorf("token: redirect_uri %q", r.PostForm.Get("redirect_uri"))
}
_, _ = io.WriteString(w, `{"access_token":"`+testAccess+`","token_type":"Bearer"}`)
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/core/users/me/":
if bearer() {
_ = json.NewEncoder(w).Encode(map[string]any{"user": map[string]any{"username": f.username}})
}
case r.Method == http.MethodPost && r.URL.Path == "/api/v3/core/tokens/":
if !bearer() {
return
}
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("tokens: Content-Type %q", ct)
}
var tr tokenRequest
if err := json.NewDecoder(r.Body).Decode(&tr); err != nil {
t.Fatal(err)
}
f.mu.Lock()
f.created = tr
f.mu.Unlock()
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"identifier":"`+tr.Identifier+`"}`)
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v3/core/tokens/") &&
strings.HasSuffix(r.URL.Path, "/view_key/"):
f.mu.Lock()
want := "/api/v3/core/tokens/" + f.created.Identifier + "/view_key/"
f.mu.Unlock()
if r.URL.Path != want {
t.Errorf("view_key: path %q, want %q", r.URL.Path, want)
}
if bearer() {
_ = json.NewEncoder(w).Encode(map[string]string{"key": testKey})
}
default:
t.Errorf("unexpected call %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}
type testCert struct {
cert *x509.Certificate
keyFile string
certFile string
}
// writeSigningFiles writes a fresh RSA key (PKCS#8) and a chain of a self-signed leaf plus a
// second certificate, as cert-manager would mount them.
func writeSigningFiles(t *testing.T) testCert {
t.Helper()
dir := t.TempDir()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
mk := func(cn string, k *rsa.PrivateKey) []byte {
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &k.PublicKey, k)
if err != nil {
t.Fatal(err)
}
return der
}
leaf := mk("wlan.renner.uno", key)
other, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
intermediate := mk("intermediate", other)
pkcs8, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatal(err)
}
tc := testCert{keyFile: filepath.Join(dir, "tls.key"), certFile: filepath.Join(dir, "tls.crt")}
if err := os.WriteFile(tc.keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}), 0o600); err != nil {
t.Fatal(err)
}
chain := append(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leaf}),
pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: intermediate})...)
if err := os.WriteFile(tc.certFile, chain, 0o600); err != nil {
t.Fatal(err)
}
tc.cert, _ = x509.ParseCertificate(leaf)
return tc
}
type harness struct {
srv *Server
handler http.Handler
fake *fakeAuthentik
signing testCert
clock *fakeClock
}
type fakeClock struct {
mu sync.Mutex
now time.Time
}
func (c *fakeClock) Now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.now }
func (c *fakeClock) Add(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.now = c.now.Add(d)
}
func newHarness(t *testing.T) *harness {
t.Helper()
fake := &fakeAuthentik{t: t, username: "Alice.Smith"}
upstream := httptest.NewServer(fake)
t.Cleanup(upstream.Close)
signing := writeSigningFiles(t)
cfg, err := loadConfig(env(map[string]string{
"WLAN_SETUP_CLIENT_SECRET": testSecret,
"AUTHENTIK_INTERNAL_URL": upstream.URL,
"SIGNING_KEY": signing.keyFile,
"SIGNING_CERT": signing.certFile,
}))
if err != nil {
t.Fatal(err)
}
srv := newServer(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
clock := &fakeClock{now: time.Date(2026, 11, 5, 9, 8, 7, 0, time.UTC)}
srv.now = clock.Now
srv.profiles.now = clock.Now
return &harness{srv: srv, handler: srv.routes(), fake: fake, signing: signing, clock: clock}
}
func env(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
func (h *harness) do(req *http.Request) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
h.handler.ServeHTTP(rec, req)
if rec.Header().Get("Cache-Control") != "no-store" {
panic("no Cache-Control: no-store on " + req.URL.Path)
}
return rec
}
func (h *harness) callback(platform, cookieState, queryState, user string) *httptest.ResponseRecorder {
q := url.Values{"state": {queryState}, "code": {testCode}}
req := httptest.NewRequest(http.MethodGet, "/"+platform+"/callback?"+q.Encode(), nil)
if cookieState != "" {
req.Header.Set("Cookie", "other=1; wlan_oauth_state="+cookieState)
}
req.Header.Set("X-authentik-username", user)
return h.do(req)
}
func TestInstall(t *testing.T) {
h := newHarness(t)
for _, platform := range []string{"android", "apple", "windows"} {
rec := h.do(httptest.NewRequest(http.MethodGet, "/"+platform+"/install", nil))
if rec.Code != http.StatusFound {
t.Fatalf("%s: status %d", platform, rec.Code)
}
cookie := rec.Header().Get("Set-Cookie")
m := regexp.MustCompile(`^wlan_oauth_state=([A-Za-z0-9_-]{32}); Path=/` + platform +
`/; Max-Age=300; HttpOnly; Secure; SameSite=Lax$`).FindStringSubmatch(cookie)
if m == nil {
t.Fatalf("%s: cookie %q", platform, cookie)
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, "https://auth.renner.uno/application/o/authorize/?response_type=code&") {
t.Errorf("%s: location %q", platform, loc)
}
if !strings.Contains(loc, "scope=openid%20profile%20goauthentik.io%2Fapi") {
t.Errorf("%s: scope not encoded like encodeURIComponent: %q", platform, loc)
}
u, err := url.Parse(loc)
if err != nil {
t.Fatal(err)
}
q := u.Query()
if q.Get("client_id") != "wlan-setup" || q.Get("redirect_uri") != "https://wlan.renner.uno/"+platform+"/callback" ||
q.Get("scope") != "openid profile goauthentik.io/api" || q.Get("state") != m[1] || q.Get("response_type") != "code" {
t.Errorf("%s: query %v", platform, q)
}
}
}
func TestCallbackStateMismatch(t *testing.T) {
h := newHarness(t)
for _, c := range []struct{ cookie, query string }{{"abc", "abd"}, {"", "abc"}, {"abc", ""}} {
rec := h.callback("android", c.cookie, c.query, "Alice.Smith")
if rec.Code != http.StatusBadRequest || rec.Body.String() != "Bitte die Einrichtung neu starten.\n" {
t.Errorf("%+v: %d %q", c, rec.Code, rec.Body.String())
}
}
req := httptest.NewRequest(http.MethodGet, "/apple/callback?state=abc", nil)
req.Header.Set("Cookie", "wlan_oauth_state=abc")
if rec := h.do(req); rec.Code != http.StatusBadRequest {
t.Errorf("no code: %d", rec.Code)
}
if len(h.fake.requests) != 0 {
t.Errorf("authentik was called: %v", h.fake.requests)
}
}
func TestCallbackUserMismatch(t *testing.T) {
h := newHarness(t)
if rec := h.callback("android", "s", "s", "bob"); rec.Code != http.StatusForbidden {
t.Errorf("other user: %d", rec.Code)
}
h.fake.username = "bad user"
if rec := h.callback("apple", "s", "s", "bad user"); rec.Code != http.StatusForbidden {
t.Errorf("unsafe username: %d", rec.Code)
}
for _, r := range h.fake.requests {
if strings.HasPrefix(r, "POST /api/v3/core/tokens/") {
t.Errorf("an app password was created for a mismatched user")
}
}
}
func TestCallbackUpstreamError(t *testing.T) {
for _, path := range []string{"/application/o/token/", "/api/v3/core/users/me/", "/api/v3/core/tokens/"} {
h := newHarness(t)
h.fake.fail = path
rec := h.callback("android", "s", "s", "Alice.Smith")
if rec.Code != http.StatusBadGateway || rec.Body.String() != "Die Einrichtung ist fehlgeschlagen.\n" {
t.Errorf("%s: %d %q", path, rec.Code, rec.Body.String())
}
if strings.Contains(rec.Header().Get("Set-Cookie"), "Max-Age=0") {
t.Errorf("%s: the state cookie was cleared on failure", path)
}
}
}
func TestCallbackWindowsWithoutSigningKey(t *testing.T) {
h := newHarness(t)
h.srv.cfg.SigningKey = filepath.Join(t.TempDir(), "missing.key")
if rec := h.callback("windows", "s", "s", "Alice.Smith"); rec.Code != http.StatusBadGateway {
t.Errorf("status %d", rec.Code)
}
if len(h.fake.requests) != 0 {
t.Errorf("authentik was called without a signing key: %v", h.fake.requests)
}
}
func checkCreated(t *testing.T, h *harness, platform, label string) {
t.Helper()
got := h.fake.created
if !regexp.MustCompile(`^wlan-` + platform + `-alice-smith-20261105T090807-[A-Za-z0-9]{6}$`).MatchString(got.Identifier) {
t.Errorf("identifier %q", got.Identifier)
}
if got.Intent != "app_password" || !got.Expiring || got.Expires != "2026-12-04T09:08:07.000Z" ||
got.Description != "WLAN "+label+", 2026-11-05 (wlan.renner.uno)" {
t.Errorf("token request %+v", got)
}
want := []string{"POST /application/o/token/", "GET /api/v3/core/users/me/", "POST /api/v3/core/tokens/",
"GET /api/v3/core/tokens/" + got.Identifier + "/view_key/"}
if strings.Join(h.fake.requests, "|") != strings.Join(want, "|") {
t.Errorf("calls %v", h.fake.requests)
}
}
func checkCleared(t *testing.T, rec *httptest.ResponseRecorder, platform string) {
t.Helper()
if got := rec.Header().Get("Set-Cookie"); got != "wlan_oauth_state=; Path=/"+platform+"/; Max-Age=0; HttpOnly; Secure; SameSite=Lax" {
t.Errorf("cookie %q", got)
}
}
func TestCallbackAndroid(t *testing.T) {
h := newHarness(t)
rec := h.callback("android", "s", "s", "Alice.Smith")
if rec.Code != http.StatusOK {
t.Fatalf("status %d %q", rec.Code, rec.Body.String())
}
checkCreated(t, h, "android", "Android")
checkCleared(t, rec, "android")
if rec.Header().Get("Content-Type") != "application/x-wifi-config" ||
rec.Header().Get("Content-Transfer-Encoding") != "base64" || rec.Header().Get("Content-Disposition") != "" {
t.Errorf("headers %v", rec.Header())
}
raw, err := base64.StdEncoding.DecodeString(rec.Body.String())
if err != nil {
t.Fatal(err)
}
msg, err := textproto.NewReader(bufio.NewReader(bytes.NewReader(raw))).ReadMIMEHeader()
if err != nil {
t.Fatal(err)
}
mediaType, params, err := mime.ParseMediaType(msg.Get("Content-Type"))
if err != nil || mediaType != "multipart/mixed" || params["boundary"] == "" ||
msg.Get("Content-Transfer-Encoding") != "base64" {
t.Fatalf("message header %v", msg)
}
body := raw[bytes.Index(raw, []byte("\r\n\r\n"))+4:]
mr := multipart.NewReader(bytes.NewReader(body), params["boundary"])
parts := map[string][]byte{}
var order []string
for {
p, err := mr.NextRawPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if p.Header.Get("Content-Transfer-Encoding") != "base64" {
t.Errorf("part %v is not base64", p.Header)
}
b, err := io.ReadAll(p)
if err != nil {
t.Fatal(err)
}
ct := p.Header.Get("Content-Type")
order = append(order, ct)
parts[ct] = b
}
if strings.Join(order, ",") != "application/x-passpoint-profile,application/x-x509-ca-cert" {
t.Errorf("parts %v", order)
}
if string(parts["application/x-x509-ca-cert"]) != isrgRootX1 {
t.Error("the trust anchor part is not ISRG Root X1")
}
pps, err := base64.StdEncoding.DecodeString(string(parts["application/x-passpoint-profile"]))
if err != nil {
t.Fatal(err)
}
type node struct {
Name string `xml:"NodeName"`
DDF string `xml:"RTProperties>Type>DDFName"`
Value *string `xml:"Value"`
Nodes []node `xml:"Node"`
}
var tree struct {
XMLName xml.Name
VerDTD string `xml:"VerDTD"`
Nodes []node `xml:"Node"`
}
if err := xml.Unmarshal(pps, &tree); err != nil {
t.Fatalf("pps: %v\n%s", err, pps)
}
if tree.XMLName != (xml.Name{Space: "syncml:dmddf1.2", Local: "MgmtTree"}) || tree.VerDTD != "1.2" ||
len(tree.Nodes) != 1 || tree.Nodes[0].Name != "PerProviderSubscription" ||
tree.Nodes[0].DDF != "urn:wfa:mo:hotspot2dot0-perprovidersubscription:1.0" {
t.Fatalf("pps %s", pps)
}
// value follows NodeNames below PerProviderSubscription/i001.
value := func(path string) string {
nodes := tree.Nodes[0].Nodes
var cur *node
for _, name := range strings.Split("i001/"+path, "/") {
cur = nil
for i := range nodes {
if nodes[i].Name == name {
cur = &nodes[i]
}
}
if cur == nil {
return "<no node>"
}
nodes = cur.Nodes
}
if cur.Value == nil {
return "<no value>"
}
return *cur.Value
}
for path, want := range map[string]string{
"HomeSP/FriendlyName": "Renner",
"HomeSP/FQDN": "renner.uno",
"Credential/Realm": "renner.uno",
"Credential/UsernamePassword/Username": "Alice.Smith",
"Credential/UsernamePassword/Password": base64.StdEncoding.EncodeToString([]byte(testKey)),
"Credential/UsernamePassword/EAPMethod/EAPType": "21",
"Credential/UsernamePassword/EAPMethod/InnerMethod": "PAP",
"Extension/Android/AAAServerTrustedNames/FQDN": "radius.renner.uno",
} {
if got := value(path); got != want {
t.Errorf("%s = %q, want %q", path, got, want)
}
}
}
func TestCallbackApple(t *testing.T) {
h := newHarness(t)
rec := h.callback("apple", "s", "s", "Alice.Smith")
if rec.Code != http.StatusOK {
t.Fatalf("status %d %q", rec.Code, rec.Body.String())
}
checkCreated(t, h, "apple", "Apple")
checkCleared(t, rec, "apple")
if rec.Header().Get("Content-Type") != "application/x-apple-aspen-config" ||
rec.Header().Get("Content-Disposition") != `attachment; filename="renner-unifi.mobileconfig"` {
t.Errorf("headers %v", rec.Header())
}
var top map[string]any
format, err := plist.Unmarshal(rec.Body.Bytes(), &top)
if err != nil || format != plist.XMLFormat {
t.Fatalf("plist (format %d): %v\n%s", format, err, rec.Body.Bytes())
}
want := map[string]any{
"PayloadContent": []any{map[string]any{
"PayloadType": "com.apple.wifi.managed",
"PayloadVersion": uint64(1),
"PayloadIdentifier": "uno.renner.wifi.renner-unifi.network",
"PayloadUUID": "824959bf-c55d-4ed2-b569-c33f3759c2b6",
"PayloadDisplayName": "Renner UniFi",
"PayloadOrganization": "Renner",
"IsHotspot": true,
"DomainName": "renner.uno",
"NAIRealmNames": []any{"renner.uno"},
"DisplayedOperatorName": "Renner",
"ServiceProviderRoamingEnabled": false,
"AutoJoin": true,
"EncryptionType": "Any",
"EAPClientConfiguration": map[string]any{
"UserName": "Alice.Smith",
"UserPassword": testKey,
"OuterIdentity": "anonymous",
"AcceptEAPTypes": []any{uint64(21)},
"TTLSInnerAuthentication": "PAP",
"TLSTrustedServerNames": []any{"radius.renner.uno"},
},
}},
"PayloadType": "Configuration",
"PayloadVersion": uint64(1),
"PayloadIdentifier": "uno.renner.wifi.renner-unifi",
"PayloadUUID": "7175a2a3-ddd3-42ea-b4ad-b7e5501892f3",
"PayloadDisplayName": "WLAN Renner UniFi",
"PayloadOrganization": "Renner",
}
if !reflect.DeepEqual(top, want) {
t.Errorf("profile\n got %#v\nwant %#v", top, want)
}
if !bytes.Contains(rec.Body.Bytes(), []byte("<string>app-password-&lt;&amp;&gt;-456</string>")) {
t.Error("the password is not escaped as XML text")
}
}
func (h *harness) windowsToken(t *testing.T) string {
t.Helper()
rec := h.callback("windows", "s", "s", "Alice.Smith")
if rec.Code != http.StatusFound {
t.Fatalf("status %d %q", rec.Code, rec.Body.String())
}
checkCleared(t, rec, "windows")
m := regexp.MustCompile(`^ms-settings:wifi-provisioning\?uri=https://wlan\.renner\.uno/windows/profile/([A-Za-z0-9_-]{43})$`).
FindStringSubmatch(rec.Header().Get("Location"))
if m == nil {
t.Fatalf("location %q", rec.Header().Get("Location"))
}
return m[1]
}
func validate(cert *x509.Certificate, body []byte) error {
doc := etree.NewDocument()
if err := doc.ReadFromBytes(body); err != nil {
return err
}
ctx := dsig.NewDefaultValidationContext(&dsig.MemoryX509CertificateStore{Roots: []*x509.Certificate{cert}})
_, err := ctx.Validate(doc.Root())
return err
}
func TestCallbackWindows(t *testing.T) {
h := newHarness(t)
token := h.windowsToken(t)
checkCreated(t, h, "windows", "Windows")
// No header or cookie: Windows Settings fetches it outside the authentik session.
head := h.do(httptest.NewRequest(http.MethodHead, "/windows/profile/"+token, nil))
if head.Code != http.StatusOK {
t.Errorf("HEAD: %d", head.Code)
}
rec := h.do(httptest.NewRequest(http.MethodGet, "/windows/profile/"+token, nil))
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "text/xml" {
t.Fatalf("first fetch: %d %v", rec.Code, rec.Header())
}
body := rec.Body.Bytes()
if again := h.do(httptest.NewRequest(http.MethodGet, "/windows/profile/"+token, nil)); again.Code != http.StatusNotFound {
t.Errorf("second fetch: %d", again.Code)
}
if err := validate(h.signing.cert, body); err != nil {
t.Fatalf("signature does not verify: %v\n%s", err, body)
}
s := string(body)
for _, want := range []string{
`<CarrierProvisioning xmlns="http://www.microsoft.com/networking/CarrierControl/v1">`,
`<Username>Alice.Smith</Username><Password>app-password-&lt;&amp;&gt;-456</Password>`,
`<Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo>`,
`<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></CanonicalizationMethod>`,
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"></SignatureMethod>`,
`<Reference URI=""><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"></Transform>`,
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"></DigestMethod>`,
`</Signature></CarrierProvisioning>`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %s", want)
}
}
// The reference digest is the SHA-1 of the canonical unsigned document; written with
// canonical settings, the unsigned root is already in that form.
unsigned := etree.NewDocument()
unsigned.SetRoot(windowsProfile("Alice.Smith", testKey).Root())
unsigned.WriteSettings = etree.WriteSettings{CanonicalEndTags: true, CanonicalText: true, CanonicalAttrVal: true}
unsignedBytes, err := unsigned.WriteToBytes()
if err != nil {
t.Fatal(err)
}
canonical, err := dsig.MakeC14N10RecCanonicalizer().Canonicalize(unsigned.Root())
if err != nil || !bytes.Equal(canonical, unsignedBytes) {
t.Fatalf("the unsigned document is not canonical: %v\n%s\n%s", err, canonical, unsignedBytes)
}
sum := sha1.Sum(canonical)
if !strings.Contains(s, "<DigestValue>"+base64.StdEncoding.EncodeToString(sum[:])+"</DigestValue>") {
t.Error("the digest is not the SHA-1 of the unsigned document")
}
signed := etree.NewDocument()
if err := signed.ReadFromBytes(body); err != nil {
t.Fatal(err)
}
if pi, ok := signed.Child[0].(*etree.ProcInst); !ok || pi.Target != "xml" || pi.Inst != `version="1.0"` {
t.Errorf("the document does not start with the XML declaration: %q", s[:min(len(s), 40)])
}
root := signed.Root()
if last := root.ChildElements()[len(root.ChildElements())-1]; last.Tag != "Signature" {
t.Errorf("the last child of the root is %s", last.Tag)
}
root.RemoveChild(root.SelectElement("Signature"))
if stripped, err := dsig.MakeC14N10RecCanonicalizer().Canonicalize(root); err != nil || !bytes.Equal(stripped, canonical) {
t.Errorf("without the signature, the document differs from the unsigned one: %v", err)
}
// Only the enveloped-signature transform, as in Microsoft's signed samples.
if n := strings.Count(s, "<Transform "); n != 1 {
t.Errorf("%d transforms, want 1", n)
}
if n := strings.Count(s, "<X509Certificate>"); n != 2 {
t.Errorf("%d certificates in KeyInfo, want the whole chain (2)", n)
}
leaf := base64.StdEncoding.EncodeToString(h.signing.cert.Raw)
if !strings.Contains(s, "<X509Data><X509Certificate>"+leaf+"</X509Certificate>") {
t.Error("the leaf is not the first certificate")
}
tampered := bytes.Replace(body, []byte("Alice.Smith"), []byte("Alice.Smitx"), 1)
if err := validate(h.signing.cert, tampered); err == nil {
t.Error("a tampered profile verifies")
}
}
// TestWindowsProfileSchema validates the signed document with xmllint against the schemas
// Windows ships in %SYSTEMROOT%\schemas\provisioning. They are Microsoft's files and not in the
// repo: point WLAN_SETUP_PROVISIONING_XSD at a copy of that folder to run it.
func TestWindowsProfileSchema(t *testing.T) {
dir := os.Getenv("WLAN_SETUP_PROVISIONING_XSD")
if dir == "" {
t.Skip("WLAN_SETUP_PROVISIONING_XSD not set")
}
xmllint, err := exec.LookPath("xmllint")
if err != nil {
t.Skip("xmllint not installed")
}
tc := writeSigningFiles(t)
signer, err := loadSigner(tc.keyFile, tc.certFile)
if err != nil {
t.Fatal(err)
}
body, err := signer.Sign(windowsProfile("Alice.Smith", testKey))
if err != nil {
t.Fatal(err)
}
// The schemas import each other by namespace only; a wrapper schema gives libxml2 the
// locations. Dependencies first, so the later imports find their namespaces loaded.
wrapper := etree.NewDocument()
schema := wrapper.CreateElement("xs:schema")
schema.CreateAttr("xmlns:xs", "http://www.w3.org/2001/XMLSchema")
schema.CreateAttr("targetNamespace", "urn:wlan-setup:schema-test")
for _, s := range []struct{ ns, file string }{
{"http://www.microsoft.com/networking/CarrierControl/Base/v1", "provisioning_base_v1.xsd"},
{"http://www.microsoft.com/networking/CarrierControl/Plans/v1", "provisioning_plans_v1.xsd"},
{"http://www.microsoft.com/networking/CarrierControl/WWAN/v1", "provisioning_wwan_v1.xsd"},
{"http://www.microsoft.com/networking/CarrierControl/WLAN/v1", "provisioning_wlan_v1.xsd"},
{dsig.Namespace, "provisioning_sig_v1.xsd"},
{nsCarrierControl, "provisioning_v1.xsd"},
} {
imp := schema.CreateElement("xs:import")
imp.CreateAttr("namespace", s.ns)
imp.CreateAttr("schemaLocation", "file://"+filepath.Join(dir, s.file))
}
tmp := t.TempDir()
wrapperFile := filepath.Join(tmp, "wrapper.xsd")
if err := wrapper.WriteToFile(wrapperFile); err != nil {
t.Fatal(err)
}
docFile := filepath.Join(tmp, "profile.xml")
if err := os.WriteFile(docFile, body, 0o600); err != nil {
t.Fatal(err)
}
out, err := exec.Command(xmllint, "--noout", "--schema", wrapperFile, docFile).CombinedOutput()
if err != nil {
t.Fatalf("xmllint: %v\n%s", err, out)
}
}
func TestWindowsProfileDocument(t *testing.T) {
tc := writeSigningFiles(t)
signer, err := loadSigner(tc.keyFile, tc.certFile)
if err != nil {
t.Fatal(err)
}
body, err := signer.Sign(windowsProfile("Alice.Smith", testKey))
if err != nil {
t.Fatal(err)
}
if err := validate(tc.cert, body); err != nil {
t.Fatalf("signature does not verify: %v", err)
}
var wellFormed struct{}
if err := xml.Unmarshal(body, &wellFormed); err != nil {
t.Fatalf("not well-formed: %v", err)
}
doc := etree.NewDocument()
if err := doc.ReadFromBytes(body); err != nil {
t.Fatal(err)
}
root := doc.Root()
const (
wlan = "WLANProfiles/WLANProfile/"
sec = wlan + "MSM/security/"
eap = sec + "OneX/EAPConfig/EapHostConfig/"
ttls = eap + "Config/EapTtls/"
creds = sec + "EapHostUserCredentials/"
sig = "Signature/"
)
for path, want := range map[string]string{
"Global/CarrierId": carrierID,
"Global/SubscriberId": "rennerunifi",
wlan + "name": "Renner UniFi",
wlan + "SSIDConfig/SSID/name": "Renner UniFi",
wlan + "Hotspot2/DomainName": "renner.uno",
wlan + "Hotspot2/NAIRealm/name": "renner.uno",
sec + "authEncryption/authentication": "WPA3ENT",
sec + "authEncryption/encryption": "AES",
sec + "authEncryption/useOneX": "true",
sec + "OneX/authMode": "user",
eap + "EapMethod/Type": "21",
eap + "EapMethod/VendorId": "0",
eap + "EapMethod/VendorType": "0",
eap + "EapMethod/AuthorId": "311",
ttls + "ServerValidation/ServerNames": "radius.renner.uno",
ttls + "ServerValidation/TrustedRootCAHash": "ca bd 2a 79 a1 07 6a 31 f2 1d 25 36 35 cb 03 9d 43 29 a5 e8",
ttls + "ServerValidation/DisablePrompt": "true",
ttls + "Phase2Authentication/PAPAuthentication": "",
ttls + "Phase1Identity/IdentityPrivacy": "true",
ttls + "Phase1Identity/AnonymousIdentity": "anonymous",
creds + "EapMethod/eapCommon:Type": "21",
creds + "EapMethod/eapCommon:AuthorId": "311",
creds + "Credentials/EapTtls/Username": "Alice.Smith",
creds + "Credentials/EapTtls/Password": testKey,
sig + "SignedInfo/Reference/Transforms/Transform": "",
} {
el := root.FindElement(path)
if el == nil {
t.Errorf("no element %s", path)
continue
}
if el.Text() != want {
t.Errorf("%s = %q, want %q", path, el.Text(), want)
}
}
for path, want := range map[string]string{
".": "http://www.microsoft.com/networking/CarrierControl/v1",
"Global/CarrierId": "http://www.microsoft.com/networking/CarrierControl/v1",
"WLANProfiles": "http://www.microsoft.com/networking/CarrierControl/v1",
wlan + "Hotspot2/DomainName": "http://www.microsoft.com/networking/CarrierControl/WLAN/v1",
sec + "OneX/authMode": "http://www.microsoft.com/networking/OneX/v1",
eap + "EapMethod": "http://www.microsoft.com/provisioning/EapHostConfig",
eap + "EapMethod/Type": "http://www.microsoft.com/provisioning/EapCommon",
eap + "EapMethod/AuthorId": "http://www.microsoft.com/provisioning/EapCommon",
ttls + "ServerValidation": "http://www.microsoft.com/provisioning/EapTtlsConnectionPropertiesV1",
creds + "EapMethod": "http://www.microsoft.com/provisioning/EapHostUserCredentials",
creds + "EapMethod/eapCommon:Type": "http://www.microsoft.com/provisioning/EapCommon",
creds + "Credentials/EapTtls/Password": "http://www.microsoft.com/provisioning/EapTtlsUserPropertiesV1",
"Signature": dsig.Namespace,
sig + "SignedInfo/Reference": dsig.Namespace,
} {
if el := root.FindElement(path); el == nil || el.NamespaceURI() != want {
t.Errorf("%s: namespace not %s", path, want)
}
}
if si := root.FindElement(sig + "SignedInfo"); si == nil || len(si.Attr) != 0 {
t.Error("SignedInfo carries its own attributes")
}
for path, want := range map[string]string{
sig + "SignedInfo/CanonicalizationMethod": "http://www.w3.org/TR/2001/REC-xml-c14n-20010315",
sig + "SignedInfo/SignatureMethod": "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
sig + "SignedInfo/Reference/Transforms/Transform": "http://www.w3.org/2000/09/xmldsig#enveloped-signature",
sig + "SignedInfo/Reference/DigestMethod": "http://www.w3.org/2000/09/xmldsig#sha1",
} {
if el := root.FindElement(path); el == nil || el.SelectAttrValue("Algorithm", "") != want {
t.Errorf("%s: Algorithm not %s", path, want)
}
}
if ref := root.FindElement(sig + "SignedInfo/Reference"); ref == nil || ref.SelectAttrValue("URI", "-") != "" {
t.Error(`Reference URI is not ""`)
}
if n := len(root.FindElements(sig + "SignedInfo/Reference/Transforms/Transform")); n != 1 {
t.Errorf("%d transforms, want 1", n)
}
certs := root.FindElements(sig + "KeyInfo/X509Data/X509Certificate")
if len(certs) != 2 || certs[0].Text() != base64.StdEncoding.EncodeToString(tc.cert.Raw) {
t.Errorf("KeyInfo holds %d certificates, want the chain with the leaf first", len(certs))
}
if _, err := signer.Sign(doc); err == nil {
t.Error("an already signed document was signed again")
}
}
func TestWindowsProfileExpires(t *testing.T) {
h := newHarness(t)
token := h.windowsToken(t)
h.clock.Add(profileTTL)
if rec := h.do(httptest.NewRequest(http.MethodGet, "/windows/profile/"+token, nil)); rec.Code != http.StatusNotFound {
t.Errorf("expired: %d", rec.Code)
}
if len(h.srv.profiles.profiles) != 0 {
t.Error("the expired profile was not purged")
}
if rec := h.do(httptest.NewRequest(http.MethodGet, "/windows/profile/unknown", nil)); rec.Code != http.StatusNotFound {
t.Errorf("unknown: %d", rec.Code)
}
}
func TestWhoamiHealthzIndexNotFound(t *testing.T) {
h := newHarness(t)
for _, c := range []struct{ user, want string }{{"Alice.Smith", "Alice.Smith"}, {"a b", ""}, {"", ""},
{strings.Repeat("a", 65), ""}} {
req := httptest.NewRequest(http.MethodGet, "/whoami", nil)
req.Header.Set("X-authentik-username", c.user)
rec := h.do(req)
if rec.Code != http.StatusOK || rec.Body.String() != c.want || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") {
t.Errorf("whoami %q: %d %q", c.user, rec.Code, rec.Body.String())
}
}
if rec := h.do(httptest.NewRequest(http.MethodGet, "/healthz", nil)); rec.Code != http.StatusOK || rec.Body.String() != "ok\n" {
t.Errorf("healthz: %d %q", rec.Code, rec.Body.String())
}
rec := h.do(httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `href="/windows/install"`) ||
strings.Contains(rec.Body.String(), ".cmd") {
t.Errorf("index: %d", rec.Code)
}
for _, req := range []*http.Request{
httptest.NewRequest(http.MethodGet, "/renner-unifi-wlan.cmd", nil),
httptest.NewRequest(http.MethodGet, "/linux/install", nil),
httptest.NewRequest(http.MethodPost, "/", nil),
httptest.NewRequest(http.MethodPost, "/android/install", nil),
httptest.NewRequest(http.MethodGet, "/index.html", nil),
} {
if rec := h.do(req); rec.Code != http.StatusNotFound {
t.Errorf("%s %s: %d", req.Method, req.URL.Path, rec.Code)
}
}
}
func TestLoadConfig(t *testing.T) {
if _, err := loadConfig(env(nil)); err == nil || !strings.Contains(err.Error(), "WLAN_SETUP_CLIENT_SECRET is not set") {
t.Errorf("missing secret: %v", err)
}
c, err := loadConfig(env(map[string]string{"WLAN_SETUP_CLIENT_SECRET": "x"}))
if err != nil {
t.Fatal(err)
}
if c.AuthentikURL != "http://authentik-server.authentik.svc.cluster.local" ||
c.AuthorizeURL != "https://auth.renner.uno/application/o/authorize/" || c.PublicURL != "https://wlan.renner.uno" ||
c.SigningKey != "/etc/wlan-signing/tls.key" || c.SigningCert != "/etc/wlan-signing/tls.crt" || c.Listen != ":8080" {
t.Errorf("defaults %+v", c)
}
if _, err := loadConfig(env(map[string]string{"WLAN_SETUP_CLIENT_SECRET": "x", "PUBLIC_URL": "wlan"})); err == nil {
t.Error("relative PUBLIC_URL accepted")
}
}
+137
View File
@@ -0,0 +1,137 @@
package main
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"os"
"github.com/beevik/etree"
dsig "github.com/russellhaering/goxmldsig"
)
// Signer holds the key and chain that sign the Windows provisioning document.
type Signer struct {
key *rsa.PrivateKey
chain [][]byte
}
// loadSigner reads the PEM key (PKCS#8 RSA) and the PEM chain (leaf first). It runs per request:
// cert-manager rotates the files.
func loadSigner(keyFile, certFile string) (*Signer, error) {
keyPEM, err := os.ReadFile(keyFile)
if err != nil {
return nil, fmt.Errorf("signing key: %w", err)
}
certPEM, err := os.ReadFile(certFile)
if err != nil {
return nil, fmt.Errorf("signing certificate: %w", err)
}
return parseSigner(keyPEM, certPEM)
}
func parseSigner(keyPEM, certPEM []byte) (*Signer, error) {
block, _ := pem.Decode(keyPEM)
if block == nil {
return nil, errors.New("signing key: no PEM block")
}
var key *rsa.PrivateKey
switch block.Type {
case "PRIVATE KEY":
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("signing key: %w", err)
}
rk, ok := k.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("signing key: not an RSA key")
}
key = rk
case "RSA PRIVATE KEY":
k, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("signing key: %w", err)
}
key = k
default:
return nil, fmt.Errorf("signing key: unexpected PEM block %q", block.Type)
}
var chain [][]byte
for rest := certPEM; ; {
var b *pem.Block
b, rest = pem.Decode(rest)
if b == nil {
break
}
if b.Type != "CERTIFICATE" {
continue
}
if _, err := x509.ParseCertificate(b.Bytes); err != nil {
return nil, fmt.Errorf("signing certificate: %w", err)
}
chain = append(chain, b.Bytes)
}
if len(chain) == 0 {
return nil, errors.New("signing certificate: no certificate")
}
leaf, _ := x509.ParseCertificate(chain[0])
if pub, ok := leaf.PublicKey.(*rsa.PublicKey); !ok || !pub.Equal(&key.PublicKey) {
return nil, errors.New("signing certificate: the first certificate does not match the key")
}
return &Signer{key: key, chain: chain}, nil
}
const dsigNS = dsig.Namespace
// Sign appends an enveloped XML-DSig signature as the last child of doc's root and returns the
// serialized document, in the form of Microsoft's CarrierProvisioning samples: Reference URI=""
// with only the enveloped-signature transform, inclusive C14N 1.0, RSA-SHA1, SHA-1 digest, and
// every certificate of the chain in KeyInfo/X509Data.
func (s *Signer) Sign(doc *etree.Document) ([]byte, error) {
root := doc.Root()
if root == nil || root.Tag != "CarrierProvisioning" || root.SelectElement(dsig.SignatureTag) != nil {
return nil, errors.New("signing: not an unsigned CarrierProvisioning document")
}
c14n := dsig.MakeC14N10RecCanonicalizer()
canonical, err := c14n.Canonicalize(root)
if err != nil {
return nil, fmt.Errorf("signing: %w", err)
}
digest := sha1.Sum(canonical)
signature := root.CreateElement(dsig.SignatureTag)
signature.CreateAttr("xmlns", dsigNS)
signedInfo := signature.CreateElement(dsig.SignedInfoTag)
signedInfo.CreateElement(dsig.CanonicalizationMethodTag).
CreateAttr(dsig.AlgorithmAttr, c14n.Algorithm().String())
signedInfo.CreateElement(dsig.SignatureMethodTag).CreateAttr(dsig.AlgorithmAttr, dsig.RSASHA1SignatureMethod)
reference := signedInfo.CreateElement(dsig.ReferenceTag)
reference.CreateAttr(dsig.URIAttr, "")
reference.CreateElement(dsig.TransformsTag).CreateElement(dsig.TransformTag).
CreateAttr(dsig.AlgorithmAttr, dsig.EnvelopedSignatureAltorithmId.String())
reference.CreateElement(dsig.DigestMethodTag).CreateAttr(dsig.AlgorithmAttr, dsigNS+"sha1")
reference.CreateElement(dsig.DigestValueTag).SetText(base64.StdEncoding.EncodeToString(digest[:]))
// In place, the canonicalizer adds the namespace SignedInfo inherits from Signature.
canonical, err = c14n.Canonicalize(signedInfo)
if err != nil {
return nil, fmt.Errorf("signing: %w", err)
}
hashed := sha1.Sum(canonical)
value, err := rsa.SignPKCS1v15(rand.Reader, s.key, crypto.SHA1, hashed[:])
if err != nil {
return nil, fmt.Errorf("signing: %w", err)
}
signature.CreateElement(dsig.SignatureValueTag).SetText(base64.StdEncoding.EncodeToString(value))
x509Data := signature.CreateElement(dsig.KeyInfoTag).CreateElement(dsig.X509DataTag)
for _, c := range s.chain {
x509Data.CreateElement(dsig.X509CertificateTag).SetText(base64.StdEncoding.EncodeToString(c))
}
return doc.WriteToBytes()
}
+70
View File
@@ -0,0 +1,70 @@
package main
import (
"sync"
"time"
)
// profileTTL is how long a signed Windows profile waits for Windows Settings to fetch it.
const profileTTL = 5 * time.Minute
// maxProfiles bounds the profiles held at once.
const maxProfiles = 1000
type storedProfile struct {
body []byte
expires time.Time
}
// ProfileStore keeps signed Windows profiles in memory, each fetchable once until it expires.
type ProfileStore struct {
mu sync.Mutex
now func() time.Time
profiles map[string]storedProfile
}
func newProfileStore(now func() time.Time) *ProfileStore {
return &ProfileStore{now: now, profiles: map[string]storedProfile{}}
}
func (s *ProfileStore) purge(now time.Time) {
for k, p := range s.profiles {
if !now.Before(p.expires) {
delete(s.profiles, k)
}
}
}
// Put stores body under token; false when the store is full.
func (s *ProfileStore) Put(token string, body []byte) bool {
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
s.purge(now)
if len(s.profiles) >= maxProfiles {
return false
}
s.profiles[token] = storedProfile{body: body, expires: now.Add(profileTTL)}
return true
}
// Take returns the profile stored under token and deletes it.
func (s *ProfileStore) Take(token string) ([]byte, bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.purge(s.now())
p, ok := s.profiles[token]
if ok {
delete(s.profiles, token)
}
return p.body, ok
}
// Has reports whether a profile is stored under token, without taking it.
func (s *ProfileStore) Has(token string) bool {
s.mu.Lock()
defer s.mu.Unlock()
s.purge(s.now())
_, ok := s.profiles[token]
return ok
}