Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a05f1431ca | ||
|
|
5718c9bae2 | ||
|
|
2c79edabea | ||
|
|
6e12a608b3 | ||
|
|
5718492e2b | ||
|
|
3bfb38ebd7 | ||
|
|
1de63af07b | ||
|
|
6c2c278f65 | ||
|
|
21625685b4 | ||
|
|
29da21c28f | ||
|
|
c781e263fc | ||
|
|
b1be3181ef | ||
|
|
08b087337e | ||
|
|
8610853a6b | ||
|
|
1b724ef114 | ||
|
|
ee1a421dc0 | ||
|
|
1e66d793d5 | ||
|
|
0996609ce4 | ||
|
|
4b27a5f273 | ||
|
|
ea195d0e4f | ||
|
|
1ae259e474 | ||
|
|
796d8459b6 | ||
|
|
a6be8247eb | ||
|
|
826042f3ea | ||
|
|
aa2543369c | ||
|
|
5add98190f | ||
|
|
1643b305b2 | ||
|
|
657e3d0d92 | ||
|
|
ac2d5d13a1 | ||
|
|
ed3be3156b | ||
|
|
05d2fcac71 | ||
|
|
697856cbf8 | ||
|
|
4962af5ed1 | ||
|
|
bc698ea22d | ||
|
|
d42862e2d8 | ||
|
|
73dd6f6362 | ||
|
|
335c423ca6 |
@@ -41,6 +41,7 @@ when:
|
||||
- "ci/images/**"
|
||||
- "secret-rotator/**"
|
||||
- "review-bot/**"
|
||||
- "wlan-setup/**"
|
||||
- ".woodpecker/build-ci-images.yml"
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "build-ci-images"'
|
||||
@@ -197,6 +198,31 @@ steps:
|
||||
--destination gitea.renner.uno/gitea_admin/review-bot:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/review-bot:latest
|
||||
|
||||
# wlan-setup: the Go service behind wlan.renner.uno (802.1X/Passpoint setup page with per-device
|
||||
# authentik app passwords). Runs after Gitea is up, so no ghcr mirror.
|
||||
- name: build-wlan-setup
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
|
||||
directory: wlan-setup
|
||||
when:
|
||||
- event: deployment
|
||||
- event: push
|
||||
path: ["wlan-setup/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
GITEA_USER: gitea_admin
|
||||
GITEA_TOKEN:
|
||||
from_secret: ci_images_token
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
|
||||
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
|
||||
- TAG=$(date +%Y-%m-%d-%H%M)
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context . \
|
||||
--dockerfile Containerfile \
|
||||
--destination gitea.renner.uno/gitea_admin/wlan-setup:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/wlan-setup:latest
|
||||
|
||||
# Runtime image, not CI tooling: a general-purpose alpine + jq base for
|
||||
# workloads that need to read JSON from a shell script. Named for what it IS,
|
||||
# not for who uses it — its first consumer is the zigbee2mqtt seed-data init
|
||||
|
||||
@@ -35,25 +35,25 @@ when:
|
||||
steps:
|
||||
- name: model-selection-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
- name: pr-review-mistral-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
commands:
|
||||
- sh scripts/ci/test-pr-review-mistral.sh
|
||||
|
||||
- name: promtool-rules-guard-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
commands:
|
||||
- sh scripts/ci/test-promtool-rules.sh
|
||||
|
||||
- name: yaml-citations-guard-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
commands:
|
||||
- sh scripts/ci/test-yaml-citations.sh
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ steps:
|
||||
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
|
||||
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
|
||||
# test from rotting (it exercises the exact injection shapes).
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ steps:
|
||||
# this step is skipped — otherwise we'd fork-bomb PRs.
|
||||
when:
|
||||
- branch: main
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -30,7 +30,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: update-audit-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Tests for the wlan-setup Go service (wlan-setup/). Offline: the tests run against a fake
|
||||
# authentik server, so the step needs no secret and no network beyond the module cache.
|
||||
when:
|
||||
- event: pull_request
|
||||
path: ["wlan-setup/**", ".woodpecker/wlan-setup-test.yml"]
|
||||
|
||||
steps:
|
||||
- name: go-test
|
||||
image: golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9
|
||||
commands:
|
||||
- test -z "$(gofmt -l wlan-setup)"
|
||||
- go -C wlan-setup vet ./...
|
||||
- go -C wlan-setup test -race ./...
|
||||
@@ -134,11 +134,13 @@ Afterwards:
|
||||
|
||||
## Day-2 notes
|
||||
|
||||
- Image bumps: edit `edge_zwavejs_image` / `edge_sml2mqtt_image` in
|
||||
defaults and re-run the play (no flux image-automation out here —
|
||||
deliberate for sml2mqtt, acceptable for zwave-js). If a zwave-js bump
|
||||
changes its Alpine major, also update `edge_zwave_alpine_base` and refresh
|
||||
the preloaded `.so` (see "Logging & eMMC" → zwave-js).
|
||||
- Image bumps: Renovate opens a PR for `edge_*_image` in
|
||||
`roles/*/defaults/main.yml` (tag + digest, `customManagers` in
|
||||
`renovate.json`). No pipeline deploys to edge-1, so after the merge run
|
||||
`ansible-playbook plays/install-zwave-gateway.yml` from `ansible/` on a
|
||||
mesh-connected workstation. The play reads the image's Alpine release
|
||||
itself and rebuilds the preloaded `.so` when it changes (see
|
||||
"Logging & eMMC" → zwave-js).
|
||||
- open3e / E3onCAN updates: both are pinned to commits in
|
||||
`roles/heating-can/defaults/main.yml`, so re-running the play does NOT pick
|
||||
up upstream changes -- that is deliberate, these drive the heat pump. The
|
||||
@@ -190,7 +192,7 @@ journal commit).
|
||||
|
||||
- **Fix:** the `zwave-js` container preloads **`libeatmydata`**
|
||||
(`LD_PRELOAD=/usr/lib/libeatmydata.so`, mounted read-only from
|
||||
`zwave-js/libeatmydata.so`). This makes `fsync`/`fdatasync` no-ops *for that
|
||||
`zwave-js/libeatmydata-alpine<X.Y>.so`). This makes `fsync`/`fdatasync` no-ops *for that
|
||||
one process*, so the kernel batches the writes via normal writeback (~30 s)
|
||||
instead of forcing each one. The WS/MQTT value stream to Home Assistant and
|
||||
the on-disk cache are **unchanged** — full data resolution, no re-interview
|
||||
@@ -199,9 +201,9 @@ journal commit).
|
||||
batch it); the only mount-level alternative is putting the cache on tmpfs,
|
||||
which makes the *whole* store volatile → re-interview on every restart.
|
||||
- **The `.so` is musl** (the image is Alpine-based), extracted from
|
||||
`edge_zwave_alpine_base` — keep that var in sync with the image's Alpine
|
||||
version; to refresh after a major bump, `rm zwave-js/libeatmydata.so` and
|
||||
re-run.
|
||||
`alpine:<X.Y>`, where `X.Y` is read from the image's `/etc/alpine-release`
|
||||
on every run. A new Alpine release gives a new file name, so the play
|
||||
extracts it, recreates the container and deletes the old file.
|
||||
- **Durability guard:** because `fsync` is neutralized, a hard power-loss
|
||||
during the (rare) settings write could truncate `settings.json`, which holds
|
||||
the **Z-Wave network keys**. `edge-zwave-settings-backup.timer` keeps a
|
||||
|
||||
@@ -13,12 +13,8 @@ zwave_base_dir: "{{ edge_base_dir }}/zwave-js"
|
||||
# survives moving the stick between machines.
|
||||
edge_zwave_device: /dev/serial/by-id/usb-Nabu_Casa_ZWA-2_80B54EE15720-if00
|
||||
|
||||
edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.21.1
|
||||
|
||||
# Alpine version the zwave-js-ui image is built on -- the source for the musl
|
||||
# libeatmydata.so preloaded into that container. Keep in sync if the image
|
||||
# bumps its Alpine major (check: docker exec zwave-js cat /etc/os-release).
|
||||
edge_zwave_alpine_base: alpine:3.22
|
||||
# Renovate-tracked (customManagers in renovate.json).
|
||||
edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.25.2@sha256:ac4874a3cbea61c0c60002a8fd8ae05104cc8eb2eae91aa05bd184972aa06853
|
||||
|
||||
# Secret from inventory/group_vars/edge_host/vault.yml:
|
||||
# edge_zwavejs_session_secret
|
||||
|
||||
@@ -15,17 +15,31 @@
|
||||
- "{{ zwave_base_dir }}"
|
||||
- "{{ zwave_base_dir }}/store"
|
||||
|
||||
# The preloaded .so is musl and must come from the image's own Alpine release.
|
||||
- name: Read the Alpine release of the zwave-js-ui image
|
||||
ansible.builtin.command:
|
||||
cmd: docker run --rm --entrypoint cat {{ edge_zwavejs_image }} /etc/alpine-release
|
||||
register: zwave_alpine_release
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Name libeatmydata.so after that Alpine release
|
||||
ansible.builtin.set_fact:
|
||||
zwave_alpine_minor: "{{ zwave_alpine_release.stdout.split('.')[:2] | join('.') }}"
|
||||
zwave_libeatmydata: "libeatmydata-alpine{{ zwave_alpine_release.stdout.split('.')[:2] | join('.') }}.so"
|
||||
|
||||
- name: Extract musl libeatmydata.so (matches the zwave-js-ui Alpine base)
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -euo pipefail
|
||||
tmp="$(mktemp)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
docker run --rm --network=host {{ edge_zwave_alpine_base }} \
|
||||
docker run --rm --network=host alpine:{{ zwave_alpine_minor }} \
|
||||
sh -c 'apk add --no-cache libeatmydata >/dev/null 2>&1 && cat /usr/lib/libeatmydata.so' > "$tmp"
|
||||
test "$(stat -c%s "$tmp")" -ge 8000 # sanity: real .so is ~14 KB, guards against a failed/empty pull
|
||||
install -m 0644 "$tmp" {{ zwave_base_dir }}/libeatmydata.so
|
||||
creates: "{{ zwave_base_dir }}/libeatmydata.so"
|
||||
install -m 0644 "$tmp" {{ zwave_base_dir }}/{{ zwave_libeatmydata }}
|
||||
creates: "{{ zwave_base_dir }}/{{ zwave_libeatmydata }}"
|
||||
executable: /bin/bash
|
||||
notify: Restart zwave-js
|
||||
|
||||
# Because fsync is neutralized, a hard power-loss during the (rare) settings
|
||||
@@ -93,3 +107,18 @@
|
||||
zwave-js could not start -- the ZWA-2 radio is not present:
|
||||
{{ zwave_compose_up.stderr_lines | select('search', 'device') | list }}
|
||||
when: zwave_compose_up.rc != 0
|
||||
|
||||
- name: Find libeatmydata.so builds of other Alpine releases
|
||||
ansible.builtin.find:
|
||||
paths: "{{ zwave_base_dir }}"
|
||||
patterns: "libeatmydata*.so"
|
||||
excludes: "{{ zwave_libeatmydata }}"
|
||||
register: zwave_stale_libeatmydata
|
||||
|
||||
- name: Remove libeatmydata.so builds of other Alpine releases
|
||||
ansible.builtin.file:
|
||||
path: "{{ item.path }}"
|
||||
state: absent
|
||||
loop: "{{ zwave_stale_libeatmydata.files }}"
|
||||
loop_control:
|
||||
label: "{{ item.path }}"
|
||||
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
- "{{ edge_zwave_device }}:/dev/zwave"
|
||||
volumes:
|
||||
# musl libeatmydata.so extracted from the image's Alpine base (ro)
|
||||
- ./libeatmydata.so:/usr/lib/libeatmydata.so:ro
|
||||
- ./{{ zwave_libeatmydata }}:/usr/lib/libeatmydata.so:ro
|
||||
# Persistent store: settings.json, device DB, network keys. Path is
|
||||
# unchanged from the previous shared project, so nothing is migrated.
|
||||
- ./store:/usr/src/app/store
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24-alpine@sha256:8a32c8cf649a49915a66e81e7a609223c708d0adabf288ee3f4077d765542f85
|
||||
FROM node:26-alpine@sha256:c6ad9e9c1d80f6eb607c26b4b0a132ff7a2344fad1c78ea3baa2a7d4c8de6d94
|
||||
|
||||
# Default to the latest Claude Code. Pinning a fixed version is the wrong
|
||||
# default here: old releases eventually stop working against the API, so a
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<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-test.network</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>c7814579-fc2b-4f38-bcc8-77e1123ab710</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Renner Test Wi-Fi</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string>Renner</string>
|
||||
<key>SSID_STR</key>
|
||||
<string>Renner Test</string>
|
||||
<key>HIDDEN_NETWORK</key>
|
||||
<false/>
|
||||
<key>AutoJoin</key>
|
||||
<true/>
|
||||
<key>EncryptionType</key>
|
||||
<string>Any</string>
|
||||
<key>EAPClientConfiguration</key>
|
||||
<dict>
|
||||
<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-test</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>b35513d4-97e5-4804-a77b-5ca1f723a408</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Renner Test Wi-Fi (802.1X)</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string>Renner</string>
|
||||
</dict>
|
||||
</plist>
|
||||
+126
-20
@@ -4,6 +4,54 @@ FreeRADIUS terminates EAP-TTLS and proxies the inner PAP request to the
|
||||
authentik RADIUS outpost `802.1X Radius`. authentik checks the password and
|
||||
the access policy and returns the VLAN.
|
||||
|
||||
## Routing by outer identity
|
||||
|
||||
FreeRADIUS decides on the outer EAP identity (`User-Name`) of each request,
|
||||
before any local EAP processing:
|
||||
|
||||
| Outer identity | Path |
|
||||
|---|---|
|
||||
| `anonymous` or `anonymous@<anything>` (any case) | EAP-TTLS terminated in FreeRADIUS, inner PAP to authentik |
|
||||
| anything else | whole EAP conversation (all rounds, `State` included) proxied to the UDM RADIUS for UniFi Endpoint accounts |
|
||||
|
||||
UniFi Endpoint clients send their real account name as outer identity; their
|
||||
names look like ordinary usernames, so the rule cannot match on name patterns.
|
||||
The UDM's Access-Accept/Reject/Challenge goes to the NAS unchanged, including
|
||||
its Tunnel-* VLAN and MS-MPPE keys. The "Accept without VLAN is rejected" rule
|
||||
applies to the authentik path only. Accounting is answered locally on both
|
||||
paths.
|
||||
|
||||
The anonymous outer identity is mandatory for authentik clients: without it
|
||||
the login goes to the UDM and fails.
|
||||
|
||||
UDM target: home server `udm`, `10.2.11.1` UDP 1812 (the UDM on VLAN 11,
|
||||
UniFi network "(11) RADIUS"), shared secret `UDM_RADIUS_SECRET` from OpenBao
|
||||
`apps/radius`. The UDM RADIUS accepts clients from `10.2.11.0/24` only, not
|
||||
from the pod CIDR.
|
||||
|
||||
### Path to the UDM
|
||||
|
||||
Each FreeRADIUS pod has a second interface `net1` on VLAN 11
|
||||
(NetworkAttachmentDefinition `freeradius-udm`,
|
||||
`kubernetes/apps/authentik/freeradius-network-attachment.yaml`):
|
||||
|
||||
- macvlan on the node link `radius0` (VLAN 11 on `uplink`, no host addresses;
|
||||
VLANConfig in `kubernetes/talos/patches/k8s-{1,2,3}.yaml`). VLAN 11 is in
|
||||
Cilium's `bpf.vlanBypass`, otherwise the tagged frames are dropped on
|
||||
`uplink`.
|
||||
- IPv4 leased from the UDM DHCP server (cni-dhcp-daemon), requested without
|
||||
the routers option: `net1` holds only the connected `10.2.11.0/24`, no
|
||||
default route. The proxy socket is not bound to an address; the kernel's
|
||||
route lookup for `10.2.11.1` picks `net1` and its leased address as source.
|
||||
- IPv6 disabled on `net1` (VLAN 11 sends router advertisements).
|
||||
- The NAS listeners are bound to `eth0` (`interface = eth0`); nothing listens
|
||||
on `net1`.
|
||||
- Traffic on `net1` bypasses Cilium; there is no CNP for the UDM hop.
|
||||
|
||||
`radius0` must exist on every node before FreeRADIUS pods are scheduled
|
||||
there: without it the macvlan ADD fails and the pod stays in
|
||||
`ContainerCreating`.
|
||||
|
||||
## authentik objects
|
||||
|
||||
Defined in `kubernetes/apps/authentik/blueprints-configmap.yaml`, key
|
||||
@@ -69,22 +117,79 @@ The FreeRADIUS clients `test` (10.2.50.0/24) and `test6` (fd02:50::/64) and
|
||||
their CIDRs in `freeradius-cnp.yaml` are for the test phase only and must be
|
||||
removed after the test.
|
||||
|
||||
## Client setup (SSID "Renner Test")
|
||||
## UniFi objects
|
||||
|
||||
Created through the UniFi Network API (site `default`):
|
||||
|
||||
| Object | ID | Settings |
|
||||
|---|---|---|
|
||||
| Firewall policy "Allow APs RADIUS" | `62c21e63-8c15-4720-adf8-b2333c090e2f` | zone Isolated `10.2.10.0/24` → zone External `10.2.99.91`, UDP `1812-1813`, ALLOW, IPv4 |
|
||||
| RADIUS profile "Cluster 802.1X" | `3e5fcfed-63dc-4e7d-ad51-17cbc074e4bb` | auth `10.2.99.91:1812`, acct `10.2.99.91:1813`, secret `DOT1X_NAS_SECRET`, VLAN assignment on (`vlan_wlan_mode: optional`) |
|
||||
| WLAN "Renner UniFi" | `ef4122f2-4a83-4dca-9ef3-468d29f39a6b` | WPA3 Enterprise (no transition), PMF required, fast roaming, BSS transition, client isolation off, RADIUS profile "Cluster 802.1X"; Application Hotspot, type Passpoint (set in the UniFi UI: the API format differs from the UI's) |
|
||||
|
||||
Passpoint on "Renner UniFi" (UniFi offers Passpoint only with WPA3 Enterprise):
|
||||
venue "Renner", Residential / Private Residence, network type Private with
|
||||
Guest Access, IPv4 single NATed private, IPv6 available, NAI realm
|
||||
`renner.uno` with EAP-TTLS, sub-methods PAP and username/password, domain
|
||||
`renner.uno`, operator friendly name "Renner", connection capability ICMP
|
||||
allow. The AKMs on air are `FT/802.1X` and `802.1X/SHA-256`; plain `802.1X`
|
||||
is not offered.
|
||||
|
||||
## Live login check
|
||||
|
||||
`tests/radius/live.sh` runs EAP-TTLS/PAP logins with `eapol_test` against the
|
||||
deployed FreeRADIUS from a test client range and prints result, duration and
|
||||
the VLAN attributes of the Access-Accept (hex) per login; secrets are read from
|
||||
files and never printed:
|
||||
|
||||
```
|
||||
LIVE_PASSWORD_VAR=CLAUDE_BOT_PASSWORD tests/radius/live.sh 10.2.99.91 claude-bot \
|
||||
~/.config/claude-bot/authentik ~/.config/claude-bot/radius-secret 20
|
||||
```
|
||||
|
||||
For IPv6 set `LIVE_SOURCE` to the workstation's `fd02:50::` address and use
|
||||
`fd99::91`. `group-id=[3530]` is VLAN `50`, `tunnel-type=[0000000d]` VLAN,
|
||||
`medium=[00000006]` IEEE-802.
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
App passwords expire when unused. The page creates them with 29 days (the API
|
||||
caps new tokens at 30 days unless the group attribute
|
||||
`goauthentik.io/user/token-maximum-lifetime` allows more). Every 802.1X login
|
||||
with an app password renews it to 60 days: the policy
|
||||
`dot1x-app-password-renew` on the `dot1x-login` binding (at most one write per
|
||||
day). authentik rejects expired app passwords and deletes them in its
|
||||
`clean_expired_models` task. A device that has not connected for 60 days, or a
|
||||
profile that was never installed, loses its app password and needs a new
|
||||
profile from the page.
|
||||
|
||||
All clients use EAP-TTLS with inner PAP. The server certificate is a public
|
||||
Let's Encrypt certificate for `radius.renner.uno`, so no CA needs to be
|
||||
installed. Username and password are the authentik credentials. PAP runs
|
||||
inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air.
|
||||
installed. The 802.1X password stage (`dot1x-password`) accepts the authentik
|
||||
password and app passwords. PAP runs inside the TLS tunnel to FreeRADIUS and
|
||||
is never sent in clear over the air.
|
||||
|
||||
### Android
|
||||
|
||||
- EAP method: TTLS
|
||||
- Phase 2 authentication: PAP
|
||||
- CA certificate: Use system certificates
|
||||
- Domain: `radius.renner.uno`
|
||||
- Identity: authentik username
|
||||
- Anonymous identity: `anonymous`
|
||||
- Password: authentik password
|
||||
Passpoint profile from the setup page ("Auf Android installieren").
|
||||
|
||||
### Linux (NetworkManager)
|
||||
|
||||
@@ -93,14 +198,15 @@ inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air.
|
||||
- Inner authentication: PAP
|
||||
- CA certificate: system bundle (e.g. `/etc/ssl/certs/ca-certificates.crt`)
|
||||
- Domain: `radius.renner.uno`
|
||||
- Anonymous identity: `anonymous` (mandatory)
|
||||
- Username / password: authentik credentials
|
||||
|
||||
### Windows 11
|
||||
|
||||
Settings > Network & Internet > Wi-Fi > Manage known networks > Add network:
|
||||
|
||||
- Network name: `Renner Test`
|
||||
- Security type: WPA2-Enterprise AES
|
||||
- Network name: `Renner UniFi`
|
||||
- Security type: WPA3-Enterprise AES
|
||||
- EAP method: EAP-TTLS
|
||||
- Authentication method: Unencrypted password (PAP)
|
||||
- Server validation: where offered ("Verify server identity" / trusted
|
||||
@@ -115,14 +221,14 @@ wireless network" instead.
|
||||
|
||||
### Apple (iOS, iPadOS, macOS)
|
||||
|
||||
Apple devices need the configuration profile
|
||||
`docs/radius/clients/renner-test.mobileconfig`. Install it via AirDrop, Files
|
||||
or Settings (Profile Downloaded). The device asks for username and password on
|
||||
the first join. The profile sets security type Any (WPA2/WPA3), outer identity
|
||||
`anonymous` and trusted server name `radius.renner.uno`; it contains no
|
||||
password and no certificate. Apple
|
||||
devices may show a one-time "trust certificate" prompt for `radius.renner.uno`
|
||||
on the first join.
|
||||
Passpoint configuration profile from the setup page ("Profil laden"),
|
||||
installed via Settings (Profile Downloaded) or, on a Mac, System Settings >
|
||||
General > Device Management. It sets `IsHotspot` with domain and NAI realm
|
||||
`renner.uno` (no SSID), security type Any, outer identity `anonymous`,
|
||||
trusted server name `radius.renner.uno`, username and app password; it
|
||||
contains no certificate. Its PayloadIdentifier is fixed, so installing it
|
||||
replaces an earlier profile. Apple devices may show a one-time "trust
|
||||
certificate" prompt for `radius.renner.uno` on the first join.
|
||||
|
||||
## Measurement: authentication latency per SSID
|
||||
|
||||
|
||||
@@ -1483,8 +1483,11 @@ data:
|
||||
name: dot1x-password
|
||||
state: present
|
||||
attrs:
|
||||
# App passwords: the WLAN credential that devices store (Android
|
||||
# Passpoint profiles carry it), created by wlan.renner.uno.
|
||||
backends:
|
||||
- authentik.core.auth.InbuiltBackend
|
||||
- authentik.core.auth.TokenBackend
|
||||
|
||||
- id: dot1x-login
|
||||
model: authentik_stages_user_login.userloginstage
|
||||
@@ -1506,12 +1509,54 @@ data:
|
||||
order: 20
|
||||
state: present
|
||||
|
||||
- model: authentik_flows.flowstagebinding
|
||||
- id: binding-dot1x-login
|
||||
model: authentik_flows.flowstagebinding
|
||||
identifiers:
|
||||
target: !KeyOf dot1x-flow
|
||||
stage: !KeyOf dot1x-login
|
||||
order: 100
|
||||
state: present
|
||||
attrs:
|
||||
evaluate_on_plan: false
|
||||
re_evaluate_policies: true
|
||||
|
||||
# Sliding expiry: an app password used for 802.1X expires 60 days
|
||||
# after its last use; authentik rejects and deletes expired app
|
||||
# passwords. auth_method_args.token is set by TokenBackend. Always
|
||||
# passes; failure_result true keeps an error from blocking the login.
|
||||
- id: dot1x-app-password-renew
|
||||
model: authentik_policies_expression.expressionpolicy
|
||||
identifiers:
|
||||
name: dot1x-app-password-renew
|
||||
state: present
|
||||
attrs:
|
||||
expression: |
|
||||
from datetime import timedelta
|
||||
from django.utils.timezone import now
|
||||
from authentik.core.models import Token, TokenIntents
|
||||
ref = (request.context.get("auth_method_args") or {}).get("token")
|
||||
if request.context.get("auth_method") == "token" and isinstance(ref, dict):
|
||||
renewed = now() + timedelta(days=60)
|
||||
count = Token.objects.filter(
|
||||
pk=ref.get("pk"),
|
||||
user=request.user,
|
||||
intent=TokenIntents.INTENT_APP_PASSWORD,
|
||||
expiring=True,
|
||||
expires__lt=renewed - timedelta(days=1),
|
||||
).update(expires=renewed)
|
||||
ak_logger.info("dot1x app password", token=ref.get("name"), renewed=count)
|
||||
return True
|
||||
|
||||
- model: authentik_policies.policybinding
|
||||
identifiers:
|
||||
policy: !KeyOf dot1x-app-password-renew
|
||||
target: !KeyOf binding-dot1x-login
|
||||
order: 0
|
||||
state: present
|
||||
attrs:
|
||||
enabled: true
|
||||
negate: false
|
||||
failure_result: true
|
||||
|
||||
- id: dot1x-has-vlan
|
||||
model: authentik_policies_expression.expressionpolicy
|
||||
@@ -1624,3 +1669,45 @@ data:
|
||||
kubernetes_httproute_annotations: {}
|
||||
kubernetes_httproute_parent_refs: []
|
||||
kubernetes_json_patches: null
|
||||
|
||||
# wlan.renner.uno (wlan-setup.yaml): signs the user in and, with the
|
||||
# user's own access token (scope goauthentik.io/api), creates the WLAN
|
||||
# app password for the Android Passpoint profile. Confidential client;
|
||||
# the secret comes from wlan-setup-oauth (wlan-setup-oauth.yaml). No
|
||||
# policy binding: every authentik user may set up the WLAN.
|
||||
- id: wlan-setup-provider
|
||||
model: authentik_providers_oauth2.oauth2provider
|
||||
identifiers:
|
||||
name: WLAN setup
|
||||
state: present
|
||||
attrs:
|
||||
authentication_flow: null
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
client_type: confidential
|
||||
client_id: wlan-setup
|
||||
client_secret: !Env WLAN_SETUP_CLIENT_SECRET
|
||||
grant_types:
|
||||
- authorization_code
|
||||
access_token_validity: minutes=5
|
||||
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
|
||||
property_mappings:
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-authentik_api]]
|
||||
redirect_uris:
|
||||
- url: https://wlan.renner.uno/android/callback
|
||||
matching_mode: strict
|
||||
- url: https://wlan.renner.uno/apple/callback
|
||||
matching_mode: strict
|
||||
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: wlan-setup
|
||||
state: present
|
||||
attrs:
|
||||
name: WLAN setup
|
||||
slug: wlan-setup
|
||||
provider: !KeyOf wlan-setup-provider
|
||||
meta_launch_url: https://wlan.renner.uno
|
||||
meta_description: "WLAN setup page; creates WLAN app passwords"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# FreeRADIUS 3.2 config for 802.1X: EAP-TTLS terminated here, inner PAP
|
||||
# proxied to the authentik outpost ak-outpost-8021x-radius. Replaces the
|
||||
# image's raddb completely. Functional test: tests/radius/run.sh.
|
||||
# FreeRADIUS 3.2 config for 802.1X: outer identity anonymous[@...] -> EAP-TTLS
|
||||
# terminated here, inner PAP proxied to the authentik outpost
|
||||
# ak-outpost-8021x-radius; any other outer identity -> whole EAP conversation
|
||||
# proxied to the UDM RADIUS (UniFi Endpoint). Replaces the image's raddb
|
||||
# completely. Functional test: tests/radius/run.sh.
|
||||
# VLAN contract: docs/radius/dot1x.md.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
@@ -32,9 +34,10 @@ data:
|
||||
|
||||
max_request_time = 30
|
||||
cleanup_delay = 5
|
||||
# Greater than response_window (proxy.conf): never forward a NAS
|
||||
# retransmit to the outpost. A TTLS inner request sent twice to the home
|
||||
# server leaves a freed request in the proxy hash; radiusd segfaults.
|
||||
# Greater than every home server's response_window (proxy.conf): never
|
||||
# forward a NAS retransmit to a home server. A TTLS inner request sent
|
||||
# twice to the home server leaves a freed request in the proxy hash;
|
||||
# radiusd segfaults.
|
||||
proxy_dedup_window = 10
|
||||
max_requests = 16384
|
||||
hostname_lookups = no
|
||||
@@ -152,6 +155,42 @@ data:
|
||||
nostrip
|
||||
}
|
||||
|
||||
# UDM RADIUS (FreeRADIUS) for UniFi Endpoint accounts; gets the whole
|
||||
# outer EAP conversation. Address: UDM on VLAN 11 "(11) RADIUS", reached
|
||||
# over net1 (freeradius-network-attachment.yaml); the UDM accepts
|
||||
# RADIUS clients from 10.2.11.0/24 only.
|
||||
home_server udm {
|
||||
type = auth
|
||||
ipaddr = 10.2.11.1
|
||||
port = 1812
|
||||
proto = udp
|
||||
secret = $ENV{UDM_RADIUS_SECRET}
|
||||
# RFC 3579: every reply to an EAP request carries Message-Authenticator.
|
||||
require_message_authenticator = yes
|
||||
# Less than proxy_dedup_window (radiusd.conf).
|
||||
response_window = 5
|
||||
zombie_period = 20
|
||||
# Probe with an Access-Request for a user that cannot exist (no
|
||||
# EAP-Message, so no Endpoint login); the reject counts as an answer.
|
||||
# Sent only while zombie or dead.
|
||||
status_check = request
|
||||
username = "udm-status-probe.invalid"
|
||||
password = "not-a-secret"
|
||||
check_interval = 6
|
||||
check_timeout = 4
|
||||
num_answers_to_alive = 3
|
||||
}
|
||||
|
||||
home_server_pool udm {
|
||||
type = fail-over
|
||||
home_server = udm
|
||||
}
|
||||
|
||||
realm udm {
|
||||
auth_pool = udm
|
||||
nostrip
|
||||
}
|
||||
|
||||
mods-enabled_always: |
|
||||
always ok {
|
||||
rcode = ok
|
||||
@@ -194,35 +233,50 @@ data:
|
||||
|
||||
sites-enabled_default: |
|
||||
server default {
|
||||
# NAS listeners on eth0 only; net1 (VLAN 11) carries the UDM proxy
|
||||
# traffic and nothing else.
|
||||
listen {
|
||||
type = auth
|
||||
ipaddr = *
|
||||
port = 1812
|
||||
interface = eth0
|
||||
}
|
||||
|
||||
listen {
|
||||
type = acct
|
||||
ipaddr = *
|
||||
port = 1813
|
||||
interface = eth0
|
||||
}
|
||||
|
||||
listen {
|
||||
type = auth
|
||||
ipv6addr = ::
|
||||
port = 1812
|
||||
interface = eth0
|
||||
}
|
||||
|
||||
listen {
|
||||
type = acct
|
||||
ipv6addr = ::
|
||||
port = 1813
|
||||
interface = eth0
|
||||
}
|
||||
|
||||
authorize {
|
||||
# EAP-TTLS only.
|
||||
# EAP only.
|
||||
if (!&EAP-Message) {
|
||||
reject
|
||||
}
|
||||
# Outer identity anonymous[@realm]: authentik clients, EAP-TTLS here.
|
||||
# Any other: UniFi Endpoint account (real name as outer identity);
|
||||
# every round goes to the UDM unprocessed.
|
||||
if (&User-Name && &User-Name !~ /^anonymous(@.*)?$/i) {
|
||||
update control {
|
||||
&Proxy-To-Realm := udm
|
||||
}
|
||||
return
|
||||
}
|
||||
eap {
|
||||
ok = return
|
||||
updated = return
|
||||
@@ -245,12 +299,23 @@ data:
|
||||
ok
|
||||
}
|
||||
|
||||
# UDM replies pass unchanged.
|
||||
post-auth {
|
||||
update reply {
|
||||
&Cisco-AVPair !* ANY
|
||||
if (&control:Proxy-To-Realm == "udm") {
|
||||
noop
|
||||
}
|
||||
else {
|
||||
update reply {
|
||||
&Cisco-AVPair !* ANY
|
||||
}
|
||||
}
|
||||
Post-Auth-Type REJECT {
|
||||
eap
|
||||
if (&control:Proxy-To-Realm == "udm") {
|
||||
noop
|
||||
}
|
||||
else {
|
||||
eap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +324,12 @@ data:
|
||||
|
||||
# Runs the TTLS callback that hands the outpost reply back into the tunnel.
|
||||
post-proxy {
|
||||
eap
|
||||
if (&control:Proxy-To-Realm == "udm") {
|
||||
noop
|
||||
}
|
||||
else {
|
||||
eap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
# FreeRADIUS net1: macvlan on radius0, VLAN 11 "(11) RADIUS" (VLANConfig in
|
||||
# kubernetes/talos/patches/k8s-{1,2,3}.yaml). Carries only the proxy traffic
|
||||
# to home server `udm` (freeradius-config.yaml): the UDM RADIUS accepts
|
||||
# clients from 10.2.11.0/24, not from the pod CIDR. Bypasses Cilium policy.
|
||||
# Address leased from the UDM; nothing references it.
|
||||
#
|
||||
# net1 holds only its connected /24, so the unbound proxy socket reaches the
|
||||
# UDM via net1 with the leased source. No sbr: it moves that route into a
|
||||
# source-based table, and the proxy would leave via eth0 from the pod IP.
|
||||
# skipDefault + subnet-mask: the lease is requested without routers, so no
|
||||
# default route lands on net1 (dnsmasq sends only requested options).
|
||||
# IPv6 off on net1: VLAN 11 sends RAs (default route, SLAAC); the UDM
|
||||
# RADIUS clients are IPv4 only.
|
||||
#
|
||||
# Requires cni-dhcp-daemon (infrastructure/multus) and VLAN 11 in Cilium's
|
||||
# bpf.vlanBypass (infrastructure/cilium/helmrelease.yaml).
|
||||
apiVersion: k8s.cni.cncf.io/v1
|
||||
kind: NetworkAttachmentDefinition
|
||||
metadata:
|
||||
name: freeradius-udm
|
||||
namespace: authentik
|
||||
spec:
|
||||
config: |
|
||||
{
|
||||
"cniVersion": "0.3.1",
|
||||
"name": "freeradius-udm",
|
||||
"plugins": [
|
||||
{
|
||||
"type": "macvlan",
|
||||
"master": "radius0",
|
||||
"mode": "bridge",
|
||||
"ipam": {
|
||||
"type": "dhcp",
|
||||
"request": [
|
||||
{ "skipDefault": true },
|
||||
{ "option": "subnet-mask" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tuning",
|
||||
"sysctl": {
|
||||
"net.ipv6.conf.IFNAME.disable_ipv6": "1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -18,6 +18,9 @@ spec:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: freeradius
|
||||
annotations:
|
||||
# net1 on VLAN 11: the path to the UDM RADIUS.
|
||||
k8s.v1.cni.cncf.io/networks: freeradius-udm
|
||||
spec:
|
||||
enableServiceLinks: false
|
||||
affinity:
|
||||
@@ -89,6 +92,11 @@ spec:
|
||||
secretKeyRef:
|
||||
name: radius-secrets
|
||||
key: DOT1X_OUTPOST_SECRET
|
||||
- name: UDM_RADIUS_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: radius-secrets
|
||||
key: UDM_RADIUS_SECRET
|
||||
ports:
|
||||
- name: radius
|
||||
containerPort: 1812
|
||||
|
||||
@@ -171,10 +171,11 @@ spec:
|
||||
worker:
|
||||
# Overrides the global key; keep authentik-secrets in the list.
|
||||
podAnnotations:
|
||||
secret.reloader.stakater.com/reload: authentik-secrets,radius-secrets
|
||||
# Shared secret of the 802.1X Radius provider, set via `!Env` in
|
||||
# dot1x-radius.yaml. Worker only (blueprint applier); optional so a
|
||||
# missing Secret fails only the dot1x blueprint entry.
|
||||
secret.reloader.stakater.com/reload: authentik-secrets,radius-secrets,wlan-setup-oauth
|
||||
# Shared secret of the 802.1X Radius provider and client secret of the
|
||||
# WLAN setup provider, set via `!Env` in dot1x-radius.yaml. Worker only
|
||||
# (blueprint applier); optional so a missing Secret fails only the
|
||||
# blueprint entries that use it.
|
||||
env:
|
||||
- name: DOT1X_OUTPOST_SECRET
|
||||
valueFrom:
|
||||
@@ -182,6 +183,12 @@ spec:
|
||||
name: radius-secrets
|
||||
key: DOT1X_OUTPOST_SECRET
|
||||
optional: true
|
||||
- name: WLAN_SETUP_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wlan-setup-oauth
|
||||
key: client_secret
|
||||
optional: true
|
||||
resources:
|
||||
requests:
|
||||
# Same reasoning as server above, and the worker is burstier still:
|
||||
|
||||
@@ -13,8 +13,12 @@ resources:
|
||||
- radius-cert.yaml
|
||||
- radius-externalsecret.yaml
|
||||
- freeradius-config.yaml
|
||||
- freeradius-network-attachment.yaml
|
||||
- freeradius.yaml
|
||||
- freeradius-cnp.yaml
|
||||
- wlan-setup-oauth.yaml
|
||||
- wlan-setup-config.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).
|
||||
# authentik has no data PVCs — all state lives in ak-pg. This KS depends on
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
# 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,32 @@
|
||||
# Client secret of the "WLAN setup" OAuth2 provider (dot1x-radius blueprint).
|
||||
# Generated once; the authentik worker reads it as WLAN_SETUP_CLIENT_SECRET
|
||||
# for the blueprint, wlan-setup reads it for the code exchange.
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
metadata:
|
||||
name: wlan-setup-oauth
|
||||
namespace: authentik
|
||||
spec:
|
||||
length: 64
|
||||
digits: 16
|
||||
symbols: 0
|
||||
allowRepeat: true
|
||||
noUpper: false
|
||||
secretKeys:
|
||||
- client_secret
|
||||
---
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: wlan-setup-oauth
|
||||
namespace: authentik
|
||||
spec:
|
||||
refreshPolicy: CreatedOnce
|
||||
target:
|
||||
name: wlan-setup-oauth
|
||||
dataFrom:
|
||||
- sourceRef:
|
||||
generatorRef:
|
||||
apiVersion: generators.external-secrets.io/v1alpha1
|
||||
kind: Password
|
||||
name: wlan-setup-oauth
|
||||
@@ -0,0 +1,163 @@
|
||||
# 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).
|
||||
# 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.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: wlan-setup
|
||||
namespace: authentik
|
||||
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
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: wlan-setup
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: wlan-setup
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 101
|
||||
runAsGroup: 101
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b5047b9b76845eac124358373c6852377c52e4b05834a47647e955dd91cb8f1e
|
||||
env:
|
||||
# Client secret of the "WLAN setup" OAuth2 provider (passpoint.js).
|
||||
- name: WLAN_SETUP_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wlan-setup-oauth
|
||||
key: client_secret
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
resources:
|
||||
requests:
|
||||
cpu: 5m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
volumeMounts:
|
||||
# subPath: replaced on rollout, which the reloader annotation
|
||||
# triggers on every ConfigMap change.
|
||||
- name: main
|
||||
mountPath: /etc/nginx/nginx.conf
|
||||
subPath: nginx.conf
|
||||
readOnly: true
|
||||
- name: config
|
||||
mountPath: /etc/nginx/conf.d
|
||||
readOnly: true
|
||||
- name: njs
|
||||
mountPath: /etc/nginx/njs
|
||||
readOnly: true
|
||||
- name: html
|
||||
mountPath: /usr/share/nginx/html
|
||||
readOnly: true
|
||||
# pid file and temp paths of the unprivileged image.
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: main
|
||||
configMap:
|
||||
name: wlan-setup
|
||||
items:
|
||||
- key: nginx.conf
|
||||
path: nginx.conf
|
||||
- name: config
|
||||
configMap:
|
||||
name: wlan-setup
|
||||
items:
|
||||
- key: default.conf
|
||||
path: default.conf
|
||||
- name: njs
|
||||
configMap:
|
||||
name: wlan-setup
|
||||
items:
|
||||
- key: passpoint.js
|
||||
path: passpoint.js
|
||||
- name: html
|
||||
configMap:
|
||||
name: wlan-setup
|
||||
items:
|
||||
- key: index.html
|
||||
path: index.html
|
||||
- key: renner-unifi-wlan.cmd
|
||||
path: renner-unifi-wlan.cmd
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 16Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: wlan-setup
|
||||
namespace: authentik
|
||||
labels:
|
||||
app.kubernetes.io/name: wlan-setup
|
||||
spec:
|
||||
ipFamilyPolicy: PreferDualStack
|
||||
selector:
|
||||
app.kubernetes.io/name: wlan-setup
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: wlan-setup
|
||||
namespace: authentik
|
||||
labels:
|
||||
# Public: Cloudflare CNAME → NetBird proxy → Traefik.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- wlan.renner.uno
|
||||
secretName: wlan-setup-tls
|
||||
rules:
|
||||
- host: wlan.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: wlan-setup
|
||||
port:
|
||||
number: 80
|
||||
@@ -64,7 +64,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: shelly-manager
|
||||
image: gitea.renner.uno/konairius/shelly-manager:2026-11-02-1412 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
image: gitea.renner.uno/konairius/shelly-manager:2026-11-05-0732 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
|
||||
@@ -128,7 +128,7 @@ spec:
|
||||
memory: 32Mi
|
||||
containers:
|
||||
- name: home-assistant
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.10.4 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.11.0 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
env:
|
||||
- name: TZ
|
||||
value: Europe/Berlin
|
||||
|
||||
@@ -32,7 +32,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: immich-machine-learning
|
||||
image: ghcr.io/immich-app/immich-machine-learning:v3.3.5 # {"$imagepolicy": "flux-system:immich-machine-learning"}
|
||||
image: ghcr.io/immich-app/immich-machine-learning:v3.3.6 # {"$imagepolicy": "flux-system:immich-machine-learning"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3003
|
||||
|
||||
@@ -87,7 +87,7 @@ spec:
|
||||
mountPath: /ca
|
||||
containers:
|
||||
- name: immich-server
|
||||
image: ghcr.io/immich-app/immich-server:v3.3.5 # {"$imagepolicy": "flux-system:immich-server"}
|
||||
image: ghcr.io/immich-app/immich-server:v3.3.6 # {"$imagepolicy": "flux-system:immich-server"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3001
|
||||
|
||||
@@ -134,7 +134,7 @@ spec:
|
||||
# is a device-bricking class of risk. Bumps are a reviewed, manual
|
||||
# act — see kubernetes/infrastructure/image-automation/README.md.
|
||||
- name: terminus
|
||||
image: ghcr.io/usetrmnl/terminus:0.79.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
image: ghcr.io/usetrmnl/terminus:0.80.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 2345
|
||||
@@ -197,7 +197,7 @@ spec:
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
- name: sidekiq
|
||||
image: ghcr.io/usetrmnl/terminus:0.79.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
image: ghcr.io/usetrmnl/terminus:0.80.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
command: ["bundle", "exec", "sidekiq", "-r", "./config/sidekiq.rb"]
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
|
||||
@@ -95,19 +95,20 @@ spec:
|
||||
# VLAN-tagged frames, accounted as "VLAN traffic disallowed by VLAN
|
||||
# filter" in cilium_drop_count_total.
|
||||
#
|
||||
# Only VLAN 30 (v30iot) needs listing. Cilium attaches to devices that
|
||||
# carry an address, so it sees mgmt0's frames already untagged by the
|
||||
# 8021q layer — nothing for a VLAN filter to match. ens19 is deliberately
|
||||
# address-less, so its frames only meet the filter on the shared parent,
|
||||
# still tagged. Adding 40 would change nothing and would touch the
|
||||
# datapath carrying etcd and the API.
|
||||
# Only the address-less macvlan parents need listing: VLAN 30 (ens19,
|
||||
# v30iot) and VLAN 11 (radius0, freeradius-udm). Cilium attaches to
|
||||
# devices that carry an address, so it sees mgmt0's frames already
|
||||
# untagged by the 8021q layer — nothing for a VLAN filter to match.
|
||||
# ens19 and radius0 are deliberately address-less, so their frames only
|
||||
# meet the filter on the shared parent, still tagged. Adding 40 would
|
||||
# change nothing and would touch the datapath carrying etcd and the API.
|
||||
#
|
||||
# The symptom when this is wrong is not a DHCP timeout: a tc-layer drop
|
||||
# reaches an AF_PACKET sender as ENOBUFS, so the CNI dhcp plugin reports
|
||||
# "no buffer space available" (#592).
|
||||
#
|
||||
# Same three-copy problem as policyMapMax above. Takes an agent restart.
|
||||
vlanBypass: [30]
|
||||
vlanBypass: [30, 11]
|
||||
# Let pods reach the cluster's own LoadBalancer external IPs; Cilium
|
||||
# redirects the socket directly to the backend pod instead of sending
|
||||
# traffic out to the VIP and back (which fails with L2 announcements).
|
||||
|
||||
@@ -46,6 +46,7 @@ existing `flux-system` GitRepository whose Secret was created by
|
||||
| authentik-gate-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
|
||||
| kuma-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
|
||||
| cloudflare-lb-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
|
||||
| wlan-setup | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
|
||||
| secret-rotator | `YYYY-MM-DD` | In-house (build-ci-images.yml); newest tag wins; staged: only the Home Assistant rotation CronJobs carry the marker so far |
|
||||
| gatus | `5.x` | v-prefix filter |
|
||||
| velero-plugin-for-aws | `1.13.x` | Tight: must match velero server major.minor |
|
||||
|
||||
@@ -42,5 +42,6 @@ resources:
|
||||
- velero-plugin-for-aws.yaml
|
||||
- vsftpd.yaml
|
||||
- wiki.yaml
|
||||
- wlan-setup.yaml
|
||||
- woodpecker.yaml
|
||||
- wyoming.yaml
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: wlan-setup
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: gitea.renner.uno/gitea_admin/wlan-setup
|
||||
interval: 30m
|
||||
---
|
||||
# build-ci-images.yml tags each build YYYY-MM-DD-HHMM; newest wins.
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: wlan-setup
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: wlan-setup
|
||||
filterTags:
|
||||
pattern: '^\d{4}-\d{2}-\d{2}-\d{4}$'
|
||||
policy:
|
||||
alphabetical:
|
||||
order: asc
|
||||
@@ -8,6 +8,10 @@ machine:
|
||||
# 10.2.30.0/24 route makes node replies bypass the UDM and breaks every
|
||||
# DNATed flow.
|
||||
net.ipv6.conf.ens19.disable_ipv6: "1"
|
||||
# radius0 (VLAN 11) is LINK ONLY as well: the macvlan parent for the
|
||||
# FreeRADIUS attachment. VLAN 11 announces RAs; the node must not
|
||||
# autoconfigure from them.
|
||||
net.ipv6.conf.radius0.disable_ipv6: "1"
|
||||
# These two sit on the untagged fallback network, where nothing may run.
|
||||
# Left enabled they autoconfigure a ULA and an IPv6 default route from its
|
||||
# RAs, which ties with mgmt0's and costs the cluster IPv6 egress.
|
||||
@@ -168,6 +172,17 @@ vlanID: 30
|
||||
parent: uplink
|
||||
up: true
|
||||
---
|
||||
# VLAN 11, UniFi "(11) RADIUS": link only, no addresses, no DHCP. Macvlan
|
||||
# parent of the freeradius-udm attachment
|
||||
# (kubernetes/apps/authentik/freeradius-network-attachment.yaml), which names
|
||||
# it as `master`; renaming this link means editing that too.
|
||||
apiVersion: v1alpha1
|
||||
kind: VLANConfig
|
||||
name: radius0
|
||||
vlanID: 11
|
||||
parent: uplink
|
||||
up: true
|
||||
---
|
||||
# Pod network, VLAN 48. Node-to-node only: the VXLAN overlay rides it once the
|
||||
# kubelet node IP moves here, and nothing on it is routed off the switch.
|
||||
#
|
||||
|
||||
@@ -7,6 +7,8 @@ machine:
|
||||
# macvlan parent for the v30iot pod attachments; host addresses on that L2
|
||||
# make node replies bypass the UDM and break every UDM-DNATed flow.
|
||||
net.ipv6.conf.ens19.disable_ipv6: "1"
|
||||
# radius0 (VLAN 11): link only — see k8s-1.yaml.
|
||||
net.ipv6.conf.radius0.disable_ipv6: "1"
|
||||
# Same for the physical ports on the untagged fallback network: left
|
||||
# enabled they take an IPv6 default route from its RAs and cost the
|
||||
# cluster IPv6 egress.
|
||||
@@ -115,6 +117,14 @@ vlanID: 30
|
||||
parent: uplink
|
||||
up: true
|
||||
---
|
||||
# VLAN 11, "(11) RADIUS": link only, no host addressing — see k8s-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
kind: VLANConfig
|
||||
name: radius0
|
||||
vlanID: 11
|
||||
parent: uplink
|
||||
up: true
|
||||
---
|
||||
# Pod network, VLAN 48. Node-to-node only, statically addressed, no gateway
|
||||
# and no DHCP — see k8s-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
|
||||
@@ -4,6 +4,8 @@ machine:
|
||||
sysctls:
|
||||
# v30iot NIC carries no host addressing — see k8s-1.yaml.
|
||||
net.ipv6.conf.ens19.disable_ipv6: "1"
|
||||
# radius0 (VLAN 11): link only — see k8s-1.yaml.
|
||||
net.ipv6.conf.radius0.disable_ipv6: "1"
|
||||
# Same for the physical ports on the untagged fallback network — see
|
||||
# k8s-1.yaml.
|
||||
net.ipv6.conf.uplink.disable_ipv6: "1"
|
||||
@@ -102,6 +104,14 @@ vlanID: 30
|
||||
parent: uplink
|
||||
up: true
|
||||
---
|
||||
# VLAN 11, "(11) RADIUS": link only, no host addressing — see k8s-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
kind: VLANConfig
|
||||
name: radius0
|
||||
vlanID: 11
|
||||
parent: uplink
|
||||
up: true
|
||||
---
|
||||
# Pod network, VLAN 48. Node-to-node only, statically addressed, no gateway
|
||||
# and no DHCP — see k8s-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
|
||||
+30
-1
@@ -7,7 +7,8 @@
|
||||
"flux",
|
||||
"kubernetes",
|
||||
"woodpecker",
|
||||
"dockerfile"
|
||||
"dockerfile",
|
||||
"custom.regex"
|
||||
],
|
||||
"ignorePaths": [
|
||||
"kubernetes/clusters/homelab/flux-system/**",
|
||||
@@ -23,6 +24,20 @@
|
||||
"/^kubernetes/.+\\.ya?ml$/"
|
||||
]
|
||||
},
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Container images of the Ansible roles: `edge_<name>_image: <image>:<tag>@<digest>` in ansible/roles/*/defaults/main.yml. No other manager reads these files.",
|
||||
"managerFilePatterns": [
|
||||
"/^ansible/roles/[^/]+/defaults/main\\.ya?ml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"(?:^|\\n)edge_[a-z0-9_]+_image:[ \\t]*[\"']?(?<depName>[^\\s:@\"']+):(?<currentValue>[^\\s@\"']+)(?:@(?<currentDigest>sha256:[a-f0-9]{64}))?"
|
||||
],
|
||||
"datasourceTemplate": "docker",
|
||||
"versioningTemplate": "docker"
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Flux image automation (kubernetes/infrastructure/image-automation) already bumps these images. Renovate must not open a second, competing update. Keep this list equal to the ImageRepository images there.",
|
||||
@@ -134,6 +149,20 @@
|
||||
"groupName": "CI non-major",
|
||||
"groupSlug": "ci-minor-patch"
|
||||
},
|
||||
{
|
||||
"description": "Edge-host images (ansible/): no pipeline deploys them, the play runs from a workstation after the merge. One PR of its own, so the merge and the play run go together.",
|
||||
"matchFileNames": [
|
||||
"ansible/**"
|
||||
],
|
||||
"matchUpdateTypes": [
|
||||
"patch",
|
||||
"minor",
|
||||
"digest",
|
||||
"pin"
|
||||
],
|
||||
"groupName": "edge hosts (ansible)",
|
||||
"groupSlug": "edge-hosts-ansible"
|
||||
},
|
||||
{
|
||||
"description": "Stateful apps and the CNI: one PR each, never in a group (REN-315). A git revert does not undo a DB migration or a CNI change, so each needs its own review and, where it can disrupt the cluster, a window.",
|
||||
"matchUpdateTypes": [
|
||||
|
||||
+22
-4
@@ -7,9 +7,11 @@ tests/radius/run.sh
|
||||
Runs the production FreeRADIUS config from
|
||||
`kubernetes/apps/authentik/freeradius-config.yaml` (rendered through the
|
||||
projected volume in `freeradius.yaml`, same image, args and secret env names)
|
||||
in Docker against a mock authentik outpost, and drives it with `eapol_test`
|
||||
(EAP-TTLS, inner PAP) as a NAS in the test client ranges `10.2.50.0/24` and
|
||||
`fd02:50::/64`. The v6 cases (`v6-*`) hit the IPv6 listeners and client `test6`.
|
||||
in Docker against a mock authentik outpost and a mock UDM RADIUS, and drives
|
||||
it with `eapol_test` (EAP-TTLS, inner PAP) as a NAS in the test client ranges
|
||||
`10.2.50.0/24` and `fd02:50::/64`. The v6 cases (`v6-*`) hit the IPv6
|
||||
listeners and client `test6`. Cases use outer identity `anonymous` unless
|
||||
noted.
|
||||
|
||||
| Case | Expected |
|
||||
|---|---|
|
||||
@@ -17,12 +19,25 @@ in Docker against a mock authentik outpost, and drives it with `eapol_test`
|
||||
| `vlan-avpair` (outpost sends only `Cisco-AV-Pair = "dot1x-vlan=50"`) | SUCCESS, Tunnel-* VLAN 50, no Cisco-AV-Pair |
|
||||
| `novlan` (Accept without VLAN) | FAILURE (Access-Reject) |
|
||||
| wrong password / unknown user (Reject without Message-Authenticator) | FAILURE (Access-Reject) within 1 s |
|
||||
| Accounting-Request | answered locally, nothing sent to the outpost |
|
||||
| Accounting-Request | answered locally, nothing sent to the outpost or the UDM |
|
||||
| `anon-realm` (outer identity `Anonymous@Example.Org`) | local TTLS, as `vlan-tunnel` |
|
||||
| `udm-accept`, `v6-udm-accept` (outer identity = account name) | both EAP rounds reach the mock UDM (second with its `State`); its Access-Accept reaches the NAS with Tunnel-* VLAN 200 and MS-MPPE keys unchanged |
|
||||
| `udm-reject` | the mock UDM's Access-Reject reaches the NAS within 1 s |
|
||||
| all `anonymous` cases | never reach the mock UDM |
|
||||
| `recovery` | outpost stalls until marked dead, then returns: login succeeds within 20 s |
|
||||
|
||||
Pieces:
|
||||
|
||||
- `mock_outpost.py`: stand-in outpost on UDP 1812, reachable under the
|
||||
outpost Service FQDN through a Docker network alias.
|
||||
- `mock_udm.py`: stand-in UDM RADIUS at the `udm` home server address from
|
||||
`proxy.conf` (`10.2.11.1`), on a second internal bridge `radtest-udm` (that
|
||||
address's /24, no host address). FreeRADIUS gets a second interface there,
|
||||
as `net1` in the pod: only the connected /24, no default route, and the
|
||||
proxy socket picks it by route lookup. The macvlan/DHCP attachment itself
|
||||
is not tested. Answers round 1 with Challenge + `State` +
|
||||
EAP-TTLS Start (or Reject for `endpoint-reject`), round 2 with Accept +
|
||||
EAP-Success; terminates no EAP.
|
||||
- `eapol.Dockerfile`: `eapol_test` from Debian `eapoltest`.
|
||||
- Docker network `radtest`: internal bridge on `10.2.50.0/24` and
|
||||
`fd02:50::/64` with no host address (`inhibit_ipv4`,
|
||||
@@ -31,3 +46,6 @@ Pieces:
|
||||
Requires Docker, `openssl` and `yq` (the jq wrapper). Secrets, password and
|
||||
certificates are generated dummies. `RADTEST_DEBUG=1` runs radiusd with `-xx`
|
||||
and prints all logs. Containers, network and temp files are removed on exit.
|
||||
|
||||
`live.sh` logs in against the deployed FreeRADIUS instead; usage in
|
||||
`docs/radius/dot1x.md` ("Live login check").
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# Live EAP-TTLS/PAP login against the deployed FreeRADIUS from a test client
|
||||
# (10.2.50.0/24 / fd02:50::/64, client "test"/"test6").
|
||||
#
|
||||
# Usage: tests/radius/live.sh <server-ip> <identity> <password-file> <secret-file> [count]
|
||||
# password-file: the password on the first line, or `export NAME=value` lines
|
||||
# (LIVE_PASSWORD_VAR selects the variable, default CLAUDE_BOT_TOKEN)
|
||||
# secret-file: `bao kv get` output of apps/radius, or the bare
|
||||
# DOT1X_TEST_SECRET on the first line
|
||||
# count: number of logins (default 1)
|
||||
# LIVE_SOURCE: optional local address for eapol_test -A (must be in a
|
||||
# test client range)
|
||||
# Prints one line per login: result, duration, VLAN. Secrets are never printed.
|
||||
set -euo pipefail
|
||||
|
||||
server="$1"
|
||||
identity="$2"
|
||||
password_file="$3"
|
||||
secret_file="$4"
|
||||
count="${5:-1}"
|
||||
password_var="${LIVE_PASSWORD_VAR:-CLAUDE_BOT_TOKEN}"
|
||||
|
||||
read_password() {
|
||||
local line
|
||||
line=$(grep -m1 -E "^export ${password_var}=" "$password_file" || true)
|
||||
if [ -n "$line" ]; then
|
||||
line="${line#*=}"
|
||||
line="${line#\"}"; line="${line%\"}"
|
||||
line="${line#\'}"; line="${line%\'}"
|
||||
printf '%s' "$line"
|
||||
else
|
||||
head -n1 "$password_file"
|
||||
fi
|
||||
}
|
||||
|
||||
read_secret() {
|
||||
local s
|
||||
s=$(sed -n 's/.*DOT1X_TEST_SECRET:\([^] ]*\).*/\1/p' "$secret_file")
|
||||
if [ -n "$s" ]; then printf '%s' "$s"; else head -n1 "$secret_file"; fi
|
||||
}
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
chmod 700 "$work"
|
||||
|
||||
password=$(read_password)
|
||||
secret=$(read_secret)
|
||||
[ -n "$password" ] || { echo "no password found in $password_file" >&2; exit 2; }
|
||||
[ -n "$secret" ] || { echo "no DOT1X_TEST_SECRET found in $secret_file" >&2; exit 2; }
|
||||
|
||||
umask 077
|
||||
cat > "$work/eapol.conf" <<EOF
|
||||
network={
|
||||
ssid="Renner Test"
|
||||
key_mgmt=WPA-EAP
|
||||
eap=TTLS
|
||||
identity="${identity}"
|
||||
anonymous_identity="anonymous"
|
||||
ca_cert="/etc/ssl/certs/ca-certificates.crt"
|
||||
domain_suffix_match="radius.renner.uno"
|
||||
phase2="auth=PAP"
|
||||
password="${password}"
|
||||
}
|
||||
EOF
|
||||
|
||||
for i in $(seq 1 "$count"); do
|
||||
start=$(date +%s%N)
|
||||
out=$(eapol_test -c "$work/eapol.conf" -a "$server" ${LIVE_SOURCE:+-A "$LIVE_SOURCE"} -s "$secret" -t 10 2>&1 || true)
|
||||
end=$(date +%s%N)
|
||||
ms=$(( (end - start) / 1000000 ))
|
||||
result=$(tail -n1 <<<"$out")
|
||||
case "$result" in SUCCESS|FAILURE) ;; *) result="NO-RESULT" ;; esac
|
||||
# Attribute lines of the Access-Accept as the NAS received it (hex values).
|
||||
attrs=$(awk '/RADIUS message: code=2 /{f=1; next} f && /^ /{print; next} f{f=0}' <<<"$out")
|
||||
attr() { awk -v a="Attribute $1 " 'index($0, a){getline; sub(/^ *Value: /, ""); print; exit}' <<<"$attrs"; }
|
||||
echo "login $i: $result ${ms}ms tunnel-type=[$(attr 64)] medium=[$(attr 65)] group-id=[$(attr 81)]"
|
||||
done
|
||||
@@ -15,7 +15,6 @@ import socket
|
||||
import struct
|
||||
|
||||
SECRET = os.environ["MOCK_SECRET"].encode()
|
||||
PASSWORD = os.environ["MOCK_PASSWORD"].encode()
|
||||
|
||||
ACCESS_REQUEST, ACCESS_ACCEPT, ACCESS_REJECT = 1, 2, 3
|
||||
ATTR_USER_NAME, ATTR_USER_PASSWORD, ATTR_MESSAGE_AUTHENTICATOR = 1, 2, 80
|
||||
@@ -85,6 +84,7 @@ def build_reply(code, ident, req_auth, attrs, with_ma):
|
||||
|
||||
|
||||
def main():
|
||||
password = os.environ["MOCK_PASSWORD"].encode()
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(("0.0.0.0", 1812))
|
||||
print("mock outpost listening on udp/1812", flush=True)
|
||||
@@ -100,7 +100,7 @@ def main():
|
||||
attrs = parse_attrs(packet[20:])
|
||||
user = next((v for t, _, v in attrs if t == ATTR_USER_NAME), b"")
|
||||
enc = next((v for t, _, v in attrs if t == ATTR_USER_PASSWORD), b"")
|
||||
password_ok = decrypt_password(enc, req_auth) == PASSWORD
|
||||
password_ok = decrypt_password(enc, req_auth) == password
|
||||
ma = request_ma_ok(packet, attrs)
|
||||
if password_ok and user in REPLIES:
|
||||
out = build_reply(ACCESS_ACCEPT, ident, req_auth, REPLIES[user], True)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Stand-in for the UDM RADIUS (UDP 1812) that UniFi Endpoint logins go to.
|
||||
|
||||
Terminates nothing; logs what arrives and answers just enough EAP to prove
|
||||
that FreeRADIUS proxies the whole conversation:
|
||||
round 1 (EAP-Response/Identity, no State)
|
||||
identity endpoint-reject Access-Reject + EAP-Failure
|
||||
any other identity Access-Challenge + State + EAP-Request/TTLS-Start
|
||||
round 2 (State from round 1)
|
||||
Access-Accept + EAP-Success, Tunnel-* VLAN 200, MS-MPPE-Recv-Key 0x11 * 32,
|
||||
MS-MPPE-Send-Key 0x22 * 32
|
||||
no EAP-Message (status probe), unknown State Access-Reject
|
||||
All replies carry Message-Authenticator and echo Proxy-State.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
|
||||
from mock_outpost import ATTR_USER_NAME, attr, build_reply, parse_attrs, request_ma_ok, vsa
|
||||
|
||||
SECRET = os.environ["MOCK_SECRET"].encode()
|
||||
|
||||
ACCESS_REQUEST, ACCESS_ACCEPT, ACCESS_REJECT, ACCESS_CHALLENGE = 1, 2, 3, 11
|
||||
ATTR_STATE, ATTR_PROXY_STATE, ATTR_EAP_MESSAGE = 24, 33, 79
|
||||
EAP_REQUEST, EAP_RESPONSE, EAP_SUCCESS, EAP_FAILURE = 1, 2, 3, 4
|
||||
EAP_IDENTITY, EAP_TTLS = 1, 21
|
||||
|
||||
STATE = b"udm-mock-state-1"
|
||||
RECV_KEY = b"\x11" * 32
|
||||
SEND_KEY = b"\x22" * 32
|
||||
TUNNEL_VLAN_200 = (
|
||||
attr(64, b"\x00\x00\x00\x0d") # Tunnel-Type = VLAN
|
||||
+ attr(65, b"\x00\x00\x00\x06") # Tunnel-Medium-Type = IEEE-802
|
||||
+ attr(81, b"200") # Tunnel-Private-Group-Id = "200"
|
||||
)
|
||||
|
||||
|
||||
def mppe_key(key, req_auth, salt):
|
||||
"""RFC 2548 2.4.2 encryption of an MS-MPPE key."""
|
||||
plain = bytes([len(key)]) + key
|
||||
plain += b"\x00" * (-len(plain) % 16)
|
||||
out, last = b"", req_auth + salt
|
||||
for i in range(0, len(plain), 16):
|
||||
b = hashlib.md5(SECRET + last).digest()
|
||||
chunk = bytes(p ^ k for p, k in zip(plain[i : i + 16], b))
|
||||
out += chunk
|
||||
last = chunk
|
||||
return salt + out
|
||||
|
||||
|
||||
def eap(code, ident, payload=b""):
|
||||
return struct.pack("!BBH", code, ident, 4 + len(payload)) + payload
|
||||
|
||||
|
||||
def main():
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(("0.0.0.0", 1812))
|
||||
print("mock udm listening on udp/1812", flush=True)
|
||||
while True:
|
||||
packet, peer = sock.recvfrom(4096)
|
||||
if len(packet) < 20:
|
||||
continue
|
||||
code, ident, _length = struct.unpack("!BBH", packet[:4])
|
||||
req_auth = packet[4:20]
|
||||
if code != ACCESS_REQUEST:
|
||||
print(f"peer={peer[0]} code={code} ignored", flush=True)
|
||||
continue
|
||||
attrs = parse_attrs(packet[20:])
|
||||
user = next((v for t, _, v in attrs if t == ATTR_USER_NAME), b"").decode(errors="replace")
|
||||
state = next((v for t, _, v in attrs if t == ATTR_STATE), None)
|
||||
eap_msg = b"".join(v for t, _, v in attrs if t == ATTR_EAP_MESSAGE)
|
||||
echo = b"".join(attr(ATTR_PROXY_STATE, v) for t, _, v in attrs if t == ATTR_PROXY_STATE)
|
||||
ma = request_ma_ok(packet, attrs)
|
||||
|
||||
if len(eap_msg) < 5:
|
||||
reply = build_reply(ACCESS_REJECT, ident, req_auth, echo, True)
|
||||
print(f"peer={peer[0]} probe user={user} request_ma={ma} -> reject", flush=True)
|
||||
elif state is None:
|
||||
eap_id = eap_msg[1]
|
||||
eap_ok = eap_msg[0] == EAP_RESPONSE and eap_msg[4] == EAP_IDENTITY
|
||||
identity = eap_msg[5:].decode(errors="replace")
|
||||
if identity == "endpoint-reject":
|
||||
body = attr(ATTR_EAP_MESSAGE, eap(EAP_FAILURE, eap_id))
|
||||
reply = build_reply(ACCESS_REJECT, ident, req_auth, echo + body, True)
|
||||
result = "reject"
|
||||
else:
|
||||
body = attr(ATTR_STATE, STATE) + attr(
|
||||
ATTR_EAP_MESSAGE, eap(EAP_REQUEST, (eap_id + 1) & 0xFF, bytes([EAP_TTLS, 0x20]))
|
||||
)
|
||||
reply = build_reply(ACCESS_CHALLENGE, ident, req_auth, echo + body, True)
|
||||
result = "challenge"
|
||||
print(
|
||||
f"peer={peer[0]} round=1 user={user} eap_identity_response={eap_ok} "
|
||||
f"eap_identity={identity} request_ma={ma} -> {result}",
|
||||
flush=True,
|
||||
)
|
||||
elif state == STATE:
|
||||
eap_id = eap_msg[1]
|
||||
eap_type = eap_msg[4]
|
||||
body = (
|
||||
TUNNEL_VLAN_200
|
||||
+ vsa(311, 17, mppe_key(RECV_KEY, req_auth, b"\x80\x01"))
|
||||
+ vsa(311, 16, mppe_key(SEND_KEY, req_auth, b"\x80\x02"))
|
||||
+ attr(ATTR_EAP_MESSAGE, eap(EAP_SUCCESS, eap_id))
|
||||
)
|
||||
reply = build_reply(ACCESS_ACCEPT, ident, req_auth, echo + body, True)
|
||||
print(
|
||||
f"peer={peer[0]} round=2 user={user} state_ok=True eap_type={eap_type} "
|
||||
f"request_ma={ma} -> accept",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
reply = build_reply(ACCESS_REJECT, ident, req_auth, echo, True)
|
||||
print(f"peer={peer[0]} user={user} state={state.hex()} unknown -> reject", flush=True)
|
||||
sock.sendto(reply, peer)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+88
-18
@@ -10,6 +10,7 @@ deploy="$app/freeradius.yaml"
|
||||
config="$app/freeradius-config.yaml"
|
||||
|
||||
net=radtest
|
||||
net_udm=radtest-udm
|
||||
# test client ranges from clients.conf
|
||||
subnet=10.2.50.0/24
|
||||
subnet6=fd02:50::/64
|
||||
@@ -32,7 +33,10 @@ cleanup() {
|
||||
local ids=()
|
||||
mapfile -t ids < <(docker ps -aq --filter name='^radtest-')
|
||||
if ((${#ids[@]} > 0)); then docker rm -f "${ids[@]}" >/dev/null; fi
|
||||
if [[ -n "$(docker network ls -q --filter name="^${net}\$")" ]]; then docker network rm "$net" >/dev/null; fi
|
||||
local n
|
||||
for n in "$net" "$net_udm"; do
|
||||
if [[ -n "$(docker network ls -q --filter name="^${n}\$")" ]]; then docker network rm "$n" >/dev/null; fi
|
||||
done
|
||||
local vols=()
|
||||
mapfile -t vols < <(docker volume ls -q --filter name='^radtest-')
|
||||
if ((${#vols[@]} > 0)); then docker volume rm "${vols[@]}" >/dev/null; fi
|
||||
@@ -147,14 +151,26 @@ start() {
|
||||
docker network create --internal --subnet "$subnet" --ipv6 --subnet "$subnet6" \
|
||||
-o com.docker.network.bridge.inhibit_ipv4=true \
|
||||
-o com.docker.network.bridge.gateway_mode_ipv6=isolated "$net" >/dev/null
|
||||
local bridge
|
||||
bridge="br-$(docker network inspect -f '{{printf "%.12s" .Id}}' "$net")"
|
||||
if [[ -n "$(ip -4 -o addr show dev "$bridge")" ]] \
|
||||
|| [[ -n "$(ip -6 -o addr show dev "$bridge" scope global)" ]] \
|
||||
|| [[ -n "$(ip -6 route show dev "$bridge" "$subnet6")" ]]; then
|
||||
echo "bridge $bridge got a host address or route in a test range; aborting" >&2
|
||||
exit 1
|
||||
fi
|
||||
# UDM home server address from proxy.conf; its /24 is a second internal
|
||||
# bridge (gateway address moved off the UDM's).
|
||||
ip_udm="$(awk '$1 == "home_server" && $2 == "udm" {f=1} f && $1 == "ipaddr" {print $3; exit}' "$work/raddb/proxy.conf")"
|
||||
docker network create --internal --subnet "${ip_udm%.*}.0/24" --gateway "${ip_udm%.*}.254" \
|
||||
-o com.docker.network.bridge.inhibit_ipv4=true "$net_udm" >/dev/null
|
||||
local bridge n
|
||||
for n in "$net" "$net_udm"; do
|
||||
bridge="br-$(docker network inspect -f '{{printf "%.12s" .Id}}' "$n")"
|
||||
if [[ -n "$(ip -4 -o addr show dev "$bridge")" ]] \
|
||||
|| [[ -n "$(ip -6 -o addr show dev "$bridge" scope global)" ]] \
|
||||
|| [[ -n "$(ip -6 route show dev "$bridge" "$subnet6")" ]]; then
|
||||
echo "bridge $bridge got a host address or route in a test range; aborting" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
docker run -d --name radtest-udm --network "$net_udm" --ip "$ip_udm" \
|
||||
-e MOCK_SECRET=dummy-udm_radius_secret \
|
||||
-v "$here/mock_outpost.py:/mock/mock_outpost.py:ro" -v "$here/mock_udm.py:/mock/mock_udm.py:ro" \
|
||||
"$mock_image" python3 -u /mock/mock_udm.py >/dev/null
|
||||
|
||||
docker run -d --name radtest-mock --network "$net" --ip "$ip_mock" \
|
||||
--network-alias "$outpost_fqdn" \
|
||||
@@ -167,14 +183,14 @@ start() {
|
||||
|
||||
# Same restrictions as the pod.
|
||||
mount_opts freeradius
|
||||
local run_opts=(--network "$net" "${pod_opts[@]}" "${secret_env[@]}" "${mounts[@]}")
|
||||
local run_opts=("${pod_opts[@]}" "${secret_env[@]}" "${mounts[@]}")
|
||||
local confdir="" i
|
||||
for i in "${!args[@]}"; do
|
||||
if [[ "${args[$i]}" == -d ]]; then confdir="${args[$((i + 1))]}"; fi
|
||||
done
|
||||
|
||||
echo "--- radiusd -XC (config check)"
|
||||
if docker run --rm "${run_opts[@]}" "$image" radiusd -XC -d "$confdir" >"$work/configcheck.log" 2>&1 \
|
||||
if docker run --rm --network "$net" "${run_opts[@]}" "$image" radiusd -XC -d "$confdir" >"$work/configcheck.log" 2>&1 \
|
||||
&& grep -q "Configuration appears to be OK" "$work/configcheck.log"; then
|
||||
pass "radiusd -XC: Configuration appears to be OK"
|
||||
else
|
||||
@@ -183,7 +199,13 @@ start() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker run -d --name radtest-freeradius --ip "$ip_radius" --ip6 "$ip6_radius" "${run_opts[@]}" "$image" "${args[@]}" >/dev/null
|
||||
# Interface names as in the pod: eth0 the NAS side (the listeners bind to
|
||||
# it), net1 the UDM side. Docker attaches networks in no fixed order.
|
||||
docker create --name radtest-freeradius "${run_opts[@]}" \
|
||||
--network "name=$net,ip=$ip_radius,ip6=$ip6_radius,driver-opt=com.docker.network.endpoint.ifname=eth0" \
|
||||
"$image" "${args[@]}" >/dev/null
|
||||
docker network connect --driver-opt com.docker.network.endpoint.ifname=net1 "$net_udm" radtest-freeradius
|
||||
docker start radtest-freeradius >/dev/null
|
||||
sleep 2
|
||||
if [[ "$(docker inspect -f '{{.State.Running}}' radtest-freeradius)" != true ]]; then
|
||||
docker logs radtest-freeradius
|
||||
@@ -203,13 +225,13 @@ start() {
|
||||
fi
|
||||
}
|
||||
|
||||
eapol_conf() { # case user password
|
||||
eapol_conf() { # case user password [outer-identity]
|
||||
cat >"$work/eapol/$1.conf" <<EOF
|
||||
network={
|
||||
key_mgmt=WPA-EAP
|
||||
eap=TTLS
|
||||
identity="$2"
|
||||
anonymous_identity="anonymous"
|
||||
anonymous_identity="${4:-anonymous}"
|
||||
password="$3"
|
||||
phase2="auth=PAP"
|
||||
ca_cert="/eapol/ca.pem"
|
||||
@@ -233,9 +255,9 @@ attr_value() { # case attr-number
|
||||
accept_attrs "$1" | awk -v a="Attribute $2 " 'index($0, a){getline; sub(/^ *Value: /, ""); print; exit}'
|
||||
}
|
||||
|
||||
check_accept() { # case user server
|
||||
check_accept() { # case user server [outer-identity]
|
||||
local c="$1"
|
||||
eapol_conf "$c" "$2" "$password"
|
||||
eapol_conf "$c" "$2" "$password" "${4:-}"
|
||||
eapol "$c" 5 "$3"
|
||||
if [[ "$(tail -n 1 "$work/$c.out")" == SUCCESS ]]; then pass "$c: SUCCESS"; else fail "$c: SUCCESS"; fi
|
||||
[[ "$(attr_value "$c" 81)" =~ ^(00)?3530$ ]] && pass "$c: Tunnel-Private-Group-Id 50" \
|
||||
@@ -252,9 +274,9 @@ check_accept() { # case user server
|
||||
fi
|
||||
}
|
||||
|
||||
check_reject() { # case user password timeout server
|
||||
check_reject() { # case user password timeout server [outer-identity]
|
||||
local c="$1"
|
||||
eapol_conf "$c" "$2" "$3"
|
||||
eapol_conf "$c" "$2" "$3" "${6:-}"
|
||||
eapol "$c" "$4" "$5"
|
||||
if [[ "$(tail -n 1 "$work/$c.out")" == FAILURE ]] && grep -q "RADIUS message: code=3 (Access-Reject)" "$work/$c.out" \
|
||||
&& ! grep -q "EAPOL test timed out" "$work/$c.out"; then
|
||||
@@ -264,6 +286,40 @@ check_reject() { # case user password timeout server
|
||||
fi
|
||||
}
|
||||
|
||||
udm_log() { docker logs radtest-udm 2>&1; }
|
||||
|
||||
# Non-anonymous outer identity: the whole EAP conversation goes to the mock
|
||||
# UDM, its Access-Accept reaches the NAS unchanged.
|
||||
check_udm_accept() { # case user server
|
||||
local c="$1" u="$2"
|
||||
eapol_conf "$c" "$u" "$password" "$u"
|
||||
eapol "$c" 3 "$3"
|
||||
udm_log | grep -q "round=1 user=$u eap_identity_response=True eap_identity=$u request_ma=True -> challenge" \
|
||||
&& pass "$c: EAP-Response/Identity forwarded to the UDM" || fail "$c: EAP-Response/Identity forwarded to the UDM"
|
||||
udm_log | grep -q "round=2 user=$u state_ok=True eap_type=21 request_ma=True -> accept" \
|
||||
&& pass "$c: second round forwarded with the UDM's State" || fail "$c: second round forwarded with the UDM's State"
|
||||
grep -q "RADIUS message: code=2 (Access-Accept)" "$work/$c.out" \
|
||||
&& pass "$c: UDM Access-Accept reaches the NAS" || fail "$c: UDM Access-Accept reaches the NAS"
|
||||
[[ "$(attr_value "$c" 81)" =~ ^(00)?323030$ ]] && pass "$c: Tunnel-Private-Group-Id 200" \
|
||||
|| fail "$c: Tunnel-Private-Group-Id 200 (got '$(attr_value "$c" 81)')"
|
||||
[[ "$(attr_value "$c" 64)" == 0000000d ]] && pass "$c: Tunnel-Type VLAN" \
|
||||
|| fail "$c: Tunnel-Type VLAN (got '$(attr_value "$c" 64)')"
|
||||
[[ "$(attr_value "$c" 65)" == 00000006 ]] && pass "$c: Tunnel-Medium-Type IEEE-802" \
|
||||
|| fail "$c: Tunnel-Medium-Type IEEE-802 (got '$(attr_value "$c" 65)')"
|
||||
local k
|
||||
for k in "Recv-Key (crypt)|11" "Send-Key (sign)|22"; do
|
||||
grep -qF "MS-MPPE-${k%|*} - hexdump(len=32): $(printf "${k#*|} %.0s" {1..31})${k#*|}" "$work/$c.out" \
|
||||
&& pass "$c: MS-MPPE-${k%% *} unchanged" || fail "$c: MS-MPPE-${k%% *} unchanged"
|
||||
done
|
||||
}
|
||||
|
||||
check_udm_reject() { # case user server
|
||||
local c="$1" u="$2"
|
||||
check_reject "$c" "$u" "$password" 1 "$3" "$u"
|
||||
udm_log | grep -q "round=1 user=$u eap_identity_response=True eap_identity=$u request_ma=True -> reject" \
|
||||
&& pass "$c: decided by the UDM" || fail "$c: decided by the UDM"
|
||||
}
|
||||
|
||||
check_accounting() { # case server
|
||||
printf 'Acct-Status-Type = Start\nUser-Name = "acct-test"\nAcct-Session-Id = "%s"\n' \
|
||||
"$1" >"$work/eapol/$1.txt"
|
||||
@@ -338,6 +394,13 @@ main() {
|
||||
check_reject unknown-user bad "$password" 1 "$ip_radius"
|
||||
check_accounting acct "$ip_radius"
|
||||
|
||||
# Outer identity routing: anonymous[@realm] (any case) -> local TTLS,
|
||||
# anything else -> UDM.
|
||||
check_accept anon-realm vlan-tunnel "$ip_radius" "Anonymous@Example.Org"
|
||||
check_udm_accept udm-accept endpoint-accept "$ip_radius"
|
||||
check_udm_reject udm-reject endpoint-reject "$ip_radius"
|
||||
check_udm_accept v6-udm-accept v6-endpoint-accept "$ip6_radius"
|
||||
|
||||
# IPv6 listeners and clients
|
||||
check_accept v6-vlan-avpair vlan-avpair "$ip6_radius"
|
||||
check_reject v6-novlan novlan "$password" 5 "$ip6_radius"
|
||||
@@ -353,9 +416,16 @@ main() {
|
||||
pass "no accounting forwarded to the outpost"
|
||||
fi
|
||||
|
||||
if udm_log | grep -Eiq "eap_identity=anonymous|user=(vlan-|novlan|bad|anonymous)|code=4"; then
|
||||
fail "anonymous logins and accounting never reach the UDM"
|
||||
else
|
||||
pass "anonymous logins and accounting never reach the UDM"
|
||||
fi
|
||||
|
||||
if ((fails > 0)) || [[ "${RADTEST_DEBUG:-0}" == 1 ]]; then
|
||||
echo "--- freeradius log"; docker logs radtest-freeradius 2>&1
|
||||
echo "--- mock outpost log"; docker logs radtest-mock 2>&1
|
||||
echo "--- mock udm log"; udm_log
|
||||
for o in "$work"/*.out; do echo "--- $(basename "$o")"; cat "$o"; done
|
||||
fi
|
||||
echo
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# wlan-setup — static Go binary in a FROM-scratch image.
|
||||
#
|
||||
# Nothing in the final image but the binary and the CA bundle (the binary has no shell and no
|
||||
# package manager to patch or to exec).
|
||||
FROM golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9 AS build
|
||||
|
||||
WORKDIR /build
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY *.go index.html ./
|
||||
|
||||
RUN go vet ./... \
|
||||
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /wlan-setup .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=build /wlan-setup /wlan-setup
|
||||
|
||||
# Matches runAsUser in the Deployment. There is no /etc/passwd in a scratch image, so this is a
|
||||
# bare uid by necessity.
|
||||
USER 65534:65534
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/wlan-setup"]
|
||||
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// authentikHost is the Host header of every call: authentik routes by it.
|
||||
const authentikHost = "auth.renner.uno"
|
||||
|
||||
// maxReply bounds what is read of an authentik reply.
|
||||
const maxReply = 1 << 20
|
||||
|
||||
var credential = regexp.MustCompile(`^[A-Za-z0-9._~+/=-]+$`)
|
||||
|
||||
// Authentik calls authentik's OAuth and API endpoints at its internal address.
|
||||
type Authentik struct {
|
||||
base string
|
||||
secret string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// call sends one request and decodes the JSON reply into out. Errors name the method, path and
|
||||
// status, never the credential or the request body.
|
||||
func (a *Authentik) call(ctx context.Context, method, path, auth, contentType string, body []byte, out any) error {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
rd = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, a.base+path, rd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s %s: %w", method, path, err)
|
||||
}
|
||||
req.Host = authentikHost
|
||||
req.Header.Set("Authorization", auth)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s %s: %w", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxReply+1))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s %s: reading the reply: %w", method, path, err)
|
||||
}
|
||||
if len(data) > maxReply {
|
||||
return fmt.Errorf("%s %s -> %d: reply larger than %d bytes", method, path, resp.StatusCode, maxReply)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return fmt.Errorf("%s %s -> %d %s", method, path, resp.StatusCode, snippet(data))
|
||||
}
|
||||
if err := json.Unmarshal(data, out); err != nil {
|
||||
return fmt.Errorf("%s %s: decoding the reply: %w", method, path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func snippet(b []byte) string {
|
||||
s := strings.ToValidUTF8(string(b), "?")
|
||||
if len(s) > 300 {
|
||||
s = s[:300]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func bearer(token string) (string, error) {
|
||||
if !credential.MatchString(token) {
|
||||
return "", fmt.Errorf("no access token")
|
||||
}
|
||||
return "Bearer " + token, nil
|
||||
}
|
||||
|
||||
// Exchange trades the authorization code for an access token.
|
||||
func (a *Authentik) Exchange(ctx context.Context, code, redirectURI string) (string, error) {
|
||||
basic := "Basic " + base64.StdEncoding.EncodeToString([]byte(clientID+":"+a.secret))
|
||||
body := form("grant_type", "authorization_code", "code", code, "redirect_uri", redirectURI)
|
||||
var grant struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := a.call(ctx, http.MethodPost, "/application/o/token/", basic, "application/x-www-form-urlencoded",
|
||||
[]byte(body), &grant); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !credential.MatchString(grant.AccessToken) {
|
||||
return "", fmt.Errorf("POST /application/o/token/: no access token in the reply")
|
||||
}
|
||||
return grant.AccessToken, nil
|
||||
}
|
||||
|
||||
// Username returns the username of the access token's user.
|
||||
func (a *Authentik) Username(ctx context.Context, token string) (string, error) {
|
||||
auth, err := bearer(token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var me struct {
|
||||
User struct {
|
||||
Username string `json:"username"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := a.call(ctx, http.MethodGet, "/api/v3/core/users/me/", auth, "", nil, &me); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return me.User.Username, nil
|
||||
}
|
||||
|
||||
type tokenRequest struct {
|
||||
Identifier string `json:"identifier"`
|
||||
Intent string `json:"intent"`
|
||||
Description string `json:"description"`
|
||||
Expiring bool `json:"expiring"`
|
||||
Expires string `json:"expires"`
|
||||
}
|
||||
|
||||
// AppPassword creates an app password as the token's user and returns its key.
|
||||
func (a *Authentik) AppPassword(ctx context.Context, token string, t tokenRequest) (string, error) {
|
||||
auth, err := bearer(token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var created json.RawMessage
|
||||
if err := a.call(ctx, http.MethodPost, "/api/v3/core/tokens/", auth, "application/json", body, &created); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var view struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
path := "/api/v3/core/tokens/" + url.PathEscape(t.Identifier) + "/view_key/"
|
||||
if err := a.call(ctx, http.MethodGet, path, auth, "", nil, &view); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if view.Key == "" {
|
||||
return "", fmt.Errorf("GET %s: no key in the reply", path)
|
||||
}
|
||||
return view.Key, nil
|
||||
}
|
||||
|
||||
// form encodes key/value pairs in the given order, like encodeURIComponent.
|
||||
func form(pairs ...string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i+1 < len(pairs); i += 2 {
|
||||
if i > 0 {
|
||||
b.WriteByte('&')
|
||||
}
|
||||
b.WriteString(escape(pairs[i]))
|
||||
b.WriteByte('=')
|
||||
b.WriteString(escape(pairs[i+1]))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escape(s string) string {
|
||||
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config is the service configuration, read from the environment.
|
||||
type Config struct {
|
||||
ClientSecret string
|
||||
// AuthentikURL is where the calls go; they carry Host auth.renner.uno.
|
||||
AuthentikURL string
|
||||
AuthorizeURL string
|
||||
PublicURL string
|
||||
SigningKey string
|
||||
SigningCert string
|
||||
Listen string
|
||||
}
|
||||
|
||||
func loadConfig(getenv func(string) string) (Config, error) {
|
||||
get := func(key, def string) string {
|
||||
if v := strings.TrimSpace(getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
c := Config{
|
||||
ClientSecret: strings.TrimSpace(getenv("WLAN_SETUP_CLIENT_SECRET")),
|
||||
AuthentikURL: strings.TrimRight(get("AUTHENTIK_INTERNAL_URL", "http://authentik-server.authentik.svc.cluster.local"), "/"),
|
||||
AuthorizeURL: get("AUTHENTIK_AUTHORIZE_URL", "https://auth.renner.uno/application/o/authorize/"),
|
||||
PublicURL: strings.TrimRight(get("PUBLIC_URL", "https://wlan.renner.uno"), "/"),
|
||||
SigningKey: get("SIGNING_KEY", "/etc/wlan-signing/tls.key"),
|
||||
SigningCert: get("SIGNING_CERT", "/etc/wlan-signing/tls.crt"),
|
||||
Listen: get("LISTEN", ":8080"),
|
||||
}
|
||||
var errs []error
|
||||
if c.ClientSecret == "" {
|
||||
errs = append(errs, errors.New("WLAN_SETUP_CLIENT_SECRET is not set"))
|
||||
}
|
||||
for key, v := range map[string]string{
|
||||
"AUTHENTIK_INTERNAL_URL": c.AuthentikURL,
|
||||
"AUTHENTIK_AUTHORIZE_URL": c.AuthorizeURL,
|
||||
"PUBLIC_URL": c.PublicURL,
|
||||
} {
|
||||
if u, err := url.Parse(v); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
errs = append(errs, errors.New(key+" must be an absolute http(s) URL"))
|
||||
}
|
||||
}
|
||||
return c, errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
module gitea.renner.uno/konairius/homelab/wlan-setup
|
||||
|
||||
go 1.27
|
||||
|
||||
require (
|
||||
github.com/beevik/etree v1.7.0
|
||||
github.com/russellhaering/goxmldsig v1.6.1
|
||||
)
|
||||
|
||||
require github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
@@ -0,0 +1,14 @@
|
||||
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/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=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russellhaering/goxmldsig v1.6.1 h1:SB7R5ttvrGIDB2juJAK/i7DQ2Ivr7agG+ohfNJjwyYU=
|
||||
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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,123 @@
|
||||
<!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, Mac und Windows bekommen ein eigenes WLAN-App-Passwort; Linux meldet 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="/windows/install">Auf Windows installieren</a></p>
|
||||
<p>Windows öffnet die Einstellungen und fragt, ob das WLAN-Profil hinzugefügt werden soll: „Hinzufügen“ wählen.</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-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;
|
||||
|
||||
function quote(s) { return "'" + s.replace(/'/g, "'\\''") + "'"; }
|
||||
function render(user) {
|
||||
document.getElementById("user").textContent = user || "(dein Benutzername)";
|
||||
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,59 @@
|
||||
// wlan-setup serves wlan.renner.uno behind the authentik gate: the setup page for the WLAN
|
||||
// "Renner UniFi" and, per device, an authorization-code sign-in that creates an authentik app
|
||||
// password as the user and answers with a Passpoint profile (Android, Apple) or a signed
|
||||
// CarrierProvisioning document that Windows Settings fetches once.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
cfg, err := loadConfig(os.Getenv)
|
||||
if err != nil {
|
||||
log.Error("invalid configuration", "err", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := run(ctx, cfg, log); err != nil {
|
||||
log.Error("stopped", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, cfg Config, log *slog.Logger) error {
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Listen,
|
||||
Handler: newServer(cfg, log).routes(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
// A callback makes up to four authentik calls of at most 10 s each.
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
MaxHeaderBytes: 64 << 10,
|
||||
}
|
||||
failed := make(chan error, 1)
|
||||
go func() { failed <- srv.ListenAndServe() }()
|
||||
log.Info("listening", "addr", cfg.Listen)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case err := <-failed:
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
log.Info("shutting down")
|
||||
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return srv.Shutdown(shutdown)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// isrgRootX1 is the trust anchor of the radius-tls chain (DER, base64).
|
||||
const isrgRootX1 = "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="
|
||||
|
||||
// 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)) }
|
||||
|
||||
func node(name, inner string) string {
|
||||
return "<Node><NodeName>" + name + "</NodeName>" + inner + "</Node>"
|
||||
}
|
||||
|
||||
func leaf(name, value string) string { return node(name, "<Value>"+value+"</Value>") }
|
||||
|
||||
var xmlEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">")
|
||||
|
||||
func xmlText(s string) string { return xmlEscaper.Replace(s) }
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
|
||||
// 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>`
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed index.html
|
||||
var indexHTML []byte
|
||||
|
||||
const (
|
||||
clientID = "wlan-setup"
|
||||
stateCookie = "wlan_oauth_state"
|
||||
lifetimeDays = 29
|
||||
restart = "Bitte die Einrichtung neu starten.\n"
|
||||
failed = "Die Einrichtung ist fehlgeschlagen.\n"
|
||||
)
|
||||
|
||||
// userPattern admits only plain usernames into a generated file.
|
||||
var userPattern = regexp.MustCompile(`^[A-Za-z0-9._@+-]{1,64}$`)
|
||||
|
||||
var identifierUnsafe = regexp.MustCompile(`[^a-z0-9-]`)
|
||||
|
||||
var platformLabels = map[string]string{"android": "Android", "apple": "Apple", "windows": "Windows"}
|
||||
|
||||
// Server serves the setup page and the per-platform flows.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
authentik *Authentik
|
||||
profiles *ProfileStore
|
||||
now func() time.Time
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func newServer(cfg Config, log *slog.Logger) *Server {
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
authentik: &Authentik{base: cfg.AuthentikURL, secret: cfg.ClientSecret, http: &http.Client{Timeout: 10 * time.Second}},
|
||||
profiles: newProfileStore(time.Now),
|
||||
now: time.Now,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(indexHTML)
|
||||
})
|
||||
mux.HandleFunc("GET /whoami", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
if u := r.Header.Get("X-authentik-username"); userPattern.MatchString(u) {
|
||||
_, _ = io.WriteString(w, u)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = io.WriteString(w, "ok\n")
|
||||
})
|
||||
for name := range platformLabels {
|
||||
mux.HandleFunc("GET /"+name+"/install", func(w http.ResponseWriter, r *http.Request) { s.install(w, r, name) })
|
||||
mux.HandleFunc("GET /"+name+"/callback", func(w http.ResponseWriter, r *http.Request) { s.callback(w, r, name) })
|
||||
}
|
||||
mux.HandleFunc("GET /windows/profile/{token}", s.windowsProfile)
|
||||
mux.HandleFunc("/", http.NotFound)
|
||||
return noStore(mux)
|
||||
}
|
||||
|
||||
func noStore(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func random(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (s *Server) redirectURI(name string) string { return s.cfg.PublicURL + "/" + name + "/callback" }
|
||||
|
||||
func setStateCookie(w http.ResponseWriter, name, value string, maxAge int) {
|
||||
w.Header().Set("Set-Cookie", stateCookie+"="+value+"; Path=/"+name+"/; Max-Age="+strconv.Itoa(maxAge)+
|
||||
"; HttpOnly; Secure; SameSite=Lax")
|
||||
}
|
||||
|
||||
func redirect(w http.ResponseWriter, location string) {
|
||||
w.Header().Set("Location", location)
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}
|
||||
|
||||
func text(w http.ResponseWriter, status int, body string) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_, _ = io.WriteString(w, body)
|
||||
}
|
||||
|
||||
// install starts an authorization-code sign-in at authentik; the user is already signed in there.
|
||||
func (s *Server) install(w http.ResponseWriter, _ *http.Request, name string) {
|
||||
state := random(24)
|
||||
setStateCookie(w, name, state, 300)
|
||||
redirect(w, s.cfg.AuthorizeURL+"?"+form(
|
||||
"response_type", "code",
|
||||
"client_id", clientID,
|
||||
"redirect_uri", s.redirectURI(name),
|
||||
"scope", "openid profile goauthentik.io/api",
|
||||
"state", state,
|
||||
))
|
||||
}
|
||||
|
||||
func stateFrom(r *http.Request) string {
|
||||
c, err := r.Cookie(stateCookie)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
// callback creates a new app password as the user and answers with the platform's profile.
|
||||
func (s *Server) callback(w http.ResponseWriter, r *http.Request, name string) {
|
||||
q := r.URL.Query()
|
||||
state, code := stateFrom(r), q.Get("code")
|
||||
if state == "" || code == "" || subtle.ConstantTimeCompare([]byte(state), []byte(q.Get("state"))) != 1 {
|
||||
text(w, http.StatusBadRequest, restart)
|
||||
return
|
||||
}
|
||||
fail := func(reason string, args ...any) {
|
||||
s.log.Error("wlan-setup "+name+": "+reason, args...)
|
||||
text(w, http.StatusBadGateway, failed)
|
||||
}
|
||||
|
||||
// Windows signs the profile: without key and chain there is nothing to hand out, so no app
|
||||
// password is created.
|
||||
var signer *Signer
|
||||
if name == "windows" {
|
||||
var err error
|
||||
if signer, err = loadSigner(s.cfg.SigningKey, s.cfg.SigningCert); err != nil {
|
||||
fail("cannot load the signing key", "err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
access, err := s.authentik.Exchange(ctx, code, s.redirectURI(name))
|
||||
if err != nil {
|
||||
fail("token exchange", "err", err)
|
||||
return
|
||||
}
|
||||
user, err := s.authentik.Username(ctx, access)
|
||||
if err != nil {
|
||||
fail("user lookup", "err", err)
|
||||
return
|
||||
}
|
||||
if !userPattern.MatchString(user) || user != r.Header.Get("X-authentik-username") {
|
||||
s.log.Warn("wlan-setup "+name+": the token's user is not the signed-in user", "user", user)
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
now := s.now().UTC()
|
||||
suffix := strings.Map(func(c rune) rune {
|
||||
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
|
||||
return c
|
||||
}
|
||||
return 'x'
|
||||
}, random(24)[:6])
|
||||
identifier := "wlan-" + name + "-" + identifierUnsafe.ReplaceAllString(strings.ToLower(user), "-") + "-" +
|
||||
now.Format("20060102T150405") + "-" + suffix
|
||||
key, err := s.authentik.AppPassword(ctx, access, tokenRequest{
|
||||
Identifier: identifier,
|
||||
Intent: "app_password",
|
||||
Description: "WLAN " + platformLabels[name] + ", " + now.Format("2006-01-02") + " (wlan.renner.uno)",
|
||||
Expiring: true,
|
||||
Expires: now.Add(lifetimeDays * 24 * time.Hour).Format("2006-01-02T15:04:05.000Z07:00"),
|
||||
})
|
||||
if err != nil {
|
||||
fail("app password", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "android":
|
||||
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))
|
||||
case "apple":
|
||||
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))
|
||||
case "windows":
|
||||
body, err := signer.Sign(windowsProfile(user, key))
|
||||
if err != nil {
|
||||
fail("signing the profile", "err", err)
|
||||
return
|
||||
}
|
||||
token := random(32)
|
||||
if !s.profiles.Put(token, body) {
|
||||
fail("too many profiles waiting to be fetched")
|
||||
return
|
||||
}
|
||||
setStateCookie(w, name, "", 0)
|
||||
redirect(w, "ms-settings:wifi-provisioning?uri="+s.cfg.PublicURL+"/windows/profile/"+token)
|
||||
}
|
||||
}
|
||||
|
||||
// windowsProfile hands out a signed profile once. Windows Settings fetches it without the
|
||||
// authentik session: the unguessable single-use token is the only gate.
|
||||
func (s *Server) windowsProfile(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
if r.Method == http.MethodHead {
|
||||
if !s.profiles.Has(token) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
return
|
||||
}
|
||||
body, ok := s.profiles.Take(token)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/beevik/etree"
|
||||
dsig "github.com/russellhaering/goxmldsig"
|
||||
)
|
||||
|
||||
const (
|
||||
testSecret = "client-secret"
|
||||
testAccess = "access-token-123"
|
||||
testKey = "app-password-<&>-456"
|
||||
testCode = "code-789"
|
||||
)
|
||||
|
||||
// fakeAuthentik answers the four calls of a callback and records what it received.
|
||||
type fakeAuthentik struct {
|
||||
t *testing.T
|
||||
username string
|
||||
fail string // path that answers 500
|
||||
|
||||
mu sync.Mutex
|
||||
requests []string
|
||||
created tokenRequest
|
||||
}
|
||||
|
||||
func (f *fakeAuthentik) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
|
||||
f.mu.Unlock()
|
||||
t := f.t
|
||||
if r.Host != "auth.renner.uno" {
|
||||
t.Errorf("%s: Host %q", r.URL.Path, r.Host)
|
||||
}
|
||||
if r.Header.Get("Cookie") != "" {
|
||||
t.Errorf("%s: a cookie reached authentik", r.URL.Path)
|
||||
}
|
||||
if r.URL.Path == f.fail {
|
||||
http.Error(w, `{"detail":"broken"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
bearer := func() bool {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer "+testAccess {
|
||||
t.Errorf("%s: Authorization %q", r.URL.Path, got)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/application/o/token/":
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != "wlan-setup" || pass != testSecret {
|
||||
t.Errorf("token: Basic auth %q %v", user, ok)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
|
||||
t.Errorf("token: Content-Type %q", ct)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.PostForm.Get("grant_type") != "authorization_code" || r.PostForm.Get("code") != testCode {
|
||||
t.Errorf("token: form %v", r.PostForm)
|
||||
}
|
||||
platform := strings.Split(r.PostForm.Get("redirect_uri"), "/")[3]
|
||||
if want := "https://wlan.renner.uno/" + platform + "/callback"; r.PostForm.Get("redirect_uri") != want {
|
||||
t.Errorf("token: redirect_uri %q", r.PostForm.Get("redirect_uri"))
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"access_token":"`+testAccess+`","token_type":"Bearer"}`)
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/core/users/me/":
|
||||
if bearer() {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"user": map[string]any{"username": f.username}})
|
||||
}
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v3/core/tokens/":
|
||||
if !bearer() {
|
||||
return
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("tokens: Content-Type %q", ct)
|
||||
}
|
||||
var tr tokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&tr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.created = tr
|
||||
f.mu.Unlock()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = io.WriteString(w, `{"identifier":"`+tr.Identifier+`"}`)
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v3/core/tokens/") &&
|
||||
strings.HasSuffix(r.URL.Path, "/view_key/"):
|
||||
f.mu.Lock()
|
||||
want := "/api/v3/core/tokens/" + f.created.Identifier + "/view_key/"
|
||||
f.mu.Unlock()
|
||||
if r.URL.Path != want {
|
||||
t.Errorf("view_key: path %q, want %q", r.URL.Path, want)
|
||||
}
|
||||
if bearer() {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"key": testKey})
|
||||
}
|
||||
default:
|
||||
t.Errorf("unexpected call %s %s", r.Method, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
type testCert struct {
|
||||
cert *x509.Certificate
|
||||
keyFile string
|
||||
certFile string
|
||||
}
|
||||
|
||||
// writeSigningFiles writes a fresh RSA key (PKCS#8) and a chain of a self-signed leaf plus a
|
||||
// second certificate, as cert-manager would mount them.
|
||||
func writeSigningFiles(t *testing.T) testCert {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mk := func(cn string, k *rsa.PrivateKey) []byte {
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &k.PublicKey, k)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return der
|
||||
}
|
||||
leaf := mk("wlan.renner.uno", key)
|
||||
other, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
intermediate := mk("intermediate", other)
|
||||
pkcs8, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tc := testCert{keyFile: filepath.Join(dir, "tls.key"), certFile: filepath.Join(dir, "tls.crt")}
|
||||
if err := os.WriteFile(tc.keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chain := append(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leaf}),
|
||||
pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: intermediate})...)
|
||||
if err := os.WriteFile(tc.certFile, chain, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tc.cert, _ = x509.ParseCertificate(leaf)
|
||||
return tc
|
||||
}
|
||||
|
||||
type harness struct {
|
||||
srv *Server
|
||||
handler http.Handler
|
||||
fake *fakeAuthentik
|
||||
signing testCert
|
||||
clock *fakeClock
|
||||
}
|
||||
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (c *fakeClock) Now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.now }
|
||||
func (c *fakeClock) Add(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
fake := &fakeAuthentik{t: t, username: "Alice.Smith"}
|
||||
upstream := httptest.NewServer(fake)
|
||||
t.Cleanup(upstream.Close)
|
||||
signing := writeSigningFiles(t)
|
||||
cfg, err := loadConfig(env(map[string]string{
|
||||
"WLAN_SETUP_CLIENT_SECRET": testSecret,
|
||||
"AUTHENTIK_INTERNAL_URL": upstream.URL,
|
||||
"SIGNING_KEY": signing.keyFile,
|
||||
"SIGNING_CERT": signing.certFile,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := newServer(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
clock := &fakeClock{now: time.Date(2026, 11, 5, 9, 8, 7, 0, time.UTC)}
|
||||
srv.now = clock.Now
|
||||
srv.profiles.now = clock.Now
|
||||
return &harness{srv: srv, handler: srv.routes(), fake: fake, signing: signing, clock: clock}
|
||||
}
|
||||
|
||||
func env(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
||||
|
||||
func (h *harness) do(req *http.Request) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
h.handler.ServeHTTP(rec, req)
|
||||
if rec.Header().Get("Cache-Control") != "no-store" {
|
||||
panic("no Cache-Control: no-store on " + req.URL.Path)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
func (h *harness) callback(platform, cookieState, queryState, user string) *httptest.ResponseRecorder {
|
||||
q := url.Values{"state": {queryState}, "code": {testCode}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/"+platform+"/callback?"+q.Encode(), nil)
|
||||
if cookieState != "" {
|
||||
req.Header.Set("Cookie", "other=1; wlan_oauth_state="+cookieState)
|
||||
}
|
||||
req.Header.Set("X-authentik-username", user)
|
||||
return h.do(req)
|
||||
}
|
||||
|
||||
func TestInstall(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
for _, platform := range []string{"android", "apple", "windows"} {
|
||||
rec := h.do(httptest.NewRequest(http.MethodGet, "/"+platform+"/install", nil))
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("%s: status %d", platform, rec.Code)
|
||||
}
|
||||
cookie := rec.Header().Get("Set-Cookie")
|
||||
m := regexp.MustCompile(`^wlan_oauth_state=([A-Za-z0-9_-]{32}); Path=/` + platform +
|
||||
`/; Max-Age=300; HttpOnly; Secure; SameSite=Lax$`).FindStringSubmatch(cookie)
|
||||
if m == nil {
|
||||
t.Fatalf("%s: cookie %q", platform, cookie)
|
||||
}
|
||||
loc := rec.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "https://auth.renner.uno/application/o/authorize/?response_type=code&") {
|
||||
t.Errorf("%s: location %q", platform, loc)
|
||||
}
|
||||
if !strings.Contains(loc, "scope=openid%20profile%20goauthentik.io%2Fapi") {
|
||||
t.Errorf("%s: scope not encoded like encodeURIComponent: %q", platform, loc)
|
||||
}
|
||||
u, err := url.Parse(loc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("client_id") != "wlan-setup" || q.Get("redirect_uri") != "https://wlan.renner.uno/"+platform+"/callback" ||
|
||||
q.Get("scope") != "openid profile goauthentik.io/api" || q.Get("state") != m[1] || q.Get("response_type") != "code" {
|
||||
t.Errorf("%s: query %v", platform, q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackStateMismatch(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
for _, c := range []struct{ cookie, query string }{{"abc", "abd"}, {"", "abc"}, {"abc", ""}} {
|
||||
rec := h.callback("android", c.cookie, c.query, "Alice.Smith")
|
||||
if rec.Code != http.StatusBadRequest || rec.Body.String() != "Bitte die Einrichtung neu starten.\n" {
|
||||
t.Errorf("%+v: %d %q", c, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/apple/callback?state=abc", nil)
|
||||
req.Header.Set("Cookie", "wlan_oauth_state=abc")
|
||||
if rec := h.do(req); rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("no code: %d", rec.Code)
|
||||
}
|
||||
if len(h.fake.requests) != 0 {
|
||||
t.Errorf("authentik was called: %v", h.fake.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackUserMismatch(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
if rec := h.callback("android", "s", "s", "bob"); rec.Code != http.StatusForbidden {
|
||||
t.Errorf("other user: %d", rec.Code)
|
||||
}
|
||||
h.fake.username = "bad user"
|
||||
if rec := h.callback("apple", "s", "s", "bad user"); rec.Code != http.StatusForbidden {
|
||||
t.Errorf("unsafe username: %d", rec.Code)
|
||||
}
|
||||
for _, r := range h.fake.requests {
|
||||
if strings.HasPrefix(r, "POST /api/v3/core/tokens/") {
|
||||
t.Errorf("an app password was created for a mismatched user")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackUpstreamError(t *testing.T) {
|
||||
for _, path := range []string{"/application/o/token/", "/api/v3/core/users/me/", "/api/v3/core/tokens/"} {
|
||||
h := newHarness(t)
|
||||
h.fake.fail = path
|
||||
rec := h.callback("android", "s", "s", "Alice.Smith")
|
||||
if rec.Code != http.StatusBadGateway || rec.Body.String() != "Die Einrichtung ist fehlgeschlagen.\n" {
|
||||
t.Errorf("%s: %d %q", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Header().Get("Set-Cookie"), "Max-Age=0") {
|
||||
t.Errorf("%s: the state cookie was cleared on failure", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackWindowsWithoutSigningKey(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.srv.cfg.SigningKey = filepath.Join(t.TempDir(), "missing.key")
|
||||
if rec := h.callback("windows", "s", "s", "Alice.Smith"); rec.Code != http.StatusBadGateway {
|
||||
t.Errorf("status %d", rec.Code)
|
||||
}
|
||||
if len(h.fake.requests) != 0 {
|
||||
t.Errorf("authentik was called without a signing key: %v", h.fake.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func checkCreated(t *testing.T, h *harness, platform, label string) {
|
||||
t.Helper()
|
||||
got := h.fake.created
|
||||
if !regexp.MustCompile(`^wlan-` + platform + `-alice-smith-20261105T090807-[A-Za-z0-9]{6}$`).MatchString(got.Identifier) {
|
||||
t.Errorf("identifier %q", got.Identifier)
|
||||
}
|
||||
if got.Intent != "app_password" || !got.Expiring || got.Expires != "2026-12-04T09:08:07.000Z" ||
|
||||
got.Description != "WLAN "+label+", 2026-11-05 (wlan.renner.uno)" {
|
||||
t.Errorf("token request %+v", got)
|
||||
}
|
||||
want := []string{"POST /application/o/token/", "GET /api/v3/core/users/me/", "POST /api/v3/core/tokens/",
|
||||
"GET /api/v3/core/tokens/" + got.Identifier + "/view_key/"}
|
||||
if strings.Join(h.fake.requests, "|") != strings.Join(want, "|") {
|
||||
t.Errorf("calls %v", h.fake.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func checkCleared(t *testing.T, rec *httptest.ResponseRecorder, platform string) {
|
||||
t.Helper()
|
||||
if got := rec.Header().Get("Set-Cookie"); got != "wlan_oauth_state=; Path=/"+platform+"/; Max-Age=0; HttpOnly; Secure; SameSite=Lax" {
|
||||
t.Errorf("cookie %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackAndroid(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
rec := h.callback("android", "s", "s", "Alice.Smith")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
checkCreated(t, h, "android", "Android")
|
||||
checkCleared(t, rec, "android")
|
||||
if rec.Header().Get("Content-Type") != "application/x-wifi-config" ||
|
||||
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())
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
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))
|
||||
for {
|
||||
tok, err := d.Token()
|
||||
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)
|
||||
}
|
||||
return v.(map[string]any)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackApple(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
rec := h.callback("apple", "s", "s", "Alice.Smith")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
checkCreated(t, h, "apple", "Apple")
|
||||
checkCleared(t, rec, "apple")
|
||||
if rec.Header().Get("Content-Type") != "application/x-apple-aspen-config" ||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *harness) windowsToken(t *testing.T) string {
|
||||
t.Helper()
|
||||
rec := h.callback("windows", "s", "s", "Alice.Smith")
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
checkCleared(t, rec, "windows")
|
||||
m := regexp.MustCompile(`^ms-settings:wifi-provisioning\?uri=https://wlan\.renner\.uno/windows/profile/([A-Za-z0-9_-]{43})$`).
|
||||
FindStringSubmatch(rec.Header().Get("Location"))
|
||||
if m == nil {
|
||||
t.Fatalf("location %q", rec.Header().Get("Location"))
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
func validate(cert *x509.Certificate, body []byte) error {
|
||||
doc := etree.NewDocument()
|
||||
if err := doc.ReadFromBytes(body); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := dsig.NewDefaultValidationContext(&dsig.MemoryX509CertificateStore{Roots: []*x509.Certificate{cert}})
|
||||
_, err := ctx.Validate(doc.Root())
|
||||
return err
|
||||
}
|
||||
|
||||
func TestCallbackWindows(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
token := h.windowsToken(t)
|
||||
checkCreated(t, h, "windows", "Windows")
|
||||
|
||||
// No header or cookie: Windows Settings fetches it outside the authentik session.
|
||||
head := h.do(httptest.NewRequest(http.MethodHead, "/windows/profile/"+token, nil))
|
||||
if head.Code != http.StatusOK {
|
||||
t.Errorf("HEAD: %d", head.Code)
|
||||
}
|
||||
rec := h.do(httptest.NewRequest(http.MethodGet, "/windows/profile/"+token, nil))
|
||||
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "text/xml" {
|
||||
t.Fatalf("first fetch: %d %v", rec.Code, rec.Header())
|
||||
}
|
||||
body := rec.Body.Bytes()
|
||||
if again := h.do(httptest.NewRequest(http.MethodGet, "/windows/profile/"+token, nil)); again.Code != http.StatusNotFound {
|
||||
t.Errorf("second fetch: %d", again.Code)
|
||||
}
|
||||
|
||||
if err := validate(h.signing.cert, body); err != nil {
|
||||
t.Fatalf("signature does not verify: %v\n%s", err, body)
|
||||
}
|
||||
s := string(body)
|
||||
for _, want := range []string{
|
||||
`<CarrierProvisioning xmlns="http://www.microsoft.com/networking/CarrierControl/v1">`,
|
||||
`<Username>Alice.Smith</Username><Password>app-password-<&>-456</Password>`,
|
||||
`<Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo>`,
|
||||
`<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></CanonicalizationMethod>`,
|
||||
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"></SignatureMethod>`,
|
||||
`<Reference URI=""><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"></Transform>`,
|
||||
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"></DigestMethod>`,
|
||||
`</Signature></CarrierProvisioning>`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
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)))
|
||||
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")
|
||||
}
|
||||
// Only the enveloped-signature transform, as in Microsoft's signed samples.
|
||||
if n := strings.Count(s, "<Transform "); n != 1 {
|
||||
t.Errorf("%d transforms, want 1", n)
|
||||
}
|
||||
if n := strings.Count(s, "<X509Certificate>"); n != 2 {
|
||||
t.Errorf("%d certificates in KeyInfo, want the whole chain (2)", n)
|
||||
}
|
||||
leaf := base64.StdEncoding.EncodeToString(h.signing.cert.Raw)
|
||||
if !strings.Contains(s, "<X509Data><X509Certificate>"+leaf+"</X509Certificate>") {
|
||||
t.Error("the leaf is not the first certificate")
|
||||
}
|
||||
|
||||
tampered := bytes.Replace(body, []byte("Alice.Smith"), []byte("Alice.Smitx"), 1)
|
||||
if err := validate(h.signing.cert, tampered); err == nil {
|
||||
t.Error("a tampered profile verifies")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsProfileExpires(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
token := h.windowsToken(t)
|
||||
h.clock.Add(profileTTL)
|
||||
if rec := h.do(httptest.NewRequest(http.MethodGet, "/windows/profile/"+token, nil)); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("expired: %d", rec.Code)
|
||||
}
|
||||
if len(h.srv.profiles.profiles) != 0 {
|
||||
t.Error("the expired profile was not purged")
|
||||
}
|
||||
if rec := h.do(httptest.NewRequest(http.MethodGet, "/windows/profile/unknown", nil)); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("unknown: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoamiHealthzIndexNotFound(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
for _, c := range []struct{ user, want string }{{"Alice.Smith", "Alice.Smith"}, {"a b", ""}, {"", ""},
|
||||
{strings.Repeat("a", 65), ""}} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/whoami", nil)
|
||||
req.Header.Set("X-authentik-username", c.user)
|
||||
rec := h.do(req)
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != c.want || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") {
|
||||
t.Errorf("whoami %q: %d %q", c.user, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if rec := h.do(httptest.NewRequest(http.MethodGet, "/healthz", nil)); rec.Code != http.StatusOK || rec.Body.String() != "ok\n" {
|
||||
t.Errorf("healthz: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec := h.do(httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `href="/windows/install"`) ||
|
||||
strings.Contains(rec.Body.String(), ".cmd") {
|
||||
t.Errorf("index: %d", rec.Code)
|
||||
}
|
||||
for _, req := range []*http.Request{
|
||||
httptest.NewRequest(http.MethodGet, "/renner-unifi-wlan.cmd", nil),
|
||||
httptest.NewRequest(http.MethodGet, "/linux/install", nil),
|
||||
httptest.NewRequest(http.MethodPost, "/", nil),
|
||||
httptest.NewRequest(http.MethodPost, "/android/install", nil),
|
||||
httptest.NewRequest(http.MethodGet, "/index.html", nil),
|
||||
} {
|
||||
if rec := h.do(req); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s %s: %d", req.Method, req.URL.Path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig(t *testing.T) {
|
||||
if _, err := loadConfig(env(nil)); err == nil || !strings.Contains(err.Error(), "WLAN_SETUP_CLIENT_SECRET is not set") {
|
||||
t.Errorf("missing secret: %v", err)
|
||||
}
|
||||
c, err := loadConfig(env(map[string]string{"WLAN_SETUP_CLIENT_SECRET": "x"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.AuthentikURL != "http://authentik-server.authentik.svc.cluster.local" ||
|
||||
c.AuthorizeURL != "https://auth.renner.uno/application/o/authorize/" || c.PublicURL != "https://wlan.renner.uno" ||
|
||||
c.SigningKey != "/etc/wlan-signing/tls.key" || c.SigningCert != "/etc/wlan-signing/tls.crt" || c.Listen != ":8080" {
|
||||
t.Errorf("defaults %+v", c)
|
||||
}
|
||||
if _, err := loadConfig(env(map[string]string{"WLAN_SETUP_CLIENT_SECRET": "x", "PUBLIC_URL": "wlan"})); err == nil {
|
||||
t.Error("relative PUBLIC_URL accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Signer holds the key and chain that sign the Windows provisioning document.
|
||||
type Signer struct {
|
||||
key *rsa.PrivateKey
|
||||
chain [][]byte
|
||||
}
|
||||
|
||||
// loadSigner reads the PEM key (PKCS#8 RSA) and the PEM chain (leaf first). It runs per request:
|
||||
// cert-manager rotates the files.
|
||||
func loadSigner(keyFile, certFile string) (*Signer, error) {
|
||||
keyPEM, err := os.ReadFile(keyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signing key: %w", err)
|
||||
}
|
||||
certPEM, err := os.ReadFile(certFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signing certificate: %w", err)
|
||||
}
|
||||
return parseSigner(keyPEM, certPEM)
|
||||
}
|
||||
|
||||
func parseSigner(keyPEM, certPEM []byte) (*Signer, error) {
|
||||
block, _ := pem.Decode(keyPEM)
|
||||
if block == nil {
|
||||
return nil, errors.New("signing key: no PEM block")
|
||||
}
|
||||
var key *rsa.PrivateKey
|
||||
switch block.Type {
|
||||
case "PRIVATE KEY":
|
||||
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signing key: %w", err)
|
||||
}
|
||||
rk, ok := k.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New("signing key: not an RSA key")
|
||||
}
|
||||
key = rk
|
||||
case "RSA PRIVATE KEY":
|
||||
k, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signing key: %w", err)
|
||||
}
|
||||
key = k
|
||||
default:
|
||||
return nil, fmt.Errorf("signing key: unexpected PEM block %q", block.Type)
|
||||
}
|
||||
var chain [][]byte
|
||||
for rest := certPEM; ; {
|
||||
var b *pem.Block
|
||||
b, rest = pem.Decode(rest)
|
||||
if b == nil {
|
||||
break
|
||||
}
|
||||
if b.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
if _, err := x509.ParseCertificate(b.Bytes); err != nil {
|
||||
return nil, fmt.Errorf("signing certificate: %w", err)
|
||||
}
|
||||
chain = append(chain, b.Bytes)
|
||||
}
|
||||
if len(chain) == 0 {
|
||||
return nil, errors.New("signing certificate: no certificate")
|
||||
}
|
||||
leaf, _ := x509.ParseCertificate(chain[0])
|
||||
if pub, ok := leaf.PublicKey.(*rsa.PublicKey); !ok || !pub.Equal(&key.PublicKey) {
|
||||
return nil, errors.New("signing certificate: the first certificate does not match the key")
|
||||
}
|
||||
return &Signer{key: key, chain: chain}, nil
|
||||
}
|
||||
|
||||
const dsigNS = "http://www.w3.org/2000/09/xmldsig#"
|
||||
|
||||
// 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")
|
||||
}
|
||||
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))
|
||||
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
|
||||
for _, c := range s.chain {
|
||||
certs.WriteString("<X509Certificate>" + base64.StdEncoding.EncodeToString(c) + "</X509Certificate>")
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// profileTTL is how long a signed Windows profile waits for Windows Settings to fetch it.
|
||||
const profileTTL = 5 * time.Minute
|
||||
|
||||
// maxProfiles bounds the profiles held at once.
|
||||
const maxProfiles = 1000
|
||||
|
||||
type storedProfile struct {
|
||||
body []byte
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// ProfileStore keeps signed Windows profiles in memory, each fetchable once until it expires.
|
||||
type ProfileStore struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
profiles map[string]storedProfile
|
||||
}
|
||||
|
||||
func newProfileStore(now func() time.Time) *ProfileStore {
|
||||
return &ProfileStore{now: now, profiles: map[string]storedProfile{}}
|
||||
}
|
||||
|
||||
func (s *ProfileStore) purge(now time.Time) {
|
||||
for k, p := range s.profiles {
|
||||
if !now.Before(p.expires) {
|
||||
delete(s.profiles, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Put stores body under token; false when the store is full.
|
||||
func (s *ProfileStore) Put(token string, body []byte) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := s.now()
|
||||
s.purge(now)
|
||||
if len(s.profiles) >= maxProfiles {
|
||||
return false
|
||||
}
|
||||
s.profiles[token] = storedProfile{body: body, expires: now.Add(profileTTL)}
|
||||
return true
|
||||
}
|
||||
|
||||
// Take returns the profile stored under token and deletes it.
|
||||
func (s *ProfileStore) Take(token string) ([]byte, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.purge(s.now())
|
||||
p, ok := s.profiles[token]
|
||||
if ok {
|
||||
delete(s.profiles, token)
|
||||
}
|
||||
return p.body, ok
|
||||
}
|
||||
|
||||
// Has reports whether a profile is stored under token, without taking it.
|
||||
func (s *ProfileStore) Has(token string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.purge(s.now())
|
||||
_, ok := s.profiles[token]
|
||||
return ok
|
||||
}
|
||||
Reference in New Issue
Block a user