Compare commits

...
Author SHA1 Message Date
k.renner 08b087337e Merge pull request 'feat(radius): Android Passpoint profile on the WLAN setup page' (#1565) from feat/wlan-setup-passpoint into main
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
3 changed files with 199 additions and 5 deletions
+7 -2
View File
@@ -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
@@ -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/<token> 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\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). 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 "<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 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: |
<!doctype html>
<html lang="de">
@@ -167,6 +299,20 @@ data:
<div id="rest"></div>
</details>
<template id="t-android">
<section data-os="android">
<h2>Android</h2>
<form id="android-form">
<p><label>Passwort für <strong class="u">…</strong><br>
<input type="password" id="android-password" autocomplete="current-password" required></label></p>
<p><button type="submit">Profil vorbereiten</button></p>
</form>
<p><a class="button" id="android-install" href="#" hidden>Auf Android installieren</a></p>
<p class="muted" id="android-status"></p>
<p class="muted">Ändert sich dein Passwort, hier einfach neu installieren.</p>
</section>
</template>
<template id="t-apple">
<section data-os="apple">
<h2>iPhone, iPad, Mac</h2>
@@ -209,15 +355,17 @@ data:
var detected = document.getElementById("detected");
var rest = document.getElementById("rest");
["apple", "windows", "linux"].forEach(function (name) {
["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 !== "apple" && os !== "windows" && os !== "linux") document.getElementById("others").open = true;
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; });
@@ -234,6 +382,26 @@ data:
.then(function (t) { render(t.trim()); })
.catch(function () {});
// Android: the server builds the Passpoint profile from the password and
// keeps it under a one-time token; the installer only starts on a GET
// that the user triggers by tapping, so the token becomes a link.
document.getElementById("android-form").addEventListener("submit", function (ev) {
ev.preventDefault();
var field = document.getElementById("android-password");
var install = document.getElementById("android-install");
var status = document.getElementById("android-status");
if (!field.value) return;
fetch("/android/prepare", { method: "POST", credentials: "same-origin", body: field.value })
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.text(); })
.then(function (token) {
field.value = "";
install.href = "/android/profile/" + token.trim();
install.hidden = false;
status.textContent = "Bereit. Gültig für 2 Minuten.";
})
.catch(function () { status.textContent = "Das hat nicht geklappt. Bitte neu laden und erneut versuchen."; });
});
document.getElementById("copy-nmcli").addEventListener("click", function () {
var text = document.getElementById("nmcli").textContent;
var btn = this;
+21
View File
@@ -56,9 +56,18 @@ 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
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
@@ -66,12 +75,24 @@ spec:
- name: tmp
mountPath: /tmp
volumes:
- 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