From 4b287f9b1b069047443846350a6900c03787f624 Mon Sep 17 00:00:00 2001 From: Carsten Abele Date: Fri, 14 Aug 2026 09:37:45 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20Installationsanleitung,=20geh=C3=A4rtet?= =?UTF-8?q?e=20systemd-Unit=20und=20Beispielkonfiguration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68 --- README.md | 422 ++++++++++++++++++++++++++++++++ deploy/config.example.yaml | 121 +++++++++ deploy/vpnportal.service | 65 +++++ internal/config/example_test.go | 25 ++ 4 files changed, 633 insertions(+) create mode 100644 README.md create mode 100644 deploy/config.example.yaml create mode 100644 deploy/vpnportal.service create mode 100644 internal/config/example_test.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f786e8 --- /dev/null +++ b/README.md @@ -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--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 `""` — 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 ` | +| „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. diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml new file mode 100644 index 0000000..45f3cb7 --- /dev/null +++ b/deploy/config.example.yaml @@ -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 diff --git a/deploy/vpnportal.service b/deploy/vpnportal.service new file mode 100644 index 0000000..7953135 --- /dev/null +++ b/deploy/vpnportal.service @@ -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 diff --git a/internal/config/example_test.go b/internal/config/example_test.go new file mode 100644 index 0000000..d428317 --- /dev/null +++ b/internal/config/example_test.go @@ -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) + } +}