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