Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5.5 045b68f3ec feat(radius): signed Windows Wi-Fi provisioning test file
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
wlan.renner.uno/windows/test-profile.xml serves a CarrierProvisioning
document with a Passpoint profile for "Renner UniFi" (domain and NAI realm
renner.uno, WPA3ENT, EAP-TTLS/PAP, credentials that exist nowhere), signed
with XML-DSig (enveloped, C14N, rsa-sha1) by a Let's Encrypt certificate for
wlan.renner.uno. It shows whether Windows Settings
(ms-settings:wifi-provisioning) accepts that signature.

- Certificate wlan-setup-signing (PKCS#8 for njs WebCrypto), mounted
  read-only into wlan-setup (fsGroup 101, mode 0440), reloader on renewal.
- Ingress wlan-setup-provisioning: exact path, without the authentik gate.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:52:09 +01:00
k.renner 08b087337e Merge pull request 'feat(radius): Android Passpoint profile on the WLAN setup page' (#1565) from feat/wlan-setup-passpoint into main
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Reviewed-on: #1565
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-05 10:37:22 +00:00
claude-botandClaude Opus 5.5 8610853a6b feat(radius): serve the Android Passpoint profile from nginx
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Android starts its Passpoint installer only for a profile fetched with a
user-triggered GET and Content-Type application/x-wifi-config; a
browser-generated blob does not qualify. The page now POSTs the password to
/android/prepare, where njs builds the profile and stores it in a shared
dict (120 s, bound to the caller's username) under a random token; the
link /android/profile/<token> delivers it once.

AAAServerTrustedNames moves under Extension/Android, the only place
Android's PpsMoParser accepts it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:32:21 +01:00
claude-botandClaude Opus 5.5 1b724ef114 feat(radius): Android Passpoint profile on the WLAN setup page
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The Android section takes the password, builds a Passpoint R1 profile
(application/x-wifi-config: PPS MO with HomeSP FQDN and realm renner.uno,
EAP-TTLS with inner PAP, AAA server name radius.renner.uno, ISRG Root X1 as
trust anchor) in the browser and hands it to Android as a blob. The password
is not sent to any server.

Matches WLANs that advertise Hotspot 2.0 with domain and NAI realm
renner.uno.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:52:51 +01:00
k.renner ee1a421dc0 Merge pull request 'feat(radius): WLAN setup page wlan.renner.uno for "Renner UniFi"' (#1564) from feat/wlan-setup-page into main
Reviewed-on: #1564
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-05 09:02:04 +00:00
claude-botandClaude Opus 5.5 1e66d793d5 feat(radius): WLAN setup page wlan.renner.uno for "Renner UniFi"
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
nginx behind the Authentik gate (any user, public) serving:
- Apple profile (TTLS/PAP, outer identity anonymous, trusted server name
  radius.renner.uno) with the caller's username from X-authentik-username,
- a Windows .cmd that imports the WLAN profile via netsh (TTLS/PAP, server
  name radius.renner.uno, trust anchor ISRG Root X1),
- the Linux nmcli command.
No file carries a password. Usernames outside [A-Za-z0-9._@+-] are left
blank instead of being put into a generated file.

The Renner Test profile is removed; that SSID no longer exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 09:56:10 +01:00
security-bot 0996609ce4 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-11-05-0732
Updates managed by image-automation-controller.
2026-11-05 07:51:27 +00:00
k.renner 4b27a5f273 Merge pull request 'chore(ci): bump ci-claude image to 2.1.318' (#1563) from ci-image/claude-2.1.318 into main
Reviewed-on: #1563
2026-11-05 07:36:53 +00:00
claude-bot ea195d0e4f chore(ci): bump ci-claude image to 2.1.318
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-11-05 07:31:22 +00:00
k.renner 1ae259e474 Merge pull request 'chore(deps): update node.js to v26' (#1546) from renovate/node-26.x into main
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
Reviewed-on: #1546
2026-11-05 07:29:19 +00:00
k.renner 796d8459b6 Merge pull request 'fix(zwave-gateway): zwave-js-ui 11.25.2 (Server-Schema 51), Renovate für edge_*_image' (#1562) from fix/zwave-js-ui-11.25.2-renovate into main
Reviewed-on: #1562
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-04 20:54:36 +00:00
security-bot a6be8247eb chore(image-automation): bump ghcr.io/usetrmnl/terminus:0.80.0
Updates managed by image-automation-controller.
2026-11-04 20:51:49 +00:00
hermes-bot 826042f3ea fix(zwave-gateway): zwave-js-ui 11.25.2, Renovate for edge images
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Home Assistant 2026.11.0 requires Z-Wave JS Server schema 51. zwave-js-ui
11.21.1 ships @zwave-js/server 3.10 (schema 50), so the zwave_js entry is
stuck in setup_retry. 11.24.2 is the first release on @zwave-js/server
^3.11.0 (schema 51); bump to the current 11.25.2, pinned by digest.

11.25.2 is built on Alpine 3.23 (was 3.22). The role now reads the
Alpine release from the image and names the musl libeatmydata.so after
it, instead of a hand-synced edge_zwave_alpine_base: Renovate bumps the
image from now on, and the old `creates:` never refreshed the .so.
Builds for other Alpine releases are removed.

Renovate never read ansible/roles/*/defaults/main.yml: enabledManagers
had no custom.regex and there were no customManagers. Add one for
edge_<name>_image (docker datasource and versioning), and give ansible/**
updates their own PR, since the play runs from a workstation after the
merge (no Ansible pipeline since 0c1fb0a2).
2026-11-04 20:46:03 +00:00
security-bot aa2543369c chore(image-automation): bump ghcr.io/immich-app/immich-server:v3.3.6
Updates managed by image-automation-controller.
2026-11-04 18:24:23 +00:00
security-bot 5add98190f chore(image-automation): bump ghcr.io/immich-app/immich-machine-learning:v3.3.6
Updates managed by image-automation-controller.
2026-11-04 18:18:59 +00:00
security-bot 1643b305b2 chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.11.0
Updates managed by image-automation-controller.
2026-11-04 17:55:55 +00:00
k.renner 657e3d0d92 Merge pull request 'feat(radius): proxy UniFi Endpoint logins to the UDM over VLAN 11' (#1561) from feat/radius-proxy-vlan11 into main
Reviewed-on: #1561
2026-11-04 14:10:09 +00:00
k.renner ac2d5d13a1 Merge pull request 'feat(network): VLAN 11 link radius0 on the nodes, Cilium VLAN bypass' (#1560) from feat/vlan11-radius0-base into main
Reviewed-on: #1560
2026-11-04 14:07:19 +00:00
claude-botandClaude Opus 5.5 ed3be3156b feat(network): VLAN 11 link radius0 on the nodes, Cilium VLAN bypass
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
VLAN 11 "(11) RADIUS" carries the FreeRADIUS proxy traffic to the UDM
RADIUS, which accepts clients from its own networks only.

- Talos k8s-{1,2,3}: VLANConfig radius0 (VLAN 11 on uplink), link only,
  no addresses, IPv6 disabled (VLAN 11 sends RAs).
- Cilium bpf.vlanBypass [30, 11]: radius0 is address-less like ens19, so
  its tagged frames meet the VLAN filter on uplink (#592).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:05:23 +01:00
renovate-bot 335c423ca6 chore(deps): update node.js to v26
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
2026-11-04 03:05:56 +00:00
24 changed files with 1070 additions and 119 deletions
+4 -4
View File
@@ -35,25 +35,25 @@ when:
steps:
- name: model-selection-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
commands:
- sh scripts/ci/test-model-selection.sh
- name: pr-review-mistral-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
commands:
- sh scripts/ci/test-pr-review-mistral.sh
- name: promtool-rules-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
commands:
- sh scripts/ci/test-promtool-rules.sh
- name: yaml-citations-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
commands:
- sh scripts/ci/test-yaml-citations.sh
+1 -1
View File
@@ -26,7 +26,7 @@ steps:
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
# test from rotting (it exercises the exact injection shapes).
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
commands:
- sh scripts/ci/test-model-selection.sh
+1 -1
View File
@@ -70,7 +70,7 @@ steps:
# this step is skipped — otherwise we'd fork-bomb PRs.
when:
- branch: main
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -30,7 +30,7 @@ when:
steps:
- name: update-audit-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+11 -9
View File
@@ -134,11 +134,13 @@ Afterwards:
## Day-2 notes
- Image bumps: edit `edge_zwavejs_image` / `edge_sml2mqtt_image` in
defaults and re-run the play (no flux image-automation out here —
deliberate for sml2mqtt, acceptable for zwave-js). If a zwave-js bump
changes its Alpine major, also update `edge_zwave_alpine_base` and refresh
the preloaded `.so` (see "Logging & eMMC" → zwave-js).
- Image bumps: Renovate opens a PR for `edge_*_image` in
`roles/*/defaults/main.yml` (tag + digest, `customManagers` in
`renovate.json`). No pipeline deploys to edge-1, so after the merge run
`ansible-playbook plays/install-zwave-gateway.yml` from `ansible/` on a
mesh-connected workstation. The play reads the image's Alpine release
itself and rebuilds the preloaded `.so` when it changes (see
"Logging & eMMC" → zwave-js).
- open3e / E3onCAN updates: both are pinned to commits in
`roles/heating-can/defaults/main.yml`, so re-running the play does NOT pick
up upstream changes -- that is deliberate, these drive the heat pump. The
@@ -190,7 +192,7 @@ journal commit).
- **Fix:** the `zwave-js` container preloads **`libeatmydata`**
(`LD_PRELOAD=/usr/lib/libeatmydata.so`, mounted read-only from
`zwave-js/libeatmydata.so`). This makes `fsync`/`fdatasync` no-ops *for that
`zwave-js/libeatmydata-alpine<X.Y>.so`). This makes `fsync`/`fdatasync` no-ops *for that
one process*, so the kernel batches the writes via normal writeback (~30 s)
instead of forcing each one. The WS/MQTT value stream to Home Assistant and
the on-disk cache are **unchanged** — full data resolution, no re-interview
@@ -199,9 +201,9 @@ journal commit).
batch it); the only mount-level alternative is putting the cache on tmpfs,
which makes the *whole* store volatile → re-interview on every restart.
- **The `.so` is musl** (the image is Alpine-based), extracted from
`edge_zwave_alpine_base` — keep that var in sync with the image's Alpine
version; to refresh after a major bump, `rm zwave-js/libeatmydata.so` and
re-run.
`alpine:<X.Y>`, where `X.Y` is read from the image's `/etc/alpine-release`
on every run. A new Alpine release gives a new file name, so the play
extracts it, recreates the container and deletes the old file.
- **Durability guard:** because `fsync` is neutralized, a hard power-loss
during the (rare) settings write could truncate `settings.json`, which holds
the **Z-Wave network keys**. `edge-zwave-settings-backup.timer` keeps a
@@ -13,12 +13,8 @@ zwave_base_dir: "{{ edge_base_dir }}/zwave-js"
# survives moving the stick between machines.
edge_zwave_device: /dev/serial/by-id/usb-Nabu_Casa_ZWA-2_80B54EE15720-if00
edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.21.1
# Alpine version the zwave-js-ui image is built on -- the source for the musl
# libeatmydata.so preloaded into that container. Keep in sync if the image
# bumps its Alpine major (check: docker exec zwave-js cat /etc/os-release).
edge_zwave_alpine_base: alpine:3.22
# Renovate-tracked (customManagers in renovate.json).
edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.25.2@sha256:ac4874a3cbea61c0c60002a8fd8ae05104cc8eb2eae91aa05bd184972aa06853
# Secret from inventory/group_vars/edge_host/vault.yml:
# edge_zwavejs_session_secret
+32 -3
View File
@@ -15,17 +15,31 @@
- "{{ zwave_base_dir }}"
- "{{ zwave_base_dir }}/store"
# The preloaded .so is musl and must come from the image's own Alpine release.
- name: Read the Alpine release of the zwave-js-ui image
ansible.builtin.command:
cmd: docker run --rm --entrypoint cat {{ edge_zwavejs_image }} /etc/alpine-release
register: zwave_alpine_release
changed_when: false
check_mode: false
- name: Name libeatmydata.so after that Alpine release
ansible.builtin.set_fact:
zwave_alpine_minor: "{{ zwave_alpine_release.stdout.split('.')[:2] | join('.') }}"
zwave_libeatmydata: "libeatmydata-alpine{{ zwave_alpine_release.stdout.split('.')[:2] | join('.') }}.so"
- name: Extract musl libeatmydata.so (matches the zwave-js-ui Alpine base)
ansible.builtin.shell:
cmd: |
set -euo pipefail
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
docker run --rm --network=host {{ edge_zwave_alpine_base }} \
docker run --rm --network=host alpine:{{ zwave_alpine_minor }} \
sh -c 'apk add --no-cache libeatmydata >/dev/null 2>&1 && cat /usr/lib/libeatmydata.so' > "$tmp"
test "$(stat -c%s "$tmp")" -ge 8000 # sanity: real .so is ~14 KB, guards against a failed/empty pull
install -m 0644 "$tmp" {{ zwave_base_dir }}/libeatmydata.so
creates: "{{ zwave_base_dir }}/libeatmydata.so"
install -m 0644 "$tmp" {{ zwave_base_dir }}/{{ zwave_libeatmydata }}
creates: "{{ zwave_base_dir }}/{{ zwave_libeatmydata }}"
executable: /bin/bash
notify: Restart zwave-js
# Because fsync is neutralized, a hard power-loss during the (rare) settings
@@ -93,3 +107,18 @@
zwave-js could not start -- the ZWA-2 radio is not present:
{{ zwave_compose_up.stderr_lines | select('search', 'device') | list }}
when: zwave_compose_up.rc != 0
- name: Find libeatmydata.so builds of other Alpine releases
ansible.builtin.find:
paths: "{{ zwave_base_dir }}"
patterns: "libeatmydata*.so"
excludes: "{{ zwave_libeatmydata }}"
register: zwave_stale_libeatmydata
- name: Remove libeatmydata.so builds of other Alpine releases
ansible.builtin.file:
path: "{{ item.path }}"
state: absent
loop: "{{ zwave_stale_libeatmydata.files }}"
loop_control:
label: "{{ item.path }}"
@@ -31,7 +31,7 @@ services:
- "{{ edge_zwave_device }}:/dev/zwave"
volumes:
# musl libeatmydata.so extracted from the image's Alpine base (ro)
- ./libeatmydata.so:/usr/lib/libeatmydata.so:ro
- ./{{ zwave_libeatmydata }}:/usr/lib/libeatmydata.so:ro
# Persistent store: settings.json, device DB, network keys. Path is
# unchanged from the previous shared project, so nothing is migrated.
- ./store:/usr/src/app/store
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-alpine@sha256:8a32c8cf649a49915a66e81e7a609223c708d0adabf288ee3f4077d765542f85
FROM node:26-alpine@sha256:c6ad9e9c1d80f6eb607c26b4b0a132ff7a2344fad1c78ea3baa2a7d4c8de6d94
# Default to the latest Claude Code. Pinning a fixed version is the wrong
# default here: old releases eventually stop working against the API, so a
@@ -1,58 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>com.apple.wifi.managed</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>uno.renner.wifi.renner-test.network</string>
<key>PayloadUUID</key>
<string>c7814579-fc2b-4f38-bcc8-77e1123ab710</string>
<key>PayloadDisplayName</key>
<string>Renner Test Wi-Fi</string>
<key>PayloadOrganization</key>
<string>Renner</string>
<key>SSID_STR</key>
<string>Renner Test</string>
<key>HIDDEN_NETWORK</key>
<false/>
<key>AutoJoin</key>
<true/>
<key>EncryptionType</key>
<string>Any</string>
<key>EAPClientConfiguration</key>
<dict>
<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-test</string>
<key>PayloadUUID</key>
<string>b35513d4-97e5-4804-a77b-5ca1f723a408</string>
<key>PayloadDisplayName</key>
<string>Renner Test Wi-Fi (802.1X)</string>
<key>PayloadOrganization</key>
<string>Renner</string>
</dict>
</plist>
+52 -22
View File
@@ -125,7 +125,15 @@ Created through the UniFi Network API (site `default`):
|---|---|---|
| Firewall policy "Allow APs RADIUS" | `62c21e63-8c15-4720-adf8-b2333c090e2f` | zone Isolated `10.2.10.0/24` → zone External `10.2.99.91`, UDP `1812-1813`, ALLOW, IPv4 |
| RADIUS profile "Cluster 802.1X" | `3e5fcfed-63dc-4e7d-ad51-17cbc074e4bb` | auth `10.2.99.91:1812`, acct `10.2.99.91:1813`, secret `DOT1X_NAS_SECRET`, VLAN assignment on (`vlan_wlan_mode: optional`) |
| WLAN "Renner Test" | `3eba27e7-ddc2-41fb-ad3f-82add317847a` | copy of "Renner UniFi" (WPA2/WPA3 Enterprise transition, fast roaming, BSS transition, same AP group and bands), network (90) Guest as fallback, RADIUS profile "Cluster 802.1X" |
| WLAN "Renner UniFi" | `ef4122f2-4a83-4dca-9ef3-468d29f39a6b` | WPA3 Enterprise (no transition), PMF required, fast roaming, BSS transition, client isolation off, RADIUS profile "Cluster 802.1X"; Application Hotspot, type Passpoint (set in the UniFi UI: the API format differs from the UI's) |
Passpoint on "Renner UniFi" (UniFi offers Passpoint only with WPA3 Enterprise):
venue "Renner", Residential / Private Residence, network type Private with
Guest Access, IPv4 single NATed private, IPv6 available, NAI realm
`renner.uno` with EAP-TTLS, sub-methods PAP and username/password, domain
`renner.uno`, operator friendly name "Renner", connection capability ICMP
allow. The AKMs on air are `FT/802.1X` and `802.1X/SHA-256`; plain `802.1X`
is not offered.
## Live login check
@@ -143,23 +151,45 @@ For IPv6 set `LIVE_SOURCE` to the workstation's `fd02:50::` address and use
`fd99::91`. `group-id=[3530]` is VLAN `50`, `tunnel-type=[0000000d]` VLAN,
`medium=[00000006]` IEEE-802.
## Client setup (SSID "Renner Test")
## Client setup (SSID "Renner UniFi")
Setup page: `https://wlan.renner.uno` (behind the Authentik gate, any
authentik user; `kubernetes/apps/authentik/wlan-setup*.yaml`). Android and
Apple get Passpoint profiles with their own app password; Windows gets a file
that imports the WLAN profile with `netsh`, Linux an `nmcli` command
(NetworkManager has no Passpoint support). The Windows and Linux setups carry
no password.
Passpoint (Android, Apple): `/<platform>/install` (`android` or `apple`) signs
the user in through the authentik OAuth2 application `wlan-setup` (scope
`goauthentik.io/api`). On `/<platform>/callback`, nginx (njs) exchanges the
code, creates an app password (`wlan-<platform>-<user>-<timestamp>-<random>`)
with the user's own access token, and returns the profile in the response to
that GET: Android `application/x-wifi-config` (as its installer requires),
Apple a `.mobileconfig`. The page stores nothing. Deleting the app password in
authentik removes that device from the WLAN. The profiles match WLANs that
advertise Hotspot 2.0 with domain and NAI realm `renner.uno` (EAP-TTLS/PAP),
i.e. "Renner UniFi". Creating the app password needs no admin rights.
App passwords expire when unused. The page creates them with 29 days (the API
caps new tokens at 30 days unless the group attribute
`goauthentik.io/user/token-maximum-lifetime` allows more). Every 802.1X login
with an app password renews it to 60 days: the policy
`dot1x-app-password-renew` on the `dot1x-login` binding (at most one write per
day). authentik rejects expired app passwords and deletes them in its
`clean_expired_models` task. A device that has not connected for 60 days, or a
profile that was never installed, loses its app password and needs a new
profile from the page.
All clients use EAP-TTLS with inner PAP. The server certificate is a public
Let's Encrypt certificate for `radius.renner.uno`, so no CA needs to be
installed. Username and password are the authentik credentials. PAP runs
inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air.
installed. The 802.1X password stage (`dot1x-password`) accepts the authentik
password and app passwords. PAP runs inside the TLS tunnel to FreeRADIUS and
is never sent in clear over the air.
### Android
- EAP method: TTLS
- Phase 2 authentication: PAP
- CA certificate: Use system certificates
- Domain: `radius.renner.uno`
- Identity: authentik username
- Anonymous identity: `anonymous` (mandatory; empty sends the login to the
UDM, where it fails)
- Password: authentik password
Passpoint profile from the setup page ("Auf Android installieren").
### Linux (NetworkManager)
@@ -175,8 +205,8 @@ inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air.
Settings > Network & Internet > Wi-Fi > Manage known networks > Add network:
- Network name: `Renner Test`
- Security type: WPA2-Enterprise AES
- Network name: `Renner UniFi`
- Security type: WPA3-Enterprise AES
- EAP method: EAP-TTLS
- Authentication method: Unencrypted password (PAP)
- Server validation: where offered ("Verify server identity" / trusted
@@ -191,14 +221,14 @@ wireless network" instead.
### Apple (iOS, iPadOS, macOS)
Apple devices need the configuration profile
`docs/radius/clients/renner-test.mobileconfig`. Install it via AirDrop, Files
or Settings (Profile Downloaded). The device asks for username and password on
the first join. The profile sets security type Any (WPA2/WPA3), outer identity
`anonymous` and trusted server name `radius.renner.uno`; it contains no
password and no certificate. Apple
devices may show a one-time "trust certificate" prompt for `radius.renner.uno`
on the first join.
Passpoint configuration profile from the setup page ("Profil laden"),
installed via Settings (Profile Downloaded) or, on a Mac, System Settings >
General > Device Management. It sets `IsHotspot` with domain and NAI realm
`renner.uno` (no SSID), security type Any, outer identity `anonymous`,
trusted server name `radius.renner.uno`, username and app password; it
contains no certificate. Its PayloadIdentifier is fixed, so installing it
replaces an earlier profile. Apple devices may show a one-time "trust
certificate" prompt for `radius.renner.uno` on the first join.
## Measurement: authentication latency per SSID
@@ -1483,8 +1483,11 @@ data:
name: dot1x-password
state: present
attrs:
# App passwords: the WLAN credential that devices store (Android
# Passpoint profiles carry it), created by wlan.renner.uno.
backends:
- authentik.core.auth.InbuiltBackend
- authentik.core.auth.TokenBackend
- id: dot1x-login
model: authentik_stages_user_login.userloginstage
@@ -1506,12 +1509,54 @@ data:
order: 20
state: present
- model: authentik_flows.flowstagebinding
- id: binding-dot1x-login
model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf dot1x-flow
stage: !KeyOf dot1x-login
order: 100
state: present
attrs:
evaluate_on_plan: false
re_evaluate_policies: true
# Sliding expiry: an app password used for 802.1X expires 60 days
# after its last use; authentik rejects and deletes expired app
# passwords. auth_method_args.token is set by TokenBackend. Always
# passes; failure_result true keeps an error from blocking the login.
- id: dot1x-app-password-renew
model: authentik_policies_expression.expressionpolicy
identifiers:
name: dot1x-app-password-renew
state: present
attrs:
expression: |
from datetime import timedelta
from django.utils.timezone import now
from authentik.core.models import Token, TokenIntents
ref = (request.context.get("auth_method_args") or {}).get("token")
if request.context.get("auth_method") == "token" and isinstance(ref, dict):
renewed = now() + timedelta(days=60)
count = Token.objects.filter(
pk=ref.get("pk"),
user=request.user,
intent=TokenIntents.INTENT_APP_PASSWORD,
expiring=True,
expires__lt=renewed - timedelta(days=1),
).update(expires=renewed)
ak_logger.info("dot1x app password", token=ref.get("name"), renewed=count)
return True
- model: authentik_policies.policybinding
identifiers:
policy: !KeyOf dot1x-app-password-renew
target: !KeyOf binding-dot1x-login
order: 0
state: present
attrs:
enabled: true
negate: false
failure_result: true
- id: dot1x-has-vlan
model: authentik_policies_expression.expressionpolicy
@@ -1624,3 +1669,45 @@ data:
kubernetes_httproute_annotations: {}
kubernetes_httproute_parent_refs: []
kubernetes_json_patches: null
# wlan.renner.uno (wlan-setup.yaml): signs the user in and, with the
# user's own access token (scope goauthentik.io/api), creates the WLAN
# app password for the Android Passpoint profile. Confidential client;
# the secret comes from wlan-setup-oauth (wlan-setup-oauth.yaml). No
# policy binding: every authentik user may set up the WLAN.
- id: wlan-setup-provider
model: authentik_providers_oauth2.oauth2provider
identifiers:
name: WLAN setup
state: present
attrs:
authentication_flow: null
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
client_type: confidential
client_id: wlan-setup
client_secret: !Env WLAN_SETUP_CLIENT_SECRET
grant_types:
- authorization_code
access_token_validity: minutes=5
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-authentik_api]]
redirect_uris:
- url: https://wlan.renner.uno/android/callback
matching_mode: strict
- url: https://wlan.renner.uno/apple/callback
matching_mode: strict
- model: authentik_core.application
identifiers:
slug: wlan-setup
state: present
attrs:
name: WLAN setup
slug: wlan-setup
provider: !KeyOf wlan-setup-provider
meta_launch_url: https://wlan.renner.uno
meta_description: "WLAN setup page; creates WLAN app passwords"
+11 -4
View File
@@ -171,10 +171,11 @@ spec:
worker:
# Overrides the global key; keep authentik-secrets in the list.
podAnnotations:
secret.reloader.stakater.com/reload: authentik-secrets,radius-secrets
# Shared secret of the 802.1X Radius provider, set via `!Env` in
# dot1x-radius.yaml. Worker only (blueprint applier); optional so a
# missing Secret fails only the dot1x blueprint entry.
secret.reloader.stakater.com/reload: authentik-secrets,radius-secrets,wlan-setup-oauth
# Shared secret of the 802.1X Radius provider and client secret of the
# WLAN setup provider, set via `!Env` in dot1x-radius.yaml. Worker only
# (blueprint applier); optional so a missing Secret fails only the
# blueprint entries that use it.
env:
- name: DOT1X_OUTPOST_SECRET
valueFrom:
@@ -182,6 +183,12 @@ spec:
name: radius-secrets
key: DOT1X_OUTPOST_SECRET
optional: true
- name: WLAN_SETUP_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: wlan-setup-oauth
key: client_secret
optional: true
resources:
requests:
# Same reasoning as server above, and the worker is burstier still:
@@ -16,6 +16,10 @@ resources:
- freeradius-network-attachment.yaml
- freeradius.yaml
- freeradius-cnp.yaml
- wlan-setup-oauth.yaml
- wlan-setup-signing-cert.yaml
- wlan-setup-config.yaml
- wlan-setup.yaml
# ak-pg CNPG Postgres backend (moved here from the retired seed-authentik KS;
# each carries prune: disabled so ownership transferred without a teardown).
# authentik has no data PVCs — all state lives in ak-pg. This KS depends on
@@ -0,0 +1,568 @@
# wlan.renner.uno: setup files for "Renner UniFi" (802.1X, EAP-TTLS/PAP
# against freeradius, outer identity anonymous, server name radius.renner.uno).
# Served by wlan-setup (wlan-setup.yaml) behind the Authentik gate; the
# username comes from X-authentik-username. The Android and Apple Passpoint
# profiles carry a credential: a per-device authentik app password that the
# page creates with the user's own OAuth access token.
apiVersion: v1
kind: ConfigMap
metadata:
name: wlan-setup
namespace: authentik
data:
# The image's nginx.conf plus the njs module and the OAuth client secret
# passpoint.js reads from the environment.
nginx.conf: |
load_module modules/ngx_http_js_module.so;
env WLAN_SETUP_CLIENT_SECRET;
worker_processes 1;
error_log /var/log/nginx/error.log notice;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
proxy_temp_path /tmp/proxy_temp;
client_body_temp_path /tmp/client_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - [$time_local] "$request" $status $body_bytes_sent "$http_user_agent"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
include /etc/nginx/conf.d/*.conf;
}
default.conf: |
# Only plain usernames reach a generated file; anything else leaves the
# field empty and the device asks for it.
map $http_x_authentik_username $wifi_user {
"~^[A-Za-z0-9._@+-]{1,64}$" $http_x_authentik_username;
default "";
}
js_path /etc/nginx/njs/;
js_import passpoint from passpoint.js;
# authentik-server, resolved once at start; reached only by passpoint.js
# subrequests (/_ak/...).
upstream authentik {
server authentik-server.authentik.svc.cluster.local:80;
}
server {
listen 8080;
listen [::]:8080;
server_tokens off;
root /usr/share/nginx/html;
add_header Cache-Control "no-store" always;
location = / {
try_files /index.html =404;
}
location = /whoami {
default_type text/plain;
return 200 $wifi_user;
}
location = /renner-unifi-wlan.cmd {
default_type application/octet-stream;
add_header Cache-Control "no-store" always;
add_header Content-Disposition 'attachment; filename="renner-unifi-wlan.cmd"' always;
try_files /renner-unifi-wlan.cmd =404;
}
# Android and Apple: a tap on /<platform>/install starts an
# authorization-code sign-in at authentik (the user is already signed
# in there); the callback creates the app password as the user and
# answers the same navigation with the Passpoint profile. Android:
# application/x-wifi-config, base64, no Content-Disposition, as its
# installer requires. Apple: a .mobileconfig download.
location ~ ^/(android|apple)/install$ {
js_content passpoint.install;
}
# Signed Windows provisioning test file (dummy credentials), reached
# without the gate through the Ingress wlan-setup-provisioning.
location = /windows/test-profile.xml {
js_content passpoint.windowsTest;
}
location ~ ^/(android|apple)/callback$ {
# The query carries the one-time authorization code.
access_log off;
js_content passpoint.callback;
}
# Subrequests from passpoint.js only. Credential and content type
# arrive as arguments and leave as headers; proxy_pass with a variable
# URI does not forward the arguments. $arg_* only: subrequests share
# the parent's variable cache, so a map would keep its first value.
location ~ ^/_ak(/.*)$ {
internal;
proxy_pass http://authentik$1;
# njs reads the whole reply from this buffer (default one page).
subrequest_output_buffer_size 256k;
# Only the headers set here reach authentik.
proxy_pass_request_headers off;
proxy_set_header Host auth.renner.uno;
proxy_set_header Cookie "";
proxy_set_header Authorization "$arg_scheme $arg_cred";
proxy_set_header Content-Type $arg_ct;
proxy_set_header Accept "application/json";
}
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location / {
return 404;
}
}
# Windows: extracts the WLAN profile (everything after line 8 of itself)
# into %TEMP% and imports it for the current user with netsh. EAP-TTLS with
# PAPAuthentication, outer identity anonymous, server name
# radius.renner.uno, trust anchor ISRG Root X1 (SHA-1 thumbprint; the
# Let's Encrypt chain of radius-tls ends there). Windows asks for username
# and password on connect. CRLF line endings: cmd.exe needs them.
renner-unifi-wlan.cmd: "@echo off\r\nrem WLAN Renner UniFi einrichten (802.1X, Anmeldung mit dem authentik-Konto)\r\nset \"WLANXML=%TEMP%\\renner-unifi-wlan.xml\"\r\nmore +8 \"%~f0\" > \"%WLANXML%\"\r\nnetsh wlan add profile filename=\"%WLANXML%\" user=current\r\ndel \"%WLANXML%\"\r\nnetsh wlan connect name=\"Renner UniFi\"\r\npause & exit /b\r\n<?xml version=\"1.0\"?><WLANProfile xmlns=\"http://www.microsoft.com/networking/WLAN/profile/v1\"><name>Renner UniFi</name><SSIDConfig><SSID><hex>52656E6E657220556E694669</hex><name>Renner UniFi</name></SSID><nonBroadcast>false</nonBroadcast></SSIDConfig><connectionType>ESS</connectionType><connectionMode>auto</connectionMode><MSM><security><authEncryption><authentication>WPA2</authentication><encryption>AES</encryption><useOneX>true</useOneX></authEncryption><OneX xmlns=\"http://www.microsoft.com/networking/OneX/v1\"><cacheUserData>true</cacheUserData><authMode>user</authMode><EAPConfig><EapHostConfig xmlns=\"http://www.microsoft.com/provisioning/EapHostConfig\"><EapMethod><Type xmlns=\"http://www.microsoft.com/provisioning/EapCommon\">21</Type><VendorId xmlns=\"http://www.microsoft.com/provisioning/EapCommon\">0</VendorId><VendorType xmlns=\"http://www.microsoft.com/provisioning/EapCommon\">0</VendorType><AuthorId xmlns=\"http://www.microsoft.com/provisioning/EapCommon\">311</AuthorId></EapMethod><Config xmlns=\"http://www.microsoft.com/provisioning/EapHostConfig\"><EapTtls xmlns=\"http://www.microsoft.com/provisioning/EapTtlsConnectionPropertiesV1\"><ServerValidation><ServerNames>radius.renner.uno</ServerNames><TrustedRootCAHash>ca bd 2a 79 a1 07 6a 31 f2 1d 25 36 35 cb 03 9d 43 29 a5 e8</TrustedRootCAHash><DisablePrompt>false</DisablePrompt></ServerValidation><Phase2Authentication><PAPAuthentication/></Phase2Authentication><Phase1Identity><IdentityPrivacy>true</IdentityPrivacy><AnonymousIdentity>anonymous</AnonymousIdentity></Phase1Identity></EapTtls></Config></EapHostConfig></EAPConfig></OneX></security></MSM></WLANProfile>\r\n"
# Android Passpoint R1 provisioning (source.android.com, Passpoint): the
# body is base64 of a MIME multipart with the PPS MO and the trust anchor.
# HomeSP FQDN and realm renner.uno match the Hotspot 2.0 domain and NAI realm
# the Passpoint WLAN announces; EAP-TTLS (21) with inner PAP. Trust anchor
# ISRG Root X1 (the radius-tls chain ends there), AAA server name
# radius.renner.uno (Extension/Android/AAAServerTrustedNames, as Android's
# PpsMoParser expects it). The credential is a new app password per install,
# created as the user with the access token of the "WLAN setup" OAuth client
# (authorization code, scope goauthentik.io/api). It starts with 29 days
# (below the 30-day token cap); each 802.1X login renews it to 60 days
# (policy dot1x-app-password-renew).
# Apple: com.apple.wifi.managed with IsHotspot, DomainName and NAIRealmNames
# renner.uno (no SSID: Passpoint matching), TTLS (21) with inner PAP, outer
# identity anonymous, trusted server name radius.renner.uno, username and
# app password. Fixed PayloadIdentifier/UUIDs so a reinstall replaces the
# previous profile.
# Windows: provisioning XML (CarrierProvisioning) for
# ms-settings:wifi-provisioning, signed with XML-DSig (enveloped, C14N,
# rsa-sha1, as Microsoft's samples) by the wlan-setup-signing key. The
# document is written in canonical form, so the digest covers it as sent.
passpoint.js: |
import fs from "fs";
import cr from "crypto";
const ISRG_ROOT_X1 = "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygch77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZLubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVcoyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=";
const USER = /^[A-Za-z0-9._@+-]{1,64}$/;
function b64(s) { return Buffer.from(s).toString("base64"); }
function node(name, inner) { return "<Node><NodeName>" + name + "</NodeName>" + inner + "</Node>"; }
function leaf(name, value) { return node(name, "<Value>" + value + "</Value>"); }
function profile(user, password) {
const pps = '<MgmtTree xmlns="syncml:dmddf1.2"><VerDTD>1.2</VerDTD>' +
node("PerProviderSubscription",
"<RTProperties><Type><DDFName>urn:wfa:mo:hotspot2dot0-perprovidersubscription:1.0</DDFName></Type></RTProperties>" +
node("i001",
node("HomeSP", leaf("FriendlyName", "Renner") + leaf("FQDN", "renner.uno")) +
node("Credential",
leaf("Realm", "renner.uno") +
node("UsernamePassword",
leaf("Username", user) +
leaf("Password", b64(password)) +
node("EAPMethod", leaf("EAPType", "21") + leaf("InnerMethod", "PAP")))) +
node("Extension", node("Android",
node("AAAServerTrustedNames", leaf("FQDN", "radius.renner.uno")))))) +
"</MgmtTree>";
const mime = "Content-Type: multipart/mixed; boundary={boundary}\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
"--{boundary}\r\n" +
"Content-Type: application/x-passpoint-profile\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
b64(pps) + "\r\n" +
"--{boundary}\r\n" +
"Content-Type: application/x-x509-ca-cert\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
ISRG_ROOT_X1 + "\r\n" +
"--{boundary}--\r\n";
return b64(mime);
}
function xml(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function mobileconfig(user, password) {
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>' + xml(user) + '</string>' +
'<key>UserPassword</key><string>' + xml(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';
}
const SIGNING = "/etc/wlan-signing/";
const DSIG = "http://www.w3.org/2000/09/xmldsig#";
const CARRIER_ID = "{5423c37a-7530-4ce9-8f87-a2574ea006bc}";
function pem(text, label) {
const re = new RegExp("-----BEGIN " + label + "-----([^-]+)-----END " + label + "-----", "g");
const out = [];
let m;
while ((m = re.exec(text)) !== null) out.push(m[1].replace(/\s+/g, ""));
return out;
}
// Canonical form (C14N 1.0): no self-closing tags, namespaces declared
// only where they change.
function windowsProfile(user, password) {
const common = ' xmlns="http://www.microsoft.com/provisioning/EapCommon"';
return '<CarrierProvisioning xmlns="http://www.microsoft.com/networking/CarrierControl/v1">' +
'<Global><CarrierId>' + CARRIER_ID + '</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>' + xml(user) + '</Username><Password>' + xml(password) + '</Password>' +
'</EapTtls></Credentials></EapHostUserCredentials>' +
'</security></MSM></WLANProfile></WLANProfiles></CarrierProvisioning>';
}
async function signed(doc) {
const digest = cr.createHash("sha1").update(doc).digest("base64");
const signedInfo = '<SignedInfo xmlns="' + DSIG + '">' +
'<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></CanonicalizationMethod>' +
'<SignatureMethod Algorithm="' + DSIG + 'rsa-sha1"></SignatureMethod>' +
'<Reference URI=""><Transforms><Transform Algorithm="' + DSIG + 'enveloped-signature"></Transform></Transforms>' +
'<DigestMethod Algorithm="' + DSIG + 'sha1"></DigestMethod><DigestValue>' + digest + '</DigestValue></Reference>' +
'</SignedInfo>';
const der = Buffer.from(pem(fs.readFileSync(SIGNING + "tls.key", "utf8"), "PRIVATE KEY")[0], "base64");
const key = await crypto.subtle.importKey("pkcs8", der, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-1" }, false, ["sign"]);
const value = Buffer.from(await crypto.subtle.sign({ name: "RSASSA-PKCS1-v1_5" }, key, signedInfo)).toString("base64");
const certs = pem(fs.readFileSync(SIGNING + "tls.crt", "utf8"), "CERTIFICATE")
.map(c => "<X509Certificate>" + c + "</X509Certificate>").join("");
const signature = '<Signature xmlns="' + DSIG + '">' + signedInfo +
"<SignatureValue>" + value + "</SignatureValue><KeyInfo><X509Data>" + certs + "</X509Data></KeyInfo></Signature>";
return doc.replace(/<\/CarrierProvisioning>$/, signature + "</CarrierProvisioning>");
}
// Test of the signature only: fixed profile with credentials that exist
// nowhere.
async function windowsTest(r) {
try {
r.headersOut["Content-Type"] = "text/xml";
r.return(200, await signed(windowsProfile("wlan-signature-test", "not-a-real-password")));
} catch (e) {
r.error("wlan-setup windows test: " + e.message);
r.return(500, "signing failed\n");
}
}
const PLATFORMS = {
android: { label: "Android" },
apple: { label: "Apple" },
};
// "android" or "apple" from /<platform>/install or /<platform>/callback.
function platform(r) {
return r.uri.split("/")[1];
}
function redirectUri(name) {
return "https://wlan.renner.uno/" + name + "/callback";
}
const CLIENT_ID = "wlan-setup";
const AUTHORIZE = "https://auth.renner.uno/application/o/authorize/";
const STATE_COOKIE = "wlan_oauth_state";
const LIFETIME_DAYS = 29;
function random() {
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
return Buffer.from(bytes).toString("base64url");
}
function cookie(r, name) {
const all = r.headersIn["Cookie"] || "";
const parts = all.split(";");
for (let n = 0; n < parts.length; n++) {
const part = parts[n];
const i = part.indexOf("=");
if (i > 0 && part.substring(0, i).trim() === name) return part.substring(i + 1).trim();
}
return "";
}
function form(fields) {
return Object.keys(fields).map(k => encodeURIComponent(k) + "=" + encodeURIComponent(fields[k])).join("&");
}
const FORM = "application/x-www-form-urlencoded";
const JSON_CT = "application/json";
const CRED = /^[A-Za-z0-9._~+\/=-]+$/;
// authentik call through the internal /_ak location; the arguments
// become the Authorization and Content-Type headers there (raw, nginx
// does not decode $arg_*).
async function ak(r, method, path, scheme, cred, ct, body) {
if (!CRED.test(cred)) throw new Error(method + " " + path + ": no credential");
const args = "scheme=" + scheme + "&cred=" + cred + "&ct=" + ct;
const opts = { method: method, args: args };
if (body !== undefined) opts.body = body;
const reply = await r.subrequest("/_ak" + path, opts);
if (reply.status < 200 || reply.status > 299) {
throw new Error(method + " " + path + " -> " + reply.status + " " + reply.responseText.substring(0, 300));
}
return JSON.parse(reply.responseText);
}
function install(r) {
const name = platform(r);
const state = random();
r.headersOut["Set-Cookie"] = STATE_COOKIE + "=" + state +
"; Path=/" + name + "/; Max-Age=300; HttpOnly; Secure; SameSite=Lax";
r.return(302, AUTHORIZE + "?" + form({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: redirectUri(name),
scope: "openid profile goauthentik.io/api",
state: state,
}));
}
async function callback(r) {
const name = platform(r);
try {
const state = cookie(r, STATE_COOKIE);
if (!state || r.args.state !== state || !r.args.code) { r.return(400, "Bitte die Einrichtung neu starten.\n"); return; }
const client = Buffer.from(CLIENT_ID + ":" + process.env.WLAN_SETUP_CLIENT_SECRET).toString("base64");
const grant = await ak(r, "POST", "/application/o/token/", "Basic", client, FORM, form({
grant_type: "authorization_code",
code: r.args.code,
redirect_uri: redirectUri(name),
}));
const access = grant.access_token;
const me = await ak(r, "GET", "/api/v3/core/users/me/", "Bearer", access, "");
const user = me.user.username;
if (!USER.test(user) || user !== r.headersIn["X-authentik-username"]) { r.return(403); return; }
const now = new Date();
const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\..*$/, "");
const identifier = "wlan-" + name + "-" + user.replace(/[^A-Za-z0-9-]/g, "-").toLowerCase() + "-" + stamp + "-" + random().substring(0, 6).replace(/[^A-Za-z0-9]/g, "x");
const expires = new Date(now.getTime() + LIFETIME_DAYS * 86400000).toISOString();
await ak(r, "POST", "/api/v3/core/tokens/", "Bearer", access, JSON_CT, JSON.stringify({
identifier: identifier,
intent: "app_password",
description: "WLAN " + PLATFORMS[name].label + ", " + now.toISOString().substring(0, 10) + " (wlan.renner.uno)",
expiring: true,
expires: expires,
}));
const key = (await ak(r, "GET", "/api/v3/core/tokens/" + identifier + "/view_key/", "Bearer", access, "")).key;
r.headersOut["Set-Cookie"] = STATE_COOKIE + "=; Path=/" + name + "/; Max-Age=0; HttpOnly; Secure; SameSite=Lax";
if (name === "apple") {
r.headersOut["Content-Type"] = "application/x-apple-aspen-config";
r.headersOut["Content-Disposition"] = 'attachment; filename="renner-unifi.mobileconfig"';
r.return(200, mobileconfig(user, key));
} else {
r.headersOut["Content-Type"] = "application/x-wifi-config";
r.headersOut["Content-Transfer-Encoding"] = "base64";
r.return(200, profile(user, key));
}
} catch (e) {
r.error("wlan-setup " + name + ": " + e.message);
r.return(502, "Die Einrichtung ist fehlgeschlagen.\n");
}
}
export default { install, callback, windowsTest };
index.html: |
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WLAN einrichten</title>
<style>
:root { --bg: #ffffff; --fg: #1d1d1f; --muted: #5f6368; --card: #f4f5f7; --accent: #0b63ce; --code: #e9ebef; }
@media (prefers-color-scheme: dark) {
:root { --bg: #121314; --fg: #e8eaed; --muted: #9aa0a6; --card: #1e1f22; --accent: #7cacf8; --code: #2a2b2f; }
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 42rem; margin: 0 auto; padding: 1.5rem 1rem 3rem; }
h1 { font-size: 1.6rem; margin: 0 0 .25rem; }
h2 { font-size: 1.15rem; margin: 0 0 .5rem; }
p { margin: .5rem 0; }
.muted { color: var(--muted); }
section { background: var(--card); border-radius: 12px; padding: 1rem 1.1rem; margin: 1rem 0; }
section.current { outline: 2px solid var(--accent); }
a.button, button { display: inline-block; background: var(--accent); color: #fff; border: 0; border-radius: 8px; padding: .6rem 1rem; font: inherit; text-decoration: none; cursor: pointer; }
@media (prefers-color-scheme: dark) { a.button, button { color: #121314; } }
ol { padding-left: 1.3rem; margin: .5rem 0; }
pre { background: var(--code); border-radius: 8px; padding: .75rem; overflow-x: auto; white-space: pre-wrap; word-break: break-all; font-size: .85rem; margin: .5rem 0; }
details > summary { cursor: pointer; color: var(--accent); margin: 1.5rem 0 .5rem; }
</style>
</head>
<body>
<main>
<h1>WLAN einrichten</h1>
<p>WLAN <strong>Renner UniFi</strong> für dein Konto <strong id="user">…</strong>. Android, iPhone, iPad und Mac bekommen ein eigenes WLAN-App-Passwort; Windows und Linux melden sich mit deinem authentik-Passwort an.</p>
<div id="detected"></div>
<details id="others">
<summary>Andere Geräte</summary>
<div id="rest"></div>
</details>
<template id="t-android">
<section data-os="android">
<h2>Android</h2>
<p><a class="button" href="/android/install">Auf Android installieren</a></p>
<p class="muted">Das Gerät bekommt ein eigenes WLAN-App-Passwort in deinem authentik-Konto; dort gelöscht, ist das Gerät aus dem WLAN.</p>
</section>
</template>
<template id="t-apple">
<section data-os="apple">
<h2>iPhone, iPad, Mac</h2>
<p><a class="button" href="/apple/install">Profil laden</a></p>
<ol>
<li>iPhone/iPad: Einstellungen → „Profil geladen“ → Installieren. Mac: Systemeinstellungen → Allgemein → Geräteverwaltung → Profil doppelklicken.</li>
<li>Das Gerät verbindet sich danach selbst mit „Renner UniFi“.</li>
</ol>
<p class="muted">Das Gerät bekommt ein eigenes WLAN-App-Passwort in deinem authentik-Konto; dort gelöscht, ist das Gerät aus dem WLAN.</p>
</section>
</template>
<template id="t-windows">
<section data-os="windows">
<h2>Windows</h2>
<p><a class="button" href="/renner-unifi-wlan.cmd">Einrichtungsdatei laden</a></p>
<ol>
<li>Die geladene Datei doppelklicken. Fragt Windows nach („Der Computer wurde durch Windows geschützt“): „Weitere Informationen“ → „Trotzdem ausführen“.</li>
<li>Beim Verbinden Benutzername <strong class="u">…</strong> und dein Passwort eingeben.</li>
</ol>
</section>
</template>
<template id="t-linux">
<section data-os="linux">
<h2>Linux (NetworkManager)</h2>
<p>Im Terminal ausführen, danach fragt NetworkManager nach dem Passwort:</p>
<pre id="nmcli"></pre>
<p><button type="button" id="copy-nmcli">Befehl kopieren</button></p>
</section>
</template>
</main>
<script>
(function () {
var ua = navigator.userAgent;
var os = "other";
if (/Android/i.test(ua)) os = "android";
else if (/iPhone|iPad|iPod|Macintosh/i.test(ua)) os = "apple";
else if (/Windows/i.test(ua)) os = "windows";
else if (/Linux|X11/i.test(ua)) os = "linux";
var detected = document.getElementById("detected");
var rest = document.getElementById("rest");
["android", "apple", "windows", "linux"].forEach(function (name) {
var node = document.getElementById("t-" + name).content.cloneNode(true);
if (name === os) { node.querySelector("section").classList.add("current"); detected.appendChild(node); }
else rest.appendChild(node);
});
if (os === "other") document.getElementById("others").open = true;
var currentUser = "";
function quote(s) { return "'" + s.replace(/'/g, "'\\''") + "'"; }
function render(user) {
currentUser = user;
var shown = user || "(dein Benutzername)";
document.getElementById("user").textContent = shown;
document.querySelectorAll(".u").forEach(function (el) { el.textContent = shown; });
document.getElementById("nmcli").textContent =
"nmcli connection add type wifi con-name 'Renner UniFi' ssid 'Renner UniFi' " +
"wifi-sec.key-mgmt wpa-eap 802-1x.eap ttls 802-1x.phase2-auth pap " +
"802-1x.anonymous-identity anonymous 802-1x.identity " + quote(user || "BENUTZERNAME") + " " +
"802-1x.domain-suffix-match radius.renner.uno 802-1x.system-ca-certs yes " +
"802-1x.password-flags 2 && nmcli --ask connection up 'Renner UniFi'";
}
render("");
fetch("/whoami", { credentials: "same-origin" })
.then(function (r) { return r.ok ? r.text() : ""; })
.then(function (t) { render(t.trim()); })
.catch(function () {});
document.getElementById("copy-nmcli").addEventListener("click", function () {
var text = document.getElementById("nmcli").textContent;
var btn = this;
navigator.clipboard.writeText(text).then(function () {
btn.textContent = "Kopiert";
setTimeout(function () { btn.textContent = "Befehl kopieren"; }, 2000);
});
});
})();
</script>
</body>
</html>
@@ -0,0 +1,32 @@
# Client secret of the "WLAN setup" OAuth2 provider (dot1x-radius blueprint).
# Generated once; the authentik worker reads it as WLAN_SETUP_CLIENT_SECRET
# for the blueprint, wlan-setup reads it for the code exchange.
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
metadata:
name: wlan-setup-oauth
namespace: authentik
spec:
length: 64
digits: 16
symbols: 0
allowRepeat: true
noUpper: false
secretKeys:
- client_secret
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: wlan-setup-oauth
namespace: authentik
spec:
refreshPolicy: CreatedOnce
target:
name: wlan-setup-oauth
dataFrom:
- sourceRef:
generatorRef:
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
name: wlan-setup-oauth
@@ -0,0 +1,21 @@
---
# Signs the Windows Wi-Fi provisioning XML of wlan.renner.uno (XML-DSig,
# passpoint.js). PKCS#8 because njs WebCrypto imports only that format.
# Issued via the cluster-wide Cloudflare DNS-01 ClusterIssuer.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wlan-setup-signing
namespace: authentik
spec:
secretName: wlan-setup-signing
issuerRef:
name: letsencrypt-cluster
kind: ClusterIssuer
dnsNames:
- wlan.renner.uno
privateKey:
algorithm: RSA
size: 2048
encoding: PKCS8
rotationPolicy: Always
+204
View File
@@ -0,0 +1,204 @@
# wlan.renner.uno: setup page for the 802.1X WLAN "Renner UniFi" (Apple
# profile, Windows import file, Linux nmcli command, Android Passpoint profile
# with its own app password; config and files in wlan-setup-config.yaml).
# Public, so a device can reach it before it is in the WLAN (mobile data,
# Renner Gast). Any Authentik user passes the gate (no group); the page stores
# no user credentials.
apiVersion: apps/v1
kind: Deployment
metadata:
name: wlan-setup
namespace: authentik
labels:
app.kubernetes.io/name: wlan-setup
annotations:
configmap.reloader.stakater.com/reload: wlan-setup
secret.reloader.stakater.com/reload: wlan-setup-oauth,wlan-setup-signing
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: wlan-setup
template:
metadata:
labels:
app.kubernetes.io/name: wlan-setup
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
# Group of the signing key files (defaultMode 0440).
fsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: nginx
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b5047b9b76845eac124358373c6852377c52e4b05834a47647e955dd91cb8f1e
env:
# Client secret of the "WLAN setup" OAuth2 provider (passpoint.js).
- name: WLAN_SETUP_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: wlan-setup-oauth
key: client_secret
ports:
- name: http
containerPort: 8080
protocol: TCP
readinessProbe:
httpGet:
path: /healthz
port: http
livenessProbe:
httpGet:
path: /healthz
port: http
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
requests:
cpu: 5m
memory: 16Mi
limits:
memory: 64Mi
volumeMounts:
# subPath: replaced on rollout, which the reloader annotation
# triggers on every ConfigMap change.
- name: main
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
readOnly: true
- name: config
mountPath: /etc/nginx/conf.d
readOnly: true
- name: njs
mountPath: /etc/nginx/njs
readOnly: true
- name: html
mountPath: /usr/share/nginx/html
readOnly: true
# Key and chain that sign the Windows provisioning XML.
- name: signing
mountPath: /etc/wlan-signing
readOnly: true
# pid file and temp paths of the unprivileged image.
- name: tmp
mountPath: /tmp
volumes:
- name: signing
secret:
secretName: wlan-setup-signing
defaultMode: 0440
items:
- key: tls.key
path: tls.key
- key: tls.crt
path: tls.crt
- name: main
configMap:
name: wlan-setup
items:
- key: nginx.conf
path: nginx.conf
- name: config
configMap:
name: wlan-setup
items:
- key: default.conf
path: default.conf
- name: njs
configMap:
name: wlan-setup
items:
- key: passpoint.js
path: passpoint.js
- name: html
configMap:
name: wlan-setup
items:
- key: index.html
path: index.html
- key: renner-unifi-wlan.cmd
path: renner-unifi-wlan.cmd
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 16Mi
---
apiVersion: v1
kind: Service
metadata:
name: wlan-setup
namespace: authentik
labels:
app.kubernetes.io/name: wlan-setup
spec:
ipFamilyPolicy: PreferDualStack
selector:
app.kubernetes.io/name: wlan-setup
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wlan-setup
namespace: authentik
labels:
# Public: Cloudflare CNAME → NetBird proxy → Traefik.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
tls:
- hosts:
- wlan.renner.uno
secretName: wlan-setup-tls
rules:
- host: wlan.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: wlan-setup
port:
number: 80
---
# Without the gate: Windows Settings fetches provisioning XML without the
# browser's session. Traefik reaches the pods through the CNP wlan-setup-gate
# that gate-sync creates for the Ingress above.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wlan-setup-provisioning
namespace: authentik
spec:
ingressClassName: traefik
tls:
- hosts:
- wlan.renner.uno
secretName: wlan-setup-tls
rules:
- host: wlan.renner.uno
http:
paths:
- path: /windows/test-profile.xml
pathType: Exact
backend:
service:
name: wlan-setup
port:
number: 80
@@ -64,7 +64,7 @@ spec:
type: RuntimeDefault
containers:
- name: shelly-manager
image: gitea.renner.uno/konairius/shelly-manager:2026-11-02-1412 # {"$imagepolicy": "flux-system:shelly-manager"}
image: gitea.renner.uno/konairius/shelly-manager:2026-11-05-0732 # {"$imagepolicy": "flux-system:shelly-manager"}
imagePullPolicy: IfNotPresent
ports:
- name: http
@@ -128,7 +128,7 @@ spec:
memory: 32Mi
containers:
- name: home-assistant
image: ghcr.io/home-assistant/home-assistant:2026.10.4 # {"$imagepolicy": "flux-system:home-assistant"}
image: ghcr.io/home-assistant/home-assistant:2026.11.0 # {"$imagepolicy": "flux-system:home-assistant"}
env:
- name: TZ
value: Europe/Berlin
+1 -1
View File
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: immich-machine-learning
image: ghcr.io/immich-app/immich-machine-learning:v3.3.5 # {"$imagepolicy": "flux-system:immich-machine-learning"}
image: ghcr.io/immich-app/immich-machine-learning:v3.3.6 # {"$imagepolicy": "flux-system:immich-machine-learning"}
ports:
- name: http
containerPort: 3003
+1 -1
View File
@@ -87,7 +87,7 @@ spec:
mountPath: /ca
containers:
- name: immich-server
image: ghcr.io/immich-app/immich-server:v3.3.5 # {"$imagepolicy": "flux-system:immich-server"}
image: ghcr.io/immich-app/immich-server:v3.3.6 # {"$imagepolicy": "flux-system:immich-server"}
ports:
- name: http
containerPort: 3001
+2 -2
View File
@@ -134,7 +134,7 @@ spec:
# is a device-bricking class of risk. Bumps are a reviewed, manual
# act — see kubernetes/infrastructure/image-automation/README.md.
- name: terminus
image: ghcr.io/usetrmnl/terminus:0.79.0 # {"$imagepolicy": "flux-system:terminus"}
image: ghcr.io/usetrmnl/terminus:0.80.0 # {"$imagepolicy": "flux-system:terminus"}
ports:
- name: http
containerPort: 2345
@@ -197,7 +197,7 @@ spec:
capabilities:
drop: ["ALL"]
- name: sidekiq
image: ghcr.io/usetrmnl/terminus:0.79.0 # {"$imagepolicy": "flux-system:terminus"}
image: ghcr.io/usetrmnl/terminus:0.80.0 # {"$imagepolicy": "flux-system:terminus"}
command: ["bundle", "exec", "sidekiq", "-r", "./config/sidekiq.rb"]
envFrom:
- configMapRef:
+30 -1
View File
@@ -7,7 +7,8 @@
"flux",
"kubernetes",
"woodpecker",
"dockerfile"
"dockerfile",
"custom.regex"
],
"ignorePaths": [
"kubernetes/clusters/homelab/flux-system/**",
@@ -23,6 +24,20 @@
"/^kubernetes/.+\\.ya?ml$/"
]
},
"customManagers": [
{
"customType": "regex",
"description": "Container images of the Ansible roles: `edge_<name>_image: <image>:<tag>@<digest>` in ansible/roles/*/defaults/main.yml. No other manager reads these files.",
"managerFilePatterns": [
"/^ansible/roles/[^/]+/defaults/main\\.ya?ml$/"
],
"matchStrings": [
"(?:^|\\n)edge_[a-z0-9_]+_image:[ \\t]*[\"']?(?<depName>[^\\s:@\"']+):(?<currentValue>[^\\s@\"']+)(?:@(?<currentDigest>sha256:[a-f0-9]{64}))?"
],
"datasourceTemplate": "docker",
"versioningTemplate": "docker"
}
],
"packageRules": [
{
"description": "Flux image automation (kubernetes/infrastructure/image-automation) already bumps these images. Renovate must not open a second, competing update. Keep this list equal to the ImageRepository images there.",
@@ -134,6 +149,20 @@
"groupName": "CI non-major",
"groupSlug": "ci-minor-patch"
},
{
"description": "Edge-host images (ansible/): no pipeline deploys them, the play runs from a workstation after the merge. One PR of its own, so the merge and the play run go together.",
"matchFileNames": [
"ansible/**"
],
"matchUpdateTypes": [
"patch",
"minor",
"digest",
"pin"
],
"groupName": "edge hosts (ansible)",
"groupSlug": "edge-hosts-ansible"
},
{
"description": "Stateful apps and the CNI: one PR each, never in a group (REN-315). A git revert does not undo a DB migration or a CNI change, so each needs its own review and, where it can disrupt the cluster, a window.",
"matchUpdateTypes": [