Domain Controller auf Windows Server 2012 R2 koennen TLS 1.2 per Registry deaktiviert haben. ad.min_tls_version (Default 1.2) erlaubt es, fuer solche Altsysteme auf 1.0/1.1 herunterzugehen; das Portal warnt dann bei jedem Start. Die Zertifikatspruefung bleibt in jedem Fall aktiv — fuer LDAP gibt es weiterhin keine Insecure-Option. Im Legacy-Modus kommen lediglich die CBC-SHA256-Suiten hinzu, die Go als unsicher einstuft; RC4, 3DES und Suiten ohne Forward Secrecy werden nie angeboten. README dokumentiert zusaetzlich die beiden anderen 2012-R2-Stolpersteine: SHA-1-signierte DC-Zertifikate (seit Go 1.24 nicht mehr umgehbar) und RSA-Schluesselaustausch (nur ueber GODEBUG=tlsrsakex=1). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68 |
||
|---|---|---|
| cmd/vpnportal | ||
| deploy | ||
| docs | ||
| internal | ||
| .gitignore | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| PRD.md | ||
| README.md | ||
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
- Plugin
os-openvpn-client-exportinstallieren (System → Firmware → Plugins). - Gruppe anlegen (System → Access → Groups), z. B.
vpnportal-api, und ihr ausschließlich das Privileg „VPN: OpenVPN Client Export" zuweisen. - Benutzer anlegen (System → Access → Users), z. B.
svc-vpnportal, dieser Gruppe zuordnen, kein Shell-Zugang. - Für diesen Benutzer einen API-Key erzeugen; Key und Secret notieren.
Niemals einen Admin-Key verwenden. Das Portal braucht genau ein Privileg.
vpnportal checkmeldet ausdrücklich, wenn die Berechtigung fehlt.
2. Active Directory vorbereiten
- Unprivilegierten Dienstbenutzer anlegen, z. B.
svc-vpnportal@firma.local. Lesezugriff genügt; keine besonderen Rechte. - Sicherheitsgruppe für den VPN-Zugang anlegen oder benennen, z. B.
VPN-Users. Verschachtelte Gruppen werden aufgelöst. - Sicherstellen, dass die Domain Controller über ihre Hostnamen
erreichbar sind — das LDAPS-Zertifikat wird gegen den Hostnamen geprüft.
IP-Adressen in
ad.serverswerden abgelehnt. - Wird eine interne CA verwendet, deren Zertifikat als PEM bereitstellen.
3. Portal installieren
# Archiv entpacken
tar -xzf vpnportal-<version>-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
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):
vpnportal check --test-auth mmueller
5. Als Dienst einrichten
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:
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):
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:
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.
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:
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-authund 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:
{"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:
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:
{"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
"<unknown>"— 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 checkvergleicht 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 <benutzer> |
| „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:
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=1ersatzlos 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:openssl s_client -connect dc01.firma.local:636 -showcerts </dev/null 2>/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:
[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,
/healthzfü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
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.
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:
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:
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
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: DENYund eine CSP ohne jedesunsafe-*. - Die Oberfläche ist ohne JavaScript vollständig bedienbar.