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.
|
||||||
121
deploy/config.example.yaml
Normal file
121
deploy/config.example.yaml
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# Beispielkonfiguration für das VPN-Konfig-Portal.
|
||||||
|
#
|
||||||
|
# Diese Datei enthält Zugangsdaten. Rechte setzen:
|
||||||
|
# chown root:vpnportal /etc/vpnportal/config.yaml
|
||||||
|
# chmod 640 /etc/vpnportal/config.yaml
|
||||||
|
# Das Portal startet nicht, wenn die Rechte weiter gefasst sind.
|
||||||
|
#
|
||||||
|
# Die Datei wird strikt gelesen: ein Tippfehler in einem Feldnamen führt zum
|
||||||
|
# Startabbruch mit klarer Meldung. Prüfen mit:
|
||||||
|
# vpnportal check --config /etc/vpnportal/config.yaml
|
||||||
|
|
||||||
|
portal:
|
||||||
|
# Adresse, auf der das Portal lauscht. HTTPS ist Pflicht.
|
||||||
|
listen: "0.0.0.0:8443"
|
||||||
|
|
||||||
|
# Serverzertifikat und privater Schlüssel des Portals.
|
||||||
|
# Der Schlüssel muss 0600 sein, sonst startet das Portal nicht.
|
||||||
|
tls_cert: "/etc/vpnportal/portal.crt"
|
||||||
|
tls_key: "/etc/vpnportal/portal.key"
|
||||||
|
|
||||||
|
# Lebensdauer einer Anmeldung. Absolut, nicht verlängerbar. Default: 10m
|
||||||
|
session_ttl: "10m"
|
||||||
|
|
||||||
|
# White-Label: Titel im Browser-Tab und in der Kopfzeile.
|
||||||
|
title: "VPN-Portal Firma GmbH"
|
||||||
|
|
||||||
|
# Optionales Logo in der Kopfzeile (PNG oder SVG).
|
||||||
|
# logo_file: "/etc/vpnportal/logo.png"
|
||||||
|
|
||||||
|
# Wird angezeigt, wenn für einen Benutzer kein Zertifikat hinterlegt ist.
|
||||||
|
support_contact: "it-helpdesk@firma.de"
|
||||||
|
|
||||||
|
# Optionaler Versionscheck. Default: false.
|
||||||
|
# Eingeschaltet baut das Portal beim Start eine ausgehende HTTPS-Verbindung
|
||||||
|
# zur Release-URL auf ("telefoniert nach Hause"). Read-only, kein Auto-Update.
|
||||||
|
# update_check: false
|
||||||
|
|
||||||
|
opnsense:
|
||||||
|
url: "https://fw01.firma.local"
|
||||||
|
|
||||||
|
# API-Zugangsdaten eines dedizierten Benutzers, der AUSSCHLIESSLICH das
|
||||||
|
# Privileg "VPN: OpenVPN Client Export" hat. Niemals einen Admin-Key.
|
||||||
|
api_key: "BITTE-ERSETZEN"
|
||||||
|
api_secret: "BITTE-ERSETZEN"
|
||||||
|
# alternativ als Datei (kompatibel mit systemd LoadCredential, Ansible Vault):
|
||||||
|
# api_key_file: "/run/credentials/vpnportal.service/opnsense-api-key"
|
||||||
|
# api_secret_file: "/run/credentials/vpnportal.service/opnsense-api-secret"
|
||||||
|
# oder als Umgebungsvariable:
|
||||||
|
# VPNPORTAL_OPNSENSE_API_KEY, VPNPORTAL_OPNSENSE_API_SECRET
|
||||||
|
# Rangfolge: Umgebungsvariable > *_file > Wert in dieser Datei.
|
||||||
|
|
||||||
|
# Interne CA, falls die Firewall ein eigenes Zertifikat verwendet.
|
||||||
|
# ca_file: "/etc/vpnportal/firma-ca.pem"
|
||||||
|
|
||||||
|
# NUR für Tests: deaktiviert die TLS-Prüfung zur Firewall.
|
||||||
|
# Erzeugt bei jedem Start eine Warnung. Für LDAP gibt es das bewusst nicht.
|
||||||
|
# insecure_skip_verify: false
|
||||||
|
|
||||||
|
ad:
|
||||||
|
# AD-Domäne. Der Base-DN wird daraus abgeleitet (DC=firma,DC=local).
|
||||||
|
domain: "firma.local"
|
||||||
|
|
||||||
|
# Optionaler Override, z. B. um auf eine OU einzuschränken.
|
||||||
|
# base_dn: "OU=Mitarbeiter,DC=firma,DC=local"
|
||||||
|
|
||||||
|
# Domain Controller mit Failover in dieser Reihenfolge.
|
||||||
|
# HOSTNAMEN, keine IP-Adressen: das LDAPS-Zertifikat wird gegen den
|
||||||
|
# Hostnamen geprüft. IP-Adressen werden abgelehnt.
|
||||||
|
servers:
|
||||||
|
- "dc01.firma.local"
|
||||||
|
- "dc02.firma.local"
|
||||||
|
|
||||||
|
# LDAPS ist Default. Für StartTLS auf 389:
|
||||||
|
# port: 389
|
||||||
|
# tls_mode: "starttls"
|
||||||
|
# port: 636
|
||||||
|
# tls_mode: "ldaps"
|
||||||
|
|
||||||
|
# Unprivilegierter Domain-Benutzer genügt — er muss nur lesen dürfen.
|
||||||
|
bind_user: "svc-vpnportal@firma.local"
|
||||||
|
bind_password: "BITTE-ERSETZEN"
|
||||||
|
# alternativ: bind_password_file oder VPNPORTAL_AD_BIND_PASSWORD
|
||||||
|
# bind_password_file: "/run/credentials/vpnportal.service/ad-bind-password"
|
||||||
|
|
||||||
|
# Nur Mitglieder dieser Gruppe erhalten Zugang. Verschachtelte Gruppen
|
||||||
|
# werden aufgelöst. Der Gruppen-DN wird beim Start ermittelt und gecacht.
|
||||||
|
vpn_group: "VPN-Users"
|
||||||
|
|
||||||
|
# Interne CA für LDAPS. Für LDAP gibt es keine Insecure-Option.
|
||||||
|
# ca_file: "/etc/vpnportal/firma-ca.pem"
|
||||||
|
|
||||||
|
# Zeitlimit pro Domain Controller, danach wird der nächste versucht.
|
||||||
|
# timeout: "8s"
|
||||||
|
|
||||||
|
matching:
|
||||||
|
# Wie der Zertifikats-CN dem Benutzer zugeordnet wird.
|
||||||
|
# {username} ist der aus dem AD gelesene sAMAccountName in Kleinschreibung.
|
||||||
|
# Der Vergleich ignoriert Groß-/Kleinschreibung.
|
||||||
|
cn_pattern: "{username}"
|
||||||
|
# Beispiel für Bestandsumgebungen mit E-Mail-artigen CNs:
|
||||||
|
# cn_pattern: "{username}@firma.de"
|
||||||
|
|
||||||
|
# Notnagel für Bestandsumgebungen, die sich nicht in ein Template fassen
|
||||||
|
# lassen. Schließt cn_pattern aus. {username} wird vor dem Kompilieren
|
||||||
|
# quotiert eingesetzt.
|
||||||
|
# ACHTUNG: eine zu weit gefasste Regex kann fremde Zertifikate zuordnen.
|
||||||
|
# cn_regex: "^(vpn-)?{username}(-[0-9]+)?$"
|
||||||
|
|
||||||
|
logging:
|
||||||
|
# debug, info, warn, error. Im Normalbetrieb NICHT debug: auf debug werden
|
||||||
|
# die verglichenen CNs protokolliert.
|
||||||
|
level: "info"
|
||||||
|
|
||||||
|
# Das Audit-Log enthält personenbezogene Daten. Verzeichnis höchstens 0750,
|
||||||
|
# Datei wird mit 0600 angelegt.
|
||||||
|
audit_log: "/var/log/vpnportal/audit.log"
|
||||||
|
|
||||||
|
# Eingebaute größenbasierte Rotation (schont SD-Karten).
|
||||||
|
max_size_mb: 50
|
||||||
|
max_backups: 5
|
||||||
|
compress: true
|
||||||
65
deploy/vpnportal.service
Normal file
65
deploy/vpnportal.service
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
[Unit]
|
||||||
|
Description=VPN-Konfig-Portal für OPNsense
|
||||||
|
Documentation=file:/usr/share/doc/vpnportal/README.md
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/bin/vpnportal serve --config /etc/vpnportal/config.yaml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
# Dedizierter, zustandsloser Dienstbenutzer. Das Portal hält keinen Zustand
|
||||||
|
# auf der Platte außer dem Audit-Log, deshalb genügt DynamicUser.
|
||||||
|
# Alternativ einen festen Systembenutzer anlegen und User=/Group= setzen —
|
||||||
|
# siehe README, Abschnitt "Installation".
|
||||||
|
DynamicUser=yes
|
||||||
|
|
||||||
|
# Audit-Log: systemd legt /var/log/vpnportal an und übergibt es dem Dienst.
|
||||||
|
# In der config.yaml dann: audit_log: "/var/log/vpnportal/audit.log"
|
||||||
|
LogsDirectory=vpnportal
|
||||||
|
LogsDirectoryMode=0750
|
||||||
|
|
||||||
|
# Secrets werden nicht als Umgebungsvariablen übergeben, sondern als Dateien
|
||||||
|
# unter $CREDENTIALS_DIRECTORY. In der config.yaml dann z. B.:
|
||||||
|
# ad:
|
||||||
|
# bind_password_file: "/run/credentials/vpnportal.service/ad-bind-password"
|
||||||
|
# opnsense:
|
||||||
|
# api_secret_file: "/run/credentials/vpnportal.service/opnsense-api-secret"
|
||||||
|
LoadCredential=ad-bind-password:/etc/vpnportal/secrets/ad-bind-password
|
||||||
|
LoadCredential=opnsense-api-secret:/etc/vpnportal/secrets/opnsense-api-secret
|
||||||
|
|
||||||
|
# ---------- Härtung ----------
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
PrivateDevices=yes
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectKernelLogs=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
ProtectClock=yes
|
||||||
|
ProtectProc=invisible
|
||||||
|
ProcSubset=pid
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
RestrictRealtime=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6
|
||||||
|
LockPersonality=yes
|
||||||
|
MemoryDenyWriteExecute=yes
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
SystemCallFilter=@system-service
|
||||||
|
SystemCallFilter=~@privileged @resources
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
ReadOnlyPaths=/etc/vpnportal
|
||||||
|
UMask=0077
|
||||||
|
|
||||||
|
# Soll das Portal direkt auf 443 lauschen, stattdessen:
|
||||||
|
# AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||||
|
# CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||||
|
# Besser: auf 8443 lauschen lassen und davor per nftables umleiten.
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
25
internal/config/example_test.go
Normal file
25
internal/config/example_test.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Die ausgelieferte Beispielkonfiguration muss immer parsen und validieren.
|
||||||
|
// Sonst laufen Kunden beim ersten Start in einen Fehler — und der Strict-Parser
|
||||||
|
// würde jeden Tippfehler in der Vorlage gnadenlos melden.
|
||||||
|
func TestExampleConfigParsesAndValidates(t *testing.T) {
|
||||||
|
f, err := os.Open("../../deploy/config.example.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Beispielkonfiguration fehlt: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
cfg, err := Parse(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Beispielkonfiguration parst nicht: %v", err)
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Fatalf("Beispielkonfiguration ist nicht gültig: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue