# vpnportal — VPN-Konfig-Portal für OPNsense Self-Service-Webportal, über das Mitarbeitende ihre persönliche OpenVPN-Client-Konfiguration von einer OPNsense-Firewall herunterladen. Anmeldung ausschließlich gegen das Active Directory des Kunden. Das Portal ist ein reiner **Read-only-Verteiler**: Es erstellt, ändert oder widerruft keine Zertifikate und schreibt nichts auf die Firewall. Ein einzelnes statisch gelinktes Binary plus eine YAML-Datei, keine Datenbank. --- ## Voraussetzungen | | | |---|---| | Betriebssystem | Linux, `amd64` oder `arm64` (Raspberry Pi) | | Firewall | OPNsense mit Plugin `os-openvpn-client-export` (siehe `docs/opnsense-api.md`) | | Verzeichnis | Active Directory mit LDAPS (636) oder StartTLS (389) | | Zeit | NTP ist **Pflicht** — ohne korrekte Uhr werden Zertifikatslaufzeiten falsch bewertet | | TLS | Serverzertifikat für das Portal (eigene CA genügt) | Nicht unterstützt: Windows, Multi-Domain-Forests, mehrere OPNsense-Instanzen pro Portal. --- ## Installation in unter 30 Minuten ### 1. OPNsense vorbereiten 1. Plugin `os-openvpn-client-export` installieren (*System → Firmware → Plugins*). 2. Gruppe anlegen (*System → Access → Groups*), z. B. `vpnportal-api`, und ihr **ausschließlich** das Privileg **„VPN: OpenVPN Client Export"** zuweisen. 3. Benutzer anlegen (*System → Access → Users*), z. B. `svc-vpnportal`, dieser Gruppe zuordnen, kein Shell-Zugang. 4. Für diesen Benutzer einen API-Key erzeugen; Key und Secret notieren. > **Niemals einen Admin-Key verwenden.** Das Portal braucht genau ein Privileg. > `vpnportal check` meldet ausdrücklich, wenn die Berechtigung fehlt. ### 2. Active Directory vorbereiten 1. Unprivilegierten Dienstbenutzer anlegen, z. B. `svc-vpnportal@firma.local`. Lesezugriff genügt; keine besonderen Rechte. 2. Sicherheitsgruppe für den VPN-Zugang anlegen oder benennen, z. B. `VPN-Users`. Verschachtelte Gruppen werden aufgelöst. 3. Sicherstellen, dass die Domain Controller **über ihre Hostnamen** erreichbar sind — das LDAPS-Zertifikat wird gegen den Hostnamen geprüft. IP-Adressen in `ad.servers` werden abgelehnt. 4. Wird eine interne CA verwendet, deren Zertifikat als PEM bereitstellen. ### 3. Portal installieren ```bash # Archiv entpacken tar -xzf vpnportal--linux-amd64.tar.gz -C /tmp/vpnportal install -m 0755 /tmp/vpnportal/vpnportal /usr/local/bin/vpnportal # Konfiguration anlegen install -d -m 0750 /etc/vpnportal install -m 0640 /tmp/vpnportal/config.example.yaml /etc/vpnportal/config.yaml $EDITOR /etc/vpnportal/config.yaml # TLS-Material ablegen install -m 0644 portal.crt /etc/vpnportal/portal.crt install -m 0600 portal.key /etc/vpnportal/portal.key ``` Dateirechte werden **erzwungen** — das Portal startet nicht, wenn sie zu weit gefasst sind: | Datei | Höchstens | |---|---| | `config.yaml` | `0640` | | Secret-Dateien | `0640` | | `portal.key` | `0600` | | Log-Verzeichnis | `0750` | ### 4. Prüfen ```bash vpnportal check --config /etc/vpnportal/config.yaml ``` Gibt eine Checkliste aus und benennt jeden Fehler konkret: ``` ✓ Konfiguration gelesen und plausibel ✓ Dateirechte Konfiguration, Secrets und Log-Verzeichnis sind ausreichend geschützt ✗ OPNsense-Verbindung der API-Benutzer hat keine Berechtigung — auf der OPNsense das Privileg "VPN: OpenVPN Client Export" zuweisen ``` Einen konkreten Benutzer durchspielen (ohne dessen Passwort): ```bash vpnportal check --test-auth mmueller ``` ### 5. Als Dienst einrichten ```bash install -m 0644 /tmp/vpnportal/vpnportal.service /etc/systemd/system/ systemctl daemon-reload systemctl enable --now vpnportal systemctl status vpnportal ``` Die mitgelieferte Unit ist gehärtet (`DynamicUser`, `ProtectSystem=strict`, leeres `CapabilityBoundingSet`, `SystemCallFilter`). Wer einen festen Systembenutzer bevorzugt, ersetzt `DynamicUser=yes` durch: ```ini User=vpnportal Group=vpnportal ``` und legt den Benutzer mit `useradd --system --no-create-home --shell /usr/sbin/nologin vpnportal` an. --- ## Secrets Jedes Secret lässt sich auf drei Wegen setzen. **Rangfolge: Umgebungsvariable > `*_file` > Wert in der Konfigurationsdatei.** | Secret | Feld | Datei-Variante | Umgebungsvariable | |---|---|---|---| | OPNsense-Key | `opnsense.api_key` | `api_key_file` | `VPNPORTAL_OPNSENSE_API_KEY` | | OPNsense-Secret | `opnsense.api_secret` | `api_secret_file` | `VPNPORTAL_OPNSENSE_API_SECRET` | | AD-Passwort | `ad.bind_password` | `bind_password_file` | `VPNPORTAL_AD_BIND_PASSWORD` | Werden Feld und `*_file` gleichzeitig gesetzt, bricht der Start mit einer Fehlermeldung ab — stillschweigendes Übergehen wäre gefährlicher. Mit systemd empfiehlt sich `LoadCredential` (schon in der Unit vorbereitet): ```bash install -d -m 0700 /etc/vpnportal/secrets printf '%s' 'DAS-AD-PASSWORT' > /etc/vpnportal/secrets/ad-bind-password chmod 600 /etc/vpnportal/secrets/ad-bind-password ``` In der `config.yaml` dann: ```yaml ad: bind_password_file: "/run/credentials/vpnportal.service/ad-bind-password" ``` Die Konfiguration wird bewusst **nicht** verschlüsselt: der Schlüssel läge daneben, das wäre Sicherheitstheater. Schutz sind Dateirechte und die Härtung der Unit. --- ## Zertifikatszuordnung Welches Firewall-Zertifikat zu welchem Benutzer gehört, entscheidet `matching`. `{username}` ist der **aus dem AD gelesene** `sAMAccountName` in Kleinschreibung — nie die Eingabe des Benutzers. Der Vergleich ignoriert Groß- und Kleinschreibung. ```yaml matching: cn_pattern: "{username}" # CN == sAMAccountName (Standard) # cn_pattern: "{username}@firma.de" # CN ist eine E-Mail-Adresse ``` Für Bestandsumgebungen, die sich nicht in ein Template fassen lassen, gibt es `cn_regex` als Notnagel. Er schließt `cn_pattern` aus: ```yaml matching: cn_regex: "^(vpn-)?{username}(-[0-9]+)?$" ``` `{username}` wird vor dem Kompilieren quotiert eingesetzt, ein Benutzername mit Sonderzeichen kann das Muster also nicht verändern. > **Vorsicht:** Eine zu weit gefasste Regex kann fremde Zertifikate zuordnen. > Nach jeder Änderung mit `check --test-auth` und einem Testbenutzer prüfen. Zusätzlich zur Zuordnung gilt immer: **revozierte und abgelaufene Zertifikate werden nie ausgeliefert.** Die Zertifikatsliste wird bei jeder Anfrage frisch von der Firewall geholt, eine Revozierung greift also sofort. --- ## Betrieb ### Monitoring `GET /healthz` antwortet ohne Anmeldung und ohne sensible Details: ```json {"ok":true,"opnsense":true,"directory":true,"sessions":3} ``` HTTP 200 wenn `ok`, sonst 503. Ergebnisse werden 30 Sekunden gecacht, häufiges Abfragen belastet die Backends also nicht. Für Zabbix/CheckMK genügt der HTTP-Status; wer feiner prüfen will, wertet `opnsense` und `directory` einzeln aus. ### Logs lesen Alles geht parallel nach stdout (systemd-Journal) und in die Audit-Datei: ```bash journalctl -u vpnportal -f # Wer hat wann welche Konfiguration bezogen? jq -r 'select(.event=="config_download") | "\(.ts) \(.user) \(.vpn_instance)"' \ /var/log/vpnportal/audit.log # Fehlanmeldungen einer IP zählen jq -r 'select(.event=="login_failed") | .src_ip' /var/log/vpnportal/audit.log \ | sort | uniq -c | sort -rn ``` ### Neustart Ein Neustart beendet **alle** Sitzungen — Sessions liegen ausschließlich im RAM. Das ist beabsichtigt; weitere Daten gehen dabei nicht verloren. --- ## Audit-Log JSON Lines, eine Zeile pro Ereignis, Zeitstempel RFC 3339 mit Zeitzone: ```json {"ts":"2026-08-14T09:32:11+02:00","event":"login_success","user":"mmueller","src_ip":"10.1.20.34","session":"a3f9"} {"ts":"2026-08-14T09:32:40+02:00","event":"config_download","user":"mmueller","src_ip":"10.1.20.34","session":"a3f9","vpn_instance":"VPN Homeoffice","cert_cn":"mmueller","cert_expiry":"2027-03-01","format":"ovpn"} ``` **Audit-Ereignisse:** `login_success`, `login_failed` (mit `reason`), `logout`, `session_expired`, `config_download`, `download_denied` **Betriebsereignisse:** `no_cert_found`, `opnsense_unreachable`, `ldap_failover`, `rate_limited`, `startup` **`reason` bei `login_failed`:** `invalid_credentials`, `account_disabled`, `account_locked`, `password_expired`, `password_change_required`, `not_in_vpn_group`, `user_not_found`, `backend_unavailable` ### Was niemals im Log steht - **Passwörter.** Wird versehentlich ein Passwort ins Benutzernamenfeld getippt, erscheint statt des Namens `""` — der Klartextname wird nur protokolliert, wenn der Benutzer im AD tatsächlich gefunden wurde. - Konfigurationsinhalte, Zertifikate, private Schlüssel. - Vollständige Session-Tokens (nur eine 4-stellige Hash-Korrelations-ID). - Vollständige CN-Listen der Firewall (nur auf `debug`-Level, das im Normalbetrieb aus bleibt). ### Rotation und Weiterleitung Die Rotation ist eingebaut und größenbasiert (`max_size_mb`, `max_backups`, `compress`) — das schont SD-Karten. Wer stattdessen externes `logrotate` nutzt, schickt danach ein `SIGHUP`: ``` /var/log/vpnportal/audit.log { daily rotate 90 compress postrotate systemctl kill -s HUP vpnportal endscript } ``` > **Für revisionssichere Aufbewahrung das Log an ein zentrales Logsystem > weiterleiten** (über das Journal oder Syslog). Empfohlene Aufbewahrung: > 90 Tage, kundenseitig anpassbar. Das Log enthält personenbezogene Daten und ist entsprechend zu schützen. Die Datei wird mit `0600` angelegt, das Verzeichnis darf höchstens `0750` haben. --- ## Raspberry Pi - **SSD statt SD-Karte.** Das Portal schreibt zwar nur das Audit-Log, aber SD-Karten sterben trotzdem. - **Gerät in einen abschließbaren Netzwerkschrank.** Das Speichermedium trägt die Zugangsdaten zu Firewall und Verzeichnis. - **NTP prüfen** (`timedatectl status`). `vpnportal check` vergleicht die Systemzeit mit der Firewall und warnt ab zwei Minuten Abweichung. --- ## Fehlersuche | Symptom | Wahrscheinliche Ursache | Kommando | |---|---|---| | Start bricht mit `chmod`-Hinweis ab | Datei- oder Verzeichnisrechte zu weit | Der Hinweis nennt den nötigen Befehl | | Start bricht mit unbekanntem Feld ab | Tippfehler in der `config.yaml` | Die Meldung nennt das Feld | | Niemand kann sich anmelden | Dienstkonto, Gruppe oder DC-Erreichbarkeit | `vpnportal check` | | Ein einzelner Benutzer nicht | Gruppenmitgliedschaft oder Schreibweise | `vpnportal check --test-auth ` | | „Keine VPN-Konfiguration hinterlegt" | `cn_pattern` passt nicht zum CN | Audit-Feld `pattern` im Ereignis `no_cert_found` lesen | | „Dienst vorübergehend nicht verfügbar" | Firewall nicht erreichbar oder CA falsch | `vpnportal check` | | Anmeldung dauert lang und schlägt fehl | Erster DC tot, Failover greift | Ereignis `ldap_failover` im Log | | „Zu viele Anmeldeversuche" | Rate-Limit greift | Ereignis `rate_limited`; die Wartezeit läuft von allein ab | Für die Fehlersuche lässt sich `logging.level` vorübergehend auf `debug` setzen — dann werden auch die verglichenen CNs protokolliert. **Im Normalbetrieb wieder auf `info` zurückstellen.** ### Alte Domain Controller (Windows Server 2012 R2 und älter) Fehlendes TLS 1.3 ist **kein** Problem — das Portal verlangt mindestens TLS 1.2 und handelt automatisch herunter. Server 2012 R2 beherrscht TLS 1.2 für Schannel standardmäßig. Scheitert die Verbindung trotzdem, ist fast immer eine dieser drei Ursachen im Spiel. Die Fehlermeldung von `vpnportal check` sagt, welche: | Fehlermeldung enthält | Ursache | Behebung | |---|---|---| | `protocol version not supported`, `handshake failure` | TLS 1.2 ist auf dem DC per Registry deaktiviert | `ad.min_tls_version: "1.0"` setzen (siehe unten) | | `insecure algorithm SHA1-RSA`, `certificate signed by unknown authority` | DC-Zertifikat ist mit SHA-1 signiert | **Zertifikat neu ausstellen** — siehe Kasten | | `tls: no cipher suite supported by both` | DC bietet nur RSA-Schlüsselaustausch | `GODEBUG=tlsrsakex=1` in der Unit setzen | **TLS-Mindestversion herabsetzen:** ```yaml ad: min_tls_version: "1.0" # Default ist 1.2 ``` Das Portal warnt dann bei jedem Start. Die **Zertifikatsprüfung bleibt aktiv** — für LDAP gibt es bewusst keine Möglichkeit, sie abzuschalten. Herabgesetzt werden nur die zulässigen Protokollversionen und die angebotenen Cipher-Suiten; RC4 und 3DES werden auch dann nie angeboten. > **SHA-1-Zertifikate lassen sich nicht mehr umgehen.** Go 1.24 hat den > Notausgang `GODEBUG=x509sha1=1` ersatzlos entfernt. Ist das LDAPS-Zertifikat > des Domain Controllers mit SHA-1 signiert — bei ADCS-Vorlagen aus der > 2012-R2-Ära der Normalfall — hilft nur, es mit SHA-256 neu auszustellen. > Prüfen lässt sich das so: > > ```bash > openssl s_client -connect dc01.firma.local:636 -showcerts /dev/null \ > | openssl x509 -noout -text | grep -i "signature algorithm" > ``` > > Steht dort `sha1WithRSAEncryption`, muss das Zertifikat getauscht werden. **RSA-Schlüsselaustausch erzwingen** (letzte Rettung, keine Forward Secrecy). Go schaltet diese Suiten seit 1.22 ab; sie lassen sich nur global per GODEBUG reaktivieren. In der systemd-Unit: ```ini [Service] Environment=GODEBUG=tlsrsakex=1 ``` ### Passwort abgelaufen Das Portal kann keine Passwörter ändern. Ist ein AD-Passwort abgelaufen (AD-Code 532), sieht der Benutzer als einzige Ausnahme eine konkrete Meldung und muss das Passwort an seinem Arbeitsplatz ändern. Verlangt das AD eine Passwortänderung bei der nächsten Anmeldung (Code 773), erscheint die generische Meldung — dieser Fall lässt sich am Portal nicht unterscheiden kommunizieren, ohne Kontoinformationen preiszugeben. --- ## Bekannte Grenzen - **Kein WireGuard** (für v2 geplant, braucht ein eigenes Schlüsselkonzept). - **Kein Self-Service für Zertifikate** — Erstellen, Erneuern und Widerrufen bleiben auf der Firewall. - **Keine Admin-Oberfläche.** Logs per SSH/Monitoring, `/healthz` für externe Überwachung. - **Keine Mandantenfähigkeit**, eine OPNsense-Instanz pro Portal. - **Single-Domain.** Referrals werden ignoriert, Multi-Domain-Forests sind nicht unterstützt. - **Kein RADIUS und keine lokalen Portalbenutzer.** - **Nur Deutsch.** Die Textstruktur ist für i18n vorbereitet, aber nur Deutsch ist befüllt. --- ## Update ```bash systemctl stop vpnportal install -m 0755 vpnportal /usr/local/bin/vpnportal vpnportal check --config /etc/vpnportal/config.yaml systemctl start vpnportal ``` Sitzungen gehen dabei verloren, das ist beabsichtigt. Es gibt **kein Auto-Update**. `portal.update_check` ist standardmäßig **aus**; eingeschaltet baut das Portal beim Start eine ausgehende HTTPS-Verbindung zur Release-URL auf und meldet abweichende Versionen im Log. Es lädt nichts herunter und installiert nichts. --- ## Bauen aus dem Quelltext Voraussetzung: **Go 1.25 oder neuer**. Keine CGO-Abhängigkeiten, kein npm, kein Build-Step für das Frontend — Templates und Assets liegen per `go:embed` im Binary. ```bash make test # alle Tests make race # Tests mit Race-Detector make lint # go vet + gofmt make build # Binary nach dist/ make release # Archive für linux-amd64 und linux-arm64 inkl. SHA256SUMS ``` ### Go auf dem Zielhost installieren Die Go-Pakete der Distributionen sind für dieses Projekt zu alt (Debian 12: 1.19, Ubuntu 24.04: 1.22, Raspberry Pi OS: 1.19). `apt install golang` genügt also nicht — Go kommt aus dem offiziellen Tarball: ```bash uname -m # x86_64 -> amd64, aarch64 -> arm64 # Aktuelle Version auf https://go.dev/dl/ nachsehen, hier als Beispiel 1.26.5 curl -LO https://go.dev/dl/go1.26.5.linux-amd64.tar.gz sudo rm -rf /usr/local/go sudo tar -C /usr/local -xzf go1.26.5.linux-amd64.tar.gz echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.profile export PATH=$PATH:/usr/local/go/bin go version ``` Der erste Build lädt die vier Abhängigkeiten von `proxy.golang.org` — der Host braucht dafür einmalig Internetzugang. Soll er dauerhaft ohne Netz auskommen, entweder das Binary auf einer anderen Maschine bauen (`make release`) oder einmalig `go mod vendor` ausführen und das `vendor/`-Verzeichnis mitliefern. ### Integrationstests gegen eine echte OPNsense Die Feldnamen der Export-API sind versionsabhängig. Vor einer Auslieferung sind die in `docs/opnsense-api.md` dokumentierten Annahmen einmal gegen eine echte Instanz zu bestätigen: ```bash export OPNSENSE_URL="https://fw01.firma.local" export OPNSENSE_KEY="..." OPNSENSE_SECRET="..." export OPNSENSE_REVOKED_CN="testuser-revoked" # optional go test -tags integration ./internal/opnsense/ -v ``` Ohne gesetzte Variablen überspringen sich diese Tests selbst. ### Oberfläche ohne Backends ansehen ```bash go test -tags uidemo ./cmd/vpnportal/ -run TestUIDemo -v -timeout 0 # http://127.0.0.1:8080 — Anmeldung mit beliebigen Daten # VPNPORTAL_UIDEMO_EMPTY=1 zeigt den Zustand "kein Zertifikat" ``` --- ## Sicherheitsdesign in Kürze - HTTPS-Pflicht am Portal; TLS-Verifikation gegen Firewall und Verzeichnis immer aktiv. Die Insecure-Option existiert nur für die Firewall und warnt bei jedem Start; für LDAP gibt es sie gar nicht. - LDAP-Injection-Schutz durch konsequentes `ldap.EscapeFilter`. - CSRF-Token auf allen zustandsändernden Anfragen **einschließlich Downloads**. Downloads sind ausschließlich `POST`, es gibt keine GET-Download-Links. - **Jeder Download wird serverseitig neu autorisiert.** Die Auswahl in der Oberfläche ist keine Berechtigung: zwischen Anzeige und Klick kann ein Zertifikat revoziert worden sein. - Konfigurationen werden gestreamt, nie zwischengespeichert; alle Seiten nach der Anmeldung tragen `Cache-Control: no-store`. - Rate-Limiting pro Benutzername **und** pro Quell-IP mit exponentiellem Backoff, gedeckelt bei fünf Minuten. Bewusst **kein harter Lockout** — sonst könnte ein Dritter Kollegen dauerhaft aussperren. - Keine Benutzer-Enumeration: gleiche Meldung und angeglichene Antwortzeit für „Benutzer existiert nicht" und „Passwort falsch". - Security-Header auf jeder Antwort: HSTS, `nosniff`, `no-referrer`, `X-Frame-Options: DENY` und eine CSP ohne jedes `unsafe-*`. - Die Oberfläche ist ohne JavaScript vollständig bedienbar.