|
|
|
@@ -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;
|
|
|
|
|