Skip to content

802.1x eap-tls Guide

Über dieses Dokument

Diese Anleitung baut eine vollständige 802.1X-Infrastruktur von Grund auf neu auf:

  • EAP-TLS als primäre Authentifizierungsmethode (zertifikatsbasiert, kein Passwort-Handling)
  • MAB (MAC Authentication Bypass) als Fallback für Geräte ohne 802.1X-Supplicant
  • Gast-VLAN für alles, was weder per Zertifikat noch per MAC authentifiziert werden kann
  • Kritisches Fallback-VLAN, falls der RADIUS-Server selbst nicht erreichbar ist
  • Eine eigene, kleine PKI (step-ca) als Zertifizierungsstelle
  • Automatisiertes Client-Enrollment für Windows- und macOS-Geräte, inkl. automatischer Zertifikatserneuerung

Die Anleitung geht davon aus, dass nichts vorhanden ist – kein Active Directory, keine bestehende PKI, keine bestehende RADIUS-Installation. Alle Schritte sind in der Reihenfolge aufgeführt, in der sie ausgeführt werden müssen, und wurden in dieser Form real getestet (inklusive Zertifikatsprüfung, VLAN-Zuweisung und Reauthentifizierung an einem produktiven Cisco-Switch).

Warum keine Active-Directory-Domäne vorausgesetzt wird

Diese Anleitung ist bewusst für Workgroup-Umgebungen (keine Windows-Domäne, kein Microsoft Intune/MDM) geschrieben. Alle Mechanismen (SCEP-Enrollment auf macOS, Token-basiertes Enrollment auf Windows) funktionieren ohne Domänenmitgliedschaft. Wer eine AD-Domäne betreibt, kann einzelne Schritte (z. B. Zertifikatsverteilung) durch GPO-basierte Mechanismen ersetzen – das ist in dieser Anleitung nicht abgedeckt.


Platzhalter in diesem Dokument

Ersetzen Sie durchgängig folgende Platzhalter durch Ihre eigenen Werte, bevor Sie die Befehle ausführen:

Platzhalter Bedeutung Beispiel
domain.tld Ihre interne Domain/DNS-Zone firma.ch
ca.domain.tld Hostname der Zertifizierungsstelle ca.firma.ch
freeradius.domain.tld Hostname des FreeRADIUS-Servers radius.firma.ch
<CA_VM_IP> IP-Adresse der VM, auf der step-ca läuft 10.13.0.10
<RADIUS_VM_IP> IP-Adresse der VM, auf der FreeRADIUS/daloRADIUS läuft 10.13.0.11
<SWITCH_MGMT_IP> Management-IP des Cisco-Switches 10.13.0.2
<ORG-8021X-CA> Anzeigename Ihrer internen CA firma-8021x-CA

Falls step-ca und FreeRADIUS auf derselben VM laufen sollen, sind <CA_VM_IP> und <RADIUS_VM_IP> identisch – das ändert nichts an den folgenden Schritten.


Voraussetzungen

  • Eine Linux-VM (Debian oder Ubuntu, getestet mit Debian Bookworm/Trixie-Basis), mindestens 2 GB RAM, 20 GB Speicher
  • Docker Engine und Docker Compose Plugin installiert (docker compose version muss funktionieren)
  • Root- bzw. sudo-Zugriff auf die VM
  • Ein Cisco Switch mit IOS, das IBNS 2.0 unterstützt (getestet mit einem Catalyst 2960-X, IOS 15.2(4)E6 – ältere IOS-Versionen benötigen teils andere Befehle, siehe Hinweis in Abschnitt Cisco Switch)
  • Netzwerkzugriff zwischen VM und Switch (Management-Interface)
  • Ein Passwort-Safe (z. B. Vaultwarden, KeePass, 1Password) – mehrere der folgenden Schritte erzeugen Geheimnisse, die zwingend dort abgelegt werden müssen. Jede Stelle, an der das nötig ist, ist im Text mit 🔐 Passwort-Safe markiert.

Architektur-Überblick

flowchart TB
    subgraph Client["Client-Gerät"]
        Cert["Client-Zertifikat<br/>(EAP-TLS)"]
    end

    subgraph Switch["Cisco Switch"]
        Port["Access-Port<br/>dot1x + MAB"]
    end

    subgraph RadiusVM["VM: FreeRADIUS + daloRADIUS"]
        FR["FreeRADIUS<br/>(EAP-TLS Terminierung)"]
        DB[("MariaDB<br/>radcheck/radgroupreply")]
        Dalo["daloRADIUS<br/>(Web-GUI)"]
    end

    subgraph CaVM["VM: step-ca"]
        CA["step-ca<br/>Root + Intermediate CA"]
        SCEP["SCEP-Endpoint<br/>(macOS-Enrollment)"]
    end

    Client -- EAPOL --> Port
    Port -- RADIUS Access-Request --> FR
    FR -- TLS-Handshake, Zertifikatsprüfung --> Cert
    FR -- CA-Kette prüfen --> CA
    FR <--> DB
    Dalo <--> DB
    Client -. SCEP-Enrollment .-> SCEP
    Port -- Access-Accept + VLAN --> Client

Der Datenfluss in Kurzform:

  1. Ein Client verbindet sich am Switchport. Der Switch versucht zuerst 802.1X, dann MAB, und weist bei beidem Fehlschlag ein Gast-VLAN zu.
  2. FreeRADIUS terminiert den EAP-TLS-Handshake und prüft das Client-Zertifikat gegen die eigene CA-Kette.
  3. Der Benutzername (= Zertifikats-CN) wird gegen MariaDB (radusergroup/radgroupreply) abgeglichen, um das passende VLAN zu ermitteln.
  4. Der Switch erhält im Access-Accept die VLAN-Zuweisung und schaltet den Port entsprechend.

Teil 1: Zertifizierungsstelle (step-ca)

1.1 Warum step-ca und nicht OpenSSL/easy-rsa

step-ca unterstützt ACME und SCEP nativ, läuft als einfacher Docker-Container und ermöglicht automatisiertes Zertifikats-Enrollment inklusive automatischer Erneuerung – das ist mit reinem OpenSSL nur mit erheblichem Zusatzaufwand nachzubauen.

1.2 Ein wichtiger Design-Punkt vorab: Root 20 Jahre, Intermediate 10 Jahre, RSA statt ECDSA

Der Root ist der Vertrauensanker, der auf jedem Client-Gerät importiert wird – ihn neu zu verteilen ist aufwändig, daher eine lange Laufzeit. Die Intermediate signiert aktiv laufend Zertifikate und lässt sich bei Bedarf rotieren, ohne dass Clients ihr Root-Vertrauen neu einrichten müssen – daher kürzer.

