No description
Find a file
Carsten Abele 7237aee744 fix(cli): Test las die Konfiguration des Hosts statt einer Attrappe
TestRunNoArgsDefaultsToServe rief run(nil) auf und griff damit auf den
Default-Pfad /etc/vpnportal/config.yaml zu. Auf einem Entwicklungsrechner
existiert der nicht, der Test war dort gruen. Auf einem Zielhost mit
installierter Konfiguration las er dagegen die Produktivkonfiguration und
loeste echte Verbindungsversuche zu Firewall und Domain Controllern aus.

Die Kommandoauswahl ist jetzt als reine Funktion commandFor testbar. Kein
Test ruft serve oder check mehr ohne expliziten --config-Pfad auf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
2026-08-14 13:22:35 +02:00
cmd/vpnportal fix(cli): Test las die Konfiguration des Hosts statt einer Attrappe 2026-08-14 13:22:35 +02:00
deploy feat(auth): konfigurierbare TLS-Mindestversion fuer den Verzeichnisdienst 2026-08-14 13:18:24 +02:00
docs feat(opnsense,certmatch): read-only Export-Client mit Streaming und CN-Zuordnung 2026-08-14 09:15:53 +02:00
internal feat(auth): konfigurierbare TLS-Mindestversion fuer den Verzeichnisdienst 2026-08-14 13:18:24 +02:00
.gitignore feat: CLI-Skeleton mit version/serve/check und Makefile 2026-08-14 09:03:31 +02:00
go.mod fix(deps): korrekte go-Direktive und direkte Dependencies auszeichnen 2026-08-14 10:07:09 +02:00
go.sum fix(deps): korrekte go-Direktive und direkte Dependencies auszeichnen 2026-08-14 10:07:09 +02:00
Makefile feat: CLI-Skeleton mit version/serve/check und Makefile 2026-08-14 09:03:31 +02:00
PRD.md docs: PRD und Implementierungsplan für das VPN-Konfig-Portal 2026-08-14 09:00:51 +02:00
README.md feat(auth): konfigurierbare TLS-Mindestversion fuer den Verzeichnisdienst 2026-08-14 13:18:24 +02:00

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

# 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

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):

vpnportal check --test-auth mmueller

5. Als Dienst einrichten

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:

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):

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:

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.

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:

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:

{"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:

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:

{"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.

Alte Domain Controller (Windows Server 2012 R2 und älter)

Fehlendes TLS 1.3 ist kein Problem — das Portal verlangt mindestens TLS 1.2 und handelt automatisch herunter. Server 2012 R2 beherrscht TLS 1.2 für Schannel standardmäßig. Scheitert die Verbindung trotzdem, ist fast immer eine dieser drei Ursachen im Spiel. Die Fehlermeldung von vpnportal check sagt, welche:

Fehlermeldung enthält Ursache Behebung
protocol version not supported, handshake failure TLS 1.2 ist auf dem DC per Registry deaktiviert ad.min_tls_version: "1.0" setzen (siehe unten)
insecure algorithm SHA1-RSA, certificate signed by unknown authority DC-Zertifikat ist mit SHA-1 signiert Zertifikat neu ausstellen — siehe Kasten
tls: no cipher suite supported by both DC bietet nur RSA-Schlüsselaustausch GODEBUG=tlsrsakex=1 in der Unit setzen

TLS-Mindestversion herabsetzen:

ad:
  min_tls_version: "1.0"    # Default ist 1.2

Das Portal warnt dann bei jedem Start. Die Zertifikatsprüfung bleibt aktiv — für LDAP gibt es bewusst keine Möglichkeit, sie abzuschalten. Herabgesetzt werden nur die zulässigen Protokollversionen und die angebotenen Cipher-Suiten; RC4 und 3DES werden auch dann nie angeboten.

SHA-1-Zertifikate lassen sich nicht mehr umgehen. Go 1.24 hat den Notausgang GODEBUG=x509sha1=1 ersatzlos entfernt. Ist das LDAPS-Zertifikat des Domain Controllers mit SHA-1 signiert — bei ADCS-Vorlagen aus der 2012-R2-Ära der Normalfall — hilft nur, es mit SHA-256 neu auszustellen. Prüfen lässt sich das so:

openssl s_client -connect dc01.firma.local:636 -showcerts </dev/null 2>/dev/null \
  | openssl x509 -noout -text | grep -i "signature algorithm"

Steht dort sha1WithRSAEncryption, muss das Zertifikat getauscht werden.

RSA-Schlüsselaustausch erzwingen (letzte Rettung, keine Forward Secrecy). Go schaltet diese Suiten seit 1.22 ab; sie lassen sich nur global per GODEBUG reaktivieren. In der systemd-Unit:

[Service]
Environment=GODEBUG=tlsrsakex=1

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

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.25 oder neuer. Keine CGO-Abhängigkeiten, kein npm, kein Build-Step für das Frontend — Templates und Assets liegen per go:embed im Binary.

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

Go auf dem Zielhost installieren

Die Go-Pakete der Distributionen sind für dieses Projekt zu alt (Debian 12: 1.19, Ubuntu 24.04: 1.22, Raspberry Pi OS: 1.19). apt install golang genügt also nicht — Go kommt aus dem offiziellen Tarball:

uname -m        # x86_64 -> amd64,  aarch64 -> arm64

# Aktuelle Version auf https://go.dev/dl/ nachsehen, hier als Beispiel 1.26.5
curl -LO https://go.dev/dl/go1.26.5.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.26.5.linux-amd64.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.profile
export PATH=$PATH:/usr/local/go/bin
go version

Der erste Build lädt die vier Abhängigkeiten von proxy.golang.org — der Host braucht dafür einmalig Internetzugang. Soll er dauerhaft ohne Netz auskommen, entweder das Binary auf einer anderen Maschine bauen (make release) oder einmalig go mod vendor ausführen und das vendor/-Verzeichnis mitliefern.

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:

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

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.