Compare commits

..
Author SHA1 Message Date
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
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
36 changed files with 775 additions and 276 deletions
+4 -2
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).
@@ -164,7 +164,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.
+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
@@ -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 -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
@@ -0,0 +1,14 @@
# Managed by ansible (roles/os-update).
# 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
+28
View File
@@ -25,6 +25,34 @@
check_mode: false
when: os_update_docker.stat.exists
# pacman keeps every downloaded package; on the edge hosts' 7 GB eMMC root
# the cache grows to gigabytes.
- name: Ensure the pacman hook directory exists (Arch)
ansible.builtin.file:
path: /etc/pacman.d/hooks
state: directory
mode: "0755"
when: ansible_facts['os_family'] == 'Archlinux'
- name: Empty the package cache after every transaction (Arch)
ansible.builtin.copy:
src: pacman-cache.hook
dest: /etc/pacman.d/hooks/pacman-cache.hook
mode: "0644"
when: ansible_facts['os_family'] == 'Archlinux'
- name: Empty the package cache (Arch)
ansible.builtin.command:
argv: [find, /var/cache/pacman/pkg, -mindepth, "1", -delete, -print]
register: os_update_pacman_cache
changed_when: os_update_pacman_cache.stdout | length > 0
when: ansible_facts['os_family'] == 'Archlinux'
- name: Re-read mounts after the cache cleanup
ansible.builtin.setup:
gather_subset: [mounts]
when: os_update_pacman_cache is changed
- name: Assert at least 1 GiB free on /
ansible.builtin.assert:
that: >-
+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
@@ -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
+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
+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
@@ -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
@@ -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`
@@ -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
+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
+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
@@ -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"
@@ -516,6 +516,7 @@ spec:
- matchPattern: "*"
- toFQDNs:
- matchName: "edge-1.iot.renner.uno"
- matchName: "edge-2.iot.renner.uno"
toPorts:
- ports:
- port: "8443"
@@ -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
+1
View File
@@ -0,0 +1 @@
/wlan-setup
+1
View File
@@ -5,6 +5,7 @@ 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
+5
View File
@@ -2,6 +2,7 @@ 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=
@@ -10,5 +11,9 @@ github.com/russellhaering/goxmldsig v1.6.1 h1:SB7R5ttvrGIDB2juJAK/i7DQ2Ivr7agG+o
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=
+232 -97
View File
@@ -1,8 +1,16 @@
package main
import (
"bytes"
"encoding/base64"
"strings"
"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).
@@ -11,113 +19,240 @@ const isrgRootX1 = "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQEL
// carrierID identifies the Windows provisioning document; a reinstall replaces the profile.
const carrierID = "{5423c37a-7530-4ce9-8f87-a2574ea006bc}"
func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
// mimeBoundary separates the parts of the Android profile.
const mimeBoundary = "renner-passpoint-profile"
func node(name, inner string) string {
return "<Node><NodeName>" + name + "</NodeName>" + inner + "</Node>"
// 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"`
}
func leaf(name, value string) string { return node(name, "<Value>"+value+"</Value>") }
type ppsRTProperties struct {
DDFName string `xml:"Type>DDFName"`
}
var xmlEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
type ppsTree struct {
XMLName xml.Name `xml:"syncml:dmddf1.2 MgmtTree"`
VerDTD string `xml:"VerDTD"`
Nodes []ppsNode `xml:"Node"`
}
func xmlText(s string) string { return xmlEscaper.Replace(s) }
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.
func androidProfile(user, password string) string {
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>"
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" +
isrgRootX1 + "\r\n" +
"--{boundary}--\r\n"
return b64(mime)
// 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) string {
return `<?xml version="1.0" encoding="UTF-8"?>` + "\n" +
`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">` + "\n" +
`<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>IsHotspot</key><true/>` +
`<key>DomainName</key><string>renner.uno</string>` +
`<key>NAIRealmNames</key><array><string>renner.uno</string></array>` +
`<key>DisplayedOperatorName</key><string>Renner</string>` +
`<key>ServiceProviderRoamingEnabled</key><false/>` +
`<key>AutoJoin</key><true/>` +
`<key>EncryptionType</key><string>Any</string>` +
`<key>EAPClientConfiguration</key><dict>` +
`<key>UserName</key><string>` + xmlText(user) + `</string>` +
`<key>UserPassword</key><string>` + xmlText(password) + `</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>` + "\n"
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")
}
// windowsProfile is the unsigned CarrierProvisioning document: one Hotspot 2.0 WLAN profile,
// WPA3 Enterprise, EAP-TTLS with inner PAP, and the user's credentials.
func windowsProfile(user, password string) string {
const common = ` xmlns="http://www.microsoft.com/provisioning/EapCommon"`
return `<CarrierProvisioning xmlns="http://www.microsoft.com/networking/CarrierControl/v1">` +
`<Global><CarrierId>` + carrierID + `</CarrierId><SubscriberId>renner-unifi</SubscriberId></Global>` +
`<WLANProfiles><WLANProfile xmlns="http://www.microsoft.com/networking/CarrierControl/WLAN/v1">` +
`<name>Renner UniFi</name>` +
`<SSIDConfig><SSID><name>Renner UniFi</name></SSID></SSIDConfig>` +
`<Hotspot2><DomainName>renner.uno</DomainName><NAIRealm><name>renner.uno</name></NAIRealm></Hotspot2>` +
`<MSM><security>` +
`<authEncryption><authentication>WPA3ENT</authentication><encryption>AES</encryption><useOneX>true</useOneX></authEncryption>` +
`<OneX xmlns="http://www.microsoft.com/networking/OneX/v1"><authMode>user</authMode><EAPConfig>` +
`<EapHostConfig xmlns="http://www.microsoft.com/provisioning/EapHostConfig">` +
`<EapMethod><Type` + common + `>21</Type><VendorId` + common + `>0</VendorId>` +
`<VendorType` + common + `>0</VendorType><AuthorId` + common + `>311</AuthorId></EapMethod>` +
`<Config><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>true</DisablePrompt></ServerValidation>` +
`<Phase2Authentication><PAPAuthentication></PAPAuthentication></Phase2Authentication>` +
`<Phase1Identity><IdentityPrivacy>true</IdentityPrivacy><AnonymousIdentity>anonymous</AnonymousIdentity></Phase1Identity>` +
`</EapTtls></Config></EapHostConfig></EAPConfig></OneX>` +
`<EapHostUserCredentials xmlns="http://www.microsoft.com/provisioning/EapHostUserCredentials" xmlns:eapCommon="http://www.microsoft.com/provisioning/EapCommon">` +
`<EapMethod><eapCommon:Type>21</eapCommon:Type><eapCommon:AuthorId>311</eapCommon:AuthorId></EapMethod>` +
`<Credentials><EapTtls xmlns="http://www.microsoft.com/provisioning/EapTtlsUserPropertiesV1">` +
`<Username>` + xmlText(user) + `</Username><Password>` + xmlText(password) + `</Password>` +
`</EapTtls></Credentials></EapHostUserCredentials>` +
`</security></MSM></WLANProfile></WLANProfiles></CarrierProvisioning>`
// 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
}
+12 -2
View File
@@ -192,15 +192,25 @@ func (s *Server) callback(w http.ResponseWriter, r *http.Request, name string) {
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")
_, _ = io.WriteString(w, androidProfile(user, key))
_, _ = 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"`)
_, _ = io.WriteString(w, appleProfile(user, key))
_, _ = w.Write(body)
case "windows":
body, err := signer.Sign(windowsProfile(user, key))
if err != nil {
+331 -99
View File
@@ -1,6 +1,7 @@
package main
import (
"bufio"
"bytes"
"crypto/rand"
"crypto/rsa"
@@ -14,11 +15,16 @@ import (
"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"
@@ -27,6 +33,7 @@ import (
"github.com/beevik/etree"
dsig "github.com/russellhaering/goxmldsig"
"howett.net/plist"
)
const (
@@ -368,99 +375,104 @@ func TestCallbackAndroid(t *testing.T) {
rec.Header().Get("Content-Transfer-Encoding") != "base64" || rec.Header().Get("Content-Disposition") != "" {
t.Errorf("headers %v", rec.Header())
}
mime, err := base64.StdEncoding.DecodeString(rec.Body.String())
raw, err := base64.StdEncoding.DecodeString(rec.Body.String())
if err != nil {
t.Fatal(err)
}
parts := strings.Split(string(mime), "--{boundary}\r\n")
var pps string
for _, p := range parts {
if body, ok := strings.CutPrefix(p, "Content-Type: application/x-passpoint-profile\r\nContent-Transfer-Encoding: base64\r\n\r\n"); ok {
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(body))
if err != nil {
t.Fatal(err)
}
pps = string(raw)
}
msg, err := textproto.NewReader(bufio.NewReader(bytes.NewReader(raw))).ReadMIMEHeader()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(pps, "<Node><NodeName>Username</NodeName><Value>Alice.Smith</Value></Node>") ||
!strings.Contains(pps, "<Node><NodeName>Password</NodeName><Value>"+base64.StdEncoding.EncodeToString([]byte(testKey))+"</Value></Node>") {
t.Errorf("pps %s", pps)
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)
}
if !strings.Contains(string(mime), "Content-Type: application/x-x509-ca-cert\r\nContent-Transfer-Encoding: base64\r\n\r\n"+isrgRootX1+"\r\n--{boundary}--\r\n") {
t.Error("trust anchor part missing")
}
}
// plistValue decodes one plist value: dict, array, string, integer, true, false.
func plistValue(d *xml.Decoder, start xml.StartElement) (any, error) {
switch start.Name.Local {
case "dict":
m := map[string]any{}
var key string
for {
tok, err := d.Token()
if err != nil {
return nil, err
}
switch el := tok.(type) {
case xml.StartElement:
if el.Name.Local == "key" {
if err := d.DecodeElement(&key, &el); err != nil {
return nil, err
}
continue
}
v, err := plistValue(d, el)
if err != nil {
return nil, err
}
m[key] = v
case xml.EndElement:
return m, nil
}
}
case "array":
var a []any
for {
tok, err := d.Token()
if err != nil {
return nil, err
}
switch el := tok.(type) {
case xml.StartElement:
v, err := plistValue(d, el)
if err != nil {
return nil, err
}
a = append(a, v)
case xml.EndElement:
return a, nil
}
}
case "true", "false":
return start.Name.Local == "true", d.Skip()
default:
var s string
err := d.DecodeElement(&s, &start)
return s, err
}
}
func parsePlist(t *testing.T, body []byte) map[string]any {
t.Helper()
d := xml.NewDecoder(bytes.NewReader(body))
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 {
tok, err := d.Token()
p, err := mr.NextRawPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if el, ok := tok.(xml.StartElement); ok && el.Name.Local == "dict" {
v, err := plistValue(d, el)
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]
}
}
return v.(map[string]any)
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)
}
}
}
@@ -477,20 +489,47 @@ func TestCallbackApple(t *testing.T) {
rec.Header().Get("Content-Disposition") != `attachment; filename="renner-unifi.mobileconfig"` {
t.Errorf("headers %v", rec.Header())
}
top := parsePlist(t, rec.Body.Bytes())
if top["PayloadType"] != "Configuration" || top["PayloadUUID"] != "7175a2a3-ddd3-42ea-b4ad-b7e5501892f3" {
t.Errorf("top %v", top)
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())
}
wifi := top["PayloadContent"].([]any)[0].(map[string]any)
if wifi["IsHotspot"] != true || wifi["DomainName"] != "renner.uno" ||
len(wifi["NAIRealmNames"].([]any)) != 1 || wifi["NAIRealmNames"].([]any)[0] != "renner.uno" ||
wifi["PayloadUUID"] != "824959bf-c55d-4ed2-b569-c33f3759c2b6" {
t.Errorf("wifi %v", wifi)
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",
}
eap := wifi["EAPClientConfiguration"].(map[string]any)
if eap["UserName"] != "Alice.Smith" || eap["UserPassword"] != testKey || eap["OuterIdentity"] != "anonymous" ||
eap["TTLSInnerAuthentication"] != "PAP" {
t.Errorf("eap %v", eap)
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")
}
}
@@ -556,13 +595,37 @@ func TestCallbackWindows(t *testing.T) {
t.Errorf("missing %s", want)
}
}
// The unsigned document is already canonical: its SHA-1 is the reference digest.
sum := sha1.Sum([]byte(windowsProfile("Alice.Smith", testKey)))
// 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")
}
if !strings.HasPrefix(s, strings.TrimSuffix(windowsProfile("Alice.Smith", testKey), "</CarrierProvisioning>")+"<Signature ") {
t.Error("the signed document does not start with the unsigned one")
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 {
@@ -582,6 +645,175 @@ 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)
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)
+42 -26
View File
@@ -11,7 +11,9 @@ import (
"errors"
"fmt"
"os"
"strings"
"github.com/beevik/etree"
dsig "github.com/russellhaering/goxmldsig"
)
// Signer holds the key and chain that sign the Windows provisioning document.
@@ -85,37 +87,51 @@ func parseSigner(keyPEM, certPEM []byte) (*Signer, error) {
return &Signer{key: key, chain: chain}, nil
}
const dsigNS = "http://www.w3.org/2000/09/xmldsig#"
const dsigNS = dsig.Namespace
// Sign returns doc with an enveloped XML-DSig signature as the last child of the root, 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. doc must already be in canonical form
// (windowsProfile writes it that way), so its bytes are the digest input.
func (s *Signer) Sign(doc string) ([]byte, error) {
const end = "</CarrierProvisioning>"
if !strings.HasSuffix(doc, end) {
return nil, errors.New("signing: not a CarrierProvisioning document")
// 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")
}
digest := sha1.Sum([]byte(doc))
signedInfo := `<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></CanonicalizationMethod>` +
`<SignatureMethod Algorithm="` + dsigNS + `rsa-sha1"></SignatureMethod>` +
`<Reference URI=""><Transforms><Transform Algorithm="` + dsigNS + `enveloped-signature"></Transform></Transforms>` +
`<DigestMethod Algorithm="` + dsigNS + `sha1"></DigestMethod>` +
`<DigestValue>` + base64.StdEncoding.EncodeToString(digest[:]) + `</DigestValue></Reference>` +
`</SignedInfo>`
// Canonical SignedInfo carries the namespace it inherits from Signature.
hashed := sha1.Sum([]byte(`<SignedInfo xmlns="` + dsigNS + `">` + signedInfo))
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)
}
var certs strings.Builder
signature.CreateElement(dsig.SignatureValueTag).SetText(base64.StdEncoding.EncodeToString(value))
x509Data := signature.CreateElement(dsig.KeyInfoTag).CreateElement(dsig.X509DataTag)
for _, c := range s.chain {
certs.WriteString("<X509Certificate>" + base64.StdEncoding.EncodeToString(c) + "</X509Certificate>")
x509Data.CreateElement(dsig.X509CertificateTag).SetText(base64.StdEncoding.EncodeToString(c))
}
signature := `<Signature xmlns="` + dsigNS + `"><SignedInfo>` + signedInfo +
`<SignatureValue>` + base64.StdEncoding.EncodeToString(value) + `</SignatureValue>` +
`<KeyInfo><X509Data>` + certs.String() + `</X509Data></KeyInfo></Signature>`
return []byte(strings.TrimSuffix(doc, end) + signature + end), nil
return doc.WriteToBytes()
}