opnsense-portal/README.md
Carsten Abele 0fc2d8808b feat(auth): konfigurierbare TLS-Mindestversion fuer den Verzeichnisdienst
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
2026-08-14 13:18:24 +02:00

494 lines
18 KiB
Markdown

# 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-<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
```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 `"<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 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 <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:**
```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 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:
```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.