Kritisch für SCEP (macOS-Enrollment): Die Intermediate-CA muss RSA verwenden, nicht ECDSA. SCEP verschlüsselt die Zertifikatsanfrage (PKCS#7-Envelope) mit dem öffentlichen Schlüssel der Intermediate – diese Operation kann mit einem ECDSA-Schlüssel nicht durchgeführt werden. Der Root selbst darf ECDSA bleiben, das hat auf SCEP keinen Einfluss.

1.3 Verzeichnisstruktur

mkdir -p /docker/stepca/data
cd /docker/stepca

1.4 CA-Administrationspasswort generieren

🔐 Passwort-Safe: Dieses Passwort verschlüsselt den privaten Schlüssel der Intermediate-CA. Ohne dieses Passwort ist nach einem Neustart/Wiederherstellen kein Zugriff mehr auf die Intermediate möglich – es muss unbedingt sicher hinterlegt werden.

openssl rand -base64 32 > ca_password.txt
chmod 600 ca_password.txt

1.5 Root-CA mit 20 Jahren Laufzeit erzeugen

docker run --rm \
  -v /docker/stepca/data:/home/step \
  smallstep/step-ca step certificate create \
  "<ORG-8021X-CA> Root CA" \
  /home/step/root_ca.crt /home/step/root_ca_key \
  --profile root-ca \
  --not-after 175200h \
  --no-password --insecure

chown -R 1000:1000 /docker/stepca/data

🔐 Passwort-Safe: Der private Schlüssel data/root_ca_key ist der wichtigste Schlüssel der gesamten Infrastruktur – wer ihn besitzt, kann beliebige neue Intermediates ausstellen und damit jedes Client-Zertifikat fälschen. Nach dem Init (Schritt 1.7) sollte diese Datei von der VM entfernt und offline gesichert werden (siehe Abschnitt 1.9).

1.6 Passwortdatei für den automatischen Init vorbereiten

mkdir -p data/secrets
cp ca_password.txt data/secrets/password
chown -R 1000:1000 data
chmod 400 data/secrets/password

1.7 docker-compose.yaml

services:
  step-ca:
    image: smallstep/step-ca:latest
    container_name: step-ca
    restart: unless-stopped
    ports:
      - "9000:9000"
      - "8080:8080"
    volumes:
      - ./data:/home/step
    extra_hosts:
      - "ca.domain.tld:127.0.0.1"
    environment:
      - DOCKER_STEPCA_INIT_NAME=<ORG-8021X-CA>
      - DOCKER_STEPCA_INIT_DNS_NAMES=ca.domain.tld
      - DOCKER_STEPCA_INIT_PROVISIONER_NAME=admin
      - DOCKER_STEPCA_INIT_PASSWORD_FILE=/home/step/secrets/password
      - DOCKER_STEPCA_INIT_ROOT_FILE=/home/step/root_ca.crt
      - DOCKER_STEPCA_INIT_KEY_FILE=/home/step/root_ca_key

Erklärung der zwei Ports:

  • 9000/tcp: regulärer HTTPS-API-Port der CA (Zertifikatsausstellung, Verwaltung)
  • 8080/tcp: ein unverschlüsselter HTTP-Listener, ausschliesslich für SCEP. Grund: Bei manueller (Nicht-MDM) Profilinstallation auf macOS ist der frisch importierte Root zum Zeitpunkt der SCEP-Anfrage noch nicht als vertrauenswürdig für TLS markiert – ein SCEP-Request über HTTPS schlägt daher fehl (Zum Server kann keine sichere Verbindung aufgebaut werden). SCEP selbst ist bereits durch das Challenge-Secret und PKCS#7-Signierung/Verschlüsselung abgesichert, daher ist Klartext-HTTP für diesen einen Zweck ein akzeptierter, von SCEP-Implementierungen üblicher Kompromiss.

Das extra_hosts-Mapping ist nötig, damit der Container seinen eigenen konfigurierten Hostnamen (ca.domain.tld) auch dann auflösen kann, wenn dafür (noch) kein echter DNS-Eintrag existiert – für interne CLI-Aufrufe innerhalb des Containers.

1.8 CA initialisieren

docker compose up

Beim ersten Start erzeugt der Entrypoint automatisch die Intermediate-CA (signiert vom vorbereiteten Root) und startet danach den Daemon. Sie sollten in den Logs sehen:

Copying root certificate... done!
Generating intermediate certificate... done!
...
Serving HTTPS on :9000 ...

Danach in den Hintergrund wechseln:

# Strg+C, dann:
docker compose up -d

1.9 Root-Key offline auslagern (empfohlen)

Der laufende Container benötigt den Root-Key nach der Initialisierung nicht mehr – nur der Intermediate-Key signiert laufend Zertifikate.

cp /docker/stepca/data/root_ca_key /pfad/zu/einem/sicheren/offline-backup/
shred -u /docker/stepca/data/root_ca_key

🔐 Passwort-Safe: Der Speicherort dieses Offline-Backups (z. B. verschlüsselter USB-Stick im Tresor) muss dokumentiert sein. Ohne diesen Key kann im Schadensfall keine neue Intermediate mehr signiert werden – die bestehende Intermediate bleibt davon aber 10 Jahre lang unberührt funktionsfähig.

Die Datei data/certs/root_ca.crt (das öffentliche Zertifikat, kein Geheimnis) muss auf der VM bleiben.

1.10 Dedizierte RSA-Intermediate-CA für SCEP

Falls Ihre Intermediate (Schritt 1.8) automatisch als ECDSA erzeugt wurde (Standardverhalten von step-ca), muss sie durch eine RSA-Variante ersetzt werden – sonst funktioniert SCEP für macOS-Enrollment nicht (siehe Erklärung in 1.2).

docker compose down

Root-Key temporär zurückkopieren (falls bereits ausgelagert):

cp /pfad/zu/offline-backup/root_ca_key /docker/stepca/data/root_ca_key

RSA-Intermediate erzeugen (10 Jahre, signiert vom bestehenden Root):

docker run --rm \
  -v /docker/stepca/data:/home/step \
  smallstep/step-ca step certificate create \
  "<ORG-8021X-CA> Intermediate CA" \
  /home/step/certs/intermediate_ca_rsa.crt \
  /home/step/secrets/intermediate_ca_rsa_key \
  --profile intermediate-ca \
  --ca /home/step/certs/root_ca.crt \
  --ca-key /home/step/root_ca_key \
  --kty RSA --size 3072 \
  --not-after 87600h \
  --password-file /home/step/secrets/password

ca.json auf die neue Intermediate umbiegen:

sed -i \
  -e 's#/home/step/certs/intermediate_ca.crt#/home/step/certs/intermediate_ca_rsa.crt#' \
  -e 's#/home/step/secrets/intermediate_ca_key#/home/step/secrets/intermediate_ca_rsa_key#' \
  /docker/stepca/data/config/ca.json
docker compose up -d

Root-Key wieder entfernen, falls temporär zurückgeholt:

shred -u /docker/stepca/data/root_ca_key

Kontrolle:

docker exec step-ca step certificate inspect /home/step/certs/intermediate_ca_rsa.crt --short

Erwartete Ausgabe muss RSA als Schlüsseltyp zeigen.

1.11 Zertifikatslaufzeiten für den Admin-Provisioner anpassen

Standardmässig lässt der admin-Provisioner nur 24-Stunden-Zertifikate zu – zu kurz für Server-Zertifikate und Windows-Enrollment-Tokens.

docker exec -it step-ca step ca provisioner update admin \
  --x509-min-dur 2160h \
  --x509-default-dur 8760h \
  --x509-max-dur 8760h

docker compose restart step-ca

1.12 SCEP-Provisioner für macOS-Enrollment einrichten

1.12.1 Dediziertes SCEP-Entschlüsselungszertifikat erzeugen

Wichtiger Hintergrund: macOS prüft seit Ventura zwingend, dass das Zertifikat, das SCEP-Nachrichten signiert/entschlüsselt, eine passende Key-Usage-Erweiterung hat (digitalSignature/keyEncipherment). Ein reines CA-Zertifikat (wie unsere Intermediate, mit Certificate Sign, CRL Sign) erfüllt das nicht – ohne dieses dedizierte Zertifikat schlägt jedes macOS-Enrollment mit MDM-SCEP:15002 / „Die Schlüsselverwendung ist falsch" fehl.

docker exec -it step-ca step certificate create scep-decrypter \
  /home/step/scep-decrypter.crt \
  /home/step/scep-decrypter.key \
  --ca /home/step/certs/intermediate_ca_rsa.crt \
  --ca-key /home/step/secrets/intermediate_ca_rsa_key \
  --kty RSA --size 2048 \
  --not-after 8760h \
  --no-password --insecure

1.12.2 SCEP-Challenge-Secret generieren

🔐 Passwort-Safe: Dieses Secret authentifiziert jedes enrollende Gerät gegenüber SCEP. Es wird in jedem macOS-Enrollment-Profil eingebettet – wer es kennt, kann (im Rahmen der konfigurierten Limits) beliebige Zertifikate von dieser CA anfordern.

openssl rand -base64 24

1.12.3 SCEP-Provisioner anlegen

docker exec -it step-ca step ca provisioner add scep-8021x \
  --type SCEP \
  --challenge "<SCEP_CHALLENGE_SECRET>" \
  --encryption-algorithm-identifier 2 \
  --include-root \
  --scep-decrypter-certificate-file /home/step/scep-decrypter.crt \
  --scep-decrypter-key-file /home/step/scep-decrypter.key \
  --x509-min-dur 2160h \
  --x509-default-dur 8760h \
  --x509-max-dur 8760h

docker compose restart step-ca

--include-root sorgt dafür, dass macOS-Clients bei der SCEP-GetCACert-Anfrage sowohl Root als auch Intermediate erhalten (von Apple explizit für den macOS-SCEP-Client vorgesehene Option).

1.13 Root-CA-Zertifikat exportieren

Wird später für FreeRADIUS und für Client-Enrollment gebraucht:

docker exec step-ca cat /home/step/certs/root_ca.crt > /docker/stepca/root_ca.pem
docker exec step-ca cat /home/step/certs/intermediate_ca_rsa.crt > /docker/stepca/intermediate_ca.pem
cat /docker/stepca/root_ca.pem /docker/stepca/intermediate_ca.pem > /docker/stepca/ca-chain.pem

1.14 Server-Zertifikat für FreeRADIUS ausstellen

docker exec -it step-ca step ca certificate \
  "freeradius.domain.tld" \
  /home/step/freeradius-server.crt \
  /home/step/freeradius-server-key \
  --provisioner admin \
  --ca-url https://ca.domain.tld:9000 \
  --root /home/step/certs/root_ca.crt \
  --not-after 8760h

(Fragt nach dem CA-Administrationspasswort aus Schritt 1.4.)

docker cp step-ca:/home/step/freeradius-server.crt /docker/stepca/freeradius-server.pem
docker cp step-ca:/home/step/freeradius-server-key /docker/stepca/freeradius-server.key
docker exec step-ca rm /home/step/freeradius-server.crt /home/step/freeradius-server-key

Diese drei Dateien (ca-chain.pem, freeradius-server.pem, freeradius-server.key) werden in Teil 2 auf die RADIUS-VM übertragen.


Teil 2: FreeRADIUS, MariaDB und daloRADIUS

2.1 Warum FreeRADIUS aus dem Debian-Paket statt aus dem offiziellen Docker-Image

Das offizielle freeradius/freeradius-server-Image ist ausschliesslich als linux/amd64 verfügbar (kein Multi-Arch) und würde auf ARM-Plattformen (z. B. Raspberry Pi) nur emuliert laufen. Wir bauen daher ein eigenes, schlankes Image aus dem Debian-Paket – das funktioniert architekturunabhängig und ist zusätzlich transparenter (Standard-Debian-Konfigurationspfade).

2.2 Verzeichnisstruktur

mkdir -p /docker/freeradius/{config,certs,mariadb-data,freeradius,daloradius}
cd /docker/freeradius

2.3 Zertifikate von der CA-VM übertragen

Kopieren Sie die drei Dateien aus Teil 1.13/1.14 hierher:

cp /pfad/zur/ca-chain.pem /docker/freeradius/certs/ca.pem
cp /pfad/zur/freeradius-server.pem /docker/freeradius/certs/server.pem
cp /pfad/zur/freeradius-server.key /docker/freeradius/certs/server.key
chmod 600 /docker/freeradius/certs/server.key

Wichtig: ca.pem muss beide Zertifikate enthalten (Root und Intermediate) – nicht nur den Root. FreeRADIUS muss die komplette Kette kennen, um Client-Zertifikate zu verifizieren, die von der Intermediate signiert wurden. Fehlt die Intermediate hier, schlägt jede Client-Authentifizierung mit unable to get local issuer certificate fehl.

2.4 MariaDB-Zugangsdaten generieren

🔐 Passwort-Safe: Beide Passwörter (root und radius-User) gehören in den Passwort-Safe. Das radius-User-Passwort wird ausserdem an drei Stellen referenziert (MariaDB-Env, FreeRADIUS SQL-Modul, daloRADIUS-Config) – bei einer Änderung müssen alle drei synchron aktualisiert werden.

openssl rand -base64 24   # -> MYSQL_ROOT_PASSWORD
openssl rand -base64 24   # -> MYSQL_PASSWORD (radius-User)

2.5 FreeRADIUS Dockerfile

/docker/freeradius/freeradius/Dockerfile:

FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y \
    freeradius \
    freeradius-utils \
    freeradius-mysql \
    && rm -rf /var/lib/apt/lists/*

EXPOSE 1812/udp 1813/udp 1814/udp

CMD ["freeradius", "-f", "-l", "stdout"]

2.6 daloRADIUS Dockerfile

Hintergrund: daloRADIUS nutzt intern die klassische PEAR::DB-Bibliothek. Aktuelle Debian-Versionen (Trixie) führen die dafür nötigen php-pear/php-db-Pakete nicht mehr. Wir ziehen die Bibliothek daher über Composer von Packagist und kopieren die Dateien an den PHP-include_path.

/docker/freeradius/daloradius/Dockerfile:

FROM composer:2 AS composer

FROM php:8.2-apache

RUN apt-get update && apt-get install -y \
    git \
    default-mysql-client \
    libxml2-dev \
    unzip \
    && docker-php-ext-install mysqli pdo pdo_mysql \
    && rm -rf /var/lib/apt/lists/*

# Deprecation-Warnings aus PHP 8.2 in der Produktions-GUI unterdrücken
RUN { \
    echo 'display_errors = Off'; \
    echo 'log_errors = On'; \
    echo 'error_reporting = E_ALL & ~E_DEPRECATED & ~E_NOTICE'; \
} > /usr/local/etc/php/conf.d/daloradius-errors.ini

COPY --from=composer /usr/bin/composer /usr/bin/composer

# PEAR::DB per Composer beziehen (klassisches include_once('DB.php') von daloRADIUS)
RUN mkdir -p /tmp/pear-build \
    && composer require pear/db --no-interaction --working-dir=/tmp/pear-build \
    && DBPHP=$(find /tmp/pear-build/vendor -iname "DB.php" | head -1) \
    && cp "$DBPHP" /usr/local/lib/php/DB.php \
    && cp -r "$(dirname "$DBPHP")/DB" /usr/local/lib/php/DB \
    && PEARPHP=$(find /tmp/pear-build/vendor -iname "PEAR.php" | head -1) \
    && cp "$PEARPHP" /usr/local/lib/php/PEAR.php \
    && { cp -r "$(dirname "$PEARPHP")/PEAR" /usr/local/lib/php/PEAR 2>/dev/null || true; } \
    && rm -rf /tmp/pear-build

RUN git clone --depth 1 https://github.com/lirantal/daloradius.git /var/www/html/daloradius

RUN cp /var/www/html/daloradius/app/common/includes/daloradius.conf.php.sample \
       /var/www/html/daloradius/app/common/includes/daloradius.conf.php

# Datenbank-Zugangsdaten fest im Image hinterlegen
# WICHTIG: <RADIUS_DB_PASSWORD> vor dem Build durch das echte, generierte Passwort ersetzen
RUN sed -i \
    -e "s/CONFIG_DB_HOST'\] = 'localhost'/CONFIG_DB_HOST'] = 'mariadb'/" \
    -e "s/CONFIG_DB_USER'\] = 'raduser'/CONFIG_DB_USER'] = 'radius'/" \
    -e "s/CONFIG_DB_PASS'\] = 'radpass'/CONFIG_DB_PASS'] = '<RADIUS_DB_PASSWORD>'/" \
    -e "s/CONFIG_DB_NAME'\] = 'raddb'/CONFIG_DB_NAME'] = 'radius'/" \
    /var/www/html/daloradius/app/common/includes/daloradius.conf.php

RUN chown -R www-data:www-data /var/www/html/daloradius

RUN a2enmod rewrite

RUN printf '<VirtualHost *:80>\n\
    DocumentRoot /var/www/html/daloradius/app\n\
    <Directory /var/www/html/daloradius/app>\n\
        AllowOverride All\n\
        Require all granted\n\
    </Directory>\n\
</VirtualHost>\n' > /etc/apache2/sites-available/000-default.conf

RUN echo '<?php header("Location: operators/"); ?>' > /var/www/html/daloradius/app/index.php

EXPOSE 80

Hinweis zur Passwort-Handhabung im Dockerfile: Das Passwort wird hier bewusst zur Build-Zeit fest eingebaut (nicht per Environment-Variable zur Laufzeit), da daloRADIUS die Konfigurationsdatei nicht aus Umgebungsvariablen liest. Das Docker-Image selbst sollte daher nicht in eine öffentliche Registry gepusht werden, solange dieses Vorgehen genutzt wird.

2.7 docker-compose.yaml (kompletter Stack)

services:
  mariadb:
    image: mariadb:10.11
    container_name: radius-mariadb
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: <MARIADB_ROOT_PASSWORD>
      MYSQL_DATABASE: radius
      MYSQL_USER: radius
      MYSQL_PASSWORD: <RADIUS_DB_PASSWORD>
    volumes:
      - ./mariadb-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mariadb-admin", "ping", "-h", "localhost", "-u", "root", "-p<MARIADB_ROOT_PASSWORD>"]
      interval: 10s
      timeout: 5s
      retries: 5

  freeradius:
    build: ./freeradius
    container_name: freeradius
    restart: unless-stopped
    ports:
      - "1812:1812/udp"
      - "1813:1813/udp"
    volumes:
      - ./config:/etc/freeradius/3.0
      - ./certs:/etc/freeradius/3.0/certs
    depends_on:
      mariadb:
        condition: service_healthy

  daloradius:
    build: ./daloradius
    container_name: daloradius
    restart: unless-stopped
    ports:
      - "8000:80"
    depends_on:
      mariadb:
        condition: service_healthy

Ersetzen Sie <MARIADB_ROOT_PASSWORD> und <RADIUS_DB_PASSWORD> durch die in Schritt 2.4 generierten Werte (an beiden Stellen im mariadb-Service identisch).

2.8 FreeRADIUS-Basiskonfiguration extrahieren

docker compose build freeradius

docker run --rm -v /docker/freeradius/config:/tmp/out \
  --entrypoint sh freeradius-freeradius:latest \
  -c "cp -r /etc/freeradius/3.0/* /tmp/out/"

rm -rf /docker/freeradius/config/certs
chown -R 100:101 /docker/freeradius/config /docker/freeradius/certs

Der mitgelieferte certs/-Ordner (Debian-Testzertifikate) wird entfernt, da wir unsere eigenen über den Bind-Mount ./certs:/etc/freeradius/3.0/certs einhängen.

2.9 clients.conf – RADIUS-Clients eintragen

/docker/freeradius/config/clients.conf am Ende ergänzen:

client cisco-switch-1 {
    ipaddr = <SWITCH_MGMT_IP>
    secret = <RADIUS_SHARED_SECRET>
    nas_type = cisco
    require_message_authenticator = no
}

🔐 Passwort-Safe: <RADIUS_SHARED_SECRET> muss identisch auf dem Switch (radius server-Konfiguration, siehe Teil 4) hinterlegt sein. Für jeden weiteren Switch einen eigenen client-Block mit eigenem Secret anlegen.

openssl rand -base64 24

2.10 SQL-Modul konfigurieren

cd /docker/freeradius/config
ln -sf ../mods-available/sql mods-enabled/sql

In mods-available/sql folgende Werte anpassen (Zeilen suchen und wie unten setzen):

dialect = "mysql"
driver = "rlm_sql_${dialect}"

Im Abschnitt „Connection info" (auskommentierte Zeilen einkommentieren und Werte setzen):

server = "mariadb"
port = 3306
login = "radius"
password = "<RADIUS_DB_PASSWORD>"

Im mysql { }-Unterblock den tls { }-Abschnitt komplett auskommentieren (verweist standardmässig auf nicht existierende Zertifikatspfade und würde den Modul-Start verhindern, da wir intern im Docker-Netz ohnehin keine TLS-Verschlüsselung zur Datenbank benötigen):

mysql {
#       tls {
#               ca_file = "/etc/ssl/certs/my_ca.crt"
#               ca_path = "/etc/ssl/certs/"
#               certificate_file = "/etc/ssl/certs/private/client.crt"
#               private_key_file = "/etc/ssl/certs/private/client.key"
#               cipher = "DHE-RSA-AES256-SHA:AES128-SHA"
#
#               tls_required = yes
#               tls_check_cert = no
#               tls_check_cert_cn = no
#       }
        warnings = auto
}

2.11 EAP-TLS konfigurieren

In mods-available/eap:

default_eap_type = tls

Im tls-config tls-common { }-Block:

private_key_file = /etc/freeradius/3.0/certs/server.key
certificate_file = /etc/freeradius/3.0/certs/server.pem
ca_file = /etc/freeradius/3.0/certs/ca.pem

Die Zeile private_key_password auskommentieren (unser Server-Key hat kein Passwort):

#private_key_password = whatever
cd /docker/freeradius/config
ln -sf ../mods-available/eap mods-enabled/eap

2.12 Windows-Kompatibilität: host/-Präfix korrekt behandeln

Hintergrund: Windows sendet bei Computer-Authentifizierung den Benutzernamen mit dem Präfix host/ (z. B. host/laptop-01.domain.tld). Für eine einheitliche Namenskonvention in daloRADIUS (gleich wie bei macOS, ohne Präfix) muss dieses Präfix vor der Datenbankabfrage entfernt werden.

Kritischer Punkt: Das Attribut User-Name selbst darf dabei nicht verändert werden – FreeRADIUS' EAP-Modul vergleicht bei jedem Paket der laufenden TLS-Handshake-Konversation die ursprüngliche EAP-Identität gegen User-Name und bricht bei einer Abweichung mit Identity does not match User-Name. Authentication failed ab. Der gestrippte Name muss daher in ein separates temporäres Attribut geschrieben werden.

In sites-enabled/default, im authorize { }-Block, direkt nach dem eap { ok = return }-Block und vor files/sql einfügen:

if (&User-Name =~ /^host\/(.*)$/) {
        update request {
                &Tmp-String-0 := "%{1}"
        }
}

In mods-config/sql/main/mysql/queries.conf die Zeile

sql_user_name = "%{User-Name}"

ersetzen durch:

sql_user_name = "%{%{Tmp-String-0}:-%{User-Name}}"

Das nutzt den gestrippten Namen, falls vorhanden (Windows), und fällt sonst automatisch auf User-Name unverändert zurück (macOS und alle anderen Clients ohne Präfix).

2.13 Konfiguration validieren und starten

docker compose up -d
docker exec freeradius freeradius -C
echo "Exit-Code: $?"

Exit-Code 0 bestätigt eine syntaktisch korrekte Konfiguration.

docker compose logs --tail 20 freeradius

Sollte mit „Ready to process requests" enden, ohne SQL- oder TLS-Fehler.

2.14 Datenbankschema importieren

docker exec freeradius cat /etc/freeradius/3.0/mods-config/sql/main/mysql/schema.sql | \
  docker exec -i radius-mariadb mariadb -u radius -p'<RADIUS_DB_PASSWORD>' radius

Zusätzlich die daloRADIUS-eigenen Schemata (Operatoren, Billing-Tabellen, GUI-Metadaten):

for f in fr3-mariadb-freeradius.sql mariadb-daloradius.sql mariadb-daloradius-dictionaries.sql; do
  docker exec daloradius cat /var/www/html/daloradius/contrib/db/$f | \
    docker exec -i radius-mariadb mariadb -u radius -p'<RADIUS_DB_PASSWORD>' radius
done

Kontrolle:

docker exec -it radius-mariadb mariadb -u radius -p'<RADIUS_DB_PASSWORD>' radius -e "SHOW TABLES;"

Erwartete Tabellen (Auszug): radacct, radcheck, radgroupcheck, radgroupreply, radusergroup, operators, nas.

2.15 daloRADIUS-Login und Erstkonfiguration

Im Browser: http://<RADIUS_VM_IP>:8000/

Benutzername: administrator
Passwort: radius

🔐 Passwort-Safe: Das Standardpasswort ist öffentlich bekannt. Sofort nach dem ersten Login unter Management → Operators → Edit Operator ändern und das neue Passwort im Safe hinterlegen.


Teil 3: VLAN-Zuweisung über daloRADIUS-Gruppen

3.1 Prinzip

Beim EAP-TLS-Handshake setzt FreeRADIUS User-Name automatisch auf den CN des Client-Zertifikats. Die SQL-Abfrage sucht danach in radusergroup, welcher Gruppe dieser Name zugeordnet ist, und liefert die radgroupreply-Attribute dieser Gruppe zurück – inklusive VLAN-Zuweisung.

3.2 Gruppen und VLAN-Zuweisung per SQL anlegen (reproduzierbar, ohne GUI-Klicks)

Passen Sie Gruppennamen und VLAN-IDs an Ihre Umgebung an:

docker exec -it radius-mariadb mariadb -u radius -p'<RADIUS_DB_PASSWORD>' radius <<'SQL'
-- Beispiel-Gruppe fuer Corporate-Geraete, VLAN 100
INSERT INTO radgroupreply (groupname, attribute, op, value) VALUES
  ('corp-vlan100', 'Tunnel-Type', ':=', 'VLAN'),
  ('corp-vlan100', 'Tunnel-Medium-Type', ':=', 'IEEE-802'),
  ('corp-vlan100', 'Tunnel-Private-Group-Id', ':=', '100'),
  ('corp-vlan100', 'Session-Timeout', ':=', '28800'),
  ('corp-vlan100', 'Termination-Action', ':=', 'RADIUS-Request');
SQL

Bedeutung der einzelnen Attribute:

Attribut Zweck
Tunnel-Type / Tunnel-Medium-Type / Tunnel-Private-Group-Id Die drei RFC-2868-Attribute, die Cisco-Switches auswerten, um ein VLAN dynamisch zuzuweisen
Session-Timeout Nach wie vielen Sekunden eine Reauthentifizierung angestossen wird (RFC 2865)
Termination-Action = RADIUS-Request Weist den Switch an, nach Ablauf des Timeouts automatisch und nahtlos zu reauthentifizieren, statt die Session zu beenden. Ohne dieses Attribut (Default: Default) wird die Session bei Ablauf getrennt – bei Geräten mit seltenem Traffic (z. B. Infoscreens, IoT) ein spürbarer Unterbruch.

Weitere Gruppen (z. B. für IoT-Geräte, andere VLANs) nach demselben Muster mit eigenem Gruppennamen und eigener VLAN-ID anlegen.

3.3 Geräte anlegen und zuordnen

Für jedes Gerät (Windows/macOS), dessen Zertifikats-CN Sie beim Enrollment vergeben (siehe Teile 5 und 6):

docker exec -it radius-mariadb mariadb -u radius -p'<RADIUS_DB_PASSWORD>' radius <<SQL
INSERT INTO radusergroup (username, groupname, priority) VALUES
  ('geraetename.domain.tld', 'corp-vlan100', 0);
SQL

Wichtig: Fügen Sie keinen Eintrag in radcheck (z. B. kein Cleartext-Password) für diese Benutzer hinzu. Ein vorhandenes Passwort-Check-Item kann das rlm_pap-Modul dazu bringen, den bereits durch EAP-TLS gesetzten Auth-Type zu überschreiben, wodurch die Authentifizierung fehlschlägt.

Alternativ lässt sich dieselbe Zuordnung auch über die daloRADIUS-Weboberfläche unter Management → Users → New User (Gruppe zuweisen unter „Assign Group") vornehmen – funktional identisch zum SQL-Weg oben.

3.4 MAC-Adressen für MAB anlegen

Für Geräte ohne 802.1X-Supplicant (MAB-Fallback), z. B. Drucker, IoT-Geräte:

docker exec -it radius-mariadb mariadb -u radius -p'<RADIUS_DB_PASSWORD>' radius <<SQL
INSERT INTO radusergroup (username, groupname, priority) VALUES
  ('aabbccddeeff', 'corp-vlan100', 0);
SQL

Die MAC-Adresse muss dabei exakt im Format vorliegen, das der Switch als Username sendet: 12 Hex-Zeichen, Kleinbuchstaben, ohne Trennzeichen (z. B. aabbccddeeff, nicht AA:BB:CC:DD:EE:FF).


Teil 4: Cisco Switch Konfiguration

4.1 Kompatibilitätshinweis

Die folgende Konfiguration nutzt die moderne IBNS-2.0-Syntax (authentication ...-Befehle), verifiziert auf einem Catalyst 2960-X mit IOS 15.2(4)E6. Diese Syntax funktioniert ab ca. IOS 12.2(55)SE aufwärts. Bei älteren IOS-Versionen sind stattdessen die klassischen dot1x-Befehle nötig (dot1x port-control auto, dot1x guest-vlan, ohne authentication order/priority – dot1x hat dort implizit Vorrang vor MAB).

4.2 NTP (empfohlen)

Ohne Zeitsynchronisation zeigen Logs und Accounting-Daten falsche Zeitstempel:

ntp server <RADIUS_VM_IP>
clock timezone CET 1
clock summer-time CEST recurring last Sun Mar 2:00 last Sun Oct 3:00

4.3 Globale AAA/RADIUS-Konfiguration

aaa new-model
aaa authentication dot1x default group radius
aaa authorization network default group radius
aaa accounting dot1x default start-stop group radius

dot1x system-auth-control

radius server FREERADIUS-1
 address ipv4 <RADIUS_VM_IP> auth-port 1812 acct-port 1813
 key <RADIUS_SHARED_SECRET>

<RADIUS_SHARED_SECRET> muss identisch zum secret-Wert im client-Block in clients.conf sein (siehe 2.9).

4.4 VLANs anlegen

vlan 100
 name CORP-VLAN

vlan 900
 name GUEST-VLAN

vlan 999
 name CRITICAL-AUTH-FALLBACK

900 (Gast) und 999 (kritischer Fallback, falls RADIUS nicht erreichbar) sind Beispielwerte – an Ihre bestehende VLAN-Nummerierung anpassen.

4.5 Interface-Konfiguration (pro Access-Port)

interface GigabitEthernet1/0/1
 switchport mode access
 switchport access vlan 100

 authentication event fail action next-method
 authentication event server dead action authorize vlan 999
 authentication event no-response action authorize vlan 900
 authentication event server alive action reinitialize

 authentication host-mode multi-auth
 authentication order dot1x mab
 authentication priority dot1x mab
 authentication port-control auto
 authentication violation restrict

 authentication periodic
 authentication timer reauthenticate server

 mab
 dot1x pae authenticator
 dot1x timeout tx-period 5
 dot1x max-reauth-req 2

 spanning-tree portfast edge

Bedeutung der zentralen Zeilen:

Befehl Zweck
authentication order dot1x mab / authentication priority dot1x mab Reihenfolge: zuerst 802.1X (EAPOL) versuchen, bei Timeout auf MAB zurückfallen
authentication event no-response action authorize vlan 900 Gast-VLAN-Zuweisung, falls weder 802.1X noch MAB erfolgreich sind
authentication event server dead action authorize vlan 999 Kritisches Fallback-VLAN, falls FreeRADIUS selbst nicht erreichbar ist – ohne dieses hätten Geräte bei RADIUS-Ausfall gar keinen Netzzugriff mehr
authentication host-mode multi-auth Mehrere Geräte pro Port können unabhängig authentifiziert werden (z. B. Hub/kleiner Switch dahinter)
authentication periodic + authentication timer reauthenticate server Periodische Reauthentifizierung, gesteuert über das Session-Timeout-Attribut aus daloRADIUS (siehe 3.2), statt eines festen lokalen Werts

4.6 Verifikation

show authentication sessions interface GigabitEthernet1/0/1 details

Nach erfolgreicher Authentifizierung sollte erscheinen:

Status: Authorized
Server Policies:
   Vlan Group: Vlan: 100

Kompakter Überblick über alle Ports:

show authentication sessions

RADIUS-Kommunikationsstatistik (Access-Requests, Rejects, Timeouts):

show radius statistics

Teil 5: macOS Client-Enrollment (SCEP)

5.1 Funktionsweise

Ein einzelnes .mobileconfig-Profil enthält drei Payloads: Root-CA-Vertrauen, SCEP-Zertifikatsanfrage und die Wired-802.1X-Netzwerkkonfiguration. Der Nutzer installiert das Profil einmalig; macOS fordert daraufhin automatisch ein Zertifikat von der CA an und konfiguriert die Netzwerkverbindung.

5.2 Wahl des Netzwerk-Payload-Typs

Für Geräte mit wechselnden USB-Ethernet-Dongles oder Docking-Stations ist com.apple.globalethernet.managed die richtige Wahl – dieser Payload-Typ bindet sich an jede Ethernet-Schnittstelle, unabhängig von der intern von macOS geführten Geräte-Reihenfolge. Die Alternativen com.apple.firstactiveethernetnetwork/com.apple.firstethernetnetwork binden sich nur an eine bestimmte Position in dieser Liste und können bei Hardware-Wechsel „leer laufen".

5.3 Automatische Zertifikatserneuerung

Seit macOS Ventura erneuern sich SCEP-ausgestellte Zertifikate automatisch im Hintergrund, auch bei manuell (nicht per MDM) installierten Profilen – ein zusätzliches Renewal-Skript ist auf macOS nicht nötig. Diese automatische Erneuerung lässt sich bei Bedarf deaktivieren:

sudo defaults write /Library/Preferences/com.apple.mdmclient AutoRenewCertificatesEnabled -bool NO

Hinweis: Testen Sie die automatische Erneuerung an einem Gerät einmalig kurz vor Ablauf der ersten Zertifikatslaufzeit (z. B. nach 11 Monaten bei 1 Jahr Laufzeit), um sicherzustellen, dass es in Ihrer Umgebung reibungslos funktioniert, bevor sich das massenhaft im Feld zeigt.

5.4 Profil-Generator-Skript

generate-macos-profile.sh (auf der step-ca-VM ablegen):

#!/bin/bash
# Nutzung: ./generate-macos-profile.sh <geraetename>
# Beispiel: ./generate-macos-profile.sh corp-macbook-01
#
# Erzeugt ein .mobileconfig-Profil fuer SCEP-Enrollment + Wired 802.1X.

set -euo pipefail

DEVICE_NAME="${1:?Nutzung: $0 <geraetename>}"

SCEP_CHALLENGE="<SCEP_CHALLENGE_SECRET>"
SCEP_URL="http://ca.domain.tld:8080/scep/scep-8021x"
CN="${DEVICE_NAME}.domain.tld"

ROOT_CERT_DER_B64=$(docker exec step-ca cat /home/step/certs/root_ca.crt | openssl x509 -outform DER | base64 | tr -d '\n')

ROOT_UUID=$(cat /proc/sys/kernel/random/uuid)
SCEP_UUID=$(cat /proc/sys/kernel/random/uuid)
NET_UUID=$(cat /proc/sys/kernel/random/uuid)
PROFILE_UUID=$(cat /proc/sys/kernel/random/uuid)

OUTDIR="/docker/stepca/profiles"
mkdir -p "$OUTDIR"
OUTFILE="${OUTDIR}/${DEVICE_NAME}.mobileconfig"

cat > "$OUTFILE" << EOF
<?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.security.root</string>
            <key>PayloadIdentifier</key>
            <string>tld.domain.8021x.rootca</string>
            <key>PayloadUUID</key>
            <string>${ROOT_UUID}</string>
            <key>PayloadVersion</key>
            <integer>1</integer>
            <key>PayloadDisplayName</key>
            <string><ORG-8021X-CA> Root</string>
            <key>PayloadCertificateFileName</key>
            <string>root_ca.der</string>
            <key>PayloadContent</key>
            <data>${ROOT_CERT_DER_B64}</data>
        </dict>
        <dict>
            <key>PayloadType</key>
            <string>com.apple.security.scep</string>
            <key>PayloadIdentifier</key>
            <string>tld.domain.8021x.scep</string>
            <key>PayloadUUID</key>
            <string>${SCEP_UUID}</string>
            <key>PayloadVersion</key>
            <integer>1</integer>
            <key>PayloadDisplayName</key>
            <string>802.1X Client Certificate Enrollment</string>
            <key>PayloadContent</key>
            <dict>
                <key>URL</key>
                <string>${SCEP_URL}</string>
                <key>Name</key>
                <string>${CN}</string>
                <key>Subject</key>
                <array>
                    <array>
                        <array>
                            <string>CN</string>
                            <string>${CN}</string>
                        </array>
                    </array>
                </array>
                <key>Challenge</key>
                <string>${SCEP_CHALLENGE}</string>
                <key>Keysize</key>
                <integer>2048</integer>
                <key>KeyType</key>
                <string>RSA</string>
                <key>KeyUsage</key>
                <integer>5</integer>
                <key>Retries</key>
                <integer>3</integer>
                <key>RetryDelay</key>
                <integer>10</integer>
            </dict>
        </dict>
        <dict>
            <key>PayloadType</key>
            <string>com.apple.globalethernet.managed</string>
            <key>PayloadIdentifier</key>
            <string>tld.domain.8021x.wired</string>
            <key>PayloadUUID</key>
            <string>${NET_UUID}</string>
            <key>PayloadVersion</key>
            <integer>1</integer>
            <key>PayloadDisplayName</key>
            <string>Wired 802.1X - domain.tld</string>
            <key>EAPClientConfiguration</key>
            <dict>
                <key>AcceptEAPTypes</key>
                <array>
                    <integer>13</integer>
                </array>
                <key>PayloadCertificateAnchorUUID</key>
                <array>
                    <string>${ROOT_UUID}</string>
                </array>
                <key>TLSCertificateIsRequired</key>
                <true/>
            </dict>
            <key>PayloadCertificateUUID</key>
            <string>${SCEP_UUID}</string>
        </dict>
    </array>
    <key>PayloadDisplayName</key>
    <string>802.1X Wired Network - ${DEVICE_NAME}</string>
    <key>PayloadIdentifier</key>
    <string>tld.domain.8021x.profile.${DEVICE_NAME}</string>
    <key>PayloadOrganization</key>
    <string>domain.tld</string>
    <key>PayloadRemovalDisallowed</key>
    <false/>
    <key>PayloadType</key>
    <string>Configuration</string>
    <key>PayloadUUID</key>
    <string>${PROFILE_UUID}</string>
    <key>PayloadVersion</key>
    <integer>1</integer>
</dict>
</plist>
EOF

echo "Profil erstellt: $OUTFILE"
echo "CN des Client-Zertifikats: $CN"
echo "Nicht vergessen: '$CN' in daloRADIUS als User anlegen und einer VLAN-Gruppe zuordnen (siehe Teil 3.3)."
chmod +x generate-macos-profile.sh

5.5 Profil erzeugen und verteilen

./generate-macos-profile.sh corp-macbook-01

Verteilung z. B. per AirDrop, internem Download-Link oder USB. Der Nutzer öffnet die Datei; macOS führt durch die Installation (Systemeinstellungen → Profil installieren).

Nach der Installation: Zertifikats-CN in daloRADIUS als User anlegen (siehe 3.3).


Teil 6: Windows Client-Enrollment

6.1 Warum kein SCEP auf Windows

Ohne Active-Directory-Domäne bzw. Microsoft Intune bietet Windows keinen zuverlässigen nativen SCEP-Client. Statt eines Drittanbieter-SCEP-Clients nutzen wir die step-CLI direkt mit einem Einmal-Token pro Gerät – das ist robuster und benötigt keine Zusatzsoftware ausser der step-CLI selbst.

6.2 Token pro Gerät generieren (auf der step-ca-VM)

docker exec -it step-ca step ca token \
  "geraetename.domain.tld" \
  --provisioner admin \
  --not-after 1h

(Fragt nach dem CA-Administrationspasswort. Die CA begrenzt die Gültigkeit eines Bootstrap-Tokens standardmässig auf 1 Stunde – das reicht für einen einzelnen Enrollment-Vorgang; die tatsächliche Zertifikatslaufzeit richtet sich nach den in 1.11 gesetzten Werten, hier 1 Jahr.)

🔐 Passwort-Safe: Das Token selbst ist nur 1 Stunde gültig und muss nicht dauerhaft gespeichert werden – es dient ausschliesslich als einmaliges Bootstrap-Geheimnis für genau diesen einen Enrollment-Vorgang.

6.3 Root-CA auf dem Windows-Gerät importieren

curl.exe -k https://<CA_VM_IP>:9000/roots.pem -o C:\temp\root_ca.pem
certutil -addstore -f "Root" C:\temp\root_ca.pem

(-k/-addstore sind hier für den einmaligen Vertrauens-Bootstrap unproblematisch, da wir genau dieses Root-Zertifikat gerade zum ersten Mal als vertrauenswürdig einrichten.)

6.4 step CLI installieren

curl.exe -LO https://dl.smallstep.com/cli/docs-cli-install/latest/step_windows_amd64.zip
Expand-Archive -LiteralPath .\step_windows_amd64.zip -DestinationPath C:\step -Force
$env:Path += ";C:\step\step_windows_amd64\bin"
step version

6.5 Vollständiges Enrollment-Skript

enroll-8021x.ps1 (als Administrator ausführen):

# ============================================
# 802.1X Windows Enrollment + Auto-Renewal Setup
# Nutzung: .\enroll-8021x.ps1 -DeviceName "geraetename" -Token "<TOKEN>"
# ============================================

param(
    [Parameter(Mandatory=$true)][string]$DeviceName,
    [Parameter(Mandatory=$true)][string]$Token
)

$CN = "$DeviceName.domain.tld"
$CaUrl = "https://ca.domain.tld:9000"
$WorkDir = "C:\ProgramData\8021x"
$RootCaPath = "$WorkDir\root_ca.pem"

New-Item -ItemType Directory -Path $WorkDir -Force | Out-Null

# --------------------------------------------
# 1. Root-CA importieren
# --------------------------------------------
Write-Host "Importiere Root-CA..."
curl.exe -k "https://<CA_VM_IP>:9000/roots.pem" -o $RootCaPath
certutil -addstore -f "Root" $RootCaPath

# --------------------------------------------
# 2. step CLI sicherstellen
# --------------------------------------------
if (-not (Get-Command step -ErrorAction SilentlyContinue)) {
    Write-Host "Installiere step CLI..."
    curl.exe -LO https://dl.smallstep.com/cli/docs-cli-install/latest/step_windows_amd64.zip
    Expand-Archive -LiteralPath .\step_windows_amd64.zip -DestinationPath C:\step -Force
    $env:Path += ";C:\step\step_windows_amd64\bin"
    [Environment]::SetEnvironmentVariable("Path", $env:Path, "Machine")
}

# --------------------------------------------
# 3. Zertifikat anfordern
# --------------------------------------------
Write-Host "Fordere Zertifikat fuer $CN an..."
step ca certificate $CN "$WorkDir\client.crt" "$WorkDir\client.key" `
    --token $Token `
    --ca-url $CaUrl `
    --root $RootCaPath

if ($LASTEXITCODE -ne 0) {
    Write-Error "Zertifikatsanfrage fehlgeschlagen. Abbruch."
    exit 1
}

# --------------------------------------------
# 4. PFX bauen (step CLI, kein externes OpenSSL noetig) und importieren
# --------------------------------------------
Write-Host "Importiere Zertifikat in den Windows-Zertifikatsspeicher..."
Add-Type -AssemblyName System.Web
$tempPass = [System.Web.Security.Membership]::GeneratePassword(20, 5)

step certificate p12 "$WorkDir\client.pfx" "$WorkDir\client.crt" "$WorkDir\client.key" `
    --ca $RootCaPath --password-file <(Write-Output $tempPass) 2>$null
if (-not (Test-Path "$WorkDir\client.pfx")) {
    # Fallback falls --password-file auf dieser step-Version nicht unterstuetzt wird:
    $tempPass | step certificate p12 "$WorkDir\client.pfx" "$WorkDir\client.crt" "$WorkDir\client.key" --ca $RootCaPath
}

Import-PfxCertificate -FilePath "$WorkDir\client.pfx" `
    -CertStoreLocation Cert:\LocalMachine\My `
    -Password (ConvertTo-SecureString $tempPass -AsPlainText -Force)

Remove-Item "$WorkDir\client.pfx" -Force

# --------------------------------------------
# 5. Root-CA-Thumbprint fuer das Netzwerkprofil ermitteln
# --------------------------------------------
$rootThumbprint = (Get-ChildItem Cert:\LocalMachine\Root |
    Where-Object { $_.Subject -like "*<ORG-8021X-CA>*" } |
    Select-Object -First 1 -ExpandProperty Thumbprint)

if (-not $rootThumbprint) {
    Write-Error "Root-CA-Thumbprint nicht gefunden. Bitte manuell in Cert:\LocalMachine\Root pruefen."
    exit 1
}

# Thumbprint in das von Windows erwartete Format bringen (durch Leerzeichen getrennte Byte-Paare)
$formattedThumbprint = ($rootThumbprint -split '(?<=\G.{2})(?!$)') -join ' '

# --------------------------------------------
# 6. Wired AutoConfig Dienst aktivieren
# --------------------------------------------
Set-Service -Name "dot3svc" -StartupType Automatic
Start-Service -Name "dot3svc"

# --------------------------------------------
# 7. 802.1X-Netzwerkprofil erzeugen und anwenden
# --------------------------------------------
# WICHTIG: authMode=machineOnly + allowDialog=no vermeiden interaktive Prompts;
# TrustedRootCA vermeidet die manuelle Zertifikatsauswahl-Abfrage, da sonst
# JEDES im Zertifikatsspeicher vorhandene Zertifikat als Kandidat infrage kaeme.
$profileXml = @"
<?xml version="1.0"?>
<LANProfile xmlns="http://www.microsoft.com/networking/LAN/profile/v1">
    <MSM>
        <security>
            <OneXEnforced>false</OneXEnforced>
            <OneXEnabled>true</OneXEnabled>
            <OneX xmlns="http://www.microsoft.com/networking/OneX/v1">
                <EAPConfig>
                    <EapHostConfig xmlns="http://www.microsoft.com/provisioning/EapHostConfig">
                        <EapMethod>
                            <Type xmlns="http://www.microsoft.com/provisioning/EapCommon">13</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">0</AuthorId>
                        </EapMethod>
                        <Config xmlns="http://www.microsoft.com/provisioning/EapHostConfig">
                            <Eap xmlns="http://www.microsoft.com/provisioning/BaseEapConnectionPropertiesV1">
                                <Type>13</Type>
                                <EapType xmlns="http://www.microsoft.com/provisioning/EapTlsConnectionPropertiesV1">
                                    <CredentialsSource>
                                        <CertificateStore>
                                            <SimpleCertSelection>true</SimpleCertSelection>
                                        </CertificateStore>
                                    </CredentialsSource>
                                    <ServerValidation>
                                        <DisableUserPromptForServerValidation>true</DisableUserPromptForServerValidation>
                                        <ServerNames></ServerNames>
                                        <TrustedRootCA>$formattedThumbprint</TrustedRootCA>
                                    </ServerValidation>
                                    <DifferentUsername>false</DifferentUsername>
                                </EapType>
                            </Eap>
                        </Config>
                    </EapHostConfig>
                </EAPConfig>
            </OneX>
        </security>
    </MSM>
</LANProfile>
"@
$profileXml | Out-File -FilePath "$WorkDir\8021x-profile.xml" -Encoding utf8

# Bestehendes Maschinenprofil entfernen (ohne interface= -> betrifft das globale Maschinenprofil)
netsh lan delete profile 2>$null | Out-Null

# Ohne interface= gilt das Profil fuer alle aktuellen UND kuenftigen Ethernet-Adapter
netsh lan add profile filename="$WorkDir\8021x-profile.xml"
netsh lan set profileparameter authMode=machineOnly allowDialog=no

# --------------------------------------------
# 8. Renewal-Skript ablegen
# --------------------------------------------
$renewScript = @'
$WorkDir = "C:\ProgramData\8021x"
$RootCaPath = "$WorkDir\root_ca.pem"
$CaUrl = "https://ca.domain.tld:9000"
$LogFile = "$WorkDir\renewal.log"

function Log($msg) {
    "$(Get-Date -Format "yyyy-MM-dd HH:mm:ss") - $msg" | Out-File -Append $LogFile
}

Log "Renewal-Check gestartet."

step ca renew "$WorkDir\client.crt" "$WorkDir\client.key" `
    --ca-url $CaUrl --root $RootCaPath --force

if ($LASTEXITCODE -eq 0) {
    Log "Renewal erfolgreich, aktualisiere Zertifikatsspeicher."

    $oldCerts = Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Subject -like "*$env:COMPUTERNAME*" }
    foreach ($c in $oldCerts) { Remove-Item $c.PSPath -Force }

    Add-Type -AssemblyName System.Web
    $tempPass = [System.Web.Security.Membership]::GeneratePassword(20, 5)
    $tempPass | step certificate p12 "$WorkDir\client.pfx" "$WorkDir\client.crt" "$WorkDir\client.key" --ca $RootCaPath

    Import-PfxCertificate -FilePath "$WorkDir\client.pfx" -CertStoreLocation Cert:\LocalMachine\My `
        -Password (ConvertTo-SecureString $tempPass -AsPlainText -Force)
    Remove-Item "$WorkDir\client.pfx" -Force

    Log "Zertifikat erfolgreich erneuert und importiert."
} else {
    Log "WARNUNG: Renewal fehlgeschlagen! Exit-Code $LASTEXITCODE"
}
'@
$renewScript | Out-File -FilePath "$WorkDir\renew-8021x-cert.ps1" -Encoding UTF8 -Force

# --------------------------------------------
# 9. Taegliche Renewal-Aufgabe einrichten
# --------------------------------------------
$action = New-ScheduledTaskAction -Execute "powershell.exe" `
    -Argument "-ExecutionPolicy Bypass -File $WorkDir\renew-8021x-cert.ps1"
$trigger = New-ScheduledTaskTrigger -Daily -At 3am
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -RunOnlyIfNetworkAvailable

Register-ScheduledTask -TaskName "Renew-8021x-Cert" `
    -Action $action -Trigger $trigger -Settings $settings `
    -RunLevel Highest -User "SYSTEM" -Force

Write-Host ""
Write-Host "=== Fertig ===" -ForegroundColor Green
Write-Host "Geraet $CN ist fuer 802.1X konfiguriert." -ForegroundColor Green
Write-Host "Automatische Erneuerung laeuft taeglich um 03:00 Uhr." -ForegroundColor Green
Write-Host ""
Write-Host "Nicht vergessen: '$CN' in daloRADIUS als User anlegen und einer VLAN-Gruppe zuordnen." -ForegroundColor Yellow

Hinweis: Der Schritt step certificate p12 mit --password-file <(...) funktioniert nur, falls Process Substitution unterstützt wird; das Skript enthält einen einfachen Fallback über Pipe. Prüfen Sie bei Ihrer installierten step-CLI-Version (step certificate p12 --help), welche Passwortübergabe unterstützt wird, und passen Sie den Block bei Bedarf an.

6.6 Nutzung

.\enroll-8021x.ps1 -DeviceName "corp-winlaptop-01" -Token "<TOKEN-AUS-6.2>"

Danach in daloRADIUS den User corp-winlaptop-01.domain.tld anlegen und einer VLAN-Gruppe zuordnen (siehe 3.3) – ohne host/-Präfix, da die serverseitige Regel aus 2.12 das Präfix automatisch für die Gruppenzuordnung entfernt.

6.7 Warum kein Active-Directory-GPO-Ansatz

Auf Workgroup-Geräten ohne Domänenmitgliedschaft fehlt in gpedit.msc häufig die Möglichkeit, unter „Richtlinien für kabelgebundene Netzwerke (IEEE 802.3)" eine neue Richtlinie zu erstellen (das Kontextmenü zeigt nur „Anzeigen/Aktualisieren/Exportieren", aber keine „Neu erstellen"-Option) – dieser GPO-Mechanismus ist primär für domänengebundene Umgebungen gedacht. Der oben beschriebene netsh lan-Weg funktioniert dagegen unabhängig von einer Domänenmitgliedschaft zuverlässig.


Zusammenfassung: Passwort-Safe-Checkliste

Vor dem produktiven Einsatz sicherstellen, dass alle folgenden Geheimnisse im Passwort-Safe hinterlegt sind:

# Geheimnis Fundort Warum kritisch
1 CA-Administrationspasswort ca_password.txt / data/secrets/password Entschlüsselt den Intermediate-Private-Key; ohne dieses Passwort kein Zugriff nach Neustart
2 Root-CA-Private-Key (Datei) root_ca_key, offline ausgelagert Wichtigster Schlüssel der Infrastruktur – Kompromittierung erlaubt beliebige Zertifikatsausstellung
3 SCEP-Challenge-Secret Provisioner-Konfiguration, macOS-Profile Authentifiziert jedes SCEP-Enrollment; bei Bekanntwerden können unautorisierte Geräte Zertifikate anfordern
4 MariaDB Root-Passwort docker-compose.yaml (RADIUS-Stack) Voller Datenbankzugriff
5 MariaDB radius-User-Passwort docker-compose.yaml, mods-available/sql, daloRADIUS-Config Wird an drei Stellen referenziert – bei Änderung überall synchron aktualisieren
6 RADIUS Shared Secret (pro Switch) clients.conf, Switch-Konfiguration Ermöglicht das Fälschen von RADIUS-Antworten gegenüber dem Switch bei Bekanntwerden
7 daloRADIUS-Administrator-Passwort Nach erstem Login geändert Default-Passwort ist öffentlich bekannt

Verifikations-Checkliste nach Abschluss

  • [ ] docker exec freeradius freeradius -C liefert Exit-Code 0
  • [ ] Client-Zertifikat wird im Switch-Log mit Authc Success bestätigt (show authentication sessions ... details)
  • [ ] Server Policies: Vlan Group: Vlan: <X> erscheint im Session-Detail (nicht nur der statische Port-Default – am besten einmalig verifizieren, indem das statische Port-VLAN testweise auf einen anderen Wert gesetzt wird)
  • [ ] Ein Gerät ohne Zertifikat/MAC-Eintrag landet zuverlässig im Gast-VLAN
  • [ ] Reauthentifizierung nach Ablauf des Session-Timeout erfolgt nahtlos (Timeout action: Reauthenticate, unveränderte Common Session ID)
  • [ ] Root-CA-Private-Key ist offline ausgelagert, nicht mehr auf der VM vorhanden
  • [ ] Alle Einträge aus der Passwort-Safe-Checkliste sind hinterlegt