feat(wlan-setup): run wlan.renner.uno on the Go service #1571

Merged
k.renner merged 1 commits from feat/wlan-setup-go-deploy into main 2026-11-05 13:59:55 +00:00
6 changed files with 101 additions and 555 deletions
+26 -15
View File
@@ -154,22 +154,31 @@ For IPv6 set `LIVE_SOURCE` to the workstation's `fd02:50::` address and use
## Client setup (SSID "Renner UniFi")
Setup page: `https://wlan.renner.uno` (behind the Authentik gate, any
authentik user; `kubernetes/apps/authentik/wlan-setup*.yaml`). 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.
authentik user), served by the Go service `wlan-setup/`
(`kubernetes/apps/authentik/wlan-setup*.yaml`). Android, Apple and Windows get
Passpoint profiles with their own app password; Linux an `nmcli` command
(NetworkManager has no Passpoint support) that carries no password.
Passpoint (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
Passpoint: `/<platform>/install` (`android`, `apple` or `windows`) signs the
user in through the authentik OAuth2 application `wlan-setup` (scope
`goauthentik.io/api`). On `/<platform>/callback`, the service exchanges the
code, creates an app password (`wlan-<platform>-<user>-<timestamp>-<random>`)
with the user's own access token, and 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.
with the user's own access token, and hands out the profile:
- Android: `application/x-wifi-config` in the response to that GET, as its
installer requires.
- Apple: a `.mobileconfig` download.
- Windows: a CarrierProvisioning XML signed with XML-DSig (enveloped, C14N
1.0, rsa-sha1, chain in KeyInfo) by the Let's Encrypt certificate
`wlan-setup-signing`; kept for 5 minutes under a single-use token. The
browser is sent to `ms-settings:wifi-provisioning?uri=https://wlan.renner.uno/windows/profile/<token>`;
Windows Settings fetches it (Ingress `wlan-setup-provisioning`, without the
gate) and offers "Add".
The service stores nothing else. Deleting the app password in authentik removes
that device from the WLAN. The profiles match WLANs that advertise Hotspot 2.0
with domain and NAI realm `renner.uno` (EAP-TTLS/PAP), i.e. "Renner UniFi".
Creating the app password needs no admin rights.
App passwords expire when unused. The page creates them with 29 days (the API
caps new tokens at 30 days unless the group attribute
@@ -203,7 +212,9 @@ Passpoint profile from the setup page ("Auf Android installieren").
### Windows 11
Settings > Network & Internet > Wi-Fi > Manage known networks > Add network:
Passpoint profile from the setup page ("Auf Windows installieren"). Manual
setup without the page: Settings > Network & Internet > Wi-Fi > Manage known
networks > Add network:
- Network name: `Renner UniFi`
- Security type: WPA3-Enterprise AES
@@ -1700,6 +1700,8 @@ data:
matching_mode: strict
- url: https://wlan.renner.uno/apple/callback
matching_mode: strict
- url: https://wlan.renner.uno/windows/callback
matching_mode: strict
- model: authentik_core.application
identifiers:
+1 -1
View File
@@ -17,7 +17,7 @@ resources:
- freeradius.yaml
- freeradius-cnp.yaml
- wlan-setup-oauth.yaml
- wlan-setup-config.yaml
- wlan-setup-signing-cert.yaml
- wlan-setup.yaml
# ak-pg CNPG Postgres backend (moved here from the retired seed-authentik KS;
# each carries prune: disabled so ownership transferred without a teardown).
@@ -1,482 +0,0 @@
# wlan.renner.uno: setup files for "Renner UniFi" (802.1X, EAP-TTLS/PAP
# against freeradius, outer identity anonymous, server name radius.renner.uno).
# Served by wlan-setup (wlan-setup.yaml) behind the Authentik gate; the
# username comes from X-authentik-username. 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;
}
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.
passpoint.js: |
const ISRG_ROOT_X1 = "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygch77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZLubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVcoyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=";
const USER = /^[A-Za-z0-9._@+-]{1,64}$/;
function b64(s) { return Buffer.from(s).toString("base64"); }
function node(name, inner) { return "<Node><NodeName>" + name + "</NodeName>" + inner + "</Node>"; }
function leaf(name, value) { return node(name, "<Value>" + value + "</Value>"); }
function profile(user, password) {
const pps = '<MgmtTree xmlns="syncml:dmddf1.2"><VerDTD>1.2</VerDTD>' +
node("PerProviderSubscription",
"<RTProperties><Type><DDFName>urn:wfa:mo:hotspot2dot0-perprovidersubscription:1.0</DDFName></Type></RTProperties>" +
node("i001",
node("HomeSP", leaf("FriendlyName", "Renner") + leaf("FQDN", "renner.uno")) +
node("Credential",
leaf("Realm", "renner.uno") +
node("UsernamePassword",
leaf("Username", user) +
leaf("Password", b64(password)) +
node("EAPMethod", leaf("EAPType", "21") + leaf("InnerMethod", "PAP")))) +
node("Extension", node("Android",
node("AAAServerTrustedNames", leaf("FQDN", "radius.renner.uno")))))) +
"</MgmtTree>";
const mime = "Content-Type: multipart/mixed; boundary={boundary}\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
"--{boundary}\r\n" +
"Content-Type: application/x-passpoint-profile\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
b64(pps) + "\r\n" +
"--{boundary}\r\n" +
"Content-Type: application/x-x509-ca-cert\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
ISRG_ROOT_X1 + "\r\n" +
"--{boundary}--\r\n";
return b64(mime);
}
function 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 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 };
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,21 @@
---
# Signs the Windows Wi-Fi provisioning XML of wlan.renner.uno (XML-DSig in the
# wlan-setup service). Issued via the cluster-wide Cloudflare DNS-01
# ClusterIssuer.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wlan-setup-signing
namespace: authentik
spec:
secretName: wlan-setup-signing
issuerRef:
name: letsencrypt-cluster
kind: ClusterIssuer
dnsNames:
- wlan.renner.uno
privateKey:
algorithm: RSA
size: 2048
encoding: PKCS8
rotationPolicy: Always
+51 -57
View File
@@ -1,9 +1,9 @@
# 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).
# wlan.renner.uno: setup page for the 802.1X WLAN "Renner UniFi", served by the
# wlan-setup Go service (wlan-setup/). Android, Apple and Windows get Passpoint
# profiles with a per-device authentik app password; Linux an nmcli command.
# Public, so a device can reach it before it is in the WLAN (mobile data,
# Renner Gast). Any Authentik user passes the gate (no group); the page stores
# no user credentials.
# Renner Gast). Any Authentik user passes the gate (no group); the service
# stores no user credentials beyond the 5-minute single-use Windows profile.
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -12,7 +12,6 @@ metadata:
labels:
app.kubernetes.io/name: wlan-setup
annotations:
configmap.reloader.stakater.com/reload: wlan-setup
secret.reloader.stakater.com/reload: wlan-setup-oauth
spec:
replicas: 1
@@ -27,15 +26,17 @@ spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
runAsUser: 65534
runAsGroup: 65534
# Group of the signing key files (defaultMode 0440).
fsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: nginx
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b5047b9b76845eac124358373c6852377c52e4b05834a47647e955dd91cb8f1e
- name: wlan-setup
image: gitea.renner.uno/gitea_admin/wlan-setup:2026-11-05-1352 # {"$imagepolicy": "flux-system:wlan-setup"}
env:
# Client secret of the "WLAN setup" OAuth2 provider (passpoint.js).
# Client secret of the "WLAN setup" OAuth2 provider.
- name: WLAN_SETUP_CLIENT_SECRET
valueFrom:
secretKeyRef:
@@ -65,55 +66,21 @@ spec:
limits:
memory: 64Mi
volumeMounts:
# subPath: replaced on rollout, which the reloader annotation
# triggers on every ConfigMap change.
- name: main
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
# Key and chain that sign the Windows provisioning XML; read per
# request, so cert-manager renewals need no restart.
- name: signing
mountPath: /etc/wlan-signing
readOnly: true
- name: config
mountPath: /etc/nginx/conf.d
readOnly: true
- name: njs
mountPath: /etc/nginx/njs
readOnly: true
- name: html
mountPath: /usr/share/nginx/html
readOnly: true
# pid file and temp paths of the unprivileged image.
- name: tmp
mountPath: /tmp
volumes:
- name: main
configMap:
name: wlan-setup
- name: signing
secret:
secretName: wlan-setup-signing
defaultMode: 0440
items:
- key: nginx.conf
path: nginx.conf
- name: config
configMap:
name: wlan-setup
items:
- key: default.conf
path: default.conf
- name: njs
configMap:
name: wlan-setup
items:
- key: passpoint.js
path: passpoint.js
- name: html
configMap:
name: wlan-setup
items:
- key: index.html
path: index.html
- key: renner-unifi-wlan.cmd
path: renner-unifi-wlan.cmd
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 16Mi
- key: tls.key
path: tls.key
- key: tls.crt
path: tls.crt
---
apiVersion: v1
kind: Service
@@ -161,3 +128,30 @@ spec:
name: wlan-setup
port:
number: 80
---
# Without the gate: Windows Settings fetches the signed provisioning XML
# without the browser's session; the single-use token in the path is the only
# gate. Traefik reaches the pods through the CNP wlan-setup-gate that
# gate-sync creates for the Ingress above.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wlan-setup-provisioning
namespace: authentik
spec:
ingressClassName: traefik
tls:
- hosts:
- wlan.renner.uno
secretName: wlan-setup-tls
rules:
- host: wlan.renner.uno
http:
paths:
- path: /windows/profile/
pathType: Prefix
backend:
service:
name: wlan-setup
port:
number: 80