docs: Installationsanleitung, gehärtete systemd-Unit und Beispielkonfiguration

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
Carsten Abele 2026-08-14 09:37:45 +02:00
parent 2d223c5823
commit 4b287f9b1b
4 changed files with 633 additions and 0 deletions

422
README.md Normal file
View file

@ -0,0 +1,422 @@
# 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.**
### 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.26 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
```
### 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.