Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5.5 49a72721bd fix(ssh-ca): install sudo before the Claude sudo rules
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:17:15 +01: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
19 changed files with 113 additions and 26 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ 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).
+2
View File
@@ -22,6 +22,7 @@ proxy-2.renner.uno ansible_host=proxy-2.nb.renner.uno ansible_user=root
# 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]
@@ -73,6 +74,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
+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
+1 -1
View File
@@ -16,7 +16,7 @@ edge_mqtt_bridge_cafile: /etc/ssl/certs/ca-certificates.crt
# 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
edge_mqtt_bridge_account: "{{ inventory_hostname_short }}"
# HTTPS port of edge-mqtt-cred (esp32 device API, mTLS). Only a client
# certificate from the MQTT CA with this CN is accepted.
edge_mqtt_cred_port: 8443
@@ -7,7 +7,7 @@ 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
remote_clientid {{ edge_mqtt_bridge_account | replace('-', '') }}-bridge
cleansession false
try_private true
restart_timeout 5 30
+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
+5 -4
View File
@@ -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
+1 -1
View File
@@ -34,7 +34,7 @@ spec:
type: RuntimeDefault
containers:
- name: wlan-setup
image: gitea.renner.uno/gitea_admin/wlan-setup:2026-11-05-1352 # {"$imagepolicy": "flux-system: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
+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
@@ -61,6 +61,11 @@
# No sml2mqtt/#: the SML meter reader is retired for good (owner,
# 2026-09-19; grid data comes from the E380 on the CAN bus).
acl: ["readwrite open3e/#"]
# edge-2's local mosquitto bridge, same role and pinning as edge-1.
- name: edge-2
auth: password
deliver: {esp32: "https://edge-2.iot.renner.uno:8443"}
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> =
# shelly-manager device id. ACL as the former shared user `shellies`
+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
+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
@@ -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"
@@ -516,6 +516,7 @@ spec:
- matchPattern: "*"
- toFQDNs:
- matchName: "edge-1.iot.renner.uno"
- matchName: "edge-2.iot.renner.uno"
toPorts:
- ports:
- port: "8443"
@@ -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
+9 -7
View File
@@ -200,10 +200,10 @@ func addText(parent *etree.Element, tag, ns, text string) *etree.Element {
}
// windowsProfile is the unsigned CarrierProvisioning document: one Hotspot 2.0 WLAN profile
// matched by domain name, EAP-TTLS with inner PAP, and the user's credentials. The
// CarrierControl WLAN schema allows authentication up to WPA2 (Enterprise); Windows adds the
// PMF that the WPA3-Enterprise network requires. Elements follow Microsoft's signed Passpoint
// provisioning samples.
// (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}
@@ -213,16 +213,18 @@ func windowsProfile(user, password string) *etree.Document {
global := add(root, "Global", "")
addText(global, "CarrierId", "", carrierID)
addText(global, "SubscriberId", "", "renner-unifi")
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")
addText(add(wlan, "Hotspot2", ""), "DomainName", "", "renner.uno")
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", "", "WPA2")
addText(auth, "authentication", "", "WPA3ENT")
addText(auth, "encryption", "", "AES")
addText(auth, "useOneX", "", "true")
+59 -2
View File
@@ -22,6 +22,7 @@ import (
"net/textproto"
"net/url"
"os"
"os/exec"
"path/filepath"
"reflect"
"regexp"
@@ -644,6 +645,61 @@ func TestCallbackWindows(t *testing.T) {
}
}
// 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)
@@ -676,11 +732,12 @@ func TestWindowsProfileDocument(t *testing.T) {
)
for path, want := range map[string]string{
"Global/CarrierId": carrierID,
"Global/SubscriberId": "renner-unifi",
"Global/SubscriberId": "rennerunifi",
wlan + "name": "Renner UniFi",
wlan + "SSIDConfig/SSID/name": "Renner UniFi",
wlan + "Hotspot2/DomainName": "renner.uno",
sec + "authEncryption/authentication": "WPA2",
wlan + "Hotspot2/NAIRealm/name": "renner.uno",
sec + "authEncryption/authentication": "WPA3ENT",
sec + "authEncryption/encryption": "AES",
sec + "authEncryption/useOneX": "true",
sec + "OneX/authMode": "user",