Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a1dc89326 | ||
|
|
d505ca2062 | ||
|
|
28e864cfc4 | ||
|
|
e7d45a5c31 | ||
|
|
7a8791c3b0 | ||
|
|
b6acbc6e79 | ||
|
|
b1b58e397c | ||
|
|
fdaf20aa3f | ||
|
|
4e61d543a8 | ||
|
|
302b177ba2 | ||
|
|
531e192e0c | ||
|
|
3416cd476d | ||
|
|
8a41b80217 |
+26
-15
@@ -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:
|
||||
|
||||
@@ -105,7 +105,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-1059 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
|
||||
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-05-1355 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -30,7 +30,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-29-1355 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
|
||||
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-11-05-1401 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
|
||||
env:
|
||||
- name: CLOUDFLARE_API_TOKEN
|
||||
valueFrom:
|
||||
|
||||
@@ -66,7 +66,7 @@ spec:
|
||||
# (machine.registries.mirrors, applied by bootstrap.sh), and containerd
|
||||
# keeps the repository path when it falls through — so the path has to
|
||||
# exist on both registries. Steady state still pulls from Gitea.
|
||||
image: gitea.renner.uno/konairius/alpine-jq:2026-10-29-1348 # {"$imagepolicy": "flux-system:alpine-jq"}
|
||||
image: gitea.renner.uno/konairius/alpine-jq:2026-11-05-1353 # {"$imagepolicy": "flux-system:alpine-jq"}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -32,7 +32,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: review-bot
|
||||
image: gitea.renner.uno/gitea_admin/review-bot:2026-11-02-1054 # {"$imagepolicy": "flux-system:review-bot"}
|
||||
image: gitea.renner.uno/gitea_admin/review-bot:2026-11-05-1350 # {"$imagepolicy": "flux-system:review-bot"}
|
||||
env:
|
||||
- name: GITEA_URL
|
||||
value: https://gitea.renner.uno
|
||||
|
||||
@@ -184,7 +184,7 @@ spec:
|
||||
# (machine.registries.mirrors, applied by bootstrap.sh), and containerd
|
||||
# keeps the repository path, so it has to exist on both registries.
|
||||
# Steady state still pulls from Gitea.
|
||||
image: gitea.renner.uno/konairius/alpine-jq:2026-10-29-1348 # {"$imagepolicy": "flux-system:alpine-jq"}
|
||||
image: gitea.renner.uno/konairius/alpine-jq:2026-11-05-1353 # {"$imagepolicy": "flux-system:alpine-jq"}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -73,7 +73,7 @@ spec:
|
||||
# No imagePullSecret: the ghcr namespace is private and its credential
|
||||
# is in machine.registries too, because a Secret would have to come
|
||||
# from OpenBao, which needs Ceph, which needs these binaries.
|
||||
image: gitea.renner.uno/konairius/cni-plugins:2026-10-29-1348 # {"$imagepolicy": "flux-system:cni-plugins"}
|
||||
image: gitea.renner.uno/konairius/cni-plugins:2026-11-05-1353 # {"$imagepolicy": "flux-system:cni-plugins"}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -71,7 +71,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: rotate
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-02 # {"$imagepolicy": "flux-system:secret-rotator"}
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-05 # {"$imagepolicy": "flux-system:secret-rotator"}
|
||||
args: ["ha-token"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
@@ -133,7 +133,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: rotate
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-02 # {"$imagepolicy": "flux-system:secret-rotator"}
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-05 # {"$imagepolicy": "flux-system:secret-rotator"}
|
||||
args: ["ha-token"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
@@ -195,7 +195,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: rotate
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-02 # {"$imagepolicy": "flux-system:secret-rotator"}
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-05 # {"$imagepolicy": "flux-system:secret-rotator"}
|
||||
args: ["ha-token"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/wlan-setup
|
||||
@@ -5,6 +5,7 @@ go 1.27
|
||||
require (
|
||||
github.com/beevik/etree v1.7.0
|
||||
github.com/russellhaering/goxmldsig v1.6.1
|
||||
howett.net/plist v1.0.1
|
||||
)
|
||||
|
||||
require github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
|
||||
@@ -2,6 +2,7 @@ github.com/beevik/etree v1.7.0 h1:xjBk9O4p4x7D1YajePjfLzdaFC4/uYUENA7P0pv6gXA=
|
||||
github.com/beevik/etree v1.7.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -10,5 +11,9 @@ github.com/russellhaering/goxmldsig v1.6.1 h1:SB7R5ttvrGIDB2juJAK/i7DQ2Ivr7agG+o
|
||||
github.com/russellhaering/goxmldsig v1.6.1/go.mod h1:haZkRcLs9W/Xp989fIjP3BrTdbFQveRF0QNZSYoH09w=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
|
||||
+230
-97
@@ -1,8 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
|
||||
"github.com/beevik/etree"
|
||||
"howett.net/plist"
|
||||
)
|
||||
|
||||
// isrgRootX1 is the trust anchor of the radius-tls chain (DER, base64).
|
||||
@@ -11,113 +19,238 @@ const isrgRootX1 = "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQEL
|
||||
// carrierID identifies the Windows provisioning document; a reinstall replaces the profile.
|
||||
const carrierID = "{5423c37a-7530-4ce9-8f87-a2574ea006bc}"
|
||||
|
||||
func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
|
||||
// mimeBoundary separates the parts of the Android profile.
|
||||
const mimeBoundary = "renner-passpoint-profile"
|
||||
|
||||
func node(name, inner string) string {
|
||||
return "<Node><NodeName>" + name + "</NodeName>" + inner + "</Node>"
|
||||
// ppsNode is a node of the PPS MO management tree: a leaf carries a Value, an interior node
|
||||
// further nodes.
|
||||
type ppsNode struct {
|
||||
XMLName xml.Name `xml:"Node"`
|
||||
NodeName string `xml:"NodeName"`
|
||||
RTProperties *ppsRTProperties `xml:"RTProperties,omitempty"`
|
||||
Value *string `xml:"Value,omitempty"`
|
||||
Nodes []ppsNode `xml:"Node"`
|
||||
}
|
||||
|
||||
func leaf(name, value string) string { return node(name, "<Value>"+value+"</Value>") }
|
||||
type ppsRTProperties struct {
|
||||
DDFName string `xml:"Type>DDFName"`
|
||||
}
|
||||
|
||||
var xmlEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">")
|
||||
type ppsTree struct {
|
||||
XMLName xml.Name `xml:"syncml:dmddf1.2 MgmtTree"`
|
||||
VerDTD string `xml:"VerDTD"`
|
||||
Nodes []ppsNode `xml:"Node"`
|
||||
}
|
||||
|
||||
func xmlText(s string) string { return xmlEscaper.Replace(s) }
|
||||
func ppsLeaf(name, value string) ppsNode { return ppsNode{NodeName: name, Value: &value} }
|
||||
|
||||
func ppsInterior(name string, nodes ...ppsNode) ppsNode {
|
||||
return ppsNode{NodeName: name, Nodes: nodes}
|
||||
}
|
||||
|
||||
// androidProfile is the application/x-wifi-config body: base64 of a MIME multipart with the
|
||||
// Passpoint R1 PPS MO and the trust anchor.
|
||||
func androidProfile(user, password string) string {
|
||||
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>"
|
||||
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" +
|
||||
isrgRootX1 + "\r\n" +
|
||||
"--{boundary}--\r\n"
|
||||
return b64(mime)
|
||||
// Passpoint R1 PPS MO and the trust anchor, each part base64.
|
||||
func androidProfile(user, password string) ([]byte, error) {
|
||||
subscription := ppsInterior("PerProviderSubscription",
|
||||
ppsInterior("i001",
|
||||
ppsInterior("HomeSP", ppsLeaf("FriendlyName", "Renner"), ppsLeaf("FQDN", "renner.uno")),
|
||||
ppsInterior("Credential",
|
||||
ppsLeaf("Realm", "renner.uno"),
|
||||
ppsInterior("UsernamePassword",
|
||||
ppsLeaf("Username", user),
|
||||
ppsLeaf("Password", base64.StdEncoding.EncodeToString([]byte(password))),
|
||||
ppsInterior("EAPMethod", ppsLeaf("EAPType", "21"), ppsLeaf("InnerMethod", "PAP")))),
|
||||
ppsInterior("Extension", ppsInterior("Android",
|
||||
ppsInterior("AAAServerTrustedNames", ppsLeaf("FQDN", "radius.renner.uno"))))))
|
||||
subscription.RTProperties = &ppsRTProperties{DDFName: "urn:wfa:mo:hotspot2dot0-perprovidersubscription:1.0"}
|
||||
pps, err := xml.Marshal(ppsTree{VerDTD: "1.2", Nodes: []ppsNode{subscription}})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pps: %w", err)
|
||||
}
|
||||
|
||||
var parts bytes.Buffer
|
||||
mw := multipart.NewWriter(&parts)
|
||||
if err := mw.SetBoundary(mimeBoundary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range []struct{ contentType, body string }{
|
||||
{"application/x-passpoint-profile", base64.StdEncoding.EncodeToString(pps)},
|
||||
{"application/x-x509-ca-cert", isrgRootX1},
|
||||
} {
|
||||
w, err := mw.CreatePart(textproto.MIMEHeader{
|
||||
"Content-Type": {p.contentType},
|
||||
"Content-Transfer-Encoding": {"base64"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := w.Write([]byte(p.body)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var message bytes.Buffer
|
||||
fmt.Fprintf(&message, "Content-Type: %s\r\nContent-Transfer-Encoding: base64\r\n\r\n",
|
||||
mime.FormatMediaType("multipart/mixed", map[string]string{"boundary": mw.Boundary()}))
|
||||
message.Write(parts.Bytes())
|
||||
return []byte(base64.StdEncoding.EncodeToString(message.Bytes())), nil
|
||||
}
|
||||
|
||||
type appleEAP struct {
|
||||
UserName string
|
||||
UserPassword string
|
||||
OuterIdentity string
|
||||
AcceptEAPTypes []int
|
||||
TTLSInnerAuthentication string
|
||||
TLSTrustedServerNames []string
|
||||
}
|
||||
|
||||
type appleWiFi struct {
|
||||
PayloadType string
|
||||
PayloadVersion int
|
||||
PayloadIdentifier string
|
||||
PayloadUUID string
|
||||
PayloadDisplayName string
|
||||
PayloadOrganization string
|
||||
IsHotspot bool
|
||||
DomainName string
|
||||
NAIRealmNames []string
|
||||
DisplayedOperatorName string
|
||||
ServiceProviderRoamingEnabled bool
|
||||
AutoJoin bool
|
||||
EncryptionType string
|
||||
EAPClientConfiguration appleEAP
|
||||
}
|
||||
|
||||
type appleConfiguration struct {
|
||||
PayloadContent []appleWiFi
|
||||
PayloadType string
|
||||
PayloadVersion int
|
||||
PayloadIdentifier string
|
||||
PayloadUUID string
|
||||
PayloadDisplayName string
|
||||
PayloadOrganization string
|
||||
}
|
||||
|
||||
// appleProfile is the .mobileconfig: com.apple.wifi.managed with Passpoint matching. Fixed
|
||||
// identifiers and UUIDs, so a reinstall replaces the previous profile.
|
||||
func appleProfile(user, password string) string {
|
||||
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>` + xmlText(user) + `</string>` +
|
||||
`<key>UserPassword</key><string>` + xmlText(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"
|
||||
func appleProfile(user, password string) ([]byte, error) {
|
||||
return plist.MarshalIndent(appleConfiguration{
|
||||
PayloadContent: []appleWiFi{{
|
||||
PayloadType: "com.apple.wifi.managed",
|
||||
PayloadVersion: 1,
|
||||
PayloadIdentifier: "uno.renner.wifi.renner-unifi.network",
|
||||
PayloadUUID: "824959bf-c55d-4ed2-b569-c33f3759c2b6",
|
||||
PayloadDisplayName: "Renner UniFi",
|
||||
PayloadOrganization: "Renner",
|
||||
IsHotspot: true,
|
||||
DomainName: "renner.uno",
|
||||
NAIRealmNames: []string{"renner.uno"},
|
||||
DisplayedOperatorName: "Renner",
|
||||
ServiceProviderRoamingEnabled: false,
|
||||
AutoJoin: true,
|
||||
EncryptionType: "Any",
|
||||
EAPClientConfiguration: appleEAP{
|
||||
UserName: user,
|
||||
UserPassword: password,
|
||||
OuterIdentity: "anonymous",
|
||||
AcceptEAPTypes: []int{21},
|
||||
TTLSInnerAuthentication: "PAP",
|
||||
TLSTrustedServerNames: []string{"radius.renner.uno"},
|
||||
},
|
||||
}},
|
||||
PayloadType: "Configuration",
|
||||
PayloadVersion: 1,
|
||||
PayloadIdentifier: "uno.renner.wifi.renner-unifi",
|
||||
PayloadUUID: "7175a2a3-ddd3-42ea-b4ad-b7e5501892f3",
|
||||
PayloadDisplayName: "WLAN Renner UniFi",
|
||||
PayloadOrganization: "Renner",
|
||||
}, plist.XMLFormat, "\t")
|
||||
}
|
||||
|
||||
// windowsProfile is the unsigned CarrierProvisioning document: one Hotspot 2.0 WLAN profile,
|
||||
// WPA3 Enterprise, EAP-TTLS with inner PAP, and the user's credentials.
|
||||
func windowsProfile(user, password string) string {
|
||||
const common = ` xmlns="http://www.microsoft.com/provisioning/EapCommon"`
|
||||
return `<CarrierProvisioning xmlns="http://www.microsoft.com/networking/CarrierControl/v1">` +
|
||||
`<Global><CarrierId>` + carrierID + `</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>` + xmlText(user) + `</Username><Password>` + xmlText(password) + `</Password>` +
|
||||
`</EapTtls></Credentials></EapHostUserCredentials>` +
|
||||
`</security></MSM></WLANProfile></WLANProfiles></CarrierProvisioning>`
|
||||
// Namespaces of the CarrierProvisioning document.
|
||||
const (
|
||||
nsCarrierControl = "http://www.microsoft.com/networking/CarrierControl/v1"
|
||||
nsWLAN = "http://www.microsoft.com/networking/CarrierControl/WLAN/v1"
|
||||
nsOneX = "http://www.microsoft.com/networking/OneX/v1"
|
||||
nsEapHostConfig = "http://www.microsoft.com/provisioning/EapHostConfig"
|
||||
nsEapCommon = "http://www.microsoft.com/provisioning/EapCommon"
|
||||
nsEapTtlsConn = "http://www.microsoft.com/provisioning/EapTtlsConnectionPropertiesV1"
|
||||
nsEapHostUserCred = "http://www.microsoft.com/provisioning/EapHostUserCredentials"
|
||||
nsEapTtlsUser = "http://www.microsoft.com/provisioning/EapTtlsUserPropertiesV1"
|
||||
)
|
||||
|
||||
// add appends an element named tag to parent. ns, when set, becomes its default namespace.
|
||||
func add(parent *etree.Element, tag, ns string) *etree.Element {
|
||||
el := parent.CreateElement(tag)
|
||||
if ns != "" {
|
||||
el.CreateAttr("xmlns", ns)
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
// addText appends an element named tag with text content to parent.
|
||||
func addText(parent *etree.Element, tag, ns, text string) *etree.Element {
|
||||
el := add(parent, tag, ns)
|
||||
el.SetText(text)
|
||||
return el
|
||||
}
|
||||
|
||||
// windowsProfile is the unsigned CarrierProvisioning document: one Hotspot 2.0 WLAN profile
|
||||
// matched by domain name, EAP-TTLS with inner PAP, and the user's credentials. The
|
||||
// CarrierControl WLAN schema allows authentication up to WPA2 (Enterprise); Windows adds the
|
||||
// PMF that the WPA3-Enterprise network requires. Elements follow Microsoft's signed Passpoint
|
||||
// provisioning samples.
|
||||
func windowsProfile(user, password string) *etree.Document {
|
||||
doc := etree.NewDocument()
|
||||
doc.WriteSettings = etree.WriteSettings{CanonicalEndTags: true, CanonicalText: true, CanonicalAttrVal: true}
|
||||
doc.CreateProcInst("xml", `version="1.0"`)
|
||||
doc.CreateText("\n")
|
||||
root := add(&doc.Element, "CarrierProvisioning", nsCarrierControl)
|
||||
|
||||
global := add(root, "Global", "")
|
||||
addText(global, "CarrierId", "", carrierID)
|
||||
addText(global, "SubscriberId", "", "renner-unifi")
|
||||
|
||||
wlan := add(add(root, "WLANProfiles", ""), "WLANProfile", nsWLAN)
|
||||
addText(wlan, "name", "", "Renner UniFi")
|
||||
addText(add(add(wlan, "SSIDConfig", ""), "SSID", ""), "name", "", "Renner UniFi")
|
||||
addText(add(wlan, "Hotspot2", ""), "DomainName", "", "renner.uno")
|
||||
|
||||
security := add(add(wlan, "MSM", ""), "security", "")
|
||||
auth := add(security, "authEncryption", "")
|
||||
addText(auth, "authentication", "", "WPA2")
|
||||
addText(auth, "encryption", "", "AES")
|
||||
addText(auth, "useOneX", "", "true")
|
||||
|
||||
oneX := add(security, "OneX", nsOneX)
|
||||
addText(oneX, "authMode", "", "user")
|
||||
eapHost := add(add(oneX, "EAPConfig", ""), "EapHostConfig", nsEapHostConfig)
|
||||
method := add(eapHost, "EapMethod", "")
|
||||
addText(method, "Type", nsEapCommon, "21")
|
||||
addText(method, "VendorId", nsEapCommon, "0")
|
||||
addText(method, "VendorType", nsEapCommon, "0")
|
||||
addText(method, "AuthorId", nsEapCommon, "311")
|
||||
ttls := add(add(eapHost, "Config", ""), "EapTtls", nsEapTtlsConn)
|
||||
validation := add(ttls, "ServerValidation", "")
|
||||
addText(validation, "ServerNames", "", "radius.renner.uno")
|
||||
addText(validation, "TrustedRootCAHash", "", "ca bd 2a 79 a1 07 6a 31 f2 1d 25 36 35 cb 03 9d 43 29 a5 e8")
|
||||
addText(validation, "DisablePrompt", "", "true")
|
||||
add(add(ttls, "Phase2Authentication", ""), "PAPAuthentication", "")
|
||||
identity := add(ttls, "Phase1Identity", "")
|
||||
addText(identity, "IdentityPrivacy", "", "true")
|
||||
addText(identity, "AnonymousIdentity", "", "anonymous")
|
||||
|
||||
userCred := add(security, "EapHostUserCredentials", nsEapHostUserCred)
|
||||
userCred.CreateAttr("xmlns:eapCommon", nsEapCommon)
|
||||
userMethod := add(userCred, "EapMethod", "")
|
||||
addText(userMethod, "eapCommon:Type", "", "21")
|
||||
addText(userMethod, "eapCommon:AuthorId", "", "311")
|
||||
credentials := add(add(userCred, "Credentials", ""), "EapTtls", nsEapTtlsUser)
|
||||
addText(credentials, "Username", "", user)
|
||||
addText(credentials, "Password", "", password)
|
||||
return doc
|
||||
}
|
||||
|
||||
+12
-2
@@ -192,15 +192,25 @@ func (s *Server) callback(w http.ResponseWriter, r *http.Request, name string) {
|
||||
|
||||
switch name {
|
||||
case "android":
|
||||
body, err := androidProfile(user, key)
|
||||
if err != nil {
|
||||
fail("building the profile", "err", err)
|
||||
return
|
||||
}
|
||||
setStateCookie(w, name, "", 0)
|
||||
w.Header().Set("Content-Type", "application/x-wifi-config")
|
||||
w.Header().Set("Content-Transfer-Encoding", "base64")
|
||||
_, _ = io.WriteString(w, androidProfile(user, key))
|
||||
_, _ = w.Write(body)
|
||||
case "apple":
|
||||
body, err := appleProfile(user, key)
|
||||
if err != nil {
|
||||
fail("building the profile", "err", err)
|
||||
return
|
||||
}
|
||||
setStateCookie(w, name, "", 0)
|
||||
w.Header().Set("Content-Type", "application/x-apple-aspen-config")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="renner-unifi.mobileconfig"`)
|
||||
_, _ = io.WriteString(w, appleProfile(user, key))
|
||||
_, _ = w.Write(body)
|
||||
case "windows":
|
||||
body, err := signer.Sign(windowsProfile(user, key))
|
||||
if err != nil {
|
||||
|
||||
+274
-99
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
@@ -14,11 +15,15 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -27,6 +32,7 @@ import (
|
||||
|
||||
"github.com/beevik/etree"
|
||||
dsig "github.com/russellhaering/goxmldsig"
|
||||
"howett.net/plist"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -368,99 +374,104 @@ func TestCallbackAndroid(t *testing.T) {
|
||||
rec.Header().Get("Content-Transfer-Encoding") != "base64" || rec.Header().Get("Content-Disposition") != "" {
|
||||
t.Errorf("headers %v", rec.Header())
|
||||
}
|
||||
mime, err := base64.StdEncoding.DecodeString(rec.Body.String())
|
||||
raw, err := base64.StdEncoding.DecodeString(rec.Body.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := strings.Split(string(mime), "--{boundary}\r\n")
|
||||
var pps string
|
||||
for _, p := range parts {
|
||||
if body, ok := strings.CutPrefix(p, "Content-Type: application/x-passpoint-profile\r\nContent-Transfer-Encoding: base64\r\n\r\n"); ok {
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pps = string(raw)
|
||||
}
|
||||
msg, err := textproto.NewReader(bufio.NewReader(bytes.NewReader(raw))).ReadMIMEHeader()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(pps, "<Node><NodeName>Username</NodeName><Value>Alice.Smith</Value></Node>") ||
|
||||
!strings.Contains(pps, "<Node><NodeName>Password</NodeName><Value>"+base64.StdEncoding.EncodeToString([]byte(testKey))+"</Value></Node>") {
|
||||
t.Errorf("pps %s", pps)
|
||||
mediaType, params, err := mime.ParseMediaType(msg.Get("Content-Type"))
|
||||
if err != nil || mediaType != "multipart/mixed" || params["boundary"] == "" ||
|
||||
msg.Get("Content-Transfer-Encoding") != "base64" {
|
||||
t.Fatalf("message header %v", msg)
|
||||
}
|
||||
if !strings.Contains(string(mime), "Content-Type: application/x-x509-ca-cert\r\nContent-Transfer-Encoding: base64\r\n\r\n"+isrgRootX1+"\r\n--{boundary}--\r\n") {
|
||||
t.Error("trust anchor part missing")
|
||||
}
|
||||
}
|
||||
|
||||
// plistValue decodes one plist value: dict, array, string, integer, true, false.
|
||||
func plistValue(d *xml.Decoder, start xml.StartElement) (any, error) {
|
||||
switch start.Name.Local {
|
||||
case "dict":
|
||||
m := map[string]any{}
|
||||
var key string
|
||||
for {
|
||||
tok, err := d.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch el := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if el.Name.Local == "key" {
|
||||
if err := d.DecodeElement(&key, &el); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
v, err := plistValue(d, el)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m[key] = v
|
||||
case xml.EndElement:
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
case "array":
|
||||
var a []any
|
||||
for {
|
||||
tok, err := d.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch el := tok.(type) {
|
||||
case xml.StartElement:
|
||||
v, err := plistValue(d, el)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a = append(a, v)
|
||||
case xml.EndElement:
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
case "true", "false":
|
||||
return start.Name.Local == "true", d.Skip()
|
||||
default:
|
||||
var s string
|
||||
err := d.DecodeElement(&s, &start)
|
||||
return s, err
|
||||
}
|
||||
}
|
||||
|
||||
func parsePlist(t *testing.T, body []byte) map[string]any {
|
||||
t.Helper()
|
||||
d := xml.NewDecoder(bytes.NewReader(body))
|
||||
body := raw[bytes.Index(raw, []byte("\r\n\r\n"))+4:]
|
||||
mr := multipart.NewReader(bytes.NewReader(body), params["boundary"])
|
||||
parts := map[string][]byte{}
|
||||
var order []string
|
||||
for {
|
||||
tok, err := d.Token()
|
||||
p, err := mr.NextRawPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if el, ok := tok.(xml.StartElement); ok && el.Name.Local == "dict" {
|
||||
v, err := plistValue(d, el)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
if p.Header.Get("Content-Transfer-Encoding") != "base64" {
|
||||
t.Errorf("part %v is not base64", p.Header)
|
||||
}
|
||||
b, err := io.ReadAll(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ct := p.Header.Get("Content-Type")
|
||||
order = append(order, ct)
|
||||
parts[ct] = b
|
||||
}
|
||||
if strings.Join(order, ",") != "application/x-passpoint-profile,application/x-x509-ca-cert" {
|
||||
t.Errorf("parts %v", order)
|
||||
}
|
||||
if string(parts["application/x-x509-ca-cert"]) != isrgRootX1 {
|
||||
t.Error("the trust anchor part is not ISRG Root X1")
|
||||
}
|
||||
pps, err := base64.StdEncoding.DecodeString(string(parts["application/x-passpoint-profile"]))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
type node struct {
|
||||
Name string `xml:"NodeName"`
|
||||
DDF string `xml:"RTProperties>Type>DDFName"`
|
||||
Value *string `xml:"Value"`
|
||||
Nodes []node `xml:"Node"`
|
||||
}
|
||||
var tree struct {
|
||||
XMLName xml.Name
|
||||
VerDTD string `xml:"VerDTD"`
|
||||
Nodes []node `xml:"Node"`
|
||||
}
|
||||
if err := xml.Unmarshal(pps, &tree); err != nil {
|
||||
t.Fatalf("pps: %v\n%s", err, pps)
|
||||
}
|
||||
if tree.XMLName != (xml.Name{Space: "syncml:dmddf1.2", Local: "MgmtTree"}) || tree.VerDTD != "1.2" ||
|
||||
len(tree.Nodes) != 1 || tree.Nodes[0].Name != "PerProviderSubscription" ||
|
||||
tree.Nodes[0].DDF != "urn:wfa:mo:hotspot2dot0-perprovidersubscription:1.0" {
|
||||
t.Fatalf("pps %s", pps)
|
||||
}
|
||||
// value follows NodeNames below PerProviderSubscription/i001.
|
||||
value := func(path string) string {
|
||||
nodes := tree.Nodes[0].Nodes
|
||||
var cur *node
|
||||
for _, name := range strings.Split("i001/"+path, "/") {
|
||||
cur = nil
|
||||
for i := range nodes {
|
||||
if nodes[i].Name == name {
|
||||
cur = &nodes[i]
|
||||
}
|
||||
}
|
||||
return v.(map[string]any)
|
||||
if cur == nil {
|
||||
return "<no node>"
|
||||
}
|
||||
nodes = cur.Nodes
|
||||
}
|
||||
if cur.Value == nil {
|
||||
return "<no value>"
|
||||
}
|
||||
return *cur.Value
|
||||
}
|
||||
for path, want := range map[string]string{
|
||||
"HomeSP/FriendlyName": "Renner",
|
||||
"HomeSP/FQDN": "renner.uno",
|
||||
"Credential/Realm": "renner.uno",
|
||||
"Credential/UsernamePassword/Username": "Alice.Smith",
|
||||
"Credential/UsernamePassword/Password": base64.StdEncoding.EncodeToString([]byte(testKey)),
|
||||
"Credential/UsernamePassword/EAPMethod/EAPType": "21",
|
||||
"Credential/UsernamePassword/EAPMethod/InnerMethod": "PAP",
|
||||
"Extension/Android/AAAServerTrustedNames/FQDN": "radius.renner.uno",
|
||||
} {
|
||||
if got := value(path); got != want {
|
||||
t.Errorf("%s = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -477,20 +488,47 @@ func TestCallbackApple(t *testing.T) {
|
||||
rec.Header().Get("Content-Disposition") != `attachment; filename="renner-unifi.mobileconfig"` {
|
||||
t.Errorf("headers %v", rec.Header())
|
||||
}
|
||||
top := parsePlist(t, rec.Body.Bytes())
|
||||
if top["PayloadType"] != "Configuration" || top["PayloadUUID"] != "7175a2a3-ddd3-42ea-b4ad-b7e5501892f3" {
|
||||
t.Errorf("top %v", top)
|
||||
var top map[string]any
|
||||
format, err := plist.Unmarshal(rec.Body.Bytes(), &top)
|
||||
if err != nil || format != plist.XMLFormat {
|
||||
t.Fatalf("plist (format %d): %v\n%s", format, err, rec.Body.Bytes())
|
||||
}
|
||||
wifi := top["PayloadContent"].([]any)[0].(map[string]any)
|
||||
if wifi["IsHotspot"] != true || wifi["DomainName"] != "renner.uno" ||
|
||||
len(wifi["NAIRealmNames"].([]any)) != 1 || wifi["NAIRealmNames"].([]any)[0] != "renner.uno" ||
|
||||
wifi["PayloadUUID"] != "824959bf-c55d-4ed2-b569-c33f3759c2b6" {
|
||||
t.Errorf("wifi %v", wifi)
|
||||
want := map[string]any{
|
||||
"PayloadContent": []any{map[string]any{
|
||||
"PayloadType": "com.apple.wifi.managed",
|
||||
"PayloadVersion": uint64(1),
|
||||
"PayloadIdentifier": "uno.renner.wifi.renner-unifi.network",
|
||||
"PayloadUUID": "824959bf-c55d-4ed2-b569-c33f3759c2b6",
|
||||
"PayloadDisplayName": "Renner UniFi",
|
||||
"PayloadOrganization": "Renner",
|
||||
"IsHotspot": true,
|
||||
"DomainName": "renner.uno",
|
||||
"NAIRealmNames": []any{"renner.uno"},
|
||||
"DisplayedOperatorName": "Renner",
|
||||
"ServiceProviderRoamingEnabled": false,
|
||||
"AutoJoin": true,
|
||||
"EncryptionType": "Any",
|
||||
"EAPClientConfiguration": map[string]any{
|
||||
"UserName": "Alice.Smith",
|
||||
"UserPassword": testKey,
|
||||
"OuterIdentity": "anonymous",
|
||||
"AcceptEAPTypes": []any{uint64(21)},
|
||||
"TTLSInnerAuthentication": "PAP",
|
||||
"TLSTrustedServerNames": []any{"radius.renner.uno"},
|
||||
},
|
||||
}},
|
||||
"PayloadType": "Configuration",
|
||||
"PayloadVersion": uint64(1),
|
||||
"PayloadIdentifier": "uno.renner.wifi.renner-unifi",
|
||||
"PayloadUUID": "7175a2a3-ddd3-42ea-b4ad-b7e5501892f3",
|
||||
"PayloadDisplayName": "WLAN Renner UniFi",
|
||||
"PayloadOrganization": "Renner",
|
||||
}
|
||||
eap := wifi["EAPClientConfiguration"].(map[string]any)
|
||||
if eap["UserName"] != "Alice.Smith" || eap["UserPassword"] != testKey || eap["OuterIdentity"] != "anonymous" ||
|
||||
eap["TTLSInnerAuthentication"] != "PAP" {
|
||||
t.Errorf("eap %v", eap)
|
||||
if !reflect.DeepEqual(top, want) {
|
||||
t.Errorf("profile\n got %#v\nwant %#v", top, want)
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte("<string>app-password-<&>-456</string>")) {
|
||||
t.Error("the password is not escaped as XML text")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,13 +594,37 @@ func TestCallbackWindows(t *testing.T) {
|
||||
t.Errorf("missing %s", want)
|
||||
}
|
||||
}
|
||||
// The unsigned document is already canonical: its SHA-1 is the reference digest.
|
||||
sum := sha1.Sum([]byte(windowsProfile("Alice.Smith", testKey)))
|
||||
// The reference digest is the SHA-1 of the canonical unsigned document; written with
|
||||
// canonical settings, the unsigned root is already in that form.
|
||||
unsigned := etree.NewDocument()
|
||||
unsigned.SetRoot(windowsProfile("Alice.Smith", testKey).Root())
|
||||
unsigned.WriteSettings = etree.WriteSettings{CanonicalEndTags: true, CanonicalText: true, CanonicalAttrVal: true}
|
||||
unsignedBytes, err := unsigned.WriteToBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical, err := dsig.MakeC14N10RecCanonicalizer().Canonicalize(unsigned.Root())
|
||||
if err != nil || !bytes.Equal(canonical, unsignedBytes) {
|
||||
t.Fatalf("the unsigned document is not canonical: %v\n%s\n%s", err, canonical, unsignedBytes)
|
||||
}
|
||||
sum := sha1.Sum(canonical)
|
||||
if !strings.Contains(s, "<DigestValue>"+base64.StdEncoding.EncodeToString(sum[:])+"</DigestValue>") {
|
||||
t.Error("the digest is not the SHA-1 of the unsigned document")
|
||||
}
|
||||
if !strings.HasPrefix(s, strings.TrimSuffix(windowsProfile("Alice.Smith", testKey), "</CarrierProvisioning>")+"<Signature ") {
|
||||
t.Error("the signed document does not start with the unsigned one")
|
||||
signed := etree.NewDocument()
|
||||
if err := signed.ReadFromBytes(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pi, ok := signed.Child[0].(*etree.ProcInst); !ok || pi.Target != "xml" || pi.Inst != `version="1.0"` {
|
||||
t.Errorf("the document does not start with the XML declaration: %q", s[:min(len(s), 40)])
|
||||
}
|
||||
root := signed.Root()
|
||||
if last := root.ChildElements()[len(root.ChildElements())-1]; last.Tag != "Signature" {
|
||||
t.Errorf("the last child of the root is %s", last.Tag)
|
||||
}
|
||||
root.RemoveChild(root.SelectElement("Signature"))
|
||||
if stripped, err := dsig.MakeC14N10RecCanonicalizer().Canonicalize(root); err != nil || !bytes.Equal(stripped, canonical) {
|
||||
t.Errorf("without the signature, the document differs from the unsigned one: %v", err)
|
||||
}
|
||||
// Only the enveloped-signature transform, as in Microsoft's signed samples.
|
||||
if n := strings.Count(s, "<Transform "); n != 1 {
|
||||
@@ -582,6 +644,119 @@ func TestCallbackWindows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsProfileDocument(t *testing.T) {
|
||||
tc := writeSigningFiles(t)
|
||||
signer, err := loadSigner(tc.keyFile, tc.certFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := signer.Sign(windowsProfile("Alice.Smith", testKey))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validate(tc.cert, body); err != nil {
|
||||
t.Fatalf("signature does not verify: %v", err)
|
||||
}
|
||||
var wellFormed struct{}
|
||||
if err := xml.Unmarshal(body, &wellFormed); err != nil {
|
||||
t.Fatalf("not well-formed: %v", err)
|
||||
}
|
||||
doc := etree.NewDocument()
|
||||
if err := doc.ReadFromBytes(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := doc.Root()
|
||||
const (
|
||||
wlan = "WLANProfiles/WLANProfile/"
|
||||
sec = wlan + "MSM/security/"
|
||||
eap = sec + "OneX/EAPConfig/EapHostConfig/"
|
||||
ttls = eap + "Config/EapTtls/"
|
||||
creds = sec + "EapHostUserCredentials/"
|
||||
sig = "Signature/"
|
||||
)
|
||||
for path, want := range map[string]string{
|
||||
"Global/CarrierId": carrierID,
|
||||
"Global/SubscriberId": "renner-unifi",
|
||||
wlan + "name": "Renner UniFi",
|
||||
wlan + "SSIDConfig/SSID/name": "Renner UniFi",
|
||||
wlan + "Hotspot2/DomainName": "renner.uno",
|
||||
sec + "authEncryption/authentication": "WPA2",
|
||||
sec + "authEncryption/encryption": "AES",
|
||||
sec + "authEncryption/useOneX": "true",
|
||||
sec + "OneX/authMode": "user",
|
||||
eap + "EapMethod/Type": "21",
|
||||
eap + "EapMethod/VendorId": "0",
|
||||
eap + "EapMethod/VendorType": "0",
|
||||
eap + "EapMethod/AuthorId": "311",
|
||||
ttls + "ServerValidation/ServerNames": "radius.renner.uno",
|
||||
ttls + "ServerValidation/TrustedRootCAHash": "ca bd 2a 79 a1 07 6a 31 f2 1d 25 36 35 cb 03 9d 43 29 a5 e8",
|
||||
ttls + "ServerValidation/DisablePrompt": "true",
|
||||
ttls + "Phase2Authentication/PAPAuthentication": "",
|
||||
ttls + "Phase1Identity/IdentityPrivacy": "true",
|
||||
ttls + "Phase1Identity/AnonymousIdentity": "anonymous",
|
||||
creds + "EapMethod/eapCommon:Type": "21",
|
||||
creds + "EapMethod/eapCommon:AuthorId": "311",
|
||||
creds + "Credentials/EapTtls/Username": "Alice.Smith",
|
||||
creds + "Credentials/EapTtls/Password": testKey,
|
||||
sig + "SignedInfo/Reference/Transforms/Transform": "",
|
||||
} {
|
||||
el := root.FindElement(path)
|
||||
if el == nil {
|
||||
t.Errorf("no element %s", path)
|
||||
continue
|
||||
}
|
||||
if el.Text() != want {
|
||||
t.Errorf("%s = %q, want %q", path, el.Text(), want)
|
||||
}
|
||||
}
|
||||
for path, want := range map[string]string{
|
||||
".": "http://www.microsoft.com/networking/CarrierControl/v1",
|
||||
"Global/CarrierId": "http://www.microsoft.com/networking/CarrierControl/v1",
|
||||
"WLANProfiles": "http://www.microsoft.com/networking/CarrierControl/v1",
|
||||
wlan + "Hotspot2/DomainName": "http://www.microsoft.com/networking/CarrierControl/WLAN/v1",
|
||||
sec + "OneX/authMode": "http://www.microsoft.com/networking/OneX/v1",
|
||||
eap + "EapMethod": "http://www.microsoft.com/provisioning/EapHostConfig",
|
||||
eap + "EapMethod/Type": "http://www.microsoft.com/provisioning/EapCommon",
|
||||
eap + "EapMethod/AuthorId": "http://www.microsoft.com/provisioning/EapCommon",
|
||||
ttls + "ServerValidation": "http://www.microsoft.com/provisioning/EapTtlsConnectionPropertiesV1",
|
||||
creds + "EapMethod": "http://www.microsoft.com/provisioning/EapHostUserCredentials",
|
||||
creds + "EapMethod/eapCommon:Type": "http://www.microsoft.com/provisioning/EapCommon",
|
||||
creds + "Credentials/EapTtls/Password": "http://www.microsoft.com/provisioning/EapTtlsUserPropertiesV1",
|
||||
"Signature": dsig.Namespace,
|
||||
sig + "SignedInfo/Reference": dsig.Namespace,
|
||||
} {
|
||||
if el := root.FindElement(path); el == nil || el.NamespaceURI() != want {
|
||||
t.Errorf("%s: namespace not %s", path, want)
|
||||
}
|
||||
}
|
||||
if si := root.FindElement(sig + "SignedInfo"); si == nil || len(si.Attr) != 0 {
|
||||
t.Error("SignedInfo carries its own attributes")
|
||||
}
|
||||
for path, want := range map[string]string{
|
||||
sig + "SignedInfo/CanonicalizationMethod": "http://www.w3.org/TR/2001/REC-xml-c14n-20010315",
|
||||
sig + "SignedInfo/SignatureMethod": "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
||||
sig + "SignedInfo/Reference/Transforms/Transform": "http://www.w3.org/2000/09/xmldsig#enveloped-signature",
|
||||
sig + "SignedInfo/Reference/DigestMethod": "http://www.w3.org/2000/09/xmldsig#sha1",
|
||||
} {
|
||||
if el := root.FindElement(path); el == nil || el.SelectAttrValue("Algorithm", "") != want {
|
||||
t.Errorf("%s: Algorithm not %s", path, want)
|
||||
}
|
||||
}
|
||||
if ref := root.FindElement(sig + "SignedInfo/Reference"); ref == nil || ref.SelectAttrValue("URI", "-") != "" {
|
||||
t.Error(`Reference URI is not ""`)
|
||||
}
|
||||
if n := len(root.FindElements(sig + "SignedInfo/Reference/Transforms/Transform")); n != 1 {
|
||||
t.Errorf("%d transforms, want 1", n)
|
||||
}
|
||||
certs := root.FindElements(sig + "KeyInfo/X509Data/X509Certificate")
|
||||
if len(certs) != 2 || certs[0].Text() != base64.StdEncoding.EncodeToString(tc.cert.Raw) {
|
||||
t.Errorf("KeyInfo holds %d certificates, want the chain with the leaf first", len(certs))
|
||||
}
|
||||
if _, err := signer.Sign(doc); err == nil {
|
||||
t.Error("an already signed document was signed again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsProfileExpires(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
token := h.windowsToken(t)
|
||||
|
||||
+42
-26
@@ -11,7 +11,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/beevik/etree"
|
||||
dsig "github.com/russellhaering/goxmldsig"
|
||||
)
|
||||
|
||||
// Signer holds the key and chain that sign the Windows provisioning document.
|
||||
@@ -85,37 +87,51 @@ func parseSigner(keyPEM, certPEM []byte) (*Signer, error) {
|
||||
return &Signer{key: key, chain: chain}, nil
|
||||
}
|
||||
|
||||
const dsigNS = "http://www.w3.org/2000/09/xmldsig#"
|
||||
const dsigNS = dsig.Namespace
|
||||
|
||||
// Sign returns doc with an enveloped XML-DSig signature as the last child of the root, in the
|
||||
// form of Microsoft's CarrierProvisioning samples: Reference URI="" with only the
|
||||
// enveloped-signature transform, inclusive C14N 1.0, RSA-SHA1, SHA-1 digest, and every
|
||||
// certificate of the chain in KeyInfo/X509Data. doc must already be in canonical form
|
||||
// (windowsProfile writes it that way), so its bytes are the digest input.
|
||||
func (s *Signer) Sign(doc string) ([]byte, error) {
|
||||
const end = "</CarrierProvisioning>"
|
||||
if !strings.HasSuffix(doc, end) {
|
||||
return nil, errors.New("signing: not a CarrierProvisioning document")
|
||||
// Sign appends an enveloped XML-DSig signature as the last child of doc's root and returns the
|
||||
// serialized document, in the form of Microsoft's CarrierProvisioning samples: Reference URI=""
|
||||
// with only the enveloped-signature transform, inclusive C14N 1.0, RSA-SHA1, SHA-1 digest, and
|
||||
// every certificate of the chain in KeyInfo/X509Data.
|
||||
func (s *Signer) Sign(doc *etree.Document) ([]byte, error) {
|
||||
root := doc.Root()
|
||||
if root == nil || root.Tag != "CarrierProvisioning" || root.SelectElement(dsig.SignatureTag) != nil {
|
||||
return nil, errors.New("signing: not an unsigned CarrierProvisioning document")
|
||||
}
|
||||
digest := sha1.Sum([]byte(doc))
|
||||
signedInfo := `<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></CanonicalizationMethod>` +
|
||||
`<SignatureMethod Algorithm="` + dsigNS + `rsa-sha1"></SignatureMethod>` +
|
||||
`<Reference URI=""><Transforms><Transform Algorithm="` + dsigNS + `enveloped-signature"></Transform></Transforms>` +
|
||||
`<DigestMethod Algorithm="` + dsigNS + `sha1"></DigestMethod>` +
|
||||
`<DigestValue>` + base64.StdEncoding.EncodeToString(digest[:]) + `</DigestValue></Reference>` +
|
||||
`</SignedInfo>`
|
||||
// Canonical SignedInfo carries the namespace it inherits from Signature.
|
||||
hashed := sha1.Sum([]byte(`<SignedInfo xmlns="` + dsigNS + `">` + signedInfo))
|
||||
c14n := dsig.MakeC14N10RecCanonicalizer()
|
||||
canonical, err := c14n.Canonicalize(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signing: %w", err)
|
||||
}
|
||||
digest := sha1.Sum(canonical)
|
||||
|
||||
signature := root.CreateElement(dsig.SignatureTag)
|
||||
signature.CreateAttr("xmlns", dsigNS)
|
||||
signedInfo := signature.CreateElement(dsig.SignedInfoTag)
|
||||
signedInfo.CreateElement(dsig.CanonicalizationMethodTag).
|
||||
CreateAttr(dsig.AlgorithmAttr, c14n.Algorithm().String())
|
||||
signedInfo.CreateElement(dsig.SignatureMethodTag).CreateAttr(dsig.AlgorithmAttr, dsig.RSASHA1SignatureMethod)
|
||||
reference := signedInfo.CreateElement(dsig.ReferenceTag)
|
||||
reference.CreateAttr(dsig.URIAttr, "")
|
||||
reference.CreateElement(dsig.TransformsTag).CreateElement(dsig.TransformTag).
|
||||
CreateAttr(dsig.AlgorithmAttr, dsig.EnvelopedSignatureAltorithmId.String())
|
||||
reference.CreateElement(dsig.DigestMethodTag).CreateAttr(dsig.AlgorithmAttr, dsigNS+"sha1")
|
||||
reference.CreateElement(dsig.DigestValueTag).SetText(base64.StdEncoding.EncodeToString(digest[:]))
|
||||
|
||||
// In place, the canonicalizer adds the namespace SignedInfo inherits from Signature.
|
||||
canonical, err = c14n.Canonicalize(signedInfo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signing: %w", err)
|
||||
}
|
||||
hashed := sha1.Sum(canonical)
|
||||
value, err := rsa.SignPKCS1v15(rand.Reader, s.key, crypto.SHA1, hashed[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signing: %w", err)
|
||||
}
|
||||
var certs strings.Builder
|
||||
signature.CreateElement(dsig.SignatureValueTag).SetText(base64.StdEncoding.EncodeToString(value))
|
||||
x509Data := signature.CreateElement(dsig.KeyInfoTag).CreateElement(dsig.X509DataTag)
|
||||
for _, c := range s.chain {
|
||||
certs.WriteString("<X509Certificate>" + base64.StdEncoding.EncodeToString(c) + "</X509Certificate>")
|
||||
x509Data.CreateElement(dsig.X509CertificateTag).SetText(base64.StdEncoding.EncodeToString(c))
|
||||
}
|
||||
signature := `<Signature xmlns="` + dsigNS + `"><SignedInfo>` + signedInfo +
|
||||
`<SignatureValue>` + base64.StdEncoding.EncodeToString(value) + `</SignatureValue>` +
|
||||
`<KeyInfo><X509Data>` + certs.String() + `</X509Data></KeyInfo></Signature>`
|
||||
return []byte(strings.TrimSuffix(doc, end) + signature + end), nil
|
||||
return doc.WriteToBytes()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user