diff --git a/docs/radius/dot1x.md b/docs/radius/dot1x.md index baddd157..abfe19c8 100644 --- a/docs/radius/dot1x.md +++ b/docs/radius/dot1x.md @@ -148,8 +148,13 @@ For IPv6 set `LIVE_SOURCE` to the workstation's `fd02:50::` address and use Setup page: `https://wlan.renner.uno` (behind the Authentik gate, any authentik user; `kubernetes/apps/authentik/wlan-setup*.yaml`). It serves the Apple profile (caller's username filled in), a Windows file that imports the -WLAN profile with `netsh`, and the Linux `nmcli` command. No file carries a password. Android has no -import path without Passpoint or an app; it uses the manual values below. +WLAN profile with `netsh`, and the Linux `nmcli` command. None of these carries a password. Android +gets a Passpoint profile (`application/x-wifi-config`): the page POSTs the +password the user types there, nginx (njs) builds the profile and keeps it in +memory for at most 120 s under a one-time token bound to the user, and a tap on +the resulting link GETs it, as the Android installer requires. It matches WLANs +that advertise Hotspot 2.0 with domain and NAI realm `renner.uno` +(EAP-TTLS/PAP). 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 diff --git a/kubernetes/apps/authentik/wlan-setup-config.yaml b/kubernetes/apps/authentik/wlan-setup-config.yaml index 5e84f762..ac888e55 100644 --- a/kubernetes/apps/authentik/wlan-setup-config.yaml +++ b/kubernetes/apps/authentik/wlan-setup-config.yaml @@ -1,13 +1,47 @@ # 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. No file carries a password. +# username comes from X-authentik-username. Only the Android Passpoint profile +# carries the password (the format requires it); it exists in nginx memory for +# at most 120 s and is handed out once. apiVersion: v1 kind: ConfigMap metadata: name: wlan-setup namespace: authentik data: + # The image's nginx.conf plus the njs module and a single worker, so the + # one-time tokens of passpoint.js live in one process. + nginx.conf: | + load_module modules/ngx_http_js_module.so; + + 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. @@ -16,6 +50,11 @@ data: default ""; } + js_path /etc/nginx/njs/; + js_import passpoint from passpoint.js; + # token -> {user, profile}; entries expire after 120 s. + js_shared_dict_zone zone=passpoint:1m timeout=120s evict; + server { listen 8080; listen [::]:8080; @@ -108,6 +147,21 @@ data: try_files /renner-unifi-wlan.cmd =404; } + # Android: the page POSTs the password and gets a one-time token; a tap + # on /android/profile/ then GETs the Passpoint profile, as the + # Android installer requires (application/x-wifi-config, base64, + # no Content-Disposition). + location = /android/prepare { + limit_except POST { deny all; } + client_max_body_size 1k; + client_body_buffer_size 1k; + js_content passpoint.prepare; + } + + location ~ "^/android/profile/[A-Za-z0-9_-]{32}$" { + js_content passpoint.deliver; + } + location = /healthz { access_log off; default_type text/plain; @@ -127,6 +181,84 @@ data: # 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\nRenner UniFi52656E6E657220556E694669Renner UniFifalseESSautoWPA2AEStruetrueuser2100311radius.renner.unoca bd 2a 79 a1 07 6a 31 f2 1d 25 36 35 cb 03 9d 43 29 a5 e8falsetrueanonymous\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). A token is bound to the user who created it and + # can be fetched once. + 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 "" + name + "" + inner + ""; } + function leaf(name, value) { return node(name, "" + value + ""); } + + function profile(user, password) { + const pps = '1.2' + + node("PerProviderSubscription", + "urn:wfa:mo:hotspot2dot0-perprovidersubscription:1.0" + + 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")))))) + + ""; + 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 caller(r) { + const user = r.headersIn["X-authentik-username"] || ""; + return USER.test(user) ? user : ""; + } + + function prepare(r) { + const user = caller(r); + const password = r.requestText || ""; + if (!user) { r.return(403); return; } + if (!password || password.length > 256) { r.return(400); return; } + const bytes = new Uint8Array(24); + crypto.getRandomValues(bytes); + const token = Buffer.from(bytes).toString("base64url"); + ngx.shared.passpoint.set(token, JSON.stringify({ user: user, body: profile(user, password) })); + r.headersOut["Content-Type"] = "text/plain"; + r.return(200, token); + } + + function deliver(r) { + const token = r.uri.substring(r.uri.lastIndexOf("/") + 1); + const dict = ngx.shared.passpoint; + const entry = dict.get(token); + if (!entry) { r.return(404); return; } + const data = JSON.parse(entry); + if (data.user !== caller(r)) { r.return(404); return; } + dict.delete(token); + r.headersOut["Content-Type"] = "application/x-wifi-config"; + r.headersOut["Content-Transfer-Encoding"] = "base64"; + r.return(200, data.body); + } + + export default { prepare, deliver }; + index.html: | @@ -167,6 +299,20 @@ data:
+ +