Compare commits

...
Author SHA1 Message Date
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
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
51 changed files with 1090 additions and 410 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.
+7 -14
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]
@@ -48,20 +49,11 @@ edge-1.iot.renner.uno
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 +65,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
+1
View File
@@ -34,6 +34,7 @@
roles:
- certificates
- common
- pacman-cache
- edge-flash-wear
- docker-install
- edge-log-shipper
+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 -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/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
+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
-2
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
+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
@@ -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:
@@ -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,150 @@
# 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
---
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,8 +186,8 @@ spec:
matchLabels:
app.kubernetes.io/name: mosquitto
ingress:
- fromEntities:
- world
- fromCIDRSet:
- cidrGroupRef: net-iot
toPorts:
- ports:
- port: "1883"
@@ -205,8 +205,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 +228,8 @@ spec:
matchLabels:
app.kubernetes.io/name: shelly-manager
ingress:
- fromEntities:
- world
- fromCIDRSet:
- cidrGroupRef: net-iot
toPorts:
- ports:
- port: "8082"
@@ -516,6 +519,7 @@ spec:
- matchPattern: "*"
- toFQDNs:
- matchName: "edge-1.iot.renner.uno"
- matchName: "edge-2.iot.renner.uno"
toPorts:
- ports:
- port: "8443"
@@ -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:
@@ -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
+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()
}