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:
parent
2d223c5823
commit
4b287f9b1b
4 changed files with 633 additions and 0 deletions
422
README.md
Normal file
422
README.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue