Merge branch 'feature/v1-portal': VPN-Konfig-Portal v1

Vollständige Implementierung nach PRD.md: AD-Authentifizierung mit
DC-Failover, read-only OPNsense-Export-Client, serverseitig autorisierter
Streaming-Download, JSONL-Audit-Log mit Rotation, Startvalidierung und
gehärtete systemd-Unit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
Carsten Abele 2026-08-14 09:40:49 +02:00
commit f202ab811b
74 changed files with 8867 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/dist/
/vpnportal
*.log
.DS_Store

44
Makefile Normal file
View file

@ -0,0 +1,44 @@
BINARY := vpnportal
PKG := git.ravensburg.dev/cabele/opnsense-portal/cmd/vpnportal
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)
# sha256sum gibt es auf macOS nicht; shasum ist dort das Äquivalent.
SHA256 := $(shell command -v sha256sum >/dev/null 2>&1 && echo sha256sum || echo "shasum -a 256")
export CGO_ENABLED := 0
.PHONY: all build test race vet lint release clean
all: vet test build
build:
go build -trimpath -ldflags '$(LDFLAGS)' -o dist/$(BINARY) $(PKG)
test:
go test ./...
race:
go test -race ./...
vet:
go vet ./...
lint: vet
@test -z "$$(gofmt -l . | tee /dev/stderr)" || (echo "gofmt-Fehler: bitte 'gofmt -w .' ausführen" && exit 1)
release: clean
@set -e; for arch in amd64 arm64; do \
echo "==> linux/$$arch"; \
mkdir -p dist/linux-$$arch; \
GOOS=linux GOARCH=$$arch go build -trimpath -ldflags '$(LDFLAGS)' \
-o dist/linux-$$arch/$(BINARY) $(PKG); \
cp deploy/config.example.yaml deploy/vpnportal.service README.md dist/linux-$$arch/; \
tar -czf dist/$(BINARY)-$(VERSION)-linux-$$arch.tar.gz -C dist/linux-$$arch .; \
done
cd dist && $(SHA256) *.tar.gz > SHA256SUMS
clean:
rm -rf dist

422
README.md Normal file
View 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.

View file

@ -0,0 +1,14 @@
package main
import "fmt"
// Per -ldflags gesetzt; siehe Makefile.
var (
version = "dev"
commit = "none"
date = "unknown"
)
func versionString() string {
return fmt.Sprintf("vpnportal %s (commit %s, built %s)", version, commit, date)
}

80
cmd/vpnportal/check.go Normal file
View file

@ -0,0 +1,80 @@
package main
import (
"context"
"fmt"
"io"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
"git.ravensburg.dev/cabele/opnsense-portal/internal/check"
"git.ravensburg.dev/cabele/opnsense-portal/internal/config"
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
)
func runCheck(args []string, stdout, stderr io.Writer) int {
fs, path := configFlags("check", stderr)
testAuth := fs.String("test-auth", "",
"Suche und Gruppenprüfung für einen konkreten Benutzer durchspielen (ohne Passwort)")
if err := fs.Parse(args); err != nil {
return 2
}
cfg, err := config.Load(*path, nil)
if err != nil {
// Ohne lesbare Konfiguration lässt sich nichts weiter prüfen.
fmt.Fprintf(stdout, "✗ Konfiguration\n %v\n", err)
return 1
}
fw, ad, err := buildBackends(cfg)
if err != nil {
fmt.Fprintf(stdout, "✗ Initialisierung\n %v\n", err)
return 1
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
c := &check.Checker{Cfg: cfg, ConfigPath: *path, FW: fw, Dir: ad}
report := c.Run(ctx, *testAuth)
report.WriteTo(stdout)
if !report.OK() {
fmt.Fprintln(stdout, "\nEs sind Fehler aufgetreten. Das Portal würde nicht korrekt arbeiten.")
return 1
}
fmt.Fprintln(stdout, "\nAlle Prüfungen bestanden.")
return 0
}
// buildBackends erzeugt Firewall- und Verzeichnis-Client aus der Konfiguration.
// Wird von serve und check gemeinsam genutzt.
func buildBackends(cfg *config.Config) (*opnsense.Client, *auth.AD, error) {
fw, err := opnsense.New(opnsense.Options{
BaseURL: cfg.OPNsense.URL,
APIKey: cfg.OPNsense.APIKey,
APISecret: cfg.OPNsense.APISecret,
CAFile: cfg.OPNsense.CAFile,
InsecureSkipVerify: cfg.OPNsense.InsecureSkipVerify,
Timeout: 20 * time.Second,
})
if err != nil {
return nil, nil, err
}
ad, err := auth.NewAD(auth.Options{
Servers: cfg.AD.Servers,
Port: cfg.AD.Port,
TLSMode: cfg.AD.TLSMode,
BaseDN: cfg.EffectiveBaseDN(),
BindUser: cfg.AD.BindUser,
BindPassword: cfg.AD.BindPassword,
VPNGroup: cfg.AD.VPNGroup,
CAFile: cfg.AD.CAFile,
Timeout: time.Duration(cfg.AD.Timeout),
})
if err != nil {
return nil, nil, err
}
return fw, ad, nil
}

62
cmd/vpnportal/health.go Normal file
View file

@ -0,0 +1,62 @@
package main
import (
"context"
"sync"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/check"
"git.ravensburg.dev/cabele/opnsense-portal/internal/web"
)
// healthCacheTTL verhindert, dass häufige Monitoring-Abfragen Firewall und
// Verzeichnis belasten.
const healthCacheTTL = 30 * time.Second
// directoryPinger ist der für /healthz benötigte Ausschnitt des AD-Clients.
type directoryPinger interface {
ResolveGroupDN(ctx context.Context) (string, error)
}
// healthChecker beantwortet /healthz mit kurz gecachten Ergebnissen.
type healthChecker struct {
fw check.Pinger
dir directoryPinger
clock func() time.Time
mu sync.Mutex
cached web.HealthReport
cachedAt time.Time
}
func newHealthChecker(fw check.Pinger, dir directoryPinger) *healthChecker {
return &healthChecker{fw: fw, dir: dir, clock: time.Now}
}
func (h *healthChecker) Check(ctx context.Context) web.HealthReport {
now := h.clock()
h.mu.Lock()
if !h.cachedAt.IsZero() && now.Sub(h.cachedAt) < healthCacheTTL {
rep := h.cached
h.mu.Unlock()
return rep
}
h.mu.Unlock()
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
var rep web.HealthReport
_, fwErr := h.fw.Ping(ctx)
rep.OPNsense = fwErr == nil
_, dirErr := h.dir.ResolveGroupDN(ctx)
rep.Directory = dirErr == nil
rep.OK = rep.OPNsense && rep.Directory
h.mu.Lock()
h.cached, h.cachedAt = rep, now
h.mu.Unlock()
return rep
}
var _ web.HealthChecker = (*healthChecker)(nil)

46
cmd/vpnportal/main.go Normal file
View file

@ -0,0 +1,46 @@
package main
import (
"fmt"
"io"
"os"
)
const usage = `vpnportal VPN-Konfig-Portal für OPNsense
Aufruf:
vpnportal serve [--config PFAD] Portal starten (Default)
vpnportal check [--config PFAD] [--test-auth BENUTZER]
vpnportal version
Optionen:
--config PFAD Pfad zur Konfigurationsdatei (Default: /etc/vpnportal/config.yaml)
`
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
cmd := "serve"
rest := args
if len(args) > 0 && len(args[0]) > 0 && args[0][0] != '-' {
cmd, rest = args[0], args[1:]
}
switch cmd {
case "version":
fmt.Fprintln(stdout, versionString())
return 0
case "serve":
return runServe(rest, stdout, stderr)
case "check":
return runCheck(rest, stdout, stderr)
case "help", "-h", "--help":
fmt.Fprint(stdout, usage)
return 0
default:
fmt.Fprintf(stderr, "Unbekanntes Kommando: %s\n\n%s", cmd, usage)
return 2
}
}

View file

@ -0,0 +1,43 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestRunVersion(t *testing.T) {
var out, errOut bytes.Buffer
code := run([]string{"version"}, &out, &errOut)
if code != 0 {
t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, errOut.String())
}
got := out.String()
for _, want := range []string{"vpnportal", "commit", "built"} {
if !strings.Contains(got, want) {
t.Errorf("output %q missing %q", got, want)
}
}
}
func TestRunUnknownCommand(t *testing.T) {
var out, errOut bytes.Buffer
if code := run([]string{"frobnicate"}, &out, &errOut); code != 2 {
t.Fatalf("exit code = %d, want 2", code)
}
if !strings.Contains(errOut.String(), "frobnicate") {
t.Errorf("stderr should name the unknown command, got %q", errOut.String())
}
}
func TestRunNoArgsDefaultsToServe(t *testing.T) {
var out, errOut bytes.Buffer
// serve ohne --config muss mit Fehler abbrechen, nicht mit "unknown command"
code := run(nil, &out, &errOut)
if code == 2 {
t.Fatalf("no args must default to serve, not unknown-command")
}
if !strings.Contains(errOut.String(), "config") {
t.Errorf("stderr should complain about missing config, got %q", errOut.String())
}
}

238
cmd/vpnportal/serve.go Normal file
View file

@ -0,0 +1,238 @@
package main
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
"git.ravensburg.dev/cabele/opnsense-portal/internal/certmatch"
"git.ravensburg.dev/cabele/opnsense-portal/internal/check"
"git.ravensburg.dev/cabele/opnsense-portal/internal/config"
"git.ravensburg.dev/cabele/opnsense-portal/internal/portal"
"git.ravensburg.dev/cabele/opnsense-portal/internal/web"
)
const defaultConfigPath = "/etc/vpnportal/config.yaml"
// providerCacheTTL ist die einzige erlaubte Cache-Ausnahme: die Liste der
// VPN-Instanzen. Zertifikate werden nie gecacht.
const providerCacheTTL = 5 * time.Minute
func configFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(stderr)
path := fs.String("config", defaultConfigPath, "Pfad zur Konfigurationsdatei")
return fs, path
}
// configChecksum liefert eine gekürzte Prüfsumme der Konfigurationsdatei
// für das startup-Ereignis — sie erlaubt, Konfigurationsstände zu
// unterscheiden, ohne Inhalte zu protokollieren.
func configChecksum(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])[:12], nil
}
// newTLSConfig lädt Zertifikat und Schlüssel des Portals.
func newTLSConfig(certFile, keyFile string) (*tls.Config, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("TLS-Zertifikat/-Schlüssel konnten nicht geladen werden: %w", err)
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
}, nil
}
func runServe(args []string, stdout, stderr io.Writer) int {
fs, path := configFlags("serve", stderr)
if err := fs.Parse(args); err != nil {
return 2
}
cfg, err := config.Load(*path, nil)
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
if cfg.OPNsense.InsecureSkipVerify {
// Bewusst bei jedem Start, nicht nur einmalig.
fmt.Fprintln(stderr,
"WARNUNG: opnsense.insecure_skip_verify ist aktiv — "+
"die TLS-Verbindung zur Firewall wird NICHT geprüft. Nur für Tests verwenden.")
}
fw, ad, err := buildBackends(cfg)
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
// Startprüfung: das Portal startet nicht mit kaputter Umgebung.
startCtx, startCancel := context.WithTimeout(context.Background(), 60*time.Second)
report := (&check.Checker{Cfg: cfg, ConfigPath: *path, FW: fw, Dir: ad}).Run(startCtx, "")
startCancel()
report.WriteTo(stdout)
if !report.OK() {
fmt.Fprintln(stderr, "\nStart abgebrochen: die Startprüfung ist fehlgeschlagen.")
return 1
}
auditLog, err := audit.New(cfg.Logging.AuditLog, audit.Options{
MaxSizeMB: cfg.Logging.MaxSizeMB,
MaxBackups: cfg.Logging.MaxBackups,
Compress: cfg.Logging.Compress,
Stdout: stdout,
})
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
defer auditLog.Close()
// Der Failover-Hook kann erst jetzt gesetzt werden — vorher gab es noch
// kein Audit-Log, in das er hätte schreiben können.
ad.SetFailoverHook(func(server string, err error) {
auditLog.Log(audit.Event{Event: audit.EventLDAPFailover, Server: server})
})
matcher, err := certmatch.NewMatcher(cfg.Matching.CNPattern, cfg.Matching.CNRegex)
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
certs := portal.NewSource(fw, matcher, providerCacheTTL, time.Now)
renderer, err := web.NewRenderer(web.Brand{
Title: cfg.Portal.Title,
SupportContact: cfg.Portal.SupportContact,
Version: version,
})
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
sessions := web.NewSessionStore(time.Duration(cfg.Portal.SessionTTL), time.Now)
limiter := web.NewLimiter(time.Now)
srv, err := web.NewServer(web.Deps{
Auth: ad,
Certs: certs,
Audit: auditLog,
Sessions: sessions,
Limiter: limiter,
Renderer: renderer,
Health: newHealthChecker(fw, ad),
Clock: time.Now,
LogoPath: cfg.Portal.LogoFile,
SupportContact: cfg.Portal.SupportContact,
SessionTTL: time.Duration(cfg.Portal.SessionTTL),
})
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
tlsCfg, err := newTLSConfig(cfg.Portal.TLSCert, cfg.Portal.TLSKey)
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
sum, _ := configChecksum(*path)
auditLog.Log(audit.Event{
Event: audit.EventStartup, Version: versionString(), ConfigSum: sum,
Detail: "listen=" + cfg.Portal.Listen,
})
if msg := checkForUpdate(context.Background(), cfg.Portal.UpdateCheck, releaseURL, version, nil); msg != "" {
fmt.Fprintln(stdout, msg)
}
httpSrv := &http.Server{
Addr: cfg.Portal.Listen,
Handler: srv.Handler(),
TLSConfig: tlsCfg,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
// WriteTimeout bleibt großzügig, weil Downloads gestreamt werden.
WriteTimeout: 5 * time.Minute,
IdleTimeout: 60 * time.Second,
}
// Hintergrundaufräumen für Sessions und Rate-Limiter.
stopGC := make(chan struct{})
go func() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
sessions.GC()
limiter.GC()
case <-stopGC:
return
}
}
}()
defer close(stopGC)
// SIGHUP öffnet das Audit-Log neu (logrotate-Umgebungen).
hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
defer signal.Stop(hup)
go func() {
for range hup {
if err := auditLog.Reopen(); err != nil {
fmt.Fprintf(stderr, "Audit-Log konnte nicht neu geöffnet werden: %v\n", err)
}
}
}()
// Geordnetes Herunterfahren.
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(shutdown)
errCh := make(chan error, 1)
go func() {
fmt.Fprintf(stdout, "%s lauscht auf https://%s\n", versionString(), cfg.Portal.Listen)
errCh <- httpSrv.ListenAndServeTLS("", "")
}()
select {
case err := <-errCh:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Fprintf(stderr, "Fehler: Server beendet: %v\n", err)
return 1
}
return 0
case sig := <-shutdown:
fmt.Fprintf(stdout, "Signal %s empfangen, fahre herunter…\n", sig)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpSrv.Shutdown(ctx); err != nil {
fmt.Fprintf(stderr, "Herunterfahren nicht sauber beendet: %v\n", err)
return 1
}
return 0
}
}

View file

@ -0,0 +1,82 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestServeRejectsMissingConfig(t *testing.T) {
var out, errOut bytes.Buffer
code := runServe([]string{"--config", "/gibt/es/nicht.yaml"}, &out, &errOut)
if code == 0 {
t.Fatal("fehlende Config muss zum Abbruch führen")
}
if !strings.Contains(errOut.String(), "nicht.yaml") {
t.Errorf("Fehlermeldung muss den Pfad nennen: %q", errOut.String())
}
}
func TestServeRejectsLooseConfigPermissions(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.yaml")
os.WriteFile(p, []byte("portal:\n listen: \"127.0.0.1:0\"\n"), 0o644)
os.Chmod(p, 0o644)
var out, errOut bytes.Buffer
if code := runServe([]string{"--config", p}, &out, &errOut); code == 0 {
t.Fatal("zu offene Dateirechte müssen den Start verhindern")
}
if !strings.Contains(errOut.String(), "chmod") {
t.Errorf("Fehlermeldung sollte den Reparaturbefehl nennen: %q", errOut.String())
}
}
func TestCheckRejectsUnknownConfigField(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.yaml")
os.WriteFile(p, []byte("portal:\n tls_cert_typo: \"x\"\n"), 0o600)
os.Chmod(p, 0o600)
var out, errOut bytes.Buffer
code := runCheck([]string{"--config", p}, &out, &errOut)
if code == 0 {
t.Fatal("Tippfehler in der Config muss zum Abbruch führen")
}
combined := out.String() + errOut.String()
if !strings.Contains(combined, "tls_cert_typo") {
t.Errorf("das unbekannte Feld muss genannt werden: %q", combined)
}
}
func TestConfigChecksumIsStable(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.yaml")
os.WriteFile(p, []byte("inhalt"), 0o600)
os.Chmod(p, 0o600)
a, err := configChecksum(p)
if err != nil {
t.Fatal(err)
}
b, _ := configChecksum(p)
if a != b || a == "" {
t.Fatalf("Prüfsumme instabil: %q vs %q", a, b)
}
if len(a) != 12 {
t.Errorf("Prüfsumme sollte gekürzt sein (12 Zeichen), ist %d", len(a))
}
os.WriteFile(p, []byte("anderer inhalt"), 0o600)
if c, _ := configChecksum(p); c == a {
t.Error("geänderte Datei muss eine andere Prüfsumme ergeben")
}
}
func TestNewTLSConfigRejectsMissingFiles(t *testing.T) {
if _, err := newTLSConfig("/nix.crt", "/nix.key"); err == nil {
t.Fatal("fehlende Zertifikatsdateien müssen abgelehnt werden")
}
}

View file

@ -0,0 +1,94 @@
//go:build uidemo
// Dieser Test startet das Portal mit Attrappen für AD und Firewall auf
// http://127.0.0.1:8080, damit die Oberfläche ohne echte Backends begutachtet
// werden kann. Aufruf:
//
// go test -tags uidemo ./cmd/vpnportal/ -run TestUIDemo -v -timeout 0
//
// Anmeldung mit beliebigem Benutzernamen und Passwort.
// Browser erlauben Secure-Cookies auf 127.0.0.1, deshalb funktioniert der
// gesamte Ablauf auch ohne TLS.
package main
import (
"context"
"io"
"net/http"
"os"
"strings"
"testing"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
"git.ravensburg.dev/cabele/opnsense-portal/internal/web"
)
type demoAuth struct{}
func (demoAuth) Authenticate(ctx context.Context, u, p string) (*auth.Identity, error) {
return &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}}, nil
}
type demoCerts struct{}
func (demoCerts) EntriesFor(ctx context.Context, user string) ([]web.CertEntry, error) {
return []web.CertEntry{
{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller",
ValidTo: time.Now().AddDate(1, 2, 0)},
{Token: "2:b1", InstanceName: "VPN Außendienst", CommonName: "mmueller",
ValidTo: time.Now().AddDate(0, 0, 12)},
}, nil
}
func (demoCerts) Export(ctx context.Context, user, vpnID, refID, format string) (*web.ExportStream, error) {
return &web.ExportStream{
Filename: "vpn-demo-mmueller.ovpn",
ContentType: "application/x-openvpn-profile",
Body: io.NopCloser(strings.NewReader("client\nremote fw01.testfirma.local 1194\n")),
InstanceName: "VPN Homeoffice",
CommonName: "mmueller",
ValidTo: time.Now().AddDate(1, 0, 0),
}, nil
}
func (demoCerts) Pattern(user string) string { return user }
// leereCerts zeigt den Zustand "kein Zertifikat hinterlegt".
type leereCerts struct{ demoCerts }
func (leereCerts) EntriesFor(ctx context.Context, user string) ([]web.CertEntry, error) {
return nil, nil
}
type demoAudit struct{ t *testing.T }
func (d demoAudit) Log(e audit.Event) { d.t.Logf("audit: %+v", e) }
func TestUIDemo(t *testing.T) {
rn, err := web.NewRenderer(web.Brand{Title: "VPN-Portal Testfirma", Version: "uidemo"})
if err != nil {
t.Fatal(err)
}
// Mit VPNPORTAL_UIDEMO_EMPTY=1 den Zustand "kein Zertifikat" begutachten.
var certs web.CertSource = demoCerts{}
if os.Getenv("VPNPORTAL_UIDEMO_EMPTY") != "" {
certs = leereCerts{}
}
srv, err := web.NewServer(web.Deps{
Auth: demoAuth{}, Certs: certs, Audit: demoAudit{t},
Sessions: web.NewSessionStore(30*time.Minute, nil),
Limiter: web.NewLimiter(nil), Renderer: rn,
SupportContact: "it-helpdesk@testfirma.de",
SessionTTL: 30 * time.Minute,
MinLoginDuration: time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
t.Log("Oberfläche unter http://127.0.0.1:8080 — mit Strg-C beenden")
if err := http.ListenAndServe("127.0.0.1:8080", srv.Handler()); err != nil {
t.Fatal(err)
}
}

View file

@ -0,0 +1,61 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// releaseURL ist der Endpunkt des Versionschecks. Er wird nur abgefragt,
// wenn portal.update_check ausdrücklich eingeschaltet ist (Default: aus).
const releaseURL = "https://git.ravensburg.dev/api/v1/repos/cabele/opnsense-portal/releases/latest"
// checkForUpdate fragt read-only die neueste Version ab und liefert einen
// Hinweistext, falls sich die veröffentlichte Version unterscheidet.
// Jeder Fehler führt zu einer leeren Rückgabe — der Check darf den Start
// unter keinen Umständen stören.
func checkForUpdate(ctx context.Context, enabled bool, url, current string, client *http.Client) string {
if !enabled {
return ""
}
if client == nil {
client = &http.Client{Timeout: 5 * time.Second}
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return ""
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "vpnportal/"+current)
resp, err := client.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
var release struct {
TagName string `json:"tag_name"`
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil || json.Unmarshal(raw, &release) != nil {
return ""
}
// Reiner Gleichheitsvergleich, keine Semver-Ordnung: das Portal meldet nur,
// dass sich die veröffentlichte Version unterscheidet, und urteilt nicht
// selbst über Versionsordnungen.
latest := strings.TrimPrefix(strings.TrimSpace(release.TagName), "v")
if latest == "" || latest == strings.TrimPrefix(strings.TrimSpace(current), "v") {
return ""
}
return fmt.Sprintf("Hinweis: Version %s ist verfügbar (installiert: %s).", latest, current)
}

View file

@ -0,0 +1,78 @@
package main
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestUpdateCheckDisabledMakesNoRequest(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
}))
defer srv.Close()
if msg := checkForUpdate(context.Background(), false, srv.URL, "1.0.0", srv.Client()); msg != "" {
t.Errorf("bei update_check=false darf nichts gemeldet werden, got %q", msg)
}
if called {
t.Fatal("bei update_check=false darf kein Netzwerkzugriff stattfinden")
}
}
func TestUpdateCheckReportsNewerVersion(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, `{"tag_name":"v1.4.0"}`)
}))
defer srv.Close()
msg := checkForUpdate(context.Background(), true, srv.URL, "1.2.0", srv.Client())
if msg == "" {
t.Fatal("abweichende Version muss gemeldet werden")
}
if !strings.Contains(msg, "1.4.0") {
t.Errorf("Meldung nennt die neue Version nicht: %q", msg)
}
}
func TestUpdateCheckSilentWhenCurrent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, `{"tag_name":"v1.2.0"}`)
}))
defer srv.Close()
// Auch mit "v"-Präfix auf der einen und ohne auf der anderen Seite.
if msg := checkForUpdate(context.Background(), true, srv.URL, "1.2.0", srv.Client()); msg != "" {
t.Errorf("bei aktueller Version darf nichts gemeldet werden, got %q", msg)
}
}
func TestUpdateCheckSurvivesUnreachableEndpoint(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
url := srv.URL
client := srv.Client()
srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Ein nicht erreichbarer Endpunkt darf den Start niemals stören.
if msg := checkForUpdate(ctx, true, url, "1.2.0", client); msg != "" {
t.Errorf("bei Fehler darf nichts gemeldet werden, got %q", msg)
}
}
func TestUpdateCheckIgnoresGarbage(t *testing.T) {
for _, body := range []string{`nicht json`, `{}`, `{"tag_name":""}`, `{"tag_name":" "}`} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, body)
}))
if msg := checkForUpdate(context.Background(), true, srv.URL, "1.2.0", srv.Client()); msg != "" {
t.Errorf("Antwort %q darf nichts melden, got %q", body, msg)
}
srv.Close()
}
}

121
deploy/config.example.yaml Normal file
View 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
View 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

102
docs/opnsense-api.md Normal file
View file

@ -0,0 +1,102 @@
# OPNsense-API: genutzte Endpunkte und Annahmen
Das Portal spricht ausschließlich das Plugin **`os-openvpn-client-export`** an
und ausschließlich lesend.
> **Diese Annahmen sind mit `go test -tags integration ./internal/opnsense/`
> gegen eine echte Instanz zu bestätigen, bevor v1 ausgeliefert wird.**
> Die exakten Feldnamen und Antwortformen wechseln zwischen OPNsense-Versionen;
> der Client ist deshalb bewusst tolerant gebaut.
## Genutzte Endpunkte
| Methode | Pfad | Zweck |
|---|---|---|
| `GET` | `/api/openvpn/export/providers` | Liste der exportierbaren VPN-Instanzen |
| `GET` | `/api/openvpn/export/accounts/{vpnid}` | Zertifikate einer Instanz |
| `GET` | `/api/openvpn/export/download/{vpnid}/{format}/{certref}` | Konfiguration herunterladen |
Alle drei sind `GET`. Das Portal ruft **keinen** schreibenden Endpunkt auf und
übergibt **keine** Exportoptionen — die auf der Firewall hinterlegten
Einstellungen sind die Quelle der Wahrheit.
## Authentifizierung
HTTP Basic Auth mit API-Key als Benutzername und API-Secret als Passwort.
Der API-Benutzer braucht ausschließlich das Privileg
**„VPN: OpenVPN Client Export"** — niemals einen Admin-Key.
Bei ungültigem Key antwortet OPNsense je nach Version mit `401` oder mit
`200` und einer HTML-Loginseite. Der Client behandelt beides als
`ErrUnauthorized`; die HTML-Erkennung prüft sowohl den `Content-Type` als
auch ein führendes `<` im Body.
## Akzeptierte Feldnamen
Der Client akzeptiert mehrere Schreibweisen und nimmt den ersten
nicht-leeren Treffer (`internal/opnsense/types.go`, `rawAccount`):
| Bedeutung | Akzeptierte JSON-Felder |
|---|---|
| Common Name | `commonName`, `common_name` |
| Beschreibung | `description`, `descr` |
| Ablaufdatum | `validTo`, `valid_to`, `validto` |
| Revoziert | `isRevoked`, `revoked`, `is_revoked` |
| VPN-ID (Provider) | `vpnid`, sonst der Map-Schlüssel |
| Instanzname | `name`, `description`, sonst `VPN <id>` |
**Revoziert** gilt, sobald *eines* der drei Felder wahr meldet — im Zweifel
restriktiv. Als „wahr" zählen `true`, `1`, `"1"`, `"true"`, `"yes"`, `"on"`.
## Akzeptierte Datumsformate
`time.RFC3339`, `2006-01-02T15:04:05`, `2006-01-02 15:04:05`, `2006-01-02`,
`Jan _2 15:04:05 2006 MST` (OpenSSL-Stil), `060102150405Z` (ASN.1 UTCTime)
sowie Unix-Zeitstempel als Zahl.
Ein **nicht auswertbares oder fehlendes** Ablaufdatum führt *nicht* zum
Ausschluss des Zertifikats — die Revocation-Prüfung bleibt maßgeblich, und ein
unbekanntes Datum darf keine gültige Konfiguration blockieren. Der
Integrationstest `TestIntegrationAccountsExpiryIsParsed` schlägt fehl, wenn
*kein* Zertifikat ein auswertbares Datum liefert; das ist das Signal, die
Layout-Liste zu ergänzen.
## Antwortformen des Downloads
Zwei Varianten sind implementiert und werden am `Content-Type` unterschieden:
1. **Rohdaten** (`application/octet-stream` o. ä.) — wird direkt
durchgestreamt, nichts wird gepuffert oder auf Platte geschrieben.
Der Dateiname kommt aus `Content-Disposition`.
2. **Base64 in JSON** (`application/json`) — Feld `content` oder `data`,
optional `filename`. Lässt sich der Inhalt nicht Base64-dekodieren, wird
er als Klartext behandelt.
## Unterstützte Exportformate
Das Portal bietet genau zwei Formate an: `ovpn` (inline `.ovpn`) und
`viscosity`. Weitere Formate sind bewusst nicht wählbar.
## Mindestversion
**Beim ersten erfolgreichen Integrationstestlauf hier eintragen:**
- OPNsense: _(zu ermitteln)_
- `os-openvpn-client-export`: _(zu ermitteln)_
## Integrationstests ausführen
```bash
export OPNSENSE_URL="https://fw01.firma.local"
export OPNSENSE_KEY="..."
export OPNSENSE_SECRET="..."
export OPNSENSE_CA="/pfad/zur/firma-ca.pem" # optional
export OPNSENSE_REVOKED_CN="testuser-revoked" # optional, für den Revocation-Test
export OPNSENSE_VPNID="1" # optional, für den Export-Test
export OPNSENSE_CERT_REF="abc123" # optional, für den Export-Test
go test -tags integration ./internal/opnsense/ -v
```
Ohne gesetzte Variablen überspringen sich die Tests selbst; sie laufen
deshalb nie versehentlich in der normalen Testsuite mit.

12
go.mod Normal file
View file

@ -0,0 +1,12 @@
module git.ravensburg.dev/cabele/opnsense-portal
go 1.26.5
require (
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-ldap/ldap/v3 v3.4.14 // indirect
github.com/google/uuid v1.6.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

13
go.sum Normal file
View file

@ -0,0 +1,13 @@
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

60
internal/audit/event.go Normal file
View file

@ -0,0 +1,60 @@
// Package audit schreibt strukturierte Audit-Ereignisse als JSON Lines.
package audit
import (
"crypto/sha256"
"encoding/hex"
)
// Audit-Ereignisse.
const (
EventLoginSuccess = "login_success"
EventLoginFailed = "login_failed"
EventLogout = "logout"
EventSessionExpired = "session_expired"
EventConfigDownload = "config_download"
EventDownloadDenied = "download_denied"
)
// Betriebsereignisse.
const (
EventNoCertFound = "no_cert_found"
EventOPNsenseUnreachable = "opnsense_unreachable"
EventLDAPFailover = "ldap_failover"
EventRateLimited = "rate_limited"
EventStartup = "startup"
)
// UnknownUser ersetzt den eingegebenen Namen, wenn der Benutzer im AD nicht
// gefunden wurde. Damit landet ein versehentlich ins Username-Feld getipptes
// Passwort nie im Klartext im Log.
const UnknownUser = "<unknown>"
// Event ist eine Zeile im Audit-Log. Leere Felder werden nicht serialisiert.
type Event struct {
TS string `json:"ts"`
Event string `json:"event"`
User string `json:"user,omitempty"`
SrcIP string `json:"src_ip,omitempty"`
Session string `json:"session,omitempty"`
Reason string `json:"reason,omitempty"`
VPNInstance string `json:"vpn_instance,omitempty"`
CertCN string `json:"cert_cn,omitempty"`
CertExpiry string `json:"cert_expiry,omitempty"`
Format string `json:"format,omitempty"`
Pattern string `json:"pattern,omitempty"`
Server string `json:"server,omitempty"`
Version string `json:"version,omitempty"`
ConfigSum string `json:"config_sum,omitempty"`
Detail string `json:"detail,omitempty"`
}
// ShortSession liefert eine kurze, nicht umkehrbare Korrelations-ID.
// Der Session-Token selbst darf niemals ins Log gelangen.
func ShortSession(token string) string {
if token == "" {
return ""
}
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])[:4]
}

77
internal/audit/logger.go Normal file
View file

@ -0,0 +1,77 @@
package audit
import (
"encoding/json"
"fmt"
"io"
"sync"
"time"
)
// Options steuert Rotation und Nebenausgabe.
type Options struct {
MaxSizeMB int
MaxBackups int
Compress bool
// Stdout erhält eine Kopie jeder Zeile (systemd-Journal). Nil = keine Kopie.
Stdout io.Writer
// Clock ist injizierbar für Tests. Nil = time.Now.
Clock func() time.Time
}
// Logger schreibt Events zeilenweise als JSON.
type Logger struct {
mu sync.Mutex
file *rotatingFile
stdout io.Writer
clock func() time.Time
}
// New öffnet das Audit-Log und liefert einen Logger.
// Ein fehlendes Verzeichnis wird angelegt.
func New(path string, opts Options) (*Logger, error) {
if opts.Clock == nil {
opts.Clock = time.Now
}
rf, err := openRotating(path, opts)
if err != nil {
return nil, err
}
return &Logger{file: rf, stdout: opts.Stdout, clock: opts.Clock}, nil
}
// Log schreibt ein Ereignis. Fehler beim Schreiben werden nach stdout gemeldet,
// dürfen den laufenden Request aber nie scheitern lassen.
func (l *Logger) Log(e Event) {
if e.TS == "" {
e.TS = l.clock().Format(time.RFC3339)
}
line, err := json.Marshal(e)
if err != nil {
return // Event enthält nur Strings; kann praktisch nicht passieren
}
line = append(line, '\n')
l.mu.Lock()
defer l.mu.Unlock()
if _, err := l.file.Write(line); err != nil && l.stdout != nil {
fmt.Fprintf(l.stdout, "{\"ts\":%q,\"event\":\"audit_write_failed\",\"detail\":%q}\n",
e.TS, err.Error())
}
if l.stdout != nil {
l.stdout.Write(line)
}
}
// Reopen schließt die aktuelle Datei und öffnet sie neu (SIGHUP/logrotate).
func (l *Logger) Reopen() error {
l.mu.Lock()
defer l.mu.Unlock()
return l.file.reopen()
}
func (l *Logger) Close() error {
l.mu.Lock()
defer l.mu.Unlock()
return l.file.Close()
}

View file

@ -0,0 +1,124 @@
package audit
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func newTestLogger(t *testing.T, opts Options) (*Logger, string, *bytes.Buffer) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "audit.log")
var stdout bytes.Buffer
opts.Stdout = &stdout
if opts.MaxSizeMB == 0 {
opts.MaxSizeMB = 50
}
lg, err := New(path, opts)
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(func() { lg.Close() })
return lg, path, &stdout
}
func readLines(t *testing.T, path string) []map[string]any {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var out []map[string]any
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
if line == "" {
continue
}
var m map[string]any
if err := json.Unmarshal([]byte(line), &m); err != nil {
t.Fatalf("Zeile ist kein gültiges JSON: %q: %v", line, err)
}
out = append(out, m)
}
return out
}
func TestLogWritesJSONLWithTimezone(t *testing.T) {
fixed := time.Date(2026, 8, 14, 9, 32, 11, 0, time.FixedZone("CEST", 2*3600))
lg, path, stdout := newTestLogger(t, Options{Clock: func() time.Time { return fixed }})
lg.Log(Event{Event: EventLoginSuccess, User: "mmueller", SrcIP: "10.1.20.34", Session: "a3f9"})
if err := lg.Close(); err != nil {
t.Fatal(err)
}
lines := readLines(t, path)
if len(lines) != 1 {
t.Fatalf("got %d Zeilen, want 1", len(lines))
}
got := lines[0]
if got["ts"] != "2026-08-14T09:32:11+02:00" {
t.Errorf("ts = %v, want RFC3339 mit Zeitzone", got["ts"])
}
if got["event"] != EventLoginSuccess || got["user"] != "mmueller" {
t.Errorf("Feldwerte falsch: %v", got)
}
if _, present := got["reason"]; present {
t.Errorf("leere Felder dürfen nicht serialisiert werden: %v", got)
}
if !strings.Contains(stdout.String(), EventLoginSuccess) {
t.Error("Event muss zusätzlich nach stdout gehen")
}
}
func TestLogIsConcurrencySafe(t *testing.T) {
lg, path, _ := newTestLogger(t, Options{})
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
lg.Log(Event{Event: EventLoginFailed, User: UnknownUser, SrcIP: "10.0.0.1"})
}()
}
wg.Wait()
if err := lg.Close(); err != nil {
t.Fatal(err)
}
if n := len(readLines(t, path)); n != 50 {
t.Fatalf("got %d Zeilen, want 50 (kein Interleaving erlaubt)", n)
}
}
func TestNewCreatesFileWithTightPermissions(t *testing.T) {
_, path, _ := newTestLogger(t, Options{})
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if perm := info.Mode().Perm(); perm&0o077 != 0 {
t.Errorf("Audit-Log hat Rechte %#o — Gruppe/Welt dürfen nicht zugreifen", perm)
}
}
func TestShortSessionNeverLeaksToken(t *testing.T) {
token := "0123456789abcdef0123456789abcdef"
short := ShortSession(token)
if len(short) != 4 {
t.Errorf("ShortSession = %q, want 4 Zeichen", short)
}
if strings.Contains(token, short) {
t.Errorf("gekürzte ID %q ist ein Präfix des Tokens — muss gehasht sein", short)
}
if ShortSession(token) != short {
t.Error("ShortSession muss deterministisch sein")
}
if ShortSession("") != "" {
t.Error("leerer Token muss leere ID liefern")
}
}

159
internal/audit/rotate.go Normal file
View file

@ -0,0 +1,159 @@
package audit
import (
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// logFileMode: nur der Eigentümer darf lesen und schreiben — das Log enthält
// personenbezogene Daten.
const logFileMode os.FileMode = 0o600
// rotatingFile ist ein größenrotierender Writer ohne externe Dependency.
// Nicht selbst gesperrt — der Aufrufer (Logger) hält das Mutex.
type rotatingFile struct {
path string
maxBytes int64
maxBackups int
compress bool
f *os.File
size int64
}
func openRotating(path string, opts Options) (*rotatingFile, error) {
if opts.MaxSizeMB <= 0 {
opts.MaxSizeMB = 50
}
rf := &rotatingFile{
path: path,
maxBytes: int64(opts.MaxSizeMB) * 1024 * 1024,
maxBackups: opts.MaxBackups,
compress: opts.Compress,
}
if err := rf.open(); err != nil {
return nil, err
}
return rf, nil
}
func (r *rotatingFile) open() error {
if err := os.MkdirAll(filepath.Dir(r.path), 0o750); err != nil {
return fmt.Errorf("Log-Verzeichnis %s: %w", filepath.Dir(r.path), err)
}
f, err := os.OpenFile(r.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, logFileMode)
if err != nil {
return fmt.Errorf("Audit-Log %s: %w", r.path, err)
}
info, err := f.Stat()
if err != nil {
f.Close()
return fmt.Errorf("Audit-Log %s: %w", r.path, err)
}
r.f, r.size = f, info.Size()
return nil
}
func (r *rotatingFile) Write(p []byte) (int, error) {
if r.size+int64(len(p)) > r.maxBytes && r.size > 0 {
if err := r.rotate(); err != nil {
return 0, err
}
}
n, err := r.f.Write(p)
r.size += int64(n)
return n, err
}
// reopen schließt und öffnet die Datei neu (SIGHUP nach externem logrotate).
func (r *rotatingFile) reopen() error {
if r.f != nil {
r.f.Close()
}
return r.open()
}
func (r *rotatingFile) Close() error {
if r.f == nil {
return nil
}
err := r.f.Close()
r.f = nil
return err
}
func (r *rotatingFile) rotate() error {
if err := r.f.Close(); err != nil {
return err
}
backup := fmt.Sprintf("%s.%s", r.path, time.Now().UTC().Format("20060102T150405.000"))
if err := os.Rename(r.path, backup); err != nil {
return fmt.Errorf("Rotation von %s: %w", r.path, err)
}
if r.compress {
if gzPath, err := gzipFile(backup); err == nil {
os.Remove(backup)
backup = gzPath
}
}
if err := r.open(); err != nil {
return err
}
r.pruneBackups()
return nil
}
func gzipFile(path string) (string, error) {
in, err := os.Open(path)
if err != nil {
return "", err
}
defer in.Close()
out, err := os.OpenFile(path+".gz", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, logFileMode)
if err != nil {
return "", err
}
defer out.Close()
zw := gzip.NewWriter(out)
if _, err := io.Copy(zw, in); err != nil {
return "", err
}
if err := zw.Close(); err != nil {
return "", err
}
return path + ".gz", nil
}
// pruneBackups löscht die ältesten rotierten Dateien über maxBackups hinaus.
func (r *rotatingFile) pruneBackups() {
if r.maxBackups <= 0 {
return
}
dir, base := filepath.Split(r.path)
if dir == "" {
dir = "."
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
var backups []string
for _, e := range entries {
name := e.Name()
if name != base && strings.HasPrefix(name, base+".") {
backups = append(backups, name)
}
}
// Die Zeitstempel im Namen sind lexikografisch sortierbar.
sort.Strings(backups)
for len(backups) > r.maxBackups {
os.Remove(filepath.Join(dir, backups[0]))
backups = backups[1:]
}
}

View file

@ -0,0 +1,106 @@
package audit
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRotationCreatesBackupAndTruncates(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.log")
rf, err := openRotating(path, Options{MaxSizeMB: 1, MaxBackups: 2})
if err != nil {
t.Fatal(err)
}
defer rf.Close()
rf.maxBytes = 200 // Rotationsschwelle für den Test verkleinern
line := []byte(strings.Repeat("x", 60) + "\n")
for i := 0; i < 10; i++ {
if _, err := rf.Write(line); err != nil {
t.Fatal(err)
}
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
var backups int
for _, e := range entries {
if e.Name() != "audit.log" && strings.HasPrefix(e.Name(), "audit.log.") {
backups++
}
}
if backups == 0 {
t.Fatal("es muss mindestens eine rotierte Datei geben")
}
if backups > 2 {
t.Fatalf("MaxBackups=2 überschritten: %d Backups", backups)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Size() > rf.maxBytes {
t.Fatalf("aktive Datei ist %d Bytes groß, Schwelle ist %d", info.Size(), rf.maxBytes)
}
}
func TestRotationCompresses(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.log")
rf, err := openRotating(path, Options{MaxSizeMB: 1, MaxBackups: 3, Compress: true})
if err != nil {
t.Fatal(err)
}
defer rf.Close()
rf.maxBytes = 100
for i := 0; i < 6; i++ {
if _, err := rf.Write([]byte(strings.Repeat("y", 60) + "\n")); err != nil {
t.Fatal(err)
}
}
entries, _ := os.ReadDir(dir)
var gz int
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".gz") {
gz++
}
}
if gz == 0 {
t.Fatal("bei Compress=true müssen rotierte Dateien .gz sein")
}
}
func TestReopenRecreatesDeletedFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.log")
rf, err := openRotating(path, Options{MaxSizeMB: 1})
if err != nil {
t.Fatal(err)
}
defer rf.Close()
if _, err := rf.Write([]byte("erste\n")); err != nil {
t.Fatal(err)
}
if err := os.Rename(path, path+".moved"); err != nil {
t.Fatal(err)
}
if err := rf.reopen(); err != nil {
t.Fatal(err)
}
if _, err := rf.Write([]byte("zweite\n")); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("nach reopen muss die Datei wieder existieren: %v", err)
}
if string(raw) != "zweite\n" {
t.Errorf("neue Datei enthält %q, want \"zweite\\n\"", raw)
}
}

330
internal/auth/ad.go Normal file
View file

@ -0,0 +1,330 @@
package auth
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/go-ldap/ldap/v3"
)
// MatchingRuleInChain ist die AD-Erweiterung LDAP_MATCHING_RULE_IN_CHAIN.
// Sie löst verschachtelte Gruppenmitgliedschaften in einer einzigen Abfrage auf.
const MatchingRuleInChain = "1.2.840.113556.1.4.1941"
const defaultTimeout = 8 * time.Second
// FailoverHook wird gerufen, wenn ein Domain Controller übersprungen wird.
type FailoverHook func(server string, err error)
// Options konfiguriert den AD-Authenticator.
type Options struct {
Servers []string
Port int
TLSMode string // "ldaps" oder "starttls"
BaseDN string
BindUser string
BindPassword string
VPNGroup string
CAFile string
Timeout time.Duration
// Dial ist injizierbar; nil = echte LDAP-Verbindung.
Dial func(ctx context.Context, server string) (conn, error)
// OnFailover kann auch nachträglich über SetFailoverHook gesetzt werden.
OnFailover FailoverHook
}
// AD authentifiziert gegen Active Directory.
type AD struct {
opts Options
// failover wird atomar gehalten, weil der Hook erst nach dem Öffnen des
// Audit-Logs gesetzt wird, withConn ihn aber sperrfrei lesen muss.
failover atomic.Pointer[FailoverHook]
mu sync.RWMutex
groupDN string
}
// NewAD prüft die Optionen und liefert den Authenticator.
// Der Gruppen-DN wird erst durch ResolveGroupDN aufgelöst und danach gecacht.
func NewAD(opts Options) (*AD, error) {
if len(opts.Servers) == 0 {
return nil, errors.New("ad: keine Domain Controller konfiguriert")
}
if opts.BindUser == "" || opts.BindPassword == "" {
return nil, errors.New("ad: Service-Account (bind_user/bind_password) fehlt")
}
if opts.BaseDN == "" {
return nil, errors.New("ad: Base-DN ist leer")
}
if opts.VPNGroup == "" {
return nil, errors.New("ad: vpn_group fehlt")
}
if opts.Timeout <= 0 {
opts.Timeout = defaultTimeout
}
if opts.Port == 0 {
opts.Port = 636
}
if opts.Dial == nil {
opts.Dial = realDialer(opts.Port, opts.TLSMode, opts.CAFile, opts.Timeout)
}
a := &AD{opts: opts}
if opts.OnFailover != nil {
a.SetFailoverHook(opts.OnFailover)
}
return a, nil
}
// SetFailoverHook setzt den Failover-Rückruf, auch noch nach dem Start.
// Das ist nötig, weil der Audit-Logger erst nach der Startprüfung geöffnet wird.
func (a *AD) SetFailoverHook(fn FailoverHook) {
if fn == nil {
a.failover.Store(nil)
return
}
a.failover.Store(&fn)
}
func (a *AD) notifyFailover(server string, err error) {
if p := a.failover.Load(); p != nil {
(*p)(server, err)
}
}
// GroupDN liefert den zwischengespeicherten Gruppen-DN.
func (a *AD) GroupDN() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.groupDN
}
// withConn verbindet sich zum ersten erreichbaren DC, bindet den
// Service-Account und ruft fn.
func (a *AD) withConn(ctx context.Context, fn func(c conn) error) error {
var lastErr error
for _, server := range a.opts.Servers {
c, err := a.opts.Dial(ctx, server)
if err != nil {
lastErr = fmt.Errorf("%s: %w", server, err)
a.notifyFailover(server, err)
continue
}
if err := c.Bind(a.opts.BindUser, a.opts.BindPassword); err != nil {
c.Close()
// Ein falsches Dienstkonto-Passwort ist ein Konfigurationsfehler
// und wird nicht durch Failover kaschiert.
return fmt.Errorf("Service-Bind an %s fehlgeschlagen: %w", server, err)
}
defer c.Close()
return fn(c)
}
return fmt.Errorf("kein Domain Controller erreichbar: %w", lastErr)
}
// searchOne führt eine Suche aus und liefert höchstens einen Eintrag.
func searchOne(c conn, baseDN, filter string, attrs []string, timeout time.Duration) (*ldap.Entry, error) {
req := ldap.NewSearchRequest(
baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
2, int(timeout.Seconds()), false, filter, attrs, nil)
res, err := c.Search(req)
if err != nil {
// Referrals in Single-Domain-Umgebungen ignorieren.
if ldap.IsErrorWithCode(err, ldap.LDAPResultReferral) {
return nil, nil
}
return nil, err
}
if len(res.Entries) == 0 {
return nil, nil
}
return res.Entries[0], nil
}
// resolveGroupDN sucht den DN der VPN-Gruppe über ihren sAMAccountName oder CN.
func (a *AD) resolveGroupDN(c conn) (string, error) {
name := ldap.EscapeFilter(a.opts.VPNGroup)
filter := fmt.Sprintf("(&(objectClass=group)(|(sAMAccountName=%s)(cn=%s)))", name, name)
e, err := searchOne(c, a.opts.BaseDN, filter, []string{"distinguishedName"}, a.opts.Timeout)
if err != nil {
return "", fmt.Errorf("Suche nach Gruppe %q: %w", a.opts.VPNGroup, err)
}
if e == nil {
return "", fmt.Errorf("Gruppe %q wurde unter %s nicht gefunden", a.opts.VPNGroup, a.opts.BaseDN)
}
return e.DN, nil
}
// ResolveGroupDN löst den Gruppen-DN auf und legt ihn im Cache ab.
// Wird beim Start und von check aufgerufen.
func (a *AD) ResolveGroupDN(ctx context.Context) (string, error) {
var dn string
err := a.withConn(ctx, func(c conn) error {
var err error
dn, err = a.resolveGroupDN(c)
return err
})
if err != nil {
return "", err
}
a.mu.Lock()
a.groupDN = dn
a.mu.Unlock()
return dn, nil
}
// groupDNOrResolve liefert den gecachten DN oder löst ihn nach.
func (a *AD) groupDNOrResolve(c conn) (string, error) {
if dn := a.GroupDN(); dn != "" {
return dn, nil
}
dn, err := a.resolveGroupDN(c)
if err != nil {
return "", err
}
a.mu.Lock()
a.groupDN = dn
a.mu.Unlock()
return dn, nil
}
// findUser sucht den Benutzer über sAMAccountName oder userPrincipalName.
func (a *AD) findUser(c conn, input string) (*ldap.Entry, error) {
esc := ldap.EscapeFilter(input)
filter := fmt.Sprintf("(&(objectClass=user)(|(sAMAccountName=%s)(userPrincipalName=%s)))", esc, esc)
return searchOne(c, a.opts.BaseDN, filter,
[]string{"sAMAccountName", "distinguishedName"}, a.opts.Timeout)
}
// inGroup prüft die Mitgliedschaft inkl. verschachtelter Gruppen.
func (a *AD) inGroup(c conn, sam, groupDN string) (bool, error) {
filter := fmt.Sprintf("(&(sAMAccountName=%s)(memberOf:%s:=%s))",
ldap.EscapeFilter(sam), MatchingRuleInChain, ldap.EscapeFilter(groupDN))
e, err := searchOne(c, a.opts.BaseDN, filter, []string{"distinguishedName"}, a.opts.Timeout)
if err != nil {
return false, fmt.Errorf("Gruppenprüfung für %q: %w", sam, err)
}
return e != nil, nil
}
// canonicalName liest sAMAccountName aus dem Verzeichnis, niemals aus der Eingabe.
func canonicalName(e *ldap.Entry) string {
return strings.ToLower(strings.TrimSpace(e.GetAttributeValue("sAMAccountName")))
}
// Authenticate führt den Zwei-Schritt-Bind aus und prüft die Gruppenmitgliedschaft.
func (a *AD) Authenticate(ctx context.Context, username, password string) (*Identity, error) {
username = strings.TrimSpace(username)
// Ein leeres Passwort würde als anonymer Bind durchgehen — hart ablehnen,
// bevor überhaupt gesucht wird.
if username == "" || password == "" {
return nil, &Error{Reason: ReasonInvalidCredentials,
Err: errors.New("Benutzername oder Passwort war leer")}
}
var id *Identity
err := a.withConn(ctx, func(c conn) error {
groupDN, err := a.groupDNOrResolve(c)
if err != nil {
return err
}
userEntry, err := a.findUser(c, username)
if err != nil {
return err
}
if userEntry == nil {
return &Error{Reason: ReasonUserNotFound,
Err: errors.New("kein Verzeichniseintrag zur Eingabe gefunden")}
}
// Schritt 2: Bind mit dem gefundenen DN und dem eingegebenen Passwort
// auf einer eigenen Verbindung, damit die Service-Verbindung
// gebunden bleibt.
if err := a.bindAs(ctx, userEntry.DN, password); err != nil {
return &Error{Reason: ReasonFromLDAPError(err), Err: err}
}
sam := canonicalName(userEntry)
if sam == "" {
return &Error{Reason: ReasonUserNotFound,
Err: fmt.Errorf("Eintrag %s hat keinen sAMAccountName", userEntry.DN)}
}
ok, err := a.inGroup(c, sam, groupDN)
if err != nil {
return err
}
if !ok {
return &Error{Reason: ReasonNotInVPNGroup,
Err: fmt.Errorf("%s ist nicht Mitglied von %s", sam, a.opts.VPNGroup)}
}
id = &Identity{Username: sam, Groups: []string{a.opts.VPNGroup}}
return nil
})
if err != nil {
var ae *Error
if errors.As(err, &ae) {
return nil, ae
}
return nil, &Error{Reason: ReasonBackendUnavailable, Err: err}
}
return id, nil
}
// bindAs öffnet eine frische Verbindung und bindet als der angegebene DN.
func (a *AD) bindAs(ctx context.Context, dn, password string) error {
var lastErr error
for _, server := range a.opts.Servers {
c, err := a.opts.Dial(ctx, server)
if err != nil {
lastErr = err
a.notifyFailover(server, err)
continue
}
err = c.Bind(dn, password)
c.Close()
return err
}
return fmt.Errorf("kein Domain Controller für Benutzer-Bind erreichbar: %w", lastErr)
}
// LookupResult ist das Ergebnis von check --test-auth.
type LookupResult struct {
DN string
SAMAccountName string
InVPNGroup bool
}
// Lookup spielt Suche und Gruppenprüfung ohne Passwort-Bind durch.
func (a *AD) Lookup(ctx context.Context, username string) (*LookupResult, error) {
var res *LookupResult
err := a.withConn(ctx, func(c conn) error {
groupDN, err := a.groupDNOrResolve(c)
if err != nil {
return err
}
e, err := a.findUser(c, strings.TrimSpace(username))
if err != nil {
return err
}
if e == nil {
return fmt.Errorf("Benutzer %q wurde unter %s nicht gefunden", username, a.opts.BaseDN)
}
sam := canonicalName(e)
ok, err := a.inGroup(c, sam, groupDN)
if err != nil {
return err
}
res = &LookupResult{DN: e.DN, SAMAccountName: sam, InVPNGroup: ok}
return nil
})
return res, err
}
var _ Authenticator = (*AD)(nil)

296
internal/auth/ad_test.go Normal file
View file

@ -0,0 +1,296 @@
package auth
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/go-ldap/ldap/v3"
)
// fakeConn ist ein LDAP-Server-Ersatz. bindErr bildet Bind-Ergebnisse pro
// DN ab; searches protokolliert alle Filter für Injection-Assertions.
type fakeConn struct {
bindErr map[string]error
entries map[string][]*ldap.Entry // Filter-Substring -> Ergebnis
searches []string
closed bool
dialErr error
}
func (f *fakeConn) Bind(dn, pw string) error {
if err, ok := f.bindErr[dn+"|"+pw]; ok {
return err
}
if err, ok := f.bindErr[dn]; ok {
return err
}
return nil
}
func (f *fakeConn) Search(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
f.searches = append(f.searches, req.Filter)
// Echtes AD vergleicht sAMAccountName ohne Rücksicht auf Groß-/Kleinschreibung;
// der Fake muss das nachbilden, sonst testet er strenger als die Wirklichkeit.
filter := strings.ToLower(req.Filter)
for needle, entries := range f.entries {
if strings.Contains(filter, strings.ToLower(needle)) {
return &ldap.SearchResult{Entries: entries}, nil
}
}
return &ldap.SearchResult{}, nil
}
func (f *fakeConn) Close() error { f.closed = true; return nil }
func entry(dn string, attrs map[string][]string) *ldap.Entry {
e := &ldap.Entry{DN: dn}
for name, vals := range attrs {
e.Attributes = append(e.Attributes, &ldap.EntryAttribute{Name: name, Values: vals})
}
return e
}
const (
userDN = "CN=Max Mueller,OU=Users,DC=firma,DC=local"
groupDN = "CN=VPN-Users,OU=Groups,DC=firma,DC=local"
)
// stdEntries liefert die Standardantworten: Gruppen-Auflösung, User-Suche,
// Mitgliedschaftsprüfung.
func stdEntries(inGroup bool) map[string][]*ldap.Entry {
m := map[string][]*ldap.Entry{
"objectClass=group": {entry(groupDN, nil)},
"sAMAccountName=mmueller)(userPrincipalName": {
entry(userDN, map[string][]string{"sAMAccountName": {"MMueller"}}),
},
}
if inGroup {
m["1.2.840.113556.1.4.1941"] = []*ldap.Entry{entry(userDN, nil)}
}
return m
}
// dialSequence liefert eine Dial-Funktion, die die übergebenen Verbindungen
// der Reihe nach ausgibt und danach bei der letzten bleibt.
func dialSequence(conns []*fakeConn) func(context.Context, string) (conn, error) {
i := 0
return func(ctx context.Context, server string) (conn, error) {
c := conns[min(i, len(conns)-1)]
i++
if c.dialErr != nil {
return nil, c.dialErr
}
return c, nil
}
}
func baseOptions() Options {
return Options{
Servers: []string{"dc01.firma.local", "dc02.firma.local"},
Port: 636,
TLSMode: "ldaps",
BaseDN: "DC=firma,DC=local",
BindUser: "svc@firma.local",
BindPassword: "svc-pw",
VPNGroup: "VPN-Users",
Timeout: time.Second,
}
}
func newAD(t *testing.T, conns ...*fakeConn) *AD {
t.Helper()
opts := baseOptions()
opts.Dial = dialSequence(conns)
a, err := NewAD(opts)
if err != nil {
t.Fatalf("NewAD: %v", err)
}
return a
}
func TestAuthenticateSuccessCanonicalisesUsername(t *testing.T) {
c := &fakeConn{entries: stdEntries(true)}
a := newAD(t, c)
id, err := a.Authenticate(context.Background(), " MMueller ", "geheim")
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
// Kanonisch = sAMAccountName aus dem Verzeichnis, lowercase.
if id.Username != "mmueller" {
t.Errorf("Username = %q, want mmueller", id.Username)
}
if !id.HasGroup("VPN-Users") {
t.Errorf("Groups = %v, muss die VPN-Gruppe enthalten", id.Groups)
}
}
func TestAuthenticateRejectsNonMember(t *testing.T) {
c := &fakeConn{entries: stdEntries(false)}
a := newAD(t, c)
_, err := a.Authenticate(context.Background(), "mmueller", "geheim")
var ae *Error
if !errors.As(err, &ae) || ae.Reason != ReasonNotInVPNGroup {
t.Fatalf("err = %v, want Reason %q", err, ReasonNotInVPNGroup)
}
}
func TestAuthenticateMapsBindFailure(t *testing.T) {
c := &fakeConn{entries: stdEntries(true)}
c.bindErr = map[string]error{
userDN + "|falsch": errors.New(
"LDAP Result Code 49 \"Invalid Credentials\": AcceptSecurityContext error, data 532, v4563"),
}
a := newAD(t, c)
_, err := a.Authenticate(context.Background(), "mmueller", "falsch")
var ae *Error
if !errors.As(err, &ae) || ae.Reason != ReasonPasswordExpired {
t.Fatalf("err = %v, want Reason %q", err, ReasonPasswordExpired)
}
}
func TestAuthenticateUnknownUserReportsGenericReason(t *testing.T) {
c := &fakeConn{entries: map[string][]*ldap.Entry{"objectClass=group": {entry(groupDN, nil)}}}
a := newAD(t, c)
_, err := a.Authenticate(context.Background(), "gibtsnicht", "egal")
var ae *Error
if !errors.As(err, &ae) || ae.Reason != ReasonUserNotFound {
t.Fatalf("err = %v, want Reason %q", err, ReasonUserNotFound)
}
if ae.UserVisible() {
t.Error("unbekannter Benutzer darf keine spezifische Meldung erzeugen")
}
}
func TestAuthenticateRejectsEmptyPassword(t *testing.T) {
c := &fakeConn{entries: stdEntries(true)}
a := newAD(t, c)
// Leeres Passwort wäre ein anonymer Bind und würde fälschlich gelingen.
_, err := a.Authenticate(context.Background(), "mmueller", "")
var ae *Error
if !errors.As(err, &ae) || ae.Reason != ReasonInvalidCredentials {
t.Fatalf("err = %v, want Reason %q", err, ReasonInvalidCredentials)
}
for _, f := range c.searches {
if strings.Contains(f, "mmueller") {
t.Error("bei leerem Passwort darf gar keine Suche stattfinden")
}
}
}
func TestAuthenticateEscapesFilterInput(t *testing.T) {
c := &fakeConn{entries: stdEntries(true)}
a := newAD(t, c)
_, _ = a.Authenticate(context.Background(), "evil)(objectClass=*", "pw")
for _, f := range c.searches {
if strings.Contains(f, "evil)(objectClass=*") {
t.Fatalf("unescapte Benutzereingabe im Filter: %q", f)
}
}
var found bool
for _, f := range c.searches {
if strings.Contains(strings.ToLower(f), `\29\28`) { // ")(" escaped
found = true
}
}
if !found {
t.Errorf("Eingabe wurde nicht escapt; Filter: %v", c.searches)
}
}
func TestFailoverToSecondDC(t *testing.T) {
dead := &fakeConn{dialErr: errors.New("connection refused")}
alive := &fakeConn{entries: stdEntries(true)}
var failedOver []string
opts := baseOptions()
opts.OnFailover = func(server string, err error) { failedOver = append(failedOver, server) }
opts.Dial = dialSequence([]*fakeConn{dead, alive})
a, err := NewAD(opts)
if err != nil {
t.Fatal(err)
}
if _, err := a.Authenticate(context.Background(), "mmueller", "geheim"); err != nil {
t.Fatalf("Failover muss gelingen: %v", err)
}
if len(failedOver) == 0 || failedOver[0] != "dc01.firma.local" {
t.Errorf("OnFailover = %v, want dc01.firma.local", failedOver)
}
}
func TestAllDCsDownIsBackendUnavailable(t *testing.T) {
dead := &fakeConn{dialErr: errors.New("connection refused")}
a := newAD(t, dead)
_, err := a.Authenticate(context.Background(), "mmueller", "geheim")
var ae *Error
if !errors.As(err, &ae) || ae.Reason != ReasonBackendUnavailable {
t.Fatalf("err = %v, want Reason %q", err, ReasonBackendUnavailable)
}
}
func TestLookupForTestAuth(t *testing.T) {
c := &fakeConn{entries: stdEntries(true)}
a := newAD(t, c)
res, err := a.Lookup(context.Background(), "MMueller")
if err != nil {
t.Fatalf("Lookup: %v", err)
}
if res.DN != userDN || res.SAMAccountName != "mmueller" || !res.InVPNGroup {
t.Errorf("Lookup = %+v", res)
}
}
func TestConnectionsAreAlwaysClosed(t *testing.T) {
c := &fakeConn{entries: stdEntries(true)}
a := newAD(t, c)
if _, err := a.Authenticate(context.Background(), "mmueller", "geheim"); err != nil {
t.Fatal(err)
}
if !c.closed {
t.Error("Verbindung muss geschlossen werden")
}
}
func TestSetFailoverHookIsUsed(t *testing.T) {
dead := &fakeConn{dialErr: errors.New("connection refused")}
alive := &fakeConn{entries: stdEntries(true)}
opts := baseOptions()
opts.Dial = dialSequence([]*fakeConn{dead, alive})
a, err := NewAD(opts)
if err != nil {
t.Fatal(err)
}
var seen []string
a.SetFailoverHook(func(server string, err error) { seen = append(seen, server) })
if _, err := a.Authenticate(context.Background(), "mmueller", "geheim"); err != nil {
t.Fatal(err)
}
if len(seen) == 0 || seen[0] != "dc01.firma.local" {
t.Fatalf("Failover-Hook = %v", seen)
}
}
func TestServiceBindFailureIsNotMaskedByFailover(t *testing.T) {
// Ein falsches Dienstkonto-Passwort ist ein Konfigurationsfehler.
// Es darf nicht als "nächster DC probieren" durchgehen.
c := &fakeConn{
entries: stdEntries(true),
bindErr: map[string]error{"svc@firma.local": errors.New("LDAP Result Code 49")},
}
a := newAD(t, c)
_, err := a.Authenticate(context.Background(), "mmueller", "geheim")
if err == nil {
t.Fatal("fehlerhafter Service-Bind muss zum Fehler führen")
}
if !strings.Contains(err.Error(), "Service-Bind") {
t.Errorf("Fehler muss den Service-Bind benennen: %v", err)
}
}

47
internal/auth/addata.go Normal file
View file

@ -0,0 +1,47 @@
package auth
import (
"regexp"
"strings"
)
// dataCodeRe findet den AD-spezifischen data-Code im Text eines
// LDAP-Result-Code-49-Fehlers, z. B. "... error, data 52e, v4563".
var dataCodeRe = regexp.MustCompile(`(?i)\bdata\s+([0-9a-f]{3,4})\b`)
// dataCodeReasons bildet AD-data-Codes auf Audit-Reasons ab.
// 525 (kein solcher User) wird bewusst wie ein falsches Passwort behandelt,
// damit die Fehlermeldung keine Benutzerexistenz verrät.
var dataCodeReasons = map[string]string{
"525": ReasonInvalidCredentials,
"52e": ReasonInvalidCredentials,
"530": ReasonInvalidCredentials, // Anmeldung außerhalb erlaubter Zeiten
"531": ReasonInvalidCredentials, // Anmeldung an dieser Station nicht erlaubt
"532": ReasonPasswordExpired,
"533": ReasonAccountDisabled,
"701": ReasonAccountDisabled, // Konto abgelaufen
"773": ReasonPasswordChangeRequired,
"775": ReasonAccountLocked,
}
// ReasonFromLDAPError leitet den Audit-Reason aus einem LDAP-Bind-Fehler ab.
// Fehler ohne erkennbaren data-Code gelten als Backend-Problem, nicht als
// falsches Passwort — sonst würde ein DC-Ausfall als Fehlanmeldung gezählt
// und den Benutzer über den Rate-Limiter aussperren.
func ReasonFromLDAPError(err error) string {
if err == nil {
return ""
}
msg := err.Error()
if m := dataCodeRe.FindStringSubmatch(msg); m != nil {
if reason, ok := dataCodeReasons[strings.ToLower(m[1])]; ok {
return reason
}
// data-Code vorhanden, aber unbekannt: es war eine echte Ablehnung.
return ReasonInvalidCredentials
}
if strings.Contains(msg, "Result Code 49") || strings.Contains(msg, "Invalid Credentials") {
return ReasonInvalidCredentials
}
return ReasonBackendUnavailable
}

View file

@ -0,0 +1,72 @@
package auth
import (
"errors"
"fmt"
"testing"
)
func TestReasonFromLDAPError(t *testing.T) {
// Formulierung wie sie AD in LDAP-Result-Code 49 liefert.
const tmpl = "LDAP Result Code 49 \"Invalid Credentials\": 80090308: LdapErr: " +
"DSID-0C0903A9, comment: AcceptSecurityContext error, data %s, v4563"
cases := map[string]string{
"52e": ReasonInvalidCredentials,
"533": ReasonAccountDisabled,
"775": ReasonAccountLocked,
"532": ReasonPasswordExpired,
"773": ReasonPasswordChangeRequired,
"525": ReasonInvalidCredentials, // user not found -> generisch, keine Enumeration
"701": ReasonAccountDisabled, // account expired
"999": ReasonInvalidCredentials, // unbekannter Code -> generisch
}
for code, want := range cases {
err := errors.New(fmt.Sprintf(tmpl, code))
if got := ReasonFromLDAPError(err); got != want {
t.Errorf("data %s: got %q, want %q", code, got, want)
}
}
}
func TestReasonFromLDAPErrorIsCaseInsensitive(t *testing.T) {
err := errors.New("AcceptSecurityContext error, DATA 52E, v4563")
if got := ReasonFromLDAPError(err); got != ReasonInvalidCredentials {
t.Errorf("got %q, want %q", got, ReasonInvalidCredentials)
}
}
func TestReasonFromLDAPErrorWithoutDataCode(t *testing.T) {
if got := ReasonFromLDAPError(errors.New("connection refused")); got != ReasonBackendUnavailable {
t.Errorf("got %q, want %q", got, ReasonBackendUnavailable)
}
if got := ReasonFromLDAPError(nil); got != "" {
t.Errorf("nil-Fehler muss leeren Reason liefern, got %q", got)
}
}
func TestErrorUserVisibleOnlyForExpiredPassword(t *testing.T) {
if !(&Error{Reason: ReasonPasswordExpired}).UserVisible() {
t.Error("abgelaufenes Passwort ist die einzige spezifische Meldung")
}
for _, r := range []string{
ReasonInvalidCredentials, ReasonAccountDisabled, ReasonAccountLocked,
ReasonNotInVPNGroup, ReasonPasswordChangeRequired, ReasonBackendUnavailable,
} {
if (&Error{Reason: r}).UserVisible() {
t.Errorf("Reason %q darf keine spezifische Meldung erzeugen", r)
}
}
}
func TestErrorUnwraps(t *testing.T) {
inner := errors.New("boom")
err := &Error{Reason: ReasonBackendUnavailable, Err: inner}
if !errors.Is(err, inner) {
t.Error("Error muss den inneren Fehler durchreichen")
}
var authErr *Error
if !errors.As(error(err), &authErr) {
t.Error("errors.As muss *Error finden")
}
}

67
internal/auth/auth.go Normal file
View file

@ -0,0 +1,67 @@
// Package auth kapselt die Benutzerauthentifizierung hinter einem schmalen
// Interface, damit später weitere Backends (OIDC/Entra ID) ergänzt werden können.
package auth
import (
"context"
"fmt"
"strings"
)
// Identity ist die kanonische Identität eines authentifizierten Benutzers.
type Identity struct {
// Username ist der aus dem Verzeichnis gelesene sAMAccountName in
// Kleinschreibung — niemals die Benutzereingabe.
Username string
// Groups enthält die Gruppen, die für Rollenentscheidungen relevant sind.
// v1 füllt hier nur die VPN-Gruppe; das Feld hält den Weg zu einer
// späteren VPN-Portal-Admins-Gruppe offen.
Groups []string
}
// HasGroup prüft Gruppenzugehörigkeit ohne Rücksicht auf Groß-/Kleinschreibung.
func (i *Identity) HasGroup(name string) bool {
for _, g := range i.Groups {
if strings.EqualFold(g, name) {
return true
}
}
return false
}
// Authenticator prüft Zugangsdaten und liefert eine kanonische Identität.
type Authenticator interface {
Authenticate(ctx context.Context, username, password string) (*Identity, error)
}
// Reason-Codes landen unverändert im Audit-Log.
const (
ReasonInvalidCredentials = "invalid_credentials"
ReasonAccountDisabled = "account_disabled"
ReasonAccountLocked = "account_locked"
ReasonPasswordExpired = "password_expired"
ReasonPasswordChangeRequired = "password_change_required"
ReasonNotInVPNGroup = "not_in_vpn_group"
ReasonUserNotFound = "user_not_found"
ReasonBackendUnavailable = "backend_unavailable"
)
// Error trägt den Audit-Reason und den technischen Ursprungsfehler.
type Error struct {
Reason string
Err error
}
func (e *Error) Error() string {
if e.Err == nil {
return e.Reason
}
return fmt.Sprintf("%s: %v", e.Reason, e.Err)
}
func (e *Error) Unwrap() error { return e.Err }
// UserVisible meldet, ob dem Benutzer eine spezifische statt der generischen
// Fehlermeldung gezeigt werden darf. Nur das abgelaufene Passwort ist eine
// Ausnahme — alles andere wäre ein Enumerationsorakel.
func (e *Error) UserVisible() bool { return e.Reason == ReasonPasswordExpired }

78
internal/auth/dial.go Normal file
View file

@ -0,0 +1,78 @@
package auth
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"os"
"strconv"
"time"
"github.com/go-ldap/ldap/v3"
)
// conn ist der Ausschnitt der LDAP-Verbindung, den der Authenticator braucht.
// Das Interface existiert, damit Tests ohne echten Verzeichnisdienst laufen.
type conn interface {
Bind(username, password string) error
Search(req *ldap.SearchRequest) (*ldap.SearchResult, error)
Close() error
}
// ldapConn adaptiert *ldap.Conn an conn (Close hat dort keine Fehlerrückgabe).
type ldapConn struct{ *ldap.Conn }
func (c ldapConn) Close() error { c.Conn.Close(); return nil }
// tlsConfigFor baut die TLS-Konfiguration. Verifikation ist immer aktiv —
// für LDAP gibt es bewusst keine Insecure-Option.
func tlsConfigFor(server, caFile string) (*tls.Config, error) {
cfg := &tls.Config{ServerName: server, MinVersion: tls.VersionTLS12}
if caFile == "" {
return cfg, nil
}
pem, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("ad.ca_file %s: %w", caFile, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("ad.ca_file %s enthält kein gültiges PEM-Zertifikat", caFile)
}
cfg.RootCAs = pool
return cfg, nil
}
// realDialer erzeugt die Dial-Funktion für den Produktivbetrieb.
func realDialer(port int, tlsMode, caFile string, timeout time.Duration) func(context.Context, string) (conn, error) {
return func(ctx context.Context, server string) (conn, error) {
tlsCfg, err := tlsConfigFor(server, caFile)
if err != nil {
return nil, err
}
addr := net.JoinHostPort(server, strconv.Itoa(port))
dialer := &net.Dialer{Timeout: timeout}
var c *ldap.Conn
if tlsMode == "starttls" {
c, err = ldap.DialURL("ldap://"+addr, ldap.DialWithDialer(dialer))
if err != nil {
return nil, fmt.Errorf("Verbindung zu %s: %w", addr, err)
}
if err := c.StartTLS(tlsCfg); err != nil {
c.Close()
return nil, fmt.Errorf("StartTLS zu %s: %w", addr, err)
}
} else {
c, err = ldap.DialURL("ldaps://"+addr,
ldap.DialWithDialer(dialer), ldap.DialWithTLSConfig(tlsCfg))
if err != nil {
return nil, fmt.Errorf("LDAPS-Verbindung zu %s: %w", addr, err)
}
}
c.SetTimeout(timeout)
return ldapConn{c}, nil
}
}

122
internal/certmatch/match.go Normal file
View file

@ -0,0 +1,122 @@
// Package certmatch ordnet Firewall-Zertifikate einem Portalbenutzer zu.
package certmatch
import (
"errors"
"fmt"
"regexp"
"strings"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
)
// Placeholder ist der Platzhalter für den kanonischen Benutzernamen.
const Placeholder = "{username}"
// Matcher entscheidet, ob ein Zertifikats-CN zu einem Benutzer gehört.
// Entweder Template- oder Regex-Modus, nie beides.
type Matcher struct {
pattern string
regex string
}
// NewMatcher baut den Matcher aus der Konfiguration.
func NewMatcher(pattern, regex string) (*Matcher, error) {
pattern, regex = strings.TrimSpace(pattern), strings.TrimSpace(regex)
switch {
case pattern == "" && regex == "":
return nil, errors.New("certmatch: cn_pattern oder cn_regex muss gesetzt sein")
case pattern != "" && regex != "":
return nil, errors.New("certmatch: cn_pattern und cn_regex schließen sich aus")
case pattern != "":
if !strings.Contains(pattern, Placeholder) {
return nil, fmt.Errorf("certmatch: cn_pattern %q enthält keinen %s-Platzhalter", pattern, Placeholder)
}
return &Matcher{pattern: pattern}, nil
default:
// Probeweise kompilieren, damit Konfigurationsfehler beim Start
// auffallen und nicht erst bei der ersten Anmeldung.
if _, err := regexp.Compile("(?i)" + strings.ReplaceAll(regex, Placeholder, "x")); err != nil {
return nil, fmt.Errorf("certmatch: cn_regex ist nicht kompilierbar: %w", err)
}
return &Matcher{regex: regex}, nil
}
}
// expand setzt den Benutzernamen in Template bzw. Regex ein.
// Im Regex-Modus wird der Name quotiert, damit Metazeichen im Namen
// nicht als Muster wirken.
func (m *Matcher) expand(username string) string {
if m.regex != "" {
return strings.ReplaceAll(m.regex, Placeholder, regexp.QuoteMeta(username))
}
return strings.ReplaceAll(m.pattern, Placeholder, username)
}
// Matches vergleicht einen Zertifikats-CN mit dem Benutzernamen.
func (m *Matcher) Matches(cn, username string) bool {
cn, username = strings.TrimSpace(cn), strings.TrimSpace(username)
if cn == "" || username == "" {
return false
}
if m.regex != "" {
// (?i) macht den Vergleich unabhängig von der Schreibweise, analog zum
// Template-Modus. Kompilierfehler wurden in NewMatcher ausgeschlossen.
re, err := regexp.Compile("(?i)" + m.expand(username))
if err != nil {
return false
}
return re.MatchString(cn)
}
return strings.EqualFold(cn, m.expand(username))
}
// Describe liefert die für diesen Benutzer angewendete Regel — geht als Feld
// pattern ins Audit-Log, damit no_cert_found nachvollziehbar bleibt.
func (m *Matcher) Describe(username string) string {
if m.regex != "" {
return "regex:" + m.expand(username)
}
return m.expand(username)
}
// Entry ist ein für den Benutzer freigegebenes Zertifikat samt VPN-Instanz.
type Entry struct {
Provider opnsense.Provider
Account opnsense.Account
}
// Token ist der undurchsichtige Bezeichner für die Auswahl in der UI.
// Er ist ausdrücklich KEINE Autorisierung — vor jedem Download wird die
// Zuordnung serverseitig neu geprüft.
func (e Entry) Token() string {
return e.Provider.VPNID + ":" + e.Account.RefID
}
// ParseToken zerlegt einen Token wieder in seine Bestandteile.
func ParseToken(s string) (vpnID, refID string, ok bool) {
vpnID, refID, found := strings.Cut(s, ":")
if !found || vpnID == "" || refID == "" || strings.Contains(refID, ":") {
return "", "", false
}
return vpnID, refID, true
}
// Filter liefert alle Zertifikate einer Instanz, die dem Benutzer gehören,
// nicht revoziert und nicht abgelaufen sind.
func (m *Matcher) Filter(username string, provider opnsense.Provider,
accounts []opnsense.Account, now time.Time) []Entry {
var out []Entry
for _, a := range accounts {
if !m.Matches(a.CommonName, username) {
continue
}
if !a.IsUsable(now) {
continue
}
out = append(out, Entry{Provider: provider, Account: a})
}
return out
}

View file

@ -0,0 +1,141 @@
package certmatch
import (
"testing"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
)
func TestPatternMatching(t *testing.T) {
m, err := NewMatcher("{username}", "")
if err != nil {
t.Fatal(err)
}
if !m.Matches("mmueller", "mmueller") {
t.Error("identischer CN muss matchen")
}
if !m.Matches("MMueller", "mmueller") {
t.Error("Vergleich muss Groß-/Kleinschreibung ignorieren")
}
if m.Matches("jdoe", "mmueller") {
t.Error("fremder CN darf nicht matchen")
}
if m.Matches("mmueller2", "mmueller") {
t.Error("Präfix-Treffer darf nicht als Match gelten")
}
if m.Matches("", "mmueller") || m.Matches("mmueller", "") {
t.Error("leere Werte dürfen nie matchen")
}
}
func TestPatternWithSuffix(t *testing.T) {
m, err := NewMatcher("{username}@firma.de", "")
if err != nil {
t.Fatal(err)
}
if !m.Matches("mmueller@firma.de", "mmueller") {
t.Error("Template mit Suffix muss matchen")
}
if m.Matches("mmueller", "mmueller") {
t.Error("CN ohne Suffix darf bei diesem Template nicht matchen")
}
}
func TestRegexMatching(t *testing.T) {
// Regex mit {username}-Platzhalter: wird vor dem Kompilieren ersetzt und
// dabei quotiert, damit Sonderzeichen im Namen nicht zur Injection werden.
m, err := NewMatcher("", `^(vpn-)?{username}(-\d+)?$`)
if err != nil {
t.Fatal(err)
}
for _, cn := range []string{"mmueller", "vpn-mmueller", "mmueller-2"} {
if !m.Matches(cn, "mmueller") {
t.Errorf("CN %q muss matchen", cn)
}
}
for _, cn := range []string{"jdoe", "mmuellerX", "vpn-jdoe"} {
if m.Matches(cn, "mmueller") {
t.Errorf("CN %q darf nicht matchen", cn)
}
}
}
func TestRegexQuotesUsername(t *testing.T) {
m, err := NewMatcher("", `^{username}$`)
if err != nil {
t.Fatal(err)
}
// Ein Benutzername mit Regex-Metazeichen darf nicht als Muster wirken.
if m.Matches("mmueller", "m.*") {
t.Error("Benutzername muss vor dem Einsetzen quotiert werden")
}
if !m.Matches("m.*", "m.*") {
t.Error("literaler Vergleich muss weiterhin funktionieren")
}
}
func TestNewMatcherValidation(t *testing.T) {
if _, err := NewMatcher("", ""); err == nil {
t.Error("weder Pattern noch Regex muss abgelehnt werden")
}
if _, err := NewMatcher("{username}", "^x$"); err == nil {
t.Error("beides gleichzeitig muss abgelehnt werden")
}
if _, err := NewMatcher("", "([unbalanced"); err == nil {
t.Error("unkompilierbare Regex muss abgelehnt werden")
}
if _, err := NewMatcher("kein-platzhalter", ""); err == nil {
t.Error("Pattern ohne {username} muss abgelehnt werden")
}
}
func TestFilterExcludesRevokedAndExpired(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
m, _ := NewMatcher("{username}", "")
prov := opnsense.Provider{VPNID: "1", Name: "VPN Homeoffice"}
accounts := []opnsense.Account{
{RefID: "ok", CommonName: "mmueller", ValidTo: now.AddDate(1, 0, 0)},
{RefID: "revoked", CommonName: "mmueller", ValidTo: now.AddDate(1, 0, 0), Revoked: true},
{RefID: "expired", CommonName: "mmueller", ValidTo: now.AddDate(0, 0, -1)},
{RefID: "fremd", CommonName: "jdoe", ValidTo: now.AddDate(1, 0, 0)},
}
got := m.Filter("mmueller", prov, accounts, now)
if len(got) != 1 {
t.Fatalf("got %d Treffer, want 1: %+v", len(got), got)
}
if got[0].Account.RefID != "ok" {
t.Errorf("falscher Treffer: %+v", got[0])
}
if got[0].Provider.Name != "VPN Homeoffice" {
t.Errorf("Provider muss durchgereicht werden: %+v", got[0].Provider)
}
}
func TestTokenRoundTrip(t *testing.T) {
e := Entry{
Provider: opnsense.Provider{VPNID: "1"},
Account: opnsense.Account{RefID: "abc123"},
}
vpnID, refID, ok := ParseToken(e.Token())
if !ok || vpnID != "1" || refID != "abc123" {
t.Fatalf("ParseToken(%q) = %q,%q,%v", e.Token(), vpnID, refID, ok)
}
for _, bad := range []string{"", "nurEins", "a:b:c", ":x", "x:"} {
if _, _, ok := ParseToken(bad); ok {
t.Errorf("ParseToken(%q) darf nicht gelingen", bad)
}
}
}
func TestDescribeForAudit(t *testing.T) {
m, _ := NewMatcher("{username}@firma.de", "")
if got := m.Describe("mmueller"); got != "mmueller@firma.de" {
t.Errorf("Describe = %q", got)
}
rx, _ := NewMatcher("", `^{username}$`)
if got := rx.Describe("mmueller"); got != `regex:^mmueller$` {
t.Errorf("Describe = %q", got)
}
}

239
internal/check/check.go Normal file
View file

@ -0,0 +1,239 @@
// Package check prüft beim Start und über das check-Kommando die gesamte
// Wirkungskette: Konfiguration, Dateirechte, Firewall, Verzeichnis, Uhrzeit.
package check
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
"git.ravensburg.dev/cabele/opnsense-portal/internal/config"
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
)
// maxClockSkew ist die tolerierte Abweichung zur Firewall-Uhrzeit.
// Eine falsche Systemzeit würde abgelaufene Zertifikate falsch bewerten.
const maxClockSkew = 2 * time.Minute
// Result ist das Ergebnis eines Prüfpunkts.
type Result struct {
Name string
OK bool
Detail string
}
// Report bündelt alle Prüfergebnisse.
type Report struct {
Results []Result
}
// OK meldet, ob alle Prüfungen bestanden wurden.
func (r *Report) OK() bool {
for _, res := range r.Results {
if !res.OK {
return false
}
}
return true
}
// WriteTo gibt die Checkliste aus.
func (r *Report) WriteTo(w io.Writer) (int64, error) {
var total int64
for _, res := range r.Results {
mark := "✓"
if !res.OK {
mark = "✗"
}
line := fmt.Sprintf("%s %s", mark, res.Name)
if res.Detail != "" {
line += "\n " + strings.ReplaceAll(res.Detail, "\n", "\n ")
}
n, err := io.WriteString(w, line+"\n")
total += int64(n)
if err != nil {
return total, err
}
}
return total, nil
}
// Pinger ist der benötigte Ausschnitt des Firewall-Clients.
type Pinger interface {
Ping(ctx context.Context) (time.Time, error)
}
// DirectoryChecker ist der benötigte Ausschnitt des AD-Authenticators.
type DirectoryChecker interface {
ResolveGroupDN(ctx context.Context) (string, error)
Lookup(ctx context.Context, username string) (*auth.LookupResult, error)
}
// Checker führt die Prüfungen aus.
type Checker struct {
Cfg *config.Config
ConfigPath string
FW Pinger
Dir DirectoryChecker
Now func() time.Time
}
// Run führt alle Prüfungen aus. testAuthUser leer = keine Testanmeldung.
func (c *Checker) Run(ctx context.Context, testAuthUser string) *Report {
now := c.Now
if now == nil {
now = time.Now
}
rep := &Report{}
add := func(name string, err error, detail string) {
if err != nil {
rep.Results = append(rep.Results, Result{Name: name, OK: false, Detail: err.Error()})
return
}
rep.Results = append(rep.Results, Result{Name: name, OK: true, Detail: detail})
}
// 1. Konfiguration
add("Konfiguration", c.Cfg.Validate(), "gelesen und plausibel")
// 2. Dateirechte
add("Dateirechte", c.checkPermissions(),
"Konfiguration, Secrets und Log-Verzeichnis sind ausreichend geschützt")
// 3. OPNsense
serverTime, fwErr := c.checkFirewall(ctx)
add("OPNsense-Verbindung", fwErr, "Export-API erreichbar, Key und Berechtigung in Ordnung")
// 4. Verzeichnisdienst
groupDN, dirErr := c.checkDirectory(ctx)
add("Verzeichnisdienst", dirErr,
"Anmeldung des Dienstkontos erfolgreich, Gruppe aufgelöst: "+groupDN)
// 5. Systemzeit (nur sinnvoll, wenn die Firewall geantwortet hat)
add("Systemzeit", checkClock(now(), serverTime, fwErr),
"Abweichung zur Firewall unterhalb der Schwelle")
// 6. Optionale Testanmeldung
if testAuthUser != "" {
detail, err := c.checkTestAuth(ctx, testAuthUser)
add("Testanmeldung "+testAuthUser, err, detail)
}
return rep
}
// checkPermissions prüft Config, Secret-Dateien, TLS-Key und Log-Verzeichnis.
func (c *Checker) checkPermissions() error {
var errs []error
if c.ConfigPath != "" {
if err := config.CheckFileMode(c.ConfigPath, 0o640); err != nil {
errs = append(errs, err)
}
}
for _, p := range []string{
c.Cfg.OPNsense.APIKeyFile, c.Cfg.OPNsense.APISecretFile, c.Cfg.AD.BindPasswordFile,
} {
if p == "" {
continue
}
if err := config.CheckFileMode(p, 0o640); err != nil {
errs = append(errs, err)
}
}
// Der private TLS-Schlüssel darf niemandem außer dem Eigentümer offenstehen.
if c.Cfg.Portal.TLSKey != "" {
if err := config.CheckFileMode(c.Cfg.Portal.TLSKey, 0o600); err != nil {
errs = append(errs, err)
}
}
if c.Cfg.Logging.AuditLog != "" {
dir := filepath.Dir(c.Cfg.Logging.AuditLog)
if _, err := os.Stat(dir); err == nil {
if err := config.CheckDirMode(dir, 0o750); err != nil {
errs = append(errs, err)
}
} else {
errs = append(errs, fmt.Errorf("Log-Verzeichnis %s existiert nicht: %w", dir, err))
}
}
return errors.Join(errs...)
}
// checkFirewall ruft den Export-Endpunkt auf und übersetzt die Fehler in
// handlungsleitende Meldungen.
func (c *Checker) checkFirewall(ctx context.Context) (time.Time, error) {
if c.FW == nil {
return time.Time{}, errors.New("kein Firewall-Client konfiguriert")
}
t, err := c.FW.Ping(ctx)
switch {
case err == nil:
return t, nil
case errors.Is(err, opnsense.ErrUnauthorized):
return time.Time{}, fmt.Errorf(
"die Zugangsdaten wurden abgelehnt — opnsense.api_key und api_secret prüfen (%v)", err)
case errors.Is(err, opnsense.ErrForbidden):
return time.Time{}, fmt.Errorf(
"der API-Benutzer hat keine Berechtigung — auf der OPNsense das Privileg "+
"\"VPN: OpenVPN Client Export\" zuweisen (%v)", err)
case errors.Is(err, opnsense.ErrUnreachable):
return time.Time{}, fmt.Errorf(
"die Firewall ist nicht erreichbar — URL, Netzwerkweg und CA prüfen (%v)", err)
default:
return time.Time{}, err
}
}
// checkDirectory bindet den Service-Account und löst die Gruppe auf.
func (c *Checker) checkDirectory(ctx context.Context) (string, error) {
if c.Dir == nil {
return "", errors.New("kein Verzeichnis-Client konfiguriert")
}
dn, err := c.Dir.ResolveGroupDN(ctx)
if err != nil {
return "", fmt.Errorf("Verzeichnisdienst nicht nutzbar: %w", err)
}
if dn == "" {
return "", fmt.Errorf("Gruppe %q konnte nicht aufgelöst werden", c.Cfg.AD.VPNGroup)
}
return dn, nil
}
// checkClock vergleicht lokale Zeit und Firewall-Zeit.
func checkClock(local, remote time.Time, fwErr error) error {
if fwErr != nil {
return errors.New("nicht prüfbar, weil die Firewall nicht geantwortet hat")
}
if remote.IsZero() {
return nil // Die Firewall hat keinen auswertbaren Date-Header geliefert.
}
skew := local.Sub(remote)
if skew < 0 {
skew = -skew
}
if skew > maxClockSkew {
return fmt.Errorf(
"die Systemzeit weicht um %s von der Firewall ab — bitte NTP einrichten "+
"(abgelaufene Zertifikate würden sonst falsch bewertet)", skew.Round(time.Second))
}
return nil
}
// checkTestAuth spielt Suche und Gruppenprüfung ohne Passwort durch.
func (c *Checker) checkTestAuth(ctx context.Context, username string) (string, error) {
res, err := c.Dir.Lookup(ctx, username)
if err != nil {
return "", err
}
if !res.InVPNGroup {
return "", fmt.Errorf("DN: %s\nsAMAccountName: %s\nMitglied in %s: NEIN — "+
"dieser Benutzer würde abgewiesen", res.DN, res.SAMAccountName, c.Cfg.AD.VPNGroup)
}
return fmt.Sprintf("DN: %s\nsAMAccountName: %s\nMitglied in %s: ja",
res.DN, res.SAMAccountName, c.Cfg.AD.VPNGroup), nil
}

View file

@ -0,0 +1,260 @@
package check
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
"git.ravensburg.dev/cabele/opnsense-portal/internal/config"
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
)
type fakePinger struct {
at time.Time
err error
}
func (f *fakePinger) Ping(ctx context.Context) (time.Time, error) { return f.at, f.err }
type fakeDir struct {
dn string
dnErr error
lookup *auth.LookupResult
lookErr error
}
func (f *fakeDir) ResolveGroupDN(ctx context.Context) (string, error) { return f.dn, f.dnErr }
func (f *fakeDir) Lookup(ctx context.Context, u string) (*auth.LookupResult, error) {
return f.lookup, f.lookErr
}
var checkNow = time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
func newChecker(t *testing.T, fw Pinger, dir DirectoryChecker) *Checker {
t.Helper()
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "config.yaml")
if err := os.WriteFile(cfgPath, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
os.Chmod(cfgPath, 0o600)
logDir := filepath.Join(tmp, "log")
os.MkdirAll(logDir, 0o750)
os.Chmod(logDir, 0o750)
cfg := config.Defaults()
cfg.Portal.TLSCert = filepath.Join(tmp, "portal.crt")
cfg.Portal.TLSKey = filepath.Join(tmp, "portal.key")
os.WriteFile(cfg.Portal.TLSCert, []byte("cert"), 0o644)
os.WriteFile(cfg.Portal.TLSKey, []byte("key"), 0o600)
os.Chmod(cfg.Portal.TLSKey, 0o600)
cfg.OPNsense.URL = "https://fw01.firma.local"
cfg.OPNsense.APIKey, cfg.OPNsense.APISecret = "k", "s"
cfg.AD.Domain = "firma.local"
cfg.AD.Servers = []string{"dc01.firma.local"}
cfg.AD.BindUser, cfg.AD.BindPassword = "svc@firma.local", "pw"
cfg.AD.VPNGroup = "VPN-Users"
cfg.Logging.AuditLog = filepath.Join(logDir, "audit.log")
return &Checker{Cfg: cfg, ConfigPath: cfgPath, FW: fw, Dir: dir,
Now: func() time.Time { return checkNow }}
}
func report(t *testing.T, rep *Report) string {
t.Helper()
var b strings.Builder
if _, err := rep.WriteTo(&b); err != nil {
t.Fatal(err)
}
return b.String()
}
func TestAllChecksPass(t *testing.T) {
c := newChecker(t, &fakePinger{at: checkNow},
&fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"})
rep := c.Run(context.Background(), "")
if !rep.OK() {
t.Fatalf("alle Prüfungen sollten bestehen:\n%s", report(t, rep))
}
out := report(t, rep)
for _, want := range []string{"Konfiguration", "Dateirechte", "OPNsense", "Verzeichnisdienst", "Systemzeit"} {
if !strings.Contains(out, want) {
t.Errorf("Prüfpunkt %q fehlt:\n%s", want, out)
}
}
}
func TestFailedAPISecretIsNamed(t *testing.T) {
c := newChecker(t, &fakePinger{err: opnsense.ErrUnauthorized},
&fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"})
rep := c.Run(context.Background(), "")
if rep.OK() {
t.Fatal("falsches Secret muss auffallen")
}
out := report(t, rep)
if !strings.Contains(out, "✗") {
t.Errorf("Ausgabe muss ein ✗ enthalten:\n%s", out)
}
if !strings.Contains(strings.ToLower(out), "zugangsdaten") {
t.Errorf("Ursache muss benannt werden:\n%s", out)
}
}
func TestMissingAPIPermissionIsDistinguished(t *testing.T) {
c := newChecker(t, &fakePinger{err: opnsense.ErrForbidden}, &fakeDir{dn: "CN=x"})
rep := c.Run(context.Background(), "")
out := report(t, rep)
if !strings.Contains(out, "Client Export") {
t.Errorf("fehlende Berechtigung muss den nötigen Privilegnamen nennen:\n%s", out)
}
}
func TestUnreachableDCIsNamed(t *testing.T) {
c := newChecker(t, &fakePinger{at: checkNow},
&fakeDir{dnErr: errors.New("dc01.firma.local: connection refused")})
rep := c.Run(context.Background(), "")
if rep.OK() {
t.Fatal("nicht erreichbarer DC muss auffallen")
}
if out := report(t, rep); !strings.Contains(out, "dc01.firma.local") {
t.Errorf("betroffener DC muss genannt werden:\n%s", out)
}
}
func TestLooseConfigPermissionsAreDetected(t *testing.T) {
c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"})
os.Chmod(c.ConfigPath, 0o644)
rep := c.Run(context.Background(), "")
if rep.OK() {
t.Fatal("0644-Config muss auffallen")
}
if out := report(t, rep); !strings.Contains(out, "chmod") {
t.Errorf("Reparaturbefehl muss genannt werden:\n%s", out)
}
}
func TestLooseTLSKeyIsDetected(t *testing.T) {
c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"})
os.Chmod(c.Cfg.Portal.TLSKey, 0o644)
rep := c.Run(context.Background(), "")
if rep.OK() {
t.Fatal("world-readable TLS-Key muss auffallen")
}
}
func TestMissingLogDirectoryIsDetected(t *testing.T) {
c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"})
c.Cfg.Logging.AuditLog = "/gibt/es/nicht/audit.log"
rep := c.Run(context.Background(), "")
if rep.OK() {
t.Fatal("fehlendes Log-Verzeichnis muss auffallen")
}
}
func TestClockSkewWarns(t *testing.T) {
c := newChecker(t, &fakePinger{at: checkNow.Add(5 * time.Minute)}, &fakeDir{dn: "CN=x"})
rep := c.Run(context.Background(), "")
out := report(t, rep)
if !strings.Contains(out, "NTP") {
t.Errorf("Zeitabweichung muss auf NTP hinweisen:\n%s", out)
}
if rep.OK() {
t.Error("Zeitabweichung über der Schwelle muss als Fehler zählen")
}
}
func TestSmallClockSkewIsFine(t *testing.T) {
c := newChecker(t, &fakePinger{at: checkNow.Add(30 * time.Second)},
&fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"})
if rep := c.Run(context.Background(), ""); !rep.OK() {
t.Fatalf("30 Sekunden Abweichung sind unkritisch:\n%s", report(t, rep))
}
}
func TestClockCheckSkippedWhenFirewallDown(t *testing.T) {
// Ohne Antwort der Firewall lässt sich die Uhr nicht vergleichen; das darf
// nicht als eigenständiger Zeitfehler dastehen.
c := newChecker(t, &fakePinger{err: opnsense.ErrUnreachable}, &fakeDir{dn: "CN=x"})
rep := c.Run(context.Background(), "")
out := report(t, rep)
if !strings.Contains(out, "nicht prüfbar") {
t.Errorf("Zeitprüfung muss als nicht prüfbar ausgewiesen werden:\n%s", out)
}
}
func TestTestAuthShowsLookupResult(t *testing.T) {
dir := &fakeDir{
dn: "CN=VPN-Users,DC=firma,DC=local",
lookup: &auth.LookupResult{
DN: "CN=Max Mueller,OU=Users,DC=firma,DC=local", SAMAccountName: "mmueller", InVPNGroup: true},
}
c := newChecker(t, &fakePinger{at: checkNow}, dir)
rep := c.Run(context.Background(), "mmueller")
out := report(t, rep)
for _, want := range []string{"CN=Max Mueller", "mmueller", "VPN-Users"} {
if !strings.Contains(out, want) {
t.Errorf("Ausgabe von --test-auth enthält %q nicht:\n%s", want, out)
}
}
}
func TestTestAuthReportsMissingGroupMembership(t *testing.T) {
dir := &fakeDir{
dn: "CN=VPN-Users,DC=firma,DC=local",
lookup: &auth.LookupResult{DN: "CN=J Doe", SAMAccountName: "jdoe", InVPNGroup: false},
}
c := newChecker(t, &fakePinger{at: checkNow}, dir)
rep := c.Run(context.Background(), "jdoe")
if rep.OK() {
t.Fatal("fehlende Gruppenmitgliedschaft muss als Fehler gelten")
}
if out := report(t, rep); !strings.Contains(out, "NEIN") {
t.Errorf("das Ergebnis muss deutlich benannt werden:\n%s", out)
}
}
func TestTestAuthSkippedWhenEmpty(t *testing.T) {
c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"})
rep := c.Run(context.Background(), "")
if strings.Contains(report(t, rep), "Testanmeldung") {
t.Error("ohne --test-auth darf kein Testanmelde-Punkt erscheinen")
}
}
func TestReportOutputFormat(t *testing.T) {
rep := &Report{Results: []Result{
{Name: "Konfiguration", OK: true},
{Name: "Dateirechte", OK: false, Detail: "config.yaml ist 0644"},
}}
out := report(t, rep)
if !strings.Contains(out, "✓ Konfiguration") {
t.Errorf("Erfolgszeile falsch:\n%s", out)
}
if !strings.Contains(out, "✗ Dateirechte") || !strings.Contains(out, "config.yaml ist 0644") {
t.Errorf("Fehlerzeile falsch:\n%s", out)
}
if rep.OK() {
t.Error("ein ✗ muss OK() falsch machen")
}
}
func TestInvalidConfigIsReported(t *testing.T) {
c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"})
c.Cfg.AD.Servers = []string{"10.1.1.10"} // IP statt Hostname
rep := c.Run(context.Background(), "")
if rep.OK() {
t.Fatal("ungültige Konfiguration muss auffallen")
}
if out := report(t, rep); !strings.Contains(out, "10.1.1.10") {
t.Errorf("der beanstandete Wert muss genannt werden:\n%s", out)
}
}

109
internal/config/config.go Normal file
View file

@ -0,0 +1,109 @@
// Package config lädt und validiert die Portal-Konfiguration.
package config
import (
"fmt"
"time"
"gopkg.in/yaml.v3"
)
// Duration erlaubt Dauerangaben als String ("10m") in YAML.
type Duration time.Duration
func (d *Duration) UnmarshalYAML(node *yaml.Node) error {
var s string
if err := node.Decode(&s); err != nil {
return fmt.Errorf("Dauer muss eine Zeichenkette sein (z. B. \"10m\"): %w", err)
}
parsed, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("ungültige Dauer %q (erwartet z. B. \"10m\", \"90s\"): %w", s, err)
}
if parsed <= 0 {
return fmt.Errorf("Dauer %q muss positiv sein", s)
}
*d = Duration(parsed)
return nil
}
func (d Duration) String() string { return time.Duration(d).String() }
type Config struct {
Portal PortalConfig `yaml:"portal"`
OPNsense OPNsenseConfig `yaml:"opnsense"`
AD ADConfig `yaml:"ad"`
Matching MatchingConfig `yaml:"matching"`
Logging LoggingConfig `yaml:"logging"`
}
type PortalConfig struct {
Listen string `yaml:"listen"`
TLSCert string `yaml:"tls_cert"`
TLSKey string `yaml:"tls_key"`
SessionTTL Duration `yaml:"session_ttl"`
Title string `yaml:"title"`
LogoFile string `yaml:"logo_file"`
SupportContact string `yaml:"support_contact"`
UpdateCheck bool `yaml:"update_check"`
}
type OPNsenseConfig struct {
URL string `yaml:"url"`
APIKey string `yaml:"api_key"`
APIKeyFile string `yaml:"api_key_file"`
APISecret string `yaml:"api_secret"`
APISecretFile string `yaml:"api_secret_file"`
CAFile string `yaml:"ca_file"`
InsecureSkipVerify bool `yaml:"insecure_skip_verify"`
}
type ADConfig struct {
Domain string `yaml:"domain"`
BaseDN string `yaml:"base_dn"`
Servers []string `yaml:"servers"`
Port int `yaml:"port"`
TLSMode string `yaml:"tls_mode"` // "ldaps" (Default) oder "starttls"
BindUser string `yaml:"bind_user"`
BindPassword string `yaml:"bind_password"`
BindPasswordFile string `yaml:"bind_password_file"`
VPNGroup string `yaml:"vpn_group"`
CAFile string `yaml:"ca_file"`
Timeout Duration `yaml:"timeout"`
}
type MatchingConfig struct {
CNPattern string `yaml:"cn_pattern"`
CNRegex string `yaml:"cn_regex"`
}
type LoggingConfig struct {
Level string `yaml:"level"`
AuditLog string `yaml:"audit_log"`
MaxSizeMB int `yaml:"max_size_mb"`
MaxBackups int `yaml:"max_backups"`
Compress bool `yaml:"compress"`
}
// Defaults liefert eine Config mit allen Vorgabewerten.
func Defaults() *Config {
return &Config{
Portal: PortalConfig{
Listen: "0.0.0.0:8443",
SessionTTL: Duration(10 * time.Minute),
Title: "VPN-Portal",
},
AD: ADConfig{
Port: 636,
TLSMode: "ldaps",
Timeout: Duration(8 * time.Second),
},
Matching: MatchingConfig{CNPattern: "{username}"},
Logging: LoggingConfig{
Level: "info",
MaxSizeMB: 50,
MaxBackups: 5,
Compress: true,
},
}
}

View 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)
}
}

116
internal/config/load.go Normal file
View file

@ -0,0 +1,116 @@
package config
import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// Namen der Umgebungsvariablen, die Secrets aus der Konfiguration überschreiben.
const (
EnvAPIKey = "VPNPORTAL_OPNSENSE_API_KEY"
EnvAPISecret = "VPNPORTAL_OPNSENSE_API_SECRET"
EnvBindPassword = "VPNPORTAL_AD_BIND_PASSWORD"
)
// maxConfigMode ist die weiteste erlaubte Berechtigung für Config- und
// Secret-Dateien: Eigentümer lesen/schreiben, Gruppe lesen, Welt nichts.
const maxConfigMode fs.FileMode = 0o640
// Parse liest YAML strikt in eine mit Defaults vorbelegte Config.
// Unbekannte Felder führen zum Fehler.
func Parse(r io.Reader) (*Config, error) {
cfg := Defaults()
dec := yaml.NewDecoder(r)
dec.KnownFields(true)
if err := dec.Decode(cfg); err != nil {
if errors.Is(err, io.EOF) {
return nil, errors.New("Konfigurationsdatei ist leer")
}
return nil, fmt.Errorf("Konfiguration konnte nicht gelesen werden: %w", err)
}
// Ein zweiter Decode-Aufruf muss EOF liefern; sonst enthält die Datei
// mehrere YAML-Dokumente, was wir nicht unterstützen.
var extra yaml.Node
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
return nil, errors.New("Konfigurationsdatei darf nur ein YAML-Dokument enthalten")
}
return cfg, nil
}
// Load liest die Konfigurationsdatei, erzwingt Dateirechte, löst
// *_file-Secrets auf und wendet Env-Overrides an.
// env wird injiziert, damit Tests ohne Prozess-Umgebung auskommen.
func Load(path string, env func(string) string) (*Config, error) {
if env == nil {
env = os.Getenv
}
if err := CheckFileMode(path, maxConfigMode); err != nil {
return nil, err
}
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("Konfigurationsdatei %s: %w", path, err)
}
defer f.Close()
cfg, err := Parse(f)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if err := resolveSecrets(cfg, env); err != nil {
return nil, err
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("Konfiguration %s ist ungültig:\n%w", path, err)
}
return cfg, nil
}
// secretSlot beschreibt ein Secret mit seinen drei Bezugsquellen.
type secretSlot struct {
name string // YAML-Feldname für Fehlermeldungen
inline *string
file string
envName string
}
// resolveSecrets wendet die Rangfolge Env > *_file > Inline an.
func resolveSecrets(cfg *Config, env func(string) string) error {
slots := []secretSlot{
{"opnsense.api_key", &cfg.OPNsense.APIKey, cfg.OPNsense.APIKeyFile, EnvAPIKey},
{"opnsense.api_secret", &cfg.OPNsense.APISecret, cfg.OPNsense.APISecretFile, EnvAPISecret},
{"ad.bind_password", &cfg.AD.BindPassword, cfg.AD.BindPasswordFile, EnvBindPassword},
}
for _, s := range slots {
if v := env(s.envName); v != "" {
*s.inline = v
continue
}
if s.file == "" {
continue
}
if *s.inline != "" {
return fmt.Errorf("%s und %s_file sind gleichzeitig gesetzt — bitte nur eines verwenden",
s.name, s.name)
}
if err := CheckFileMode(s.file, maxConfigMode); err != nil {
return fmt.Errorf("Secret-Datei für %s: %w", s.name, err)
}
raw, err := os.ReadFile(s.file)
if err != nil {
return fmt.Errorf("Secret-Datei für %s: %w", s.name, err)
}
value := strings.TrimSpace(string(raw))
if value == "" {
return fmt.Errorf("Secret-Datei %s für %s ist leer", s.file, s.name)
}
*s.inline = value
}
return nil
}

View file

@ -0,0 +1,87 @@
package config
import (
"strings"
"testing"
"time"
)
const minimalYAML = `
portal:
tls_cert: "/etc/vpnportal/portal.crt"
tls_key: "/etc/vpnportal/portal.key"
opnsense:
url: "https://fw01.firma.local"
api_key: "KEY"
api_secret: "SECRET"
ad:
domain: "firma.local"
servers: ["dc01.firma.local"]
bind_user: "svc@firma.local"
bind_password: "PW"
vpn_group: "VPN-Users"
logging:
audit_log: "/var/log/vpnportal/audit.log"
`
func TestParseAppliesDefaults(t *testing.T) {
cfg, err := Parse(strings.NewReader(minimalYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if cfg.Portal.Listen != "0.0.0.0:8443" {
t.Errorf("Listen = %q, want default 0.0.0.0:8443", cfg.Portal.Listen)
}
if time.Duration(cfg.Portal.SessionTTL) != 10*time.Minute {
t.Errorf("SessionTTL = %v, want 10m", time.Duration(cfg.Portal.SessionTTL))
}
if cfg.AD.Port != 636 {
t.Errorf("AD.Port = %d, want 636", cfg.AD.Port)
}
if cfg.Matching.CNPattern != "{username}" {
t.Errorf("CNPattern = %q, want {username}", cfg.Matching.CNPattern)
}
if cfg.Logging.MaxSizeMB != 50 || cfg.Logging.MaxBackups != 5 || !cfg.Logging.Compress {
t.Errorf("logging defaults wrong: %+v", cfg.Logging)
}
if cfg.Portal.UpdateCheck {
t.Error("UpdateCheck must default to false")
}
}
func TestParseRejectsUnknownField(t *testing.T) {
_, err := Parse(strings.NewReader(minimalYAML + "\nportal_typo:\n listen: \"x\"\n"))
if err == nil {
t.Fatal("unbekanntes Feld muss abgelehnt werden")
}
if !strings.Contains(err.Error(), "portal_typo") {
t.Errorf("Fehler muss das unbekannte Feld nennen, got: %v", err)
}
}
func TestParseRejectsUnknownNestedField(t *testing.T) {
_, err := Parse(strings.NewReader(strings.Replace(minimalYAML,
" vpn_group: \"VPN-Users\"", " vpn_group: \"VPN-Users\"\n vpngroup: \"typo\"", 1)))
if err == nil {
t.Fatal("unbekanntes verschachteltes Feld muss abgelehnt werden")
}
}
func TestParseSessionTTLOverride(t *testing.T) {
cfg, err := Parse(strings.NewReader(strings.Replace(minimalYAML,
"portal:", "portal:\n session_ttl: \"90s\"", 1)))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if time.Duration(cfg.Portal.SessionTTL) != 90*time.Second {
t.Errorf("SessionTTL = %v, want 90s", time.Duration(cfg.Portal.SessionTTL))
}
}
func TestParseRejectsBadDuration(t *testing.T) {
_, err := Parse(strings.NewReader(strings.Replace(minimalYAML,
"portal:", "portal:\n session_ttl: \"zehn Minuten\"", 1)))
if err == nil {
t.Fatal("ungültige Dauer muss abgelehnt werden")
}
}

40
internal/config/perms.go Normal file
View file

@ -0,0 +1,40 @@
package config
import (
"fmt"
"io/fs"
"os"
)
// CheckFileMode bricht ab, wenn die Datei mehr Rechte trägt als maxMode.
func CheckFileMode(path string, maxMode fs.FileMode) error {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("Datei %s nicht lesbar: %w", path, err)
}
if info.IsDir() {
return fmt.Errorf("%s ist ein Verzeichnis, erwartet wurde eine Datei", path)
}
return checkMode(path, info.Mode().Perm(), maxMode)
}
// CheckDirMode bricht ab, wenn das Verzeichnis mehr Rechte trägt als maxMode.
func CheckDirMode(path string, maxMode fs.FileMode) error {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("Verzeichnis %s nicht lesbar: %w", path, err)
}
if !info.IsDir() {
return fmt.Errorf("%s ist kein Verzeichnis", path)
}
return checkMode(path, info.Mode().Perm(), maxMode)
}
func checkMode(path string, actual, maxMode fs.FileMode) error {
if extra := actual &^ maxMode; extra != 0 {
return fmt.Errorf(
"%s hat zu weite Dateirechte %#o (erlaubt höchstens %#o). Beheben mit: chmod %#o %s",
path, actual, maxMode, maxMode, path)
}
return nil
}

View file

@ -0,0 +1,65 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeFileMode(t *testing.T, dir, name string, mode os.FileMode, content string) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(content), mode); err != nil {
t.Fatal(err)
}
if err := os.Chmod(p, mode); err != nil { // umask umgehen
t.Fatal(err)
}
return p
}
func TestCheckFileModeAcceptsTightPermissions(t *testing.T) {
dir := t.TempDir()
for _, mode := range []os.FileMode{0o600, 0o640, 0o400} {
p := writeFileMode(t, dir, "s.txt", mode, "x")
if err := CheckFileMode(p, 0o640); err != nil {
t.Errorf("mode %#o should be accepted: %v", mode, err)
}
}
}
func TestCheckFileModeRejectsLoosePermissions(t *testing.T) {
dir := t.TempDir()
for _, mode := range []os.FileMode{0o644, 0o660, 0o604, 0o777} {
p := writeFileMode(t, dir, "s.txt", mode, "x")
err := CheckFileMode(p, 0o640)
if err == nil {
t.Errorf("mode %#o must be rejected", mode)
continue
}
if !strings.Contains(err.Error(), "chmod") {
t.Errorf("Fehler sollte den Reparaturbefehl nennen, got: %v", err)
}
}
}
func TestCheckDirModeRejectsWorldWritable(t *testing.T) {
dir := t.TempDir()
sub := filepath.Join(dir, "logs")
if err := os.Mkdir(sub, 0o777); err != nil {
t.Fatal(err)
}
if err := os.Chmod(sub, 0o777); err != nil {
t.Fatal(err)
}
if err := CheckDirMode(sub, 0o750); err == nil {
t.Fatal("0777-Verzeichnis muss abgelehnt werden")
}
if err := os.Chmod(sub, 0o750); err != nil {
t.Fatal(err)
}
if err := CheckDirMode(sub, 0o750); err != nil {
t.Errorf("0750 sollte akzeptiert werden: %v", err)
}
}

View file

@ -0,0 +1,96 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func noEnv(string) string { return "" }
func writeConfig(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Chmod(p, 0o600); err != nil {
t.Fatal(err)
}
return p
}
func TestLoadRejectsWorldReadableConfig(t *testing.T) {
p := writeConfig(t, minimalYAML)
if err := os.Chmod(p, 0o644); err != nil {
t.Fatal(err)
}
if _, err := Load(p, noEnv); err == nil {
t.Fatal("0644-Config muss den Start abbrechen")
}
}
func TestLoadResolvesSecretFiles(t *testing.T) {
dir := t.TempDir()
secret := filepath.Join(dir, "apisecret")
if err := os.WriteFile(secret, []byte(" FROM-FILE\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Chmod(secret, 0o600); err != nil {
t.Fatal(err)
}
body := strings.Replace(minimalYAML,
` api_secret: "SECRET"`, ` api_secret_file: "`+secret+`"`, 1)
cfg, err := Load(writeConfig(t, body), noEnv)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.OPNsense.APISecret != "FROM-FILE" {
t.Errorf("APISecret = %q, want FROM-FILE (getrimmt)", cfg.OPNsense.APISecret)
}
}
func TestLoadRejectsLooseSecretFile(t *testing.T) {
dir := t.TempDir()
secret := filepath.Join(dir, "apisecret")
if err := os.WriteFile(secret, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(secret, 0o644); err != nil {
t.Fatal(err)
}
body := strings.Replace(minimalYAML,
` api_secret: "SECRET"`, ` api_secret_file: "`+secret+`"`, 1)
if _, err := Load(writeConfig(t, body), noEnv); err == nil {
t.Fatal("Secret-Datei mit 0644 muss abgelehnt werden")
}
}
func TestLoadEnvOverridesEverything(t *testing.T) {
env := map[string]string{
"VPNPORTAL_OPNSENSE_API_SECRET": "FROM-ENV",
"VPNPORTAL_AD_BIND_PASSWORD": "ENV-PW",
}
cfg, err := Load(writeConfig(t, minimalYAML), func(k string) string { return env[k] })
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.OPNsense.APISecret != "FROM-ENV" {
t.Errorf("APISecret = %q, want FROM-ENV", cfg.OPNsense.APISecret)
}
if cfg.AD.BindPassword != "ENV-PW" {
t.Errorf("BindPassword = %q, want ENV-PW", cfg.AD.BindPassword)
}
}
func TestLoadRejectsBothInlineAndFile(t *testing.T) {
body := strings.Replace(minimalYAML,
` api_secret: "SECRET"`,
" api_secret: \"SECRET\"\n api_secret_file: \"/tmp/x\"", 1)
_, err := Load(writeConfig(t, body), noEnv)
if err == nil || !strings.Contains(err.Error(), "api_secret") {
t.Fatalf("gleichzeitig gesetzte api_secret/api_secret_file müssen abgelehnt werden, got: %v", err)
}
}

144
internal/config/validate.go Normal file
View file

@ -0,0 +1,144 @@
package config
import (
"errors"
"fmt"
"net"
"net/url"
"regexp"
"slices"
"strings"
)
// UsernamePlaceholder ist der Platzhalter in matching.cn_pattern.
const UsernamePlaceholder = "{username}"
// BaseDNFromDomain leitet DC=firma,DC=local aus firma.local ab.
func BaseDNFromDomain(domain string) (string, error) {
domain = strings.Trim(strings.TrimSpace(domain), ".")
if domain == "" {
return "", errors.New("ad.domain ist leer")
}
parts := strings.Split(domain, ".")
if len(parts) < 2 {
return "", fmt.Errorf("ad.domain %q sieht nicht wie eine AD-Domäne aus (erwartet z. B. firma.local); "+
"andernfalls ad.base_dn explizit setzen", domain)
}
dcs := make([]string, len(parts))
for i, p := range parts {
if p == "" {
return "", fmt.Errorf("ad.domain %q enthält leere Labels", domain)
}
dcs[i] = "DC=" + p
}
return strings.Join(dcs, ","), nil
}
// EffectiveBaseDN liefert den Override oder die Ableitung aus der Domäne.
func (c *Config) EffectiveBaseDN() string {
if strings.TrimSpace(c.AD.BaseDN) != "" {
return strings.TrimSpace(c.AD.BaseDN)
}
dn, err := BaseDNFromDomain(c.AD.Domain)
if err != nil {
return ""
}
return dn
}
// Validate prüft die Konfiguration semantisch und sammelt alle Fehler,
// damit der Betreiber nicht Fehler für Fehler nachbessern muss.
func (c *Config) Validate() error {
var errs []error
req := func(value, name string) {
if strings.TrimSpace(value) == "" {
errs = append(errs, fmt.Errorf("%s ist erforderlich", name))
}
}
req(c.Portal.Listen, "portal.listen")
req(c.Portal.TLSCert, "portal.tls_cert")
req(c.Portal.TLSKey, "portal.tls_key")
if _, _, err := net.SplitHostPort(c.Portal.Listen); c.Portal.Listen != "" && err != nil {
errs = append(errs, fmt.Errorf("portal.listen %q ist keine gültige Adresse (erwartet HOST:PORT): %w",
c.Portal.Listen, err))
}
req(c.OPNsense.URL, "opnsense.url")
if c.OPNsense.URL != "" {
u, err := url.Parse(c.OPNsense.URL)
switch {
case err != nil:
errs = append(errs, fmt.Errorf("opnsense.url %q ist keine gültige URL: %w", c.OPNsense.URL, err))
case u.Scheme != "https":
errs = append(errs, fmt.Errorf("opnsense.url muss mit https:// beginnen, hat aber Schema %q", u.Scheme))
case u.Host == "":
errs = append(errs, errors.New("opnsense.url enthält keinen Host"))
}
}
req(c.OPNsense.APIKey, "opnsense.api_key (oder api_key_file / "+EnvAPIKey+")")
req(c.OPNsense.APISecret, "opnsense.api_secret (oder api_secret_file / "+EnvAPISecret+")")
req(c.AD.Domain, "ad.domain")
req(c.AD.BindUser, "ad.bind_user")
req(c.AD.BindPassword, "ad.bind_password (oder bind_password_file / "+EnvBindPassword+")")
req(c.AD.VPNGroup, "ad.vpn_group")
if len(c.AD.Servers) == 0 {
errs = append(errs, errors.New("ad.servers muss mindestens einen Domain Controller enthalten"))
}
for _, s := range c.AD.Servers {
s = strings.TrimSpace(s)
if s == "" {
errs = append(errs, errors.New("ad.servers enthält einen leeren Eintrag"))
continue
}
if net.ParseIP(s) != nil {
errs = append(errs, fmt.Errorf(
"ad.servers: %q ist eine IP-Adresse — es sind Hostnamen erforderlich, "+
"weil das LDAPS-Zertifikat gegen den Hostnamen geprüft wird", s))
}
}
if !slices.Contains([]string{"ldaps", "starttls"}, c.AD.TLSMode) {
errs = append(errs, fmt.Errorf("ad.tls_mode %q ist ungültig (erlaubt: ldaps, starttls)", c.AD.TLSMode))
}
if c.AD.Port <= 0 || c.AD.Port > 65535 {
errs = append(errs, fmt.Errorf("ad.port %d liegt außerhalb 165535", c.AD.Port))
}
if c.AD.BaseDN == "" && c.AD.Domain != "" {
if _, err := BaseDNFromDomain(c.AD.Domain); err != nil {
errs = append(errs, err)
}
}
pattern, rx := strings.TrimSpace(c.Matching.CNPattern), strings.TrimSpace(c.Matching.CNRegex)
switch {
case pattern != "" && rx != "":
errs = append(errs, errors.New(
"matching.cn_pattern und matching.cn_regex schließen sich aus — bitte nur eines setzen"))
case pattern == "" && rx == "":
errs = append(errs, errors.New("matching.cn_pattern oder matching.cn_regex muss gesetzt sein"))
case pattern != "" && !strings.Contains(pattern, UsernamePlaceholder):
errs = append(errs, fmt.Errorf("matching.cn_pattern %q enthält keinen %s-Platzhalter",
pattern, UsernamePlaceholder))
case rx != "":
// Der Platzhalter wird für den Kompiliertest durch einen harmlosen
// Literalwert ersetzt; zur Laufzeit steht dort der quotierte Benutzername.
if _, err := regexp.Compile(strings.ReplaceAll(rx, UsernamePlaceholder, "x")); err != nil {
errs = append(errs, fmt.Errorf("matching.cn_regex ist nicht kompilierbar: %w", err))
}
}
if !slices.Contains([]string{"debug", "info", "warn", "error"}, c.Logging.Level) {
errs = append(errs, fmt.Errorf("logging.level %q ist ungültig (erlaubt: debug, info, warn, error)",
c.Logging.Level))
}
req(c.Logging.AuditLog, "logging.audit_log")
if c.Logging.MaxSizeMB <= 0 {
errs = append(errs, fmt.Errorf("logging.max_size_mb muss positiv sein, ist %d", c.Logging.MaxSizeMB))
}
if c.Logging.MaxBackups < 0 {
errs = append(errs, fmt.Errorf("logging.max_backups darf nicht negativ sein, ist %d", c.Logging.MaxBackups))
}
return errors.Join(errs...)
}

View file

@ -0,0 +1,115 @@
package config
import (
"strings"
"testing"
)
func validCfg(t *testing.T) *Config {
t.Helper()
cfg, err := Parse(strings.NewReader(minimalYAML))
if err != nil {
t.Fatal(err)
}
return cfg
}
func TestBaseDNFromDomain(t *testing.T) {
cases := map[string]string{
"firma.local": "DC=firma,DC=local",
"ad.firma.example": "DC=ad,DC=firma,DC=example",
"FIRMA.LOCAL": "DC=FIRMA,DC=LOCAL",
}
for in, want := range cases {
got, err := BaseDNFromDomain(in)
if err != nil {
t.Errorf("BaseDNFromDomain(%q): %v", in, err)
continue
}
if got != want {
t.Errorf("BaseDNFromDomain(%q) = %q, want %q", in, got, want)
}
}
if _, err := BaseDNFromDomain("firmalocal"); err == nil {
t.Error("Domain ohne Punkt muss abgelehnt werden")
}
if _, err := BaseDNFromDomain(""); err == nil {
t.Error("leere Domain muss abgelehnt werden")
}
}
func TestEffectiveBaseDNPrefersOverride(t *testing.T) {
cfg := validCfg(t)
cfg.AD.BaseDN = "OU=Users,DC=firma,DC=local"
if got := cfg.EffectiveBaseDN(); got != "OU=Users,DC=firma,DC=local" {
t.Errorf("Override muss gewinnen, got %q", got)
}
cfg.AD.BaseDN = ""
if got := cfg.EffectiveBaseDN(); got != "DC=firma,DC=local" {
t.Errorf("Ableitung falsch, got %q", got)
}
}
func TestValidateAcceptsMinimalConfig(t *testing.T) {
if err := validCfg(t).Validate(); err != nil {
t.Fatalf("minimale Config sollte gültig sein: %v", err)
}
}
func TestValidateRejectsIPServers(t *testing.T) {
cfg := validCfg(t)
cfg.AD.Servers = []string{"10.1.1.10"}
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "10.1.1.10") {
t.Fatalf("IP-Adresse als DC muss abgelehnt werden, got: %v", err)
}
}
func TestValidateRejectsHTTPOPNsense(t *testing.T) {
cfg := validCfg(t)
cfg.OPNsense.URL = "http://fw01.firma.local"
if err := cfg.Validate(); err == nil {
t.Fatal("http:// muss abgelehnt werden")
}
}
func TestValidateRejectsPatternAndRegexTogether(t *testing.T) {
cfg := validCfg(t)
cfg.Matching.CNRegex = "^.*$"
if err := cfg.Validate(); err == nil {
t.Fatal("cn_pattern und cn_regex gleichzeitig muss abgelehnt werden")
}
}
func TestValidateRejectsBadRegex(t *testing.T) {
cfg := validCfg(t)
cfg.Matching.CNPattern = ""
cfg.Matching.CNRegex = "([unbalanced"
if err := cfg.Validate(); err == nil {
t.Fatal("unkompilierbare Regex muss abgelehnt werden")
}
}
func TestValidateCollectsMultipleErrors(t *testing.T) {
cfg := validCfg(t)
cfg.Portal.TLSCert = ""
cfg.Portal.TLSKey = ""
cfg.AD.VPNGroup = ""
err := cfg.Validate()
if err == nil {
t.Fatal("mehrere Fehler erwartet")
}
for _, want := range []string{"tls_cert", "tls_key", "vpn_group"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("Sammelfehler muss %q nennen, got: %v", want, err)
}
}
}
func TestValidateRejectsPatternWithoutPlaceholder(t *testing.T) {
cfg := validCfg(t)
cfg.Matching.CNPattern = "fester-cn"
if err := cfg.Validate(); err == nil {
t.Fatal("cn_pattern ohne {username} muss abgelehnt werden")
}
}

170
internal/opnsense/client.go Normal file
View file

@ -0,0 +1,170 @@
package opnsense
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// Sentinel-Fehler, damit Aufrufer Ursachen unterscheiden können.
var (
ErrUnauthorized = errors.New("OPNsense: Zugangsdaten abgelehnt (API-Key/Secret prüfen)")
ErrForbidden = errors.New("OPNsense: keine Berechtigung (API-User braucht \"VPN: OpenVPN Client Export\")")
ErrUnreachable = errors.New("OPNsense nicht erreichbar")
ErrUnexpected = errors.New("OPNsense: unerwartete Antwort")
)
// maxJSONBytes begrenzt JSON-Antworten; Exportdaten werden gestreamt und
// unterliegen dieser Grenze nicht.
const maxJSONBytes = 8 << 20
// Options konfiguriert den Client.
type Options struct {
BaseURL string
APIKey string
APISecret string
CAFile string
InsecureSkipVerify bool
Timeout time.Duration
// HTTPClient überschreibt den intern gebauten Client (Tests).
HTTPClient *http.Client
}
// Client ist ein read-only Client für die OPNsense-Export-API.
type Client struct {
baseURL string
key string
secret string
http *http.Client
}
// New baut den Client und die TLS-Konfiguration.
func New(opts Options) (*Client, error) {
if strings.TrimSpace(opts.BaseURL) == "" {
return nil, errors.New("opnsense: url fehlt")
}
if _, err := url.Parse(opts.BaseURL); err != nil {
return nil, fmt.Errorf("opnsense: url ist ungültig: %w", err)
}
if opts.APIKey == "" || opts.APISecret == "" {
return nil, errors.New("opnsense: api_key und api_secret sind erforderlich")
}
if opts.Timeout <= 0 {
opts.Timeout = 15 * time.Second
}
httpClient := opts.HTTPClient
if httpClient == nil {
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
if opts.InsecureSkipVerify {
// Nur für Tests; serve gibt bei jedem Start eine Warnung aus.
tlsCfg.InsecureSkipVerify = true
}
if opts.CAFile != "" {
pem, err := os.ReadFile(opts.CAFile)
if err != nil {
return nil, fmt.Errorf("opnsense.ca_file %s: %w", opts.CAFile, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("opnsense.ca_file %s enthält kein gültiges PEM-Zertifikat", opts.CAFile)
}
tlsCfg.RootCAs = pool
}
httpClient = &http.Client{
Timeout: opts.Timeout,
Transport: &http.Transport{TLSClientConfig: tlsCfg, ForceAttemptHTTP2: true},
}
}
return &Client{
baseURL: strings.TrimRight(opts.BaseURL, "/"),
key: opts.APIKey,
secret: opts.APISecret,
http: httpClient,
}, nil
}
// get führt einen authentifizierten GET aus und normalisiert Fehlerstatus.
// Der Aufrufer muss den Body schließen.
func (c *Client) get(ctx context.Context, path string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(c.key, c.secret)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %s: %v", ErrUnreachable, path, err)
}
switch resp.StatusCode {
case http.StatusOK:
return resp, nil
case http.StatusUnauthorized:
resp.Body.Close()
return nil, fmt.Errorf("%w (%s)", ErrUnauthorized, path)
case http.StatusForbidden:
resp.Body.Close()
return nil, fmt.Errorf("%w (%s)", ErrForbidden, path)
case http.StatusNotFound:
resp.Body.Close()
return nil, fmt.Errorf("%w: Endpunkt %s existiert nicht — ist das Plugin "+
"os-openvpn-client-export installiert?", ErrUnexpected, path)
default:
resp.Body.Close()
return nil, fmt.Errorf("%w: %s antwortete mit HTTP %d", ErrUnexpected, path, resp.StatusCode)
}
}
// getJSON liest eine JSON-Antwort und weist HTML-Loginseiten zurück.
func (c *Client) getJSON(ctx context.Context, path string, into any) error {
resp, err := c.get(ctx, path)
if err != nil {
return err
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") {
return fmt.Errorf("%w: %s lieferte HTML statt JSON — meist ein ungültiger API-Key", ErrUnauthorized, path)
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxJSONBytes))
if err != nil {
return fmt.Errorf("%w: %s: %v", ErrUnreachable, path, err)
}
if strings.HasPrefix(strings.TrimSpace(string(raw)), "<") {
return fmt.Errorf("%w: %s lieferte HTML statt JSON — meist ein ungültiger API-Key", ErrUnauthorized, path)
}
if err := json.Unmarshal(raw, into); err != nil {
return fmt.Errorf("%w: %s lieferte kein verwertbares JSON: %v", ErrUnexpected, path, err)
}
return nil
}
// Ping prüft Erreichbarkeit und Berechtigung und liefert die Serverzeit aus
// dem Date-Header (Grundlage der NTP-Plausibilitätsprüfung in check).
func (c *Client) Ping(ctx context.Context) (time.Time, error) {
resp, err := c.get(ctx, pathProviders)
if err != nil {
return time.Time{}, err
}
defer resp.Body.Close()
io.Copy(io.Discard, io.LimitReader(resp.Body, maxJSONBytes))
if d := resp.Header.Get("Date"); d != "" {
if t, err := http.ParseTime(d); err == nil {
return t.UTC(), nil
}
}
return time.Time{}, nil
}

View file

@ -0,0 +1,234 @@
package opnsense
import (
"context"
"encoding/base64"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func newTestClient(t *testing.T, h http.HandlerFunc) (*Client, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
c, err := New(Options{
BaseURL: srv.URL,
APIKey: "KEY",
APISecret: "SECRET",
Timeout: 2 * time.Second,
HTTPClient: srv.Client(),
})
if err != nil {
t.Fatalf("New: %v", err)
}
return c, srv
}
func TestProvidersSendsBasicAuthAndParsesMap(t *testing.T) {
var gotPath string
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
user, pass, ok := r.BasicAuth()
if !ok || user != "KEY" || pass != "SECRET" {
t.Errorf("BasicAuth = %q/%q ok=%v", user, pass, ok)
}
io.WriteString(w, `{
"1": {"vpnid":"1","name":"VPN Homeoffice"},
"2": {"vpnid":"2","name":"VPN Aussendienst"}
}`)
})
ps, err := c.Providers(context.Background())
if err != nil {
t.Fatalf("Providers: %v", err)
}
if gotPath != "/api/openvpn/export/providers" {
t.Errorf("Pfad = %q", gotPath)
}
if len(ps) != 2 {
t.Fatalf("got %d Provider, want 2", len(ps))
}
// Stabile Sortierung nach VPNID, damit die UI-Reihenfolge deterministisch ist.
if ps[0].VPNID != "1" || ps[0].Name != "VPN Homeoffice" {
t.Errorf("ps[0] = %+v", ps[0])
}
}
func TestProvidersFallsBackToMapKeyAsVPNID(t *testing.T) {
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, `{"7": {"name":"VPN Sieben"}}`)
})
ps, err := c.Providers(context.Background())
if err != nil {
t.Fatalf("Providers: %v", err)
}
if len(ps) != 1 || ps[0].VPNID != "7" {
t.Fatalf("ps = %+v, VPNID muss aus dem Map-Schlüssel kommen", ps)
}
}
func TestAccountsParsesRevokedAndExpiry(t *testing.T) {
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/accounts/1") {
t.Errorf("Pfad = %q", r.URL.Path)
}
io.WriteString(w, `{
"abc123": {"commonName":"mmueller","description":"Max","validTo":"2027-03-01","isRevoked":"0"},
"def456": {"commonName":"jdoe","validTo":"2027-03-01","isRevoked":"1"}
}`)
})
accs, err := c.Accounts(context.Background(), "1")
if err != nil {
t.Fatalf("Accounts: %v", err)
}
if len(accs) != 2 {
t.Fatalf("got %d Accounts, want 2", len(accs))
}
byCN := map[string]Account{}
for _, a := range accs {
byCN[a.CommonName] = a
}
if byCN["mmueller"].RefID != "abc123" || byCN["mmueller"].Revoked {
t.Errorf("mmueller = %+v", byCN["mmueller"])
}
if !byCN["jdoe"].Revoked {
t.Error("jdoe muss als revoziert erkannt werden")
}
if byCN["mmueller"].ValidTo.Year() != 2027 {
t.Errorf("ValidTo = %v", byCN["mmueller"].ValidTo)
}
}
func TestExportStreamsRawBody(t *testing.T) {
const cfg = "client\nremote fw01.firma.local 1194\n"
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", `attachment; filename="mmueller.ovpn"`)
io.WriteString(w, cfg)
})
res, err := c.Export(context.Background(), "1", "abc123", FormatOVPN)
if err != nil {
t.Fatalf("Export: %v", err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if string(body) != cfg {
t.Errorf("Body = %q", body)
}
if res.Filename != "mmueller.ovpn" {
t.Errorf("Filename = %q", res.Filename)
}
}
func TestExportDecodesBase64JSONBody(t *testing.T) {
const cfg = "client\nremote fw01 1194\n"
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"status":"ok","filename":"x.ovpn","content":"`+
base64.StdEncoding.EncodeToString([]byte(cfg))+`"}`)
})
res, err := c.Export(context.Background(), "1", "abc123", FormatOVPN)
if err != nil {
t.Fatalf("Export: %v", err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if string(body) != cfg {
t.Errorf("Body = %q, want dekodierte Konfiguration", body)
}
if res.Filename != "x.ovpn" {
t.Errorf("Filename = %q", res.Filename)
}
}
func TestUnauthorizedAndForbiddenAreDistinct(t *testing.T) {
for status, want := range map[int]error{
http.StatusUnauthorized: ErrUnauthorized,
http.StatusForbidden: ErrForbidden,
} {
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
})
_, err := c.Providers(context.Background())
if !errors.Is(err, want) {
t.Errorf("Status %d: err = %v, want %v", status, err, want)
}
}
}
func TestUnreachableIsWrapped(t *testing.T) {
c, srv := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {})
srv.Close() // Server abschalten, um Verbindungsfehler zu erzwingen
_, err := c.Providers(context.Background())
if !errors.Is(err, ErrUnreachable) {
t.Fatalf("err = %v, want ErrUnreachable", err)
}
}
func TestHTMLLoginPageIsRejected(t *testing.T) {
// OPNsense liefert bei ungültigem Key manchmal 200 mit HTML-Loginseite.
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
io.WriteString(w, "<html><body>Login</body></html>")
})
if _, err := c.Providers(context.Background()); err == nil {
t.Fatal("HTML-Antwort muss als Fehler erkannt werden")
}
}
func TestMissingPluginIsNamedInError(t *testing.T) {
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
_, err := c.Providers(context.Background())
if err == nil || !strings.Contains(err.Error(), "os-openvpn-client-export") {
t.Fatalf("404 muss auf das fehlende Plugin hinweisen, got: %v", err)
}
}
func TestPingReturnsServerTime(t *testing.T) {
want := time.Date(2026, 8, 14, 7, 32, 11, 0, time.UTC)
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Date", want.Format(http.TimeFormat))
io.WriteString(w, `{}`)
})
got, err := c.Ping(context.Background())
if err != nil {
t.Fatalf("Ping: %v", err)
}
if !got.Equal(want) {
t.Errorf("Ping-Zeit = %v, want %v", got, want)
}
}
func TestNewValidatesOptions(t *testing.T) {
if _, err := New(Options{BaseURL: "https://fw", APIKey: "k", APISecret: "s"}); err != nil {
t.Fatalf("Standardfall muss funktionieren: %v", err)
}
if _, err := New(Options{BaseURL: "", APIKey: "k", APISecret: "s"}); err == nil {
t.Fatal("leere BaseURL muss abgelehnt werden")
}
if _, err := New(Options{BaseURL: "https://fw", APIKey: "", APISecret: "s"}); err == nil {
t.Fatal("fehlender API-Key muss abgelehnt werden")
}
}
func TestExportRejectsEmptyIdentifiers(t *testing.T) {
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("bei leeren Bezeichnern darf kein Request abgehen")
})
if _, err := c.Export(context.Background(), "", "abc", FormatOVPN); err == nil {
t.Error("leere vpnid muss abgelehnt werden")
}
if _, err := c.Export(context.Background(), "1", "", FormatOVPN); err == nil {
t.Error("leere Zertifikatsreferenz muss abgelehnt werden")
}
}

144
internal/opnsense/export.go Normal file
View file

@ -0,0 +1,144 @@
package opnsense
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime"
"net/url"
"sort"
"strings"
)
// API-Pfade des Plugins os-openvpn-client-export. Alle drei sind GET und
// damit read-only — das Portal schreibt niemals auf die Firewall.
const (
pathProviders = "/api/openvpn/export/providers"
pathAccounts = "/api/openvpn/export/accounts/"
pathDownload = "/api/openvpn/export/download/"
)
// Providers listet die exportierbaren OpenVPN-Instanzen.
// Das Ergebnis darf vom Aufrufer kurz gecacht werden (Minuten).
func (c *Client) Providers(ctx context.Context) ([]Provider, error) {
var raw map[string]rawProvider
if err := c.getJSON(ctx, pathProviders, &raw); err != nil {
return nil, err
}
out := make([]Provider, 0, len(raw))
for key, rp := range raw {
id := rp.VPNID
if id == "" {
id = key // ältere Versionen führen die vpnid nur als Map-Schlüssel
}
name := firstNonEmpty(rp.Name, rp.Descr, "VPN "+id)
out = append(out, Provider{VPNID: id, Name: name})
}
sort.Slice(out, func(i, j int) bool { return out[i].VPNID < out[j].VPNID })
return out, nil
}
// Accounts listet die Zertifikate einer Instanz.
// Diese Antwort darf NIEMALS gecacht werden — eine Revozierung auf der
// Firewall muss ohne Verzögerung greifen.
func (c *Client) Accounts(ctx context.Context, vpnID string) ([]Account, error) {
if strings.TrimSpace(vpnID) == "" {
return nil, fmt.Errorf("%w: leere vpnid", ErrUnexpected)
}
var raw map[string]rawAccount
if err := c.getJSON(ctx, pathAccounts+url.PathEscape(vpnID), &raw); err != nil {
return nil, err
}
out := make([]Account, 0, len(raw))
for refID, ra := range raw {
out = append(out, ra.toAccount(refID))
}
sort.Slice(out, func(i, j int) bool { return out[i].RefID < out[j].RefID })
return out, nil
}
// ExportResult trägt den Datenstrom der Konfiguration.
// Der Aufrufer muss Body schließen und darf ihn nicht auf Platte zwischenspeichern.
type ExportResult struct {
Filename string
ContentType string
Body io.ReadCloser
}
// jsonExport ist die Antwortform älterer Plugin-Versionen: Base64 im JSON.
type jsonExport struct {
Status string `json:"status"`
Filename string `json:"filename"`
Content string `json:"content"`
Data string `json:"data"`
Message string `json:"message"`
}
// Export lädt eine Client-Konfiguration und liefert sie als Stream.
// Es werden keine Exportoptionen übergeben — die auf der Firewall
// hinterlegten Einstellungen sind die Quelle der Wahrheit.
func (c *Client) Export(ctx context.Context, vpnID, certRefID, format string) (*ExportResult, error) {
if strings.TrimSpace(vpnID) == "" || strings.TrimSpace(certRefID) == "" {
return nil, fmt.Errorf("%w: vpnid oder Zertifikatsreferenz fehlt", ErrUnexpected)
}
path := fmt.Sprintf("%s%s/%s/%s", pathDownload,
url.PathEscape(vpnID), url.PathEscape(format), url.PathEscape(certRefID))
resp, err := c.get(ctx, path)
if err != nil {
return nil, err
}
ct := resp.Header.Get("Content-Type")
filename := filenameFromDisposition(resp.Header.Get("Content-Disposition"))
// JSON-Variante: Inhalt steckt Base64-kodiert in der Antwort.
if strings.Contains(ct, "application/json") {
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxJSONBytes))
if err != nil {
return nil, fmt.Errorf("%w: Export konnte nicht gelesen werden: %v", ErrUnreachable, err)
}
var je jsonExport
if err := json.Unmarshal(raw, &je); err != nil {
return nil, fmt.Errorf("%w: Export lieferte kein verwertbares JSON: %v", ErrUnexpected, err)
}
payload := firstNonEmpty(je.Content, je.Data)
if payload == "" {
return nil, fmt.Errorf("%w: Export ohne Inhalt (status %q, message %q)",
ErrUnexpected, je.Status, je.Message)
}
decoded, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
// Manche Versionen liefern den Klartext direkt.
decoded = []byte(payload)
}
return &ExportResult{
Filename: firstNonEmpty(je.Filename, filename),
ContentType: "application/x-openvpn-profile",
Body: io.NopCloser(bytes.NewReader(decoded)),
}, nil
}
// Rohvariante: direkt durchstreamen, nichts puffern.
return &ExportResult{
Filename: filename,
ContentType: firstNonEmpty(ct, "application/octet-stream"),
Body: resp.Body,
}, nil
}
// filenameFromDisposition liest den Dateinamen aus dem Content-Disposition-Header.
func filenameFromDisposition(v string) string {
if v == "" {
return ""
}
_, params, err := mime.ParseMediaType(v)
if err != nil {
return ""
}
return params["filename"]
}

View file

@ -0,0 +1,133 @@
//go:build integration
// Diese Tests laufen nur mit `go test -tags integration ./internal/opnsense/`
// gegen eine echte OPNsense-Testinstanz. Sie bestätigen die in
// docs/opnsense-api.md dokumentierten Annahmen über Feldnamen und Formate.
package opnsense
import (
"context"
"io"
"os"
"testing"
"time"
)
func integrationClient(t *testing.T) *Client {
t.Helper()
base, key, secret := os.Getenv("OPNSENSE_URL"), os.Getenv("OPNSENSE_KEY"), os.Getenv("OPNSENSE_SECRET")
if base == "" || key == "" || secret == "" {
t.Skip("OPNSENSE_URL/OPNSENSE_KEY/OPNSENSE_SECRET nicht gesetzt")
}
c, err := New(Options{BaseURL: base, APIKey: key, APISecret: secret,
CAFile: os.Getenv("OPNSENSE_CA"), Timeout: 20 * time.Second})
if err != nil {
t.Fatal(err)
}
return c
}
// TestIntegrationProviders bestätigt Feldnamen und Map-Form der Provider-Antwort.
func TestIntegrationProviders(t *testing.T) {
ps, err := integrationClient(t).Providers(context.Background())
if err != nil {
t.Fatalf("Providers: %v", err)
}
if len(ps) == 0 {
t.Fatal("keine Provider — mindestens eine OpenVPN-Instanz muss exportierbar sein")
}
for _, p := range ps {
if p.VPNID == "" || p.Name == "" {
t.Errorf("unvollständiger Provider: %+v", p)
}
}
}
// TestIntegrationAccountsExpiryIsParsed bestätigt, dass das Ablaufdatum in
// einem der unterstützten Formate ankommt und nicht stillschweigend leer bleibt.
func TestIntegrationAccountsExpiryIsParsed(t *testing.T) {
c := integrationClient(t)
ps, err := c.Providers(context.Background())
if err != nil {
t.Fatal(err)
}
var seen, withDate int
for _, p := range ps {
accs, err := c.Accounts(context.Background(), p.VPNID)
if err != nil {
t.Fatalf("Accounts(%s): %v", p.VPNID, err)
}
for _, a := range accs {
seen++
if a.CommonName == "" {
t.Errorf("Zertifikat %s ohne CommonName — Feldname prüfen: %+v", a.RefID, a)
}
if !a.ValidTo.IsZero() {
withDate++
}
}
}
if seen == 0 {
t.Skip("keine Zertifikate auf der Testinstanz")
}
if withDate == 0 {
t.Fatalf("kein einziges der %d Zertifikate hat ein auswertbares Ablaufdatum — "+
"flexTimeLayouts bzw. die Feldnamen in rawAccount müssen ergänzt werden", seen)
}
}
// TestIntegrationAccountsRevokedField bestätigt, dass revozierte Zertifikate
// als solche erkennbar sind. Voraussetzung: auf der Testinstanz existiert ein
// revoziertes Zertifikat mit CN aus OPNSENSE_REVOKED_CN.
func TestIntegrationAccountsRevokedField(t *testing.T) {
wantCN := os.Getenv("OPNSENSE_REVOKED_CN")
if wantCN == "" {
t.Skip("OPNSENSE_REVOKED_CN nicht gesetzt")
}
c := integrationClient(t)
ps, err := c.Providers(context.Background())
if err != nil {
t.Fatal(err)
}
for _, p := range ps {
accs, err := c.Accounts(context.Background(), p.VPNID)
if err != nil {
t.Fatalf("Accounts(%s): %v", p.VPNID, err)
}
for _, a := range accs {
if a.CommonName == wantCN {
if !a.Revoked {
t.Fatalf("Zertifikat %q wird nicht als revoziert gemeldet: %+v", wantCN, a)
}
if a.IsUsable(time.Now()) {
t.Fatalf("revoziertes Zertifikat %q gilt als nutzbar", wantCN)
}
return
}
}
}
t.Fatalf("CN %q auf keiner Instanz gefunden", wantCN)
}
// TestIntegrationExportFormats bestätigt, dass beide angebotenen Formate
// nicht-leere Konfigurationen liefern.
func TestIntegrationExportFormats(t *testing.T) {
refID := os.Getenv("OPNSENSE_CERT_REF")
vpnID := os.Getenv("OPNSENSE_VPNID")
if refID == "" || vpnID == "" {
t.Skip("OPNSENSE_CERT_REF/OPNSENSE_VPNID nicht gesetzt")
}
c := integrationClient(t)
for _, format := range []string{FormatOVPN, FormatViscosity} {
res, err := c.Export(context.Background(), vpnID, refID, format)
if err != nil {
t.Errorf("Export(%s): %v", format, err)
continue
}
n, _ := io.Copy(io.Discard, res.Body)
res.Body.Close()
if n == 0 {
t.Errorf("Export(%s) lieferte 0 Bytes", format)
}
}
}

161
internal/opnsense/types.go Normal file
View file

@ -0,0 +1,161 @@
// Package opnsense spricht die read-only Export-API des Plugins
// os-openvpn-client-export an.
package opnsense
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// Exportformate, die das Portal anbietet. Alle weiteren Einstellungen kommen
// aus der Firewall-Konfiguration — das Portal überschreibt nichts.
const (
FormatOVPN = "ovpn"
FormatViscosity = "viscosity"
)
// flexBool akzeptiert die verschiedenen Boolean-Darstellungen der API
// (true, 1, "1", "yes"). Die exakte Variante ist versionsabhängig.
type flexBool bool
func (b *flexBool) UnmarshalJSON(data []byte) error {
s := strings.Trim(strings.TrimSpace(string(data)), `"`)
switch strings.ToLower(s) {
case "1", "true", "yes", "on":
*b = true
case "", "0", "false", "no", "off", "null":
*b = false
default:
return fmt.Errorf("unerwarteter Boolean-Wert %q", s)
}
return nil
}
// flexTimeLayouts deckt die von OPNsense beobachteten Datumsformate ab.
var flexTimeLayouts = []string{
time.RFC3339,
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
"2006-01-02",
"Jan _2 15:04:05 2006 MST",
"060102150405Z", // ASN.1 UTCTime
}
// flexTime parst Ablaufdaten in mehreren Formaten.
type flexTime struct{ Time time.Time }
func (t *flexTime) UnmarshalJSON(data []byte) error {
var raw any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
switch v := raw.(type) {
case nil:
return nil
case float64: // Unix-Zeitstempel
t.Time = time.Unix(int64(v), 0).UTC()
return nil
case string:
s := strings.TrimSpace(v)
if s == "" {
return nil
}
for _, layout := range flexTimeLayouts {
if parsed, err := time.Parse(layout, s); err == nil {
t.Time = parsed.UTC()
return nil
}
}
return fmt.Errorf("unbekanntes Datumsformat %q", s)
default:
return fmt.Errorf("unerwarteter Datumstyp %T", raw)
}
}
// Provider ist eine exportierbare OpenVPN-Instanz der Firewall.
type Provider struct {
VPNID string
Name string
}
// Account ist ein exportierbares Client-Zertifikat.
type Account struct {
RefID string
CommonName string
Description string
ValidTo time.Time
Revoked bool
}
// IsUsable meldet, ob das Zertifikat ausgeliefert werden darf.
// Ein fehlendes Ablaufdatum blockiert nicht — die Revocation-Prüfung greift
// weiterhin, und ein unbekanntes Datum darf keine gültige Config verhindern.
func (a Account) IsUsable(now time.Time) bool {
if a.Revoked {
return false
}
if !a.ValidTo.IsZero() && !a.ValidTo.After(now) {
return false
}
return true
}
// DaysUntilExpiry liefert die Restlaufzeit in Tagen; -1 bei unbekanntem Datum.
func (a Account) DaysUntilExpiry(now time.Time) int {
if a.ValidTo.IsZero() {
return -1
}
return int(a.ValidTo.Sub(now).Hours() / 24)
}
// rawProvider und rawAccount bilden die API-Antworten ab. Mehrere Feldnamen
// werden akzeptiert, weil die Benennung zwischen OPNsense-Versionen wechselt
// (siehe docs/opnsense-api.md).
type rawProvider struct {
VPNID string `json:"vpnid"`
Name string `json:"name"`
Descr string `json:"description"`
}
type rawAccount struct {
CommonName string `json:"commonName"`
CommonName2 string `json:"common_name"`
Description string `json:"description"`
Descr string `json:"descr"`
ValidTo flexTime `json:"validTo"`
ValidTo2 flexTime `json:"valid_to"`
ValidTo3 flexTime `json:"validto"`
Revoked flexBool `json:"isRevoked"`
Revoked2 flexBool `json:"revoked"`
Revoked3 flexBool `json:"is_revoked"`
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
func (r rawAccount) toAccount(refID string) Account {
validTo := r.ValidTo.Time
if validTo.IsZero() {
validTo = r.ValidTo2.Time
}
if validTo.IsZero() {
validTo = r.ValidTo3.Time
}
return Account{
RefID: refID,
CommonName: firstNonEmpty(r.CommonName, r.CommonName2),
Description: firstNonEmpty(r.Description, r.Descr),
ValidTo: validTo,
// Meldet irgendeines der bekannten Felder eine Revozierung, gilt das
// Zertifikat als revoziert — im Zweifel restriktiv.
Revoked: bool(r.Revoked) || bool(r.Revoked2) || bool(r.Revoked3),
}
}

View file

@ -0,0 +1,73 @@
package opnsense
import (
"encoding/json"
"testing"
"time"
)
func TestFlexBoolAcceptsAPIVariants(t *testing.T) {
cases := map[string]bool{
`true`: true, `false`: false,
`1`: true, `0`: false,
`"1"`: true, `"0"`: false,
`"true"`: true, `"false"`: false,
`"yes"`: true, `"no"`: false,
`""`: false, `null`: false,
}
for raw, want := range cases {
var b flexBool
if err := json.Unmarshal([]byte(raw), &b); err != nil {
t.Errorf("Unmarshal(%s): %v", raw, err)
continue
}
if bool(b) != want {
t.Errorf("Unmarshal(%s) = %v, want %v", raw, bool(b), want)
}
}
}
func TestFlexTimeAcceptsAPIVariants(t *testing.T) {
want := time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC)
for _, raw := range []string{
`"2027-03-01"`,
`"2027-03-01T00:00:00Z"`,
`"Mar 1 00:00:00 2027 GMT"`,
} {
var ft flexTime
if err := json.Unmarshal([]byte(raw), &ft); err != nil {
t.Errorf("Unmarshal(%s): %v", raw, err)
continue
}
if !ft.Time.Equal(want) {
t.Errorf("Unmarshal(%s) = %v, want %v", raw, ft.Time, want)
}
}
var empty flexTime
if err := json.Unmarshal([]byte(`""`), &empty); err != nil || !empty.Time.IsZero() {
t.Errorf("leerer String muss Nullzeit ergeben, got %v (%v)", empty.Time, err)
}
}
func TestAccountIsUsable(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
valid := Account{CommonName: "mmueller", ValidTo: now.AddDate(0, 6, 0)}
if !valid.IsUsable(now) {
t.Error("gültiges Zertifikat muss nutzbar sein")
}
revoked := valid
revoked.Revoked = true
if revoked.IsUsable(now) {
t.Error("revoziertes Zertifikat darf nie nutzbar sein")
}
expired := Account{CommonName: "mmueller", ValidTo: now.AddDate(0, 0, -1)}
if expired.IsUsable(now) {
t.Error("abgelaufenes Zertifikat darf nie nutzbar sein")
}
// Fehlendes Ablaufdatum: als unbekannt behandeln, aber nicht blockieren —
// die Revocation-Prüfung bleibt maßgeblich.
noDate := Account{CommonName: "mmueller"}
if !noDate.IsUsable(now) {
t.Error("fehlendes Ablaufdatum darf nicht zum Ausschluss führen")
}
}

View file

@ -0,0 +1,190 @@
// Package portal verbindet Firewall-Client und Zuordnungsregeln zu der
// Sicht, die das Webportal auf die Zertifikate eines Benutzers hat.
package portal
import (
"context"
"fmt"
"strings"
"sync"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/certmatch"
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
"git.ravensburg.dev/cabele/opnsense-portal/internal/web"
)
// OPNsense ist der benötigte Ausschnitt des Firewall-Clients.
type OPNsense interface {
Providers(ctx context.Context) ([]opnsense.Provider, error)
Accounts(ctx context.Context, vpnID string) ([]opnsense.Account, error)
Export(ctx context.Context, vpnID, refID, format string) (*opnsense.ExportResult, error)
}
// allowedFormats sind die einzigen Formate, die das Portal anbietet.
var allowedFormats = map[string]bool{
opnsense.FormatOVPN: true,
opnsense.FormatViscosity: true,
}
// Source liefert die Zertifikatssicht eines Benutzers.
type Source struct {
fw OPNsense
match *certmatch.Matcher
clock func() time.Time
providerTTL time.Duration
mu sync.Mutex
providers []opnsense.Provider
providersAt time.Time
}
// NewSource baut die Quelle. providerTTL cacht ausschließlich die Liste der
// VPN-Instanzen; Zertifikate werden grundsätzlich live geholt.
func NewSource(fw OPNsense, m *certmatch.Matcher, providerTTL time.Duration, clock func() time.Time) *Source {
if clock == nil {
clock = time.Now
}
return &Source{fw: fw, match: m, clock: clock, providerTTL: providerTTL}
}
// Pattern liefert die für einen Benutzer angewendete Regel (Audit-Feld).
func (s *Source) Pattern(username string) string { return s.match.Describe(username) }
// listProviders liefert die VPN-Instanzen aus dem Cache oder frisch.
func (s *Source) listProviders(ctx context.Context) ([]opnsense.Provider, error) {
now := s.clock()
s.mu.Lock()
if s.providers != nil && now.Sub(s.providersAt) < s.providerTTL {
cached := s.providers
s.mu.Unlock()
return cached, nil
}
s.mu.Unlock()
ps, err := s.fw.Providers(ctx)
if err != nil {
return nil, wrapBackend(err)
}
s.mu.Lock()
s.providers, s.providersAt = ps, now
s.mu.Unlock()
return ps, nil
}
// wrapBackend übersetzt Client-Fehler in die Portal-Semantik.
func wrapBackend(err error) error {
return fmt.Errorf("%w: %v", web.ErrBackendUnavailable, err)
}
// matchingEntries sammelt alle passenden, nutzbaren Zertifikate.
// Wird von EntriesFor und von Export benutzt — Export prüft damit live neu.
func (s *Source) matchingEntries(ctx context.Context, username string) ([]certmatch.Entry, error) {
providers, err := s.listProviders(ctx)
if err != nil {
return nil, err
}
now := s.clock()
var out []certmatch.Entry
for _, p := range providers {
// Accounts NIE cachen: eine Revozierung auf der Firewall muss ohne
// Verzögerung greifen.
accounts, err := s.fw.Accounts(ctx, p.VPNID)
if err != nil {
return nil, wrapBackend(err)
}
out = append(out, s.match.Filter(username, p, accounts, now)...)
}
return out, nil
}
// EntriesFor liefert die Zertifikate des Benutzers für die Übersicht.
func (s *Source) EntriesFor(ctx context.Context, username string) ([]web.CertEntry, error) {
entries, err := s.matchingEntries(ctx, username)
if err != nil {
return nil, err
}
out := make([]web.CertEntry, 0, len(entries))
for _, e := range entries {
out = append(out, web.CertEntry{
Token: e.Token(),
InstanceName: e.Provider.Name,
CommonName: e.Account.CommonName,
ValidTo: e.Account.ValidTo,
})
}
return out, nil
}
// Export prüft die Zuordnung serverseitig erneut und streamt dann.
// Die Auswahl in der Oberfläche ist ausdrücklich keine Autorisierung: zwischen
// Anzeige und Download kann ein Zertifikat revoziert worden sein.
func (s *Source) Export(ctx context.Context, username, vpnID, refID, format string) (*web.ExportStream, error) {
if !allowedFormats[format] {
return nil, fmt.Errorf("unbekanntes Exportformat %q", format)
}
entries, err := s.matchingEntries(ctx, username)
if err != nil {
return nil, err
}
var found *certmatch.Entry
for i := range entries {
if entries[i].Provider.VPNID == vpnID && entries[i].Account.RefID == refID {
found = &entries[i]
break
}
}
if found == nil {
return nil, web.ErrCertNotOwned
}
res, err := s.fw.Export(ctx, vpnID, refID, format)
if err != nil {
return nil, wrapBackend(err)
}
return &web.ExportStream{
// Bewusst der selbst gebaute Name: der Dateiname der Firewall ist
// nicht kontrolliert und könnte den Content-Disposition-Header
// aufbrechen.
Filename: SafeFilename(found.Provider.Name, username, format),
ContentType: res.ContentType,
Body: res.Body,
InstanceName: found.Provider.Name,
CommonName: found.Account.CommonName,
ValidTo: found.Account.ValidTo,
}, nil
}
// extensionFor liefert die Dateiendung je Format.
func extensionFor(format string) string {
if format == opnsense.FormatViscosity {
return ".visc.zip"
}
return ".ovpn"
}
// SafeFilename baut einen Dateinamen, der weder Pfadtrenner noch Zeichen
// enthält, die den Content-Disposition-Header aufbrechen könnten.
func SafeFilename(instance, username, format string) string {
return "vpn-" + sanitize(instance) + "-" + sanitize(username) + extensionFor(format)
}
// sanitize reduziert auf [a-z0-9._-]; alles andere wird zu '-'.
func sanitize(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(strings.TrimSpace(s)) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
b.WriteRune(r)
default:
b.WriteRune('-')
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "vpn"
}
return out
}
var _ web.CertSource = (*Source)(nil)

View file

@ -0,0 +1,296 @@
package portal
import (
"context"
"errors"
"io"
"strings"
"testing"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/certmatch"
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
"git.ravensburg.dev/cabele/opnsense-portal/internal/web"
)
// fakeFW zählt Aufrufe, damit Caching-Verhalten prüfbar wird.
type fakeFW struct {
providers []opnsense.Provider
accounts map[string][]opnsense.Account
providerCalls int
accountCalls int
exportCalls int
lastExportArgs [3]string
err error
}
func (f *fakeFW) Providers(ctx context.Context) ([]opnsense.Provider, error) {
f.providerCalls++
return f.providers, f.err
}
func (f *fakeFW) Accounts(ctx context.Context, vpnID string) ([]opnsense.Account, error) {
f.accountCalls++
if f.err != nil {
return nil, f.err
}
return f.accounts[vpnID], nil
}
func (f *fakeFW) Export(ctx context.Context, vpnID, refID, format string) (*opnsense.ExportResult, error) {
f.exportCalls++
f.lastExportArgs = [3]string{vpnID, refID, format}
return &opnsense.ExportResult{
Filename: "von-der-firewall.ovpn",
ContentType: "application/x-openvpn-profile",
Body: io.NopCloser(strings.NewReader("client\n")),
}, nil
}
var baseNow = time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
// newSourceAt baut eine Source mit einer verstellbaren Uhr.
func newSourceAt(t *testing.T, fw *fakeFW, now *time.Time) *Source {
t.Helper()
m, err := certmatch.NewMatcher("{username}", "")
if err != nil {
t.Fatal(err)
}
return NewSource(fw, m, 2*time.Minute, func() time.Time { return *now })
}
func newSource(t *testing.T, fw *fakeFW) *Source {
t.Helper()
now := baseNow
return newSourceAt(t, fw, &now)
}
func standardFW() *fakeFW {
return &fakeFW{
providers: []opnsense.Provider{
{VPNID: "1", Name: "VPN Homeoffice"},
{VPNID: "2", Name: "VPN Aussendienst"},
},
accounts: map[string][]opnsense.Account{
"1": {
{RefID: "a1", CommonName: "mmueller", ValidTo: baseNow.AddDate(1, 0, 0)},
{RefID: "a2", CommonName: "jdoe", ValidTo: baseNow.AddDate(1, 0, 0)},
},
"2": {
{RefID: "b1", CommonName: "mmueller", ValidTo: baseNow.AddDate(0, 0, 10)},
{RefID: "b2", CommonName: "mmueller", ValidTo: baseNow.AddDate(1, 0, 0), Revoked: true},
},
},
}
}
func TestEntriesForCollectsAcrossInstances(t *testing.T) {
src := newSource(t, standardFW())
entries, err := src.EntriesFor(context.Background(), "mmueller")
if err != nil {
t.Fatalf("EntriesFor: %v", err)
}
if len(entries) != 2 {
t.Fatalf("got %d Einträge, want 2: %+v", len(entries), entries)
}
names := map[string]bool{}
for _, e := range entries {
names[e.InstanceName] = true
if e.CommonName != "mmueller" {
t.Errorf("fremder CN in der Liste: %+v", e)
}
}
if !names["VPN Homeoffice"] || !names["VPN Aussendienst"] {
t.Errorf("Instanznamen fehlen: %+v", entries)
}
}
func TestEntriesForExcludesRevokedAndForeign(t *testing.T) {
src := newSource(t, standardFW())
entries, _ := src.EntriesFor(context.Background(), "mmueller")
for _, e := range entries {
if strings.Contains(e.Token, "b2") {
t.Error("revoziertes Zertifikat darf nicht erscheinen")
}
if strings.Contains(e.Token, "a2") {
t.Error("fremdes Zertifikat darf nicht erscheinen")
}
}
}
func TestAccountsAreNeverCached(t *testing.T) {
fw := standardFW()
src := newSource(t, fw)
src.EntriesFor(context.Background(), "mmueller")
first := fw.accountCalls
src.EntriesFor(context.Background(), "mmueller")
if fw.accountCalls <= first {
t.Fatal("Accounts müssen bei jeder Anfrage frisch geholt werden (Revocation muss sofort greifen)")
}
}
func TestProvidersAreCachedForTTL(t *testing.T) {
fw := standardFW()
now := baseNow
src := newSourceAt(t, fw, &now)
src.EntriesFor(context.Background(), "mmueller")
src.EntriesFor(context.Background(), "mmueller")
if fw.providerCalls != 1 {
t.Fatalf("providerCalls = %d, want 1 (TTL-Cache)", fw.providerCalls)
}
now = now.Add(3 * time.Minute)
src.EntriesFor(context.Background(), "mmueller")
if fw.providerCalls != 2 {
t.Fatalf("providerCalls = %d, want 2 (Cache abgelaufen)", fw.providerCalls)
}
}
func TestExportRevalidatesOwnership(t *testing.T) {
fw := standardFW()
src := newSource(t, fw)
// jdoes Zertifikat a2 gehört nicht zu mmueller.
_, err := src.Export(context.Background(), "mmueller", "1", "a2", opnsense.FormatOVPN)
if !errors.Is(err, web.ErrCertNotOwned) {
t.Fatalf("err = %v, want ErrCertNotOwned", err)
}
if fw.exportCalls != 0 {
t.Error("bei fehlender Zuordnung darf kein Export ausgelöst werden")
}
}
func TestExportRejectsRevokedEvenIfPreviouslyListed(t *testing.T) {
fw := standardFW()
src := newSource(t, fw)
// b2 gehört mmueller, ist aber revoziert.
_, err := src.Export(context.Background(), "mmueller", "2", "b2", opnsense.FormatOVPN)
if !errors.Is(err, web.ErrCertNotOwned) {
t.Fatalf("err = %v, want ErrCertNotOwned", err)
}
if fw.exportCalls != 0 {
t.Error("revoziertes Zertifikat darf keinen Export auslösen")
}
}
func TestExportRejectsRevocationBetweenListingAndDownload(t *testing.T) {
fw := standardFW()
src := newSource(t, fw)
// Erst auflisten: a1 ist dabei.
entries, err := src.EntriesFor(context.Background(), "mmueller")
if err != nil {
t.Fatal(err)
}
var token string
for _, e := range entries {
if strings.HasSuffix(e.Token, ":a1") {
token = e.Token
}
}
if token == "" {
t.Fatal("a1 muss zunächst gelistet sein")
}
// Jetzt wird a1 auf der Firewall revoziert — der Download muss scheitern,
// obwohl der Benutzer den Eintrag noch auf der Seite sieht.
fw.accounts["1"][0].Revoked = true
_, err = src.Export(context.Background(), "mmueller", "1", "a1", opnsense.FormatOVPN)
if !errors.Is(err, web.ErrCertNotOwned) {
t.Fatalf("err = %v, want ErrCertNotOwned — Revocation muss sofort greifen", err)
}
if fw.exportCalls != 0 {
t.Error("nach Revocation darf kein Export mehr ausgelöst werden")
}
}
func TestExportSucceedsForOwnedCert(t *testing.T) {
fw := standardFW()
src := newSource(t, fw)
st, err := src.Export(context.Background(), "mmueller", "1", "a1", opnsense.FormatOVPN)
if err != nil {
t.Fatalf("Export: %v", err)
}
defer st.Body.Close()
if fw.lastExportArgs != [3]string{"1", "a1", opnsense.FormatOVPN} {
t.Errorf("Export-Argumente = %v", fw.lastExportArgs)
}
if st.InstanceName != "VPN Homeoffice" || st.CommonName != "mmueller" {
t.Errorf("Metadaten fehlen: %+v", st)
}
body, _ := io.ReadAll(st.Body)
if string(body) != "client\n" {
t.Errorf("Body = %q", body)
}
}
func TestExportUsesOwnFilenameNotFirewalls(t *testing.T) {
// Der Dateiname der Firewall wird bewusst nicht übernommen: er ist
// nicht kontrolliert und könnte den Content-Disposition-Header aufbrechen.
src := newSource(t, standardFW())
st, err := src.Export(context.Background(), "mmueller", "1", "a1", opnsense.FormatOVPN)
if err != nil {
t.Fatal(err)
}
defer st.Body.Close()
if st.Filename == "von-der-firewall.ovpn" {
t.Error("der von der Firewall gelieferte Dateiname darf nicht übernommen werden")
}
if st.Filename != "vpn-vpn-homeoffice-mmueller.ovpn" {
t.Errorf("Filename = %q", st.Filename)
}
}
func TestExportRejectsUnknownFormat(t *testing.T) {
fw := standardFW()
src := newSource(t, fw)
if _, err := src.Export(context.Background(), "mmueller", "1", "a1", "beliebig"); err == nil {
t.Fatal("unbekanntes Format muss abgelehnt werden")
}
if fw.exportCalls != 0 {
t.Error("unbekanntes Format darf keinen Export auslösen")
}
}
func TestBackendErrorIsWrapped(t *testing.T) {
fw := standardFW()
fw.err = opnsense.ErrUnreachable
src := newSource(t, fw)
if _, err := src.EntriesFor(context.Background(), "mmueller"); !errors.Is(err, web.ErrBackendUnavailable) {
t.Fatalf("err = %v, want ErrBackendUnavailable", err)
}
}
func TestPatternIsExposedForAudit(t *testing.T) {
src := newSource(t, standardFW())
if got := src.Pattern("mmueller"); got != "mmueller" {
t.Errorf("Pattern = %q", got)
}
}
func TestSafeFilename(t *testing.T) {
cases := map[string]string{
"VPN Homeoffice": "vpn-vpn-homeoffice-mmueller.ovpn",
"Außendienst": "vpn-au-endienst-mmueller.ovpn",
}
for instance, want := range cases {
if got := SafeFilename(instance, "mmueller", opnsense.FormatOVPN); got != want {
t.Errorf("SafeFilename(%q) = %q, want %q", instance, got, want)
}
}
if got := SafeFilename("VPN A", "u", opnsense.FormatViscosity); !strings.HasSuffix(got, ".visc.zip") {
t.Errorf("Viscosity-Endung fehlt: %q", got)
}
// Kein Pfadtrenner, keine Anführungszeichen, kein CR/LF im Ergebnis —
// sonst ließe sich der Content-Disposition-Header aufbrechen.
dirty := SafeFilename("a\"/b\\c\r\nd", "u", opnsense.FormatOVPN)
for _, bad := range []string{"/", "\\", "\"", "\r", "\n"} {
if strings.Contains(dirty, bad) {
t.Errorf("Dateiname %q enthält %q", dirty, bad)
}
}
if got := SafeFilename("", "", opnsense.FormatOVPN); got == "" || strings.Contains(got, "--") {
t.Errorf("leere Eingaben ergeben %q", got)
}
}

View file

@ -0,0 +1,14 @@
// Optionale Bequemlichkeit: Passwort-Anzeigen-Schalter.
// Die Anmeldung funktioniert vollständig ohne JavaScript; der Schalter ist
// im Markup als hidden angelegt und wird erst hier eingeblendet.
(function () {
"use strict";
var wrap = document.getElementById("toggle-wrap");
var toggle = document.getElementById("show-password");
var field = document.getElementById("password");
if (!wrap || !toggle || !field) return;
wrap.hidden = false;
toggle.addEventListener("change", function () {
field.type = toggle.checked ? "text" : "password";
});
})();

View file

@ -0,0 +1,242 @@
/* VPN-Portal schlichtes, neutrales Layout.
Bewusst zurückhaltend: das Portal wird pro Kunde mit eigenem Titel und Logo
ausgeliefert und soll sich nicht gegen dessen Erscheinungsbild stellen.
Keine externen Ressourcen (Schriften, Bilder) die CSP verbietet sie. */
:root {
--bg: #f4f5f7;
--surface: #ffffff;
--text: #1c1f23;
--text-muted: #5a6472;
--border: #d3d8de;
--accent: #1f5f9e;
--accent-text: #ffffff;
--accent-hover: #17497a;
--error-bg: #fdeaea;
--error-border: #d34d4d;
--error-text: #8a1f1f;
--info-bg: #e8f1fa;
--info-border: #4a86c4;
--info-text: #14456f;
--warn-bg: #fdf3e2;
--warn-border: #d08b1e;
--warn-text: #7a4d05;
--radius: 6px;
--focus: #0b6bcb;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #16191d;
--surface: #1f242a;
--text: #e8ebef;
--text-muted: #9aa5b1;
--border: #39424c;
--accent: #4a90d9;
--accent-text: #0d1013;
--accent-hover: #6aa8e6;
--error-bg: #3a1f1f;
--error-border: #c95a5a;
--error-text: #f3c3c3;
--info-bg: #1c2d3d;
--info-border: #4a86c4;
--info-text: #bcd8f2;
--warn-bg: #3a2f18;
--warn-border: #d08b1e;
--warn-text: #f0d9a8;
--focus: #7db6f0;
}
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 1rem;
line-height: 1.5;
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* ---------- Kopfzeile ---------- */
.topbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem 1rem;
padding: 0.75rem 1.25rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.brand { display: flex; align-items: center; gap: 0.75rem; min-width: 0; }
.brand-title { font-weight: 600; font-size: 1.05rem; }
.logo { max-height: 2.25rem; max-width: 12rem; width: auto; height: auto; }
.topnav {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
}
.topnav .user { color: var(--text-muted); font-size: 0.9rem; }
/* ---------- Inhalt ---------- */
main {
flex: 1;
width: 100%;
max-width: 46rem;
margin: 0 auto;
padding: 1.5rem 1.25rem 3rem;
}
h1 { font-size: 1.5rem; margin: 0 0 1rem; }
h2 { font-size: 1.15rem; margin: 0 0 0.5rem; }
p { margin: 0 0 1rem; }
p:last-child { margin-bottom: 0; }
a { color: var(--accent); }
a:hover { color: var(--accent-hover); }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
margin-bottom: 1rem;
}
.cards { list-style: none; margin: 0; padding: 0; }
.card dl {
display: grid;
grid-template-columns: minmax(6rem, auto) 1fr;
gap: 0.25rem 1rem;
margin: 0 0 1rem;
}
.card dt { color: var(--text-muted); font-size: 0.9rem; }
.card dd { margin: 0; overflow-wrap: anywhere; }
.card .client { color: var(--text-muted); margin-bottom: 0.75rem; }
.card ol { margin: 0; padding-left: 1.25rem; }
.card ol li { margin-bottom: 0.4rem; }
.suggested { border-color: var(--info-border); }
.badge {
display: inline-block;
font-size: 0.75rem;
font-weight: 400;
vertical-align: middle;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: var(--info-bg);
color: var(--info-text);
border: 1px solid var(--info-border);
}
/* ---------- Meldungen ---------- */
.flash {
padding: 0.85rem 1rem;
border-radius: var(--radius);
border: 1px solid;
margin-bottom: 1.25rem;
}
.flash-error { background: var(--error-bg); border-color: var(--error-border); color: var(--error-text); }
.flash-info { background: var(--info-bg); border-color: var(--info-border); color: var(--info-text); }
.warn {
background: var(--warn-bg);
border-left: 3px solid var(--warn-border);
color: var(--warn-text);
padding: 0.6rem 0.85rem;
border-radius: 0 var(--radius) var(--radius) 0;
}
.logout-hint { color: var(--text-muted); font-size: 0.9rem; }
/* ---------- Formulare ---------- */
.form { display: flex; flex-direction: column; }
.form label { font-weight: 500; margin-bottom: 0.3rem; }
.form small { color: var(--text-muted); margin: 0.3rem 0 1rem; }
input[type="text"], input[type="password"] {
font: inherit;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.6rem 0.7rem;
width: 100%;
min-height: 2.75rem;
}
.toggle-wrap {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 1.25rem;
}
.toggle-wrap label { font-weight: 400; margin: 0; }
.toggle-wrap input { min-height: auto; width: auto; }
button {
font: inherit;
font-weight: 500;
min-height: 2.75rem;
padding: 0.55rem 1.1rem;
border: 1px solid var(--accent);
border-radius: var(--radius);
background: var(--accent);
color: var(--accent-text);
cursor: pointer;
}
button:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
button.secondary {
background: transparent;
color: var(--accent);
border-color: var(--border);
min-height: 2.25rem;
padding: 0.35rem 0.8rem;
}
button.secondary:hover { background: var(--info-bg); border-color: var(--accent); }
.inline { display: inline; margin: 0; }
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; }
.actions .inline { display: block; }
:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 2px;
}
/* ---------- Fußzeile ---------- */
.footer {
padding: 1rem 1.25rem;
border-top: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.85rem;
text-align: center;
}
/* ---------- Schmale Bildschirme ---------- */
@media (max-width: 40rem) {
.topbar { flex-direction: column; align-items: flex-start; }
.topnav { width: 100%; }
.actions { flex-direction: column; align-items: stretch; }
.actions button { width: 100%; }
.card dl { grid-template-columns: 1fr; gap: 0; }
.card dt { margin-top: 0.5rem; }
}

97
internal/web/guides.go Normal file
View file

@ -0,0 +1,97 @@
package web
import "strings"
// Guide ist eine Kurzanleitung für eine Plattform.
type Guide struct {
Platform string
Client string
Steps []string
Suggested bool
}
// baseGuides ist die feste Reihenfolge aller Anleitungen.
var baseGuides = []Guide{
{
Platform: "Windows",
Client: "OpenVPN Community Client",
Steps: []string{
"Laden Sie den OpenVPN Community Client von openvpn.net herunter und installieren Sie ihn.",
"Laden Sie hier im Portal Ihre .ovpn-Datei herunter.",
"Klicken Sie mit der rechten Maustaste auf das OpenVPN-Symbol im Infobereich und wählen Sie „Datei importieren“.",
"Wählen Sie die heruntergeladene .ovpn-Datei aus.",
"Klicken Sie erneut auf das Symbol und wählen Sie „Verbinden“.",
},
},
{
Platform: "macOS",
Client: "Tunnelblick oder Viscosity",
Steps: []string{
"Installieren Sie Tunnelblick (kostenfrei) oder Viscosity.",
"Laden Sie hier im Portal Ihre Konfiguration herunter — für Viscosity das Viscosity-Paket, für Tunnelblick die .ovpn-Datei.",
"Öffnen Sie die heruntergeladene Datei per Doppelklick; die Konfiguration wird importiert.",
"Starten Sie die Verbindung über das Symbol in der Menüleiste.",
},
},
{
Platform: "iOS / iPadOS",
Client: "OpenVPN Connect",
Steps: []string{
"Installieren Sie „OpenVPN Connect“ aus dem App Store.",
"Laden Sie hier im Portal Ihre .ovpn-Datei herunter.",
"Öffnen Sie die Datei aus der Dateien-App heraus und wählen Sie „Teilen“ und dann „OpenVPN Connect“.",
"Bestätigen Sie den Import mit „Add“ und erlauben Sie die VPN-Konfiguration.",
},
},
{
Platform: "Android",
Client: "OpenVPN Connect",
Steps: []string{
"Installieren Sie „OpenVPN Connect“ aus dem Google Play Store.",
"Laden Sie hier im Portal Ihre .ovpn-Datei herunter.",
"Öffnen Sie OpenVPN Connect, wählen Sie „Import“, dann „File“ und dann die heruntergeladene Datei.",
"Bestätigen Sie den Import und erlauben Sie die VPN-Verbindung.",
},
},
}
// detectPlatform rät die Plattform aus dem User-Agent.
// Das Ergebnis ist ausdrücklich nur ein Vorschlag, nie ein Filter.
// iOS wird vor macOS geprüft, weil iPad-User-Agents „Mac OS X“ enthalten können.
func detectPlatform(userAgent string) string {
ua := strings.ToLower(userAgent)
switch {
case strings.Contains(ua, "iphone"), strings.Contains(ua, "ipad"), strings.Contains(ua, "ipod"):
return "iOS / iPadOS"
case strings.Contains(ua, "android"):
return "Android"
case strings.Contains(ua, "windows"):
return "Windows"
case strings.Contains(ua, "mac os x"), strings.Contains(ua, "macintosh"):
return "macOS"
default:
return ""
}
}
// Guides liefert alle Anleitungen; die erkannte Plattform steht vorn und ist
// als Vorschlag markiert. Es wird niemals etwas ausgeblendet — die
// User-Agent-Erkennung ist zu unzuverlässig, um Inhalte zu verstecken.
func Guides(userAgent string) []Guide {
detected := detectPlatform(userAgent)
out := make([]Guide, 0, len(baseGuides))
if detected != "" {
for _, g := range baseGuides {
if g.Platform == detected {
g.Suggested = true
out = append(out, g)
}
}
}
for _, g := range baseGuides {
if g.Platform != detected {
out = append(out, g)
}
}
return out
}

View file

@ -0,0 +1,208 @@
package web
import (
"errors"
"fmt"
"io"
"math"
"net/http"
"strings"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
)
// expiryWarningDays ist die Schwelle, ab der vor dem Ablauf gewarnt wird.
const expiryWarningDays = 30
// downloadFormats sind die in der Oberfläche angebotenen Formate.
// Alle weiteren Exporteinstellungen kommen von der Firewall.
var downloadFormats = []struct {
Format string
LabelKey string
}{
{"ovpn", "download_ovpn"},
{"viscosity", "download_viscosity"},
}
// overviewData füllt overview.html.
type overviewData struct {
Entries []overviewEntry
SupportHint string
}
type overviewEntry struct {
InstanceName string
CommonName string
ValidUntil string
ExpiryWarning string
Formats []overviewFormat
}
type overviewFormat struct {
Token string
Format string
Label string
}
func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request, sess *Session) {
user := sess.Identity.Username
entries, err := s.d.Certs.EntriesFor(r.Context(), user)
if err != nil {
if errors.Is(err, ErrBackendUnavailable) {
s.log(audit.Event{
Event: audit.EventOPNsenseUnreachable,
User: user,
SrcIP: ClientIP(r),
Session: audit.ShortSession(sess.Token),
})
}
// Bewusst eigene Seite: "Firewall weg" ist etwas anderes als
// "kein Zertifikat vorhanden".
s.renderError(w, r, http.StatusServiceUnavailable, sess,
"error_service_unavailable", "error_service_unavailable_body")
return
}
if len(entries) == 0 {
s.log(audit.Event{
Event: audit.EventNoCertFound,
User: user,
SrcIP: ClientIP(r),
Session: audit.ShortSession(sess.Token),
Pattern: s.pattern(user),
})
}
var data overviewData
if s.d.SupportContact != "" {
data.SupportHint = fmt.Sprintf(T("overview_no_certs_contact"), s.d.SupportContact)
}
now := s.d.Clock()
for _, e := range entries {
oe := overviewEntry{InstanceName: e.InstanceName, CommonName: e.CommonName}
if !e.ValidTo.IsZero() {
oe.ValidUntil = e.ValidTo.Format("02.01.2006")
// Aufrunden: ein Zertifikat mit 11 Tagen und 23 Stunden Restlaufzeit
// als "11 Tage" anzuzeigen wäre für den Benutzer irreführend.
if days := int(math.Ceil(e.ValidTo.Sub(now).Hours() / 24)); days < expiryWarningDays {
if days <= 0 {
oe.ExpiryWarning = T("overview_expired_today")
} else {
oe.ExpiryWarning = fmt.Sprintf(T("overview_expiry_warning"), days)
}
}
}
for _, f := range downloadFormats {
oe.Formats = append(oe.Formats, overviewFormat{
Token: e.Token, Format: f.Format, Label: T(f.LabelKey)})
}
data.Entries = append(data.Entries, oe)
}
s.renderPage(w, r, http.StatusOK, "overview", sess, PageData{Data: data})
}
// pattern liefert die angewendete Zuordnungsregel für das Audit-Log, sofern
// die CertSource sie kennt.
func (s *Server) pattern(username string) string {
if p, ok := s.d.Certs.(interface{ Pattern(string) string }); ok {
return p.Pattern(username)
}
return ""
}
func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request, sess *Session) {
user := sess.Identity.Username
ip := ClientIP(r)
short := audit.ShortSession(sess.Token)
deny := func(detail string) {
s.log(audit.Event{Event: audit.EventDownloadDenied, User: user, SrcIP: ip,
Session: short, Detail: detail})
s.renderError(w, r, http.StatusForbidden, sess, "error_forbidden", "error_forbidden_body")
}
if err := r.ParseForm(); err != nil {
deny("formular_unlesbar")
return
}
if !sess.ValidCSRF(r.PostFormValue("csrf_token")) {
s.renderError(w, r, http.StatusForbidden, sess, "error_forbidden", "error_csrf")
return
}
vpnID, refID, ok := parseCertToken(r.PostFormValue("cert"))
if !ok {
deny("token_ungueltig")
return
}
format := r.PostFormValue("format")
if !isKnownFormat(format) {
deny("format_unbekannt")
return
}
// Serverseitige Autorisierung: die CertSource prüft live, ob das
// Zertifikat dem angemeldeten Benutzer gehört und nutzbar ist.
stream, err := s.d.Certs.Export(r.Context(), user, vpnID, refID, format)
if err != nil {
if errors.Is(err, ErrCertNotOwned) {
deny("nicht_zugeordnet")
return
}
if errors.Is(err, ErrBackendUnavailable) {
s.log(audit.Event{Event: audit.EventOPNsenseUnreachable, User: user,
SrcIP: ip, Session: short})
}
s.renderError(w, r, http.StatusServiceUnavailable, sess,
"error_service_unavailable", "error_service_unavailable_body")
return
}
defer stream.Body.Close()
expiry := ""
if !stream.ValidTo.IsZero() {
expiry = stream.ValidTo.Format("2006-01-02")
}
s.log(audit.Event{
Event: audit.EventConfigDownload,
User: user,
SrcIP: ip,
Session: short,
VPNInstance: stream.InstanceName,
CertCN: stream.CommonName,
CertExpiry: expiry,
Format: format,
})
NoStore(w)
w.Header().Set("Content-Type", stream.ContentType)
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename=%q`, stream.Filename))
w.WriteHeader(http.StatusOK)
// Durchstreamen — nichts auf Platte, nichts vollständig im Speicher.
if _, err := io.Copy(w, stream.Body); err != nil {
// Der Header ist bereits raus; mehr als Protokollieren bleibt nicht.
s.log(audit.Event{Event: audit.EventOPNsenseUnreachable, User: user,
SrcIP: ip, Session: short, Detail: "abbruch_beim_streamen"})
}
}
// parseCertToken zerlegt den Auswahl-Token aus dem Formular.
func parseCertToken(s string) (vpnID, refID string, ok bool) {
vpnID, refID, found := strings.Cut(s, ":")
if !found || vpnID == "" || refID == "" || strings.Contains(refID, ":") {
return "", "", false
}
return vpnID, refID, true
}
func isKnownFormat(f string) bool {
for _, d := range downloadFormats {
if d.Format == f {
return true
}
}
return false
}

View file

@ -0,0 +1,305 @@
package web
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
)
// certsWithExport ist eine CertSource mit funktionierendem Export.
type certsWithExport struct {
entries []CertEntry
listErr error
exportErr error
exportCall int
lastArgs [4]string
}
func (c *certsWithExport) EntriesFor(ctx context.Context, username string) ([]CertEntry, error) {
return c.entries, c.listErr
}
func (c *certsWithExport) Export(ctx context.Context, username, vpnID, refID, format string) (*ExportStream, error) {
c.exportCall++
c.lastArgs = [4]string{username, vpnID, refID, format}
if c.exportErr != nil {
return nil, c.exportErr
}
return &ExportStream{
Filename: "vpn-homeoffice-mmueller.ovpn",
ContentType: "application/x-openvpn-profile",
Body: io.NopCloser(strings.NewReader("client\nremote fw01 1194\n")),
InstanceName: "VPN Homeoffice",
CommonName: "mmueller",
ValidTo: time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC),
}, nil
}
// Pattern macht certsWithExport zur Quelle des Audit-Feldes pattern.
func (c *certsWithExport) Pattern(username string) string { return username }
// loggedInServer liefert Server, Audit-Mitschrift, Cookie und Session eines
// angemeldeten Benutzers.
func loggedInServer(t *testing.T, certs CertSource) (*Server, *recordAudit, *http.Cookie, *Session) {
t.Helper()
store := NewSessionStore(10*time.Minute, nil)
srv, rec := newTestServer(t, Deps{
Auth: &fakeAuth{id: &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}}},
Certs: certs,
Sessions: store,
SupportContact: "it-helpdesk@firma.de",
})
login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}})
cookie := sessionCookie(login)
if cookie == nil {
t.Fatal("Anmeldung lieferte kein Session-Cookie")
}
sess, ok := store.Get(cookie.Value)
if !ok {
t.Fatal("Anmeldung fehlgeschlagen")
}
return srv, rec, cookie, sess
}
func getWithCookie(h http.Handler, path string, c *http.Cookie) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodGet, path, nil)
r.RemoteAddr = "10.1.20.34:5000"
if c != nil {
r.AddCookie(c)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
return rec
}
func TestOverviewListsCertificates(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{
{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller",
ValidTo: time.Now().AddDate(1, 0, 0)},
{Token: "2:b1", InstanceName: "VPN Aussendienst", CommonName: "mmueller",
ValidTo: time.Now().AddDate(1, 0, 0)},
}}
srv, _, cookie, _ := loggedInServer(t, certs)
rec := getWithCookie(srv.Handler(), "/", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("Code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"VPN Homeoffice", "VPN Aussendienst",
S["download_ovpn"], S["download_viscosity"], `name="csrf_token"`} {
if !strings.Contains(body, want) {
t.Errorf("Übersicht enthält %q nicht", want)
}
}
if strings.Contains(body, `<a href="/download`) {
t.Error("Downloads dürfen keine GET-Links sein")
}
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control = %q", cc)
}
}
func TestOverviewShowsExpiryWarning(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{
{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller",
ValidTo: time.Now().AddDate(0, 0, 12)},
}}
srv, _, cookie, _ := loggedInServer(t, certs)
body := getWithCookie(srv.Handler(), "/", cookie).Body.String()
if !strings.Contains(body, "12 Tagen") {
t.Errorf("Ablaufwarnung fehlt: %s", body)
}
}
func TestOverviewNoWarningForDistantExpiry(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{
{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller",
ValidTo: time.Now().AddDate(1, 0, 0)},
}}
srv, _, cookie, _ := loggedInServer(t, certs)
body := getWithCookie(srv.Handler(), "/", cookie).Body.String()
if strings.Contains(body, "Achtung") {
t.Error("bei ferner Gültigkeit darf keine Warnung erscheinen")
}
}
func TestOverviewNoCertsShowsSupportContact(t *testing.T) {
srv, rec, cookie, _ := loggedInServer(t, &certsWithExport{})
out := getWithCookie(srv.Handler(), "/", cookie)
body := out.Body.String()
if !strings.Contains(body, S["overview_no_certs"]) {
t.Error("Meldung für fehlende Zertifikate fehlt")
}
if !strings.Contains(body, "it-helpdesk@firma.de") {
t.Error("support_contact fehlt")
}
ev, ok := rec.find(audit.EventNoCertFound)
if !ok {
t.Fatal("no_cert_found fehlt im Audit-Log")
}
if ev.User != "mmueller" {
t.Errorf("Audit-Event = %+v", ev)
}
if ev.Pattern == "" {
t.Error("angewendetes Muster muss im Audit-Log stehen")
}
}
func TestOverviewBackendDownShowsServiceError(t *testing.T) {
certs := &certsWithExport{listErr: ErrBackendUnavailable}
srv, rec, cookie, _ := loggedInServer(t, certs)
out := getWithCookie(srv.Handler(), "/", cookie)
if out.Code != http.StatusServiceUnavailable {
t.Fatalf("Code = %d, want 503", out.Code)
}
body := out.Body.String()
if !strings.Contains(body, S["error_service_unavailable"]) {
t.Error("Dienstfehlerseite fehlt")
}
if strings.Contains(body, S["overview_no_certs"]) {
t.Error("Firewall-Ausfall darf nicht wie \"kein Zertifikat\" aussehen")
}
if _, ok := rec.find(audit.EventOPNsenseUnreachable); !ok {
t.Error("opnsense_unreachable fehlt im Audit-Log")
}
if _, ok := rec.find(audit.EventNoCertFound); ok {
t.Error("bei Firewall-Ausfall darf kein no_cert_found geloggt werden")
}
}
func TestDownloadStreamsWithHardenedHeaders(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{
{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller"},
}}
srv, rec, cookie, sess := loggedInServer(t, certs)
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {sess.CSRF}, "cert": {"1:a1"}, "format": {"ovpn"},
}, cookie)
if out.Code != http.StatusOK {
t.Fatalf("Code = %d, body: %s", out.Code, out.Body.String())
}
cd := out.Header().Get("Content-Disposition")
if !strings.Contains(cd, `attachment; filename="vpn-homeoffice-mmueller.ovpn"`) {
t.Errorf("Content-Disposition = %q", cd)
}
if cc := out.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control = %q", cc)
}
if !strings.Contains(out.Body.String(), "remote fw01 1194") {
t.Errorf("Body = %q", out.Body.String())
}
// Der Export muss mit dem kanonischen Benutzernamen aufgerufen werden.
if certs.lastArgs[0] != "mmueller" {
t.Errorf("Export-Benutzername = %q, want mmueller", certs.lastArgs[0])
}
ev, ok := rec.find(audit.EventConfigDownload)
if !ok {
t.Fatal("config_download fehlt im Audit-Log")
}
if ev.User != "mmueller" || ev.VPNInstance != "VPN Homeoffice" ||
ev.Format != "ovpn" || ev.CertCN != "mmueller" || ev.CertExpiry != "2027-03-01" {
t.Errorf("Audit-Event unvollständig: %+v", ev)
}
}
func TestDownloadRequiresCSRF(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1", InstanceName: "X"}}}
srv, _, cookie, _ := loggedInServer(t, certs)
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {"falsch"}, "cert": {"1:a1"}, "format": {"ovpn"},
}, cookie)
if out.Code != http.StatusForbidden {
t.Fatalf("Code = %d, want 403", out.Code)
}
if certs.exportCall != 0 {
t.Error("ohne gültiges CSRF-Token darf kein Export ausgelöst werden")
}
}
func TestDownloadGETIsNotRouted(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1"}}}
srv, _, cookie, _ := loggedInServer(t, certs)
out := getWithCookie(srv.Handler(), "/download?cert=1:a1&format=ovpn", cookie)
if out.Code == http.StatusOK {
t.Fatal("Downloads per GET dürfen nicht möglich sein")
}
if certs.exportCall != 0 {
t.Error("GET darf keinen Export auslösen")
}
}
func TestForeignCertificateIsDenied(t *testing.T) {
certs := &certsWithExport{
entries: []CertEntry{{Token: "1:a1", InstanceName: "X"}},
exportErr: ErrCertNotOwned,
}
srv, rec, cookie, sess := loggedInServer(t, certs)
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {sess.CSRF}, "cert": {"9:fremd"}, "format": {"ovpn"},
}, cookie)
if out.Code != http.StatusForbidden {
t.Fatalf("Code = %d, want 403", out.Code)
}
ev, ok := rec.find(audit.EventDownloadDenied)
if !ok {
t.Fatal("download_denied fehlt im Audit-Log")
}
if ev.User != "mmueller" {
t.Errorf("Audit-Event = %+v", ev)
}
}
func TestDownloadRejectsMalformedToken(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1"}}}
srv, _, cookie, sess := loggedInServer(t, certs)
for _, bad := range []string{"", "keinDoppelpunkt", "a:b:c"} {
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {sess.CSRF}, "cert": {bad}, "format": {"ovpn"},
}, cookie)
if out.Code != http.StatusForbidden && out.Code != http.StatusBadRequest {
t.Errorf("Token %q ergab Code %d", bad, out.Code)
}
}
if certs.exportCall != 0 {
t.Error("kaputte Tokens dürfen keinen Export auslösen")
}
}
func TestDownloadRejectsUnknownFormat(t *testing.T) {
certs := &certsWithExport{entries: []CertEntry{{Token: "1:a1"}}}
srv, _, cookie, sess := loggedInServer(t, certs)
out := postForm(srv.Handler(), "/download", url.Values{
"csrf_token": {sess.CSRF}, "cert": {"1:a1"}, "format": {"exe"},
}, cookie)
if out.Code == http.StatusOK {
t.Fatal("unbekanntes Format muss abgelehnt werden")
}
if certs.exportCall != 0 {
t.Error("unbekanntes Format darf keinen Export auslösen")
}
}
func TestDownloadWithoutSessionRedirects(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
out := postForm(srv.Handler(), "/download", url.Values{"cert": {"1:a1"}, "format": {"ovpn"}})
if out.Code != http.StatusSeeOther {
t.Fatalf("Code = %d, want 303", out.Code)
}
}

View file

@ -0,0 +1,183 @@
package web
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
)
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
NoStore(w)
// Eine bereits gültige Session überspringt das Formular.
if c, err := r.Cookie(SessionCookieName); err == nil {
if _, ok := s.d.Sessions.Get(c.Value); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
}
var data PageData
switch {
case r.URL.Query().Get("expired") != "":
data.Flash, data.FlashKind = T("error_session_expired"), "info"
case r.URL.Query().Get("abgemeldet") != "":
data.Flash, data.FlashKind = T("logout_done"), "info"
}
s.renderPage(w, r, http.StatusOK, "login", nil, data)
}
// loginFailed rendert die Anmeldeseite mit einer Meldung.
func (s *Server) loginFailed(w http.ResponseWriter, r *http.Request, status int, message string) {
s.renderPage(w, r, status, "login", nil, PageData{Flash: message, FlashKind: "error"})
}
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
NoStore(w)
started := time.Now()
ip := ClientIP(r)
if err := r.ParseForm(); err != nil {
s.loginFailed(w, r, http.StatusBadRequest, T("error_generic_login"))
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
// Rate-Limit vor jedem Verzeichniszugriff prüfen.
if wait, ok := s.d.Limiter.Allow(username, ip); !ok {
s.log(audit.Event{
Event: audit.EventRateLimited,
User: audit.UnknownUser, // an dieser Stelle ist der Name ungeprüft
SrcIP: ip,
Detail: wait.Round(time.Second).String(),
})
w.Header().Set("Retry-After", strconv.Itoa(int(wait.Round(time.Second).Seconds())))
s.loginFailed(w, r, http.StatusTooManyRequests,
fmt.Sprintf(T("error_rate_limited"), humanDuration(wait)))
return
}
id, err := s.d.Auth.Authenticate(r.Context(), username, password)
s.equalizeTiming(started)
if err != nil {
s.d.Limiter.RecordFailure(username, ip)
reason, known := reasonAndKnownUser(err)
s.log(audit.Event{
Event: audit.EventLoginFailed,
User: auditUser(username, known),
SrcIP: ip,
Reason: reason,
})
message := T("error_generic_login")
var ae *auth.Error
if errors.As(err, &ae) && ae.UserVisible() {
message = T("error_password_expired")
}
s.loginFailed(w, r, http.StatusUnauthorized, message)
return
}
s.d.Limiter.RecordSuccess(username, ip)
sess, err := s.d.Sessions.Create(id)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, nil,
"error_internal", "error_internal_body")
return
}
s.setCookie(w, sess)
s.log(audit.Event{
Event: audit.EventLoginSuccess,
User: id.Username,
SrcIP: ip,
Session: audit.ShortSession(sess.Token),
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess *Session) {
if err := r.ParseForm(); err != nil || !sess.ValidCSRF(r.PostFormValue("csrf_token")) {
s.renderError(w, r, http.StatusForbidden, sess, "error_forbidden", "error_csrf")
return
}
s.d.Sessions.Destroy(sess.Token)
s.clearCookie(w)
s.log(audit.Event{
Event: audit.EventLogout,
User: sess.Identity.Username,
SrcIP: ClientIP(r),
Session: audit.ShortSession(sess.Token),
})
http.Redirect(w, r, "/login?abgemeldet=1", http.StatusSeeOther)
}
// setCookie setzt das gehärtete Session-Cookie.
func (s *Server) setCookie(w http.ResponseWriter, sess *Session) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: sess.Token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(s.d.SessionTTL.Seconds()),
})
}
func (s *Server) clearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName, Value: "", Path: "/",
HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: -1,
})
}
// equalizeTiming hält die Antwortzeit auf einem Mindestwert, damit die Dauer
// nicht verrät, ob ein Konto existiert.
func (s *Server) equalizeTiming(started time.Time) {
if elapsed := time.Since(started); elapsed < s.d.MinLoginDuration {
time.Sleep(s.d.MinLoginDuration - elapsed)
}
}
// reasonAndKnownUser liefert den Audit-Reason und ob der Benutzer im
// Verzeichnis existiert. Nur dann darf sein Name im Klartext geloggt werden.
func reasonAndKnownUser(err error) (reason string, known bool) {
var ae *auth.Error
if !errors.As(err, &ae) {
return auth.ReasonBackendUnavailable, false
}
switch ae.Reason {
case auth.ReasonUserNotFound, auth.ReasonBackendUnavailable, "":
return ae.Reason, false
default:
// Der Benutzer wurde im Verzeichnis gefunden; sein Name ist ein
// echter Kontoname und kein versehentlich eingegebenes Passwort.
return ae.Reason, true
}
}
// auditUser entscheidet, ob der eingegebene Name im Log erscheinen darf.
func auditUser(input string, known bool) string {
if !known {
return audit.UnknownUser
}
return strings.ToLower(input)
}
// humanDuration formatiert Wartezeiten deutsch lesbar.
func humanDuration(d time.Duration) string {
d = d.Round(time.Second)
if d < time.Minute {
return fmt.Sprintf("%d Sekunden", int(d.Seconds()))
}
minutes := int(d.Minutes())
if minutes == 1 {
return "einer Minute"
}
return fmt.Sprintf("%d Minuten", minutes)
}

View file

@ -0,0 +1,363 @@
package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
)
// fakeAuth ist ein Authenticator-Double.
type fakeAuth struct {
id *auth.Identity
err error
// calls zählt Aufrufe, um Rate-Limiting-Verhalten zu prüfen.
calls int
}
func (f *fakeAuth) Authenticate(ctx context.Context, u, p string) (*auth.Identity, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.id, nil
}
// recordAudit sammelt Events für Assertions.
type recordAudit struct{ events []audit.Event }
func (r *recordAudit) Log(e audit.Event) { r.events = append(r.events, e) }
func (r *recordAudit) find(name string) (audit.Event, bool) {
for _, e := range r.events {
if e.Event == name {
return e, true
}
}
return audit.Event{}, false
}
// fakeCerts ist eine CertSource ohne Firewall.
type fakeCerts struct {
entries []CertEntry
err error
}
func (f *fakeCerts) EntriesFor(ctx context.Context, username string) ([]CertEntry, error) {
return f.entries, f.err
}
func (f *fakeCerts) Export(ctx context.Context, username, vpnID, refID, format string) (*ExportStream, error) {
return nil, ErrCertNotOwned
}
func newTestServer(t *testing.T, d Deps) (*Server, *recordAudit) {
t.Helper()
rec := &recordAudit{}
if d.Audit == nil {
d.Audit = rec
} else if r, ok := d.Audit.(*recordAudit); ok {
rec = r
}
if d.Sessions == nil {
d.Sessions = NewSessionStore(10*time.Minute, d.Clock)
}
if d.Limiter == nil {
d.Limiter = NewLimiter(d.Clock)
}
if d.Certs == nil {
d.Certs = &fakeCerts{}
}
if d.Renderer == nil {
rn, err := NewRenderer(Brand{Title: "Testportal", Version: "test"})
if err != nil {
t.Fatal(err)
}
d.Renderer = rn
}
// Tests sollen nicht an der Timing-Angleichung hängen.
if d.MinLoginDuration == 0 {
d.MinLoginDuration = time.Millisecond
}
srv, err := NewServer(d)
if err != nil {
t.Fatalf("NewServer: %v", err)
}
return srv, rec
}
func postForm(h http.Handler, path string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.RemoteAddr = "10.1.20.34:5000"
for _, c := range cookies {
r.AddCookie(c)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
return rec
}
func sessionCookie(rec *httptest.ResponseRecorder) *http.Cookie {
for _, c := range rec.Result().Cookies() {
if c.Name == SessionCookieName && c.Value != "" {
return c
}
}
return nil
}
func TestLoginGETRendersForm(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
if rec.Code != http.StatusOK {
t.Fatalf("Code = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), S["login_submit"]) {
t.Error("Anmeldeformular fehlt")
}
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control = %q", cc)
}
}
func TestLoginSuccessSetsHardenedCookieAndRedirects(t *testing.T) {
fa := &fakeAuth{id: &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}}}
srv, rec := newTestServer(t, Deps{Auth: fa})
resp := postForm(srv.Handler(), "/login",
url.Values{"username": {"MMueller"}, "password": {"geheim"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("Code = %d, want 303", resp.Code)
}
if loc := resp.Header().Get("Location"); loc != "/" {
t.Errorf("Location = %q", loc)
}
c := sessionCookie(resp)
if c == nil {
t.Fatal("Session-Cookie fehlt")
}
if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteStrictMode || c.Path != "/" {
t.Errorf("Cookie nicht gehärtet: %+v", c)
}
ev, ok := rec.find(audit.EventLoginSuccess)
if !ok {
t.Fatal("login_success fehlt im Audit-Log")
}
if ev.User != "mmueller" || ev.SrcIP != "10.1.20.34" || ev.Session == "" {
t.Errorf("Audit-Event unvollständig: %+v", ev)
}
if strings.Contains(c.Value, ev.Session) {
t.Error("Session-Feld darf kein Teil des echten Tokens sein")
}
}
func TestLoginFailureShowsGenericMessage(t *testing.T) {
fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonAccountDisabled}}
srv, rec := newTestServer(t, Deps{Auth: fa})
resp := postForm(srv.Handler(), "/login",
url.Values{"username": {"mmueller"}, "password": {"x"}})
if resp.Code != http.StatusUnauthorized {
t.Fatalf("Code = %d, want 401", resp.Code)
}
body := resp.Body.String()
if !strings.Contains(body, S["error_generic_login"]) {
t.Error("generische Fehlermeldung fehlt")
}
if strings.Contains(body, "deaktiviert") || strings.Contains(body, auth.ReasonAccountDisabled) {
t.Error("der Grund darf dem Benutzer nicht verraten werden")
}
ev, ok := rec.find(audit.EventLoginFailed)
if !ok || ev.Reason != auth.ReasonAccountDisabled {
t.Errorf("Audit-Reason = %+v", ev)
}
}
func TestExpiredPasswordIsTheOnlySpecificMessage(t *testing.T) {
fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonPasswordExpired}}
srv, _ := newTestServer(t, Deps{Auth: fa})
resp := postForm(srv.Handler(), "/login",
url.Values{"username": {"mmueller"}, "password": {"x"}})
if !strings.Contains(resp.Body.String(), S["error_password_expired"]) {
t.Error("bei abgelaufenem Passwort muss die spezifische Meldung erscheinen")
}
}
func TestUnknownUserIsNeverLoggedInClear(t *testing.T) {
fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonUserNotFound}}
srv, rec := newTestServer(t, Deps{Auth: fa})
// Klassischer Unfall: Passwort im Benutzernamenfeld.
secret := "MeinGeheimesPasswort123"
postForm(srv.Handler(), "/login", url.Values{"username": {secret}, "password": {"x"}})
ev, ok := rec.find(audit.EventLoginFailed)
if !ok {
t.Fatal("login_failed fehlt")
}
if ev.User != audit.UnknownUser {
t.Errorf("User = %q, want %q", ev.User, audit.UnknownUser)
}
for _, e := range rec.events {
if strings.Contains(e.User+e.Detail+e.Reason, secret) {
t.Fatalf("Eingabe aus dem Benutzerfeld ist im Log gelandet: %+v", e)
}
}
}
func TestPasswordNeverAppearsInAnyEvent(t *testing.T) {
fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonInvalidCredentials}}
srv, rec := newTestServer(t, Deps{Auth: fa})
const pw = "Sup3rGeheim!"
postForm(srv.Handler(), "/login", url.Values{"username": {"mmueller"}, "password": {pw}})
for _, e := range rec.events {
if strings.Contains(e.User+e.Detail+e.Reason+e.Format+e.Pattern, pw) {
t.Fatalf("Passwort im Audit-Log: %+v", e)
}
}
}
func TestRateLimitBlocksAndLogs(t *testing.T) {
fa := &fakeAuth{err: &auth.Error{Reason: auth.ReasonInvalidCredentials}}
srv, rec := newTestServer(t, Deps{Auth: fa})
form := url.Values{"username": {"mmueller"}, "password": {"falsch"}}
for i := 0; i < 4; i++ {
postForm(srv.Handler(), "/login", form)
}
before := fa.calls
resp := postForm(srv.Handler(), "/login", form)
if resp.Code != http.StatusTooManyRequests {
t.Fatalf("Code = %d, want 429", resp.Code)
}
if fa.calls != before {
t.Error("bei Rate-Limit darf das Verzeichnis nicht mehr befragt werden")
}
if resp.Header().Get("Retry-After") == "" {
t.Error("Retry-After-Header fehlt")
}
if _, ok := rec.find(audit.EventRateLimited); !ok {
t.Error("rate_limited fehlt im Audit-Log")
}
}
func TestLogoutDestroysSession(t *testing.T) {
fa := &fakeAuth{id: &auth.Identity{Username: "mmueller"}}
store := NewSessionStore(10*time.Minute, nil)
srv, rec := newTestServer(t, Deps{Auth: fa, Sessions: store})
login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}})
cookie := sessionCookie(login)
sess, ok := store.Get(cookie.Value)
if !ok {
t.Fatal("Session muss existieren")
}
out := postForm(srv.Handler(), "/logout", url.Values{"csrf_token": {sess.CSRF}}, cookie)
if out.Code != http.StatusSeeOther {
t.Fatalf("Code = %d, want 303", out.Code)
}
if _, ok := store.Get(cookie.Value); ok {
t.Fatal("Session muss zerstört sein")
}
if _, ok := rec.find(audit.EventLogout); !ok {
t.Error("logout fehlt im Audit-Log")
}
}
func TestLogoutRequiresCSRF(t *testing.T) {
fa := &fakeAuth{id: &auth.Identity{Username: "mmueller"}}
store := NewSessionStore(10*time.Minute, nil)
srv, _ := newTestServer(t, Deps{Auth: fa, Sessions: store})
login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}})
cookie := sessionCookie(login)
out := postForm(srv.Handler(), "/logout", url.Values{"csrf_token": {"falsch"}}, cookie)
if out.Code != http.StatusForbidden {
t.Fatalf("Code = %d, want 403", out.Code)
}
if _, ok := store.Get(cookie.Value); !ok {
t.Error("bei CSRF-Fehler darf die Session nicht zerstört werden")
}
}
func TestProtectedPageRedirectsWithoutSession(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusSeeOther {
t.Fatalf("Code = %d, want 303", rec.Code)
}
if loc := rec.Header().Get("Location"); !strings.HasPrefix(loc, "/login") {
t.Errorf("Location = %q", loc)
}
}
func TestExpiredSessionRedirectsWithHint(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
clock := func() time.Time { return now }
store := NewSessionStore(10*time.Minute, clock)
fa := &fakeAuth{id: &auth.Identity{Username: "mmueller"}}
srv, rec := newTestServer(t, Deps{Auth: fa, Sessions: store, Clock: clock})
login := postForm(srv.Handler(), "/login", url.Values{"username": {"m"}, "password": {"p"}})
cookie := sessionCookie(login)
now = now.Add(11 * time.Minute)
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(cookie)
out := httptest.NewRecorder()
srv.Handler().ServeHTTP(out, r)
if out.Code != http.StatusSeeOther {
t.Fatalf("Code = %d, want 303", out.Code)
}
if loc := out.Header().Get("Location"); !strings.Contains(loc, "expired") {
t.Errorf("Location = %q, muss den Ablaufhinweis tragen", loc)
}
if _, ok := rec.find(audit.EventSessionExpired); !ok {
t.Error("session_expired fehlt im Audit-Log")
}
}
func TestLoginPageShowsExpiredHint(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login?expired=1", nil))
if !strings.Contains(rec.Body.String(), S["error_session_expired"]) {
t.Error("Ablaufhinweis fehlt auf der Anmeldeseite")
}
}
func TestLoginTimingIsEqualisedAcrossReasons(t *testing.T) {
// "Benutzer existiert nicht" und "Passwort falsch" müssen gleich lange
// dauern, sonst verrät die Laufzeit die Existenz eines Kontos.
const floor = 60 * time.Millisecond
measure := func(reason string) time.Duration {
srv, _ := newTestServer(t, Deps{
Auth: &fakeAuth{err: &auth.Error{Reason: reason}},
MinLoginDuration: floor,
})
start := time.Now()
postForm(srv.Handler(), "/login", url.Values{"username": {"x"}, "password": {"y"}})
return time.Since(start)
}
for _, reason := range []string{auth.ReasonUserNotFound, auth.ReasonInvalidCredentials} {
if d := measure(reason); d < floor {
t.Errorf("Reason %q antwortete in %v, Mindestdauer ist %v", reason, d, floor)
}
}
}

View file

@ -0,0 +1,35 @@
package web
import (
"encoding/json"
"net/http"
)
// guidesData füllt guides.html.
type guidesData struct {
Guides []Guide
}
func (s *Server) handleGuides(w http.ResponseWriter, r *http.Request, sess *Session) {
s.renderPage(w, r, http.StatusOK, "guides", sess,
PageData{Data: guidesData{Guides: Guides(r.UserAgent())}})
}
// handleHealth beantwortet Monitoring-Anfragen ohne Authentifizierung und
// ohne sensible Details — nur boolesche Zustände und die Sessionzahl.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
report := HealthReport{OK: true, OPNsense: true, Directory: true}
if s.d.Health != nil {
report = s.d.Health.Check(r.Context())
}
report.Sessions = s.d.Sessions.Count()
status := http.StatusOK
if !report.OK {
status = http.StatusServiceUnavailable
}
NoStore(w)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
json.NewEncoder(w).Encode(report)
}

View file

@ -0,0 +1,193 @@
package web
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type fakeHealth struct{ rep HealthReport }
func (f *fakeHealth) Check(ctx context.Context) HealthReport { return f.rep }
func TestGuidesOrderPutsDetectedPlatformFirst(t *testing.T) {
const winUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
g := Guides(winUA)
if len(g) < 4 {
t.Fatalf("es müssen Anleitungen für alle Plattformen existieren, got %d", len(g))
}
if !strings.Contains(g[0].Platform, "Windows") || !g[0].Suggested {
t.Errorf("erste Anleitung = %+v, want Windows und Suggested", g[0])
}
// Kein Filter: alle Plattformen bleiben erreichbar.
for _, want := range []string{"Windows", "macOS", "iOS", "Android"} {
var found bool
for _, x := range g {
if strings.Contains(x.Platform, want) {
found = true
}
}
if !found {
t.Errorf("Anleitung für %s fehlt", want)
}
}
}
func TestGuidesDetectsOtherPlatforms(t *testing.T) {
cases := map[string]string{
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)": "macOS",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)": "iOS",
"Mozilla/5.0 (Linux; Android 14; Pixel 8)": "Android",
}
for ua, want := range cases {
g := Guides(ua)
if !strings.Contains(g[0].Platform, want) {
t.Errorf("UA %q -> %q, want %q zuerst", ua, g[0].Platform, want)
}
}
// Unbekannter User-Agent: Reihenfolge stabil, nichts als Vorschlag markiert.
g := Guides("irgendwas")
if g[0].Suggested {
t.Error("bei unbekanntem User-Agent darf nichts vorgeschlagen werden")
}
}
func TestGuidesNeverLosesEntries(t *testing.T) {
for _, ua := range []string{"", "Windows", "iPhone", "Android", "Macintosh", "unbekannt"} {
if got, want := len(Guides(ua)), len(baseGuides); got != want {
t.Errorf("UA %q liefert %d Anleitungen, want %d — es darf nie gefiltert werden", ua, got, want)
}
}
}
func TestGuidesPageRequiresSession(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/anleitungen", nil))
if rec.Code != http.StatusSeeOther {
t.Fatalf("Code = %d, want 303", rec.Code)
}
}
func TestGuidesPageRenders(t *testing.T) {
srv, _, cookie, _ := loggedInServer(t, &certsWithExport{})
rec := getWithCookie(srv.Handler(), "/anleitungen", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("Code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{S["guides_title"], "OpenVPN", "Tunnelblick", "OpenVPN Connect"} {
if !strings.Contains(body, want) {
t.Errorf("Anleitungsseite enthält %q nicht", want)
}
}
}
func TestHealthzHealthy(t *testing.T) {
srv, _ := newTestServer(t, Deps{
Auth: &fakeAuth{},
Health: &fakeHealth{rep: HealthReport{OK: true, OPNsense: true, Directory: true}},
})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("Code = %d, want 200", rec.Code)
}
var got HealthReport
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("Antwort ist kein JSON: %v (%s)", err, rec.Body.String())
}
if !got.OK || !got.OPNsense || !got.Directory {
t.Errorf("Report = %+v", got)
}
}
func TestHealthzUnhealthyReturns503(t *testing.T) {
srv, _ := newTestServer(t, Deps{
Auth: &fakeAuth{},
Health: &fakeHealth{rep: HealthReport{OK: false, OPNsense: false, Directory: true}},
})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("Code = %d, want 503", rec.Code)
}
}
func TestHealthzNeedsNoAuthAndLeaksNoDetails(t *testing.T) {
srv, _ := newTestServer(t, Deps{
Auth: &fakeAuth{},
Health: &fakeHealth{rep: HealthReport{OK: true, OPNsense: true, Directory: true}},
})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("/healthz muss ohne Anmeldung antworten, Code = %d", rec.Code)
}
body := strings.ToLower(rec.Body.String())
for _, forbidden := range []string{"firma.local", "dc01", "https://", "password", "secret"} {
if strings.Contains(body, forbidden) {
t.Errorf("/healthz verrät %q: %s", forbidden, rec.Body.String())
}
}
}
func TestHealthzWithoutCheckerStillAnswers(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("Code = %d — ohne Checker gilt das Portal selbst als gesund", rec.Code)
}
}
func TestUnknownPathReturns404(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/gibtsnicht", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("Code = %d, want 404", rec.Code)
}
}
func TestAssetsAreServed(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
for path, wantType := range map[string]string{
"/assets/style.css": "text/css",
"/assets/app.js": "text/javascript",
} {
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Errorf("%s: Code = %d", path, rec.Code)
continue
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, wantType) {
t.Errorf("%s: Content-Type = %q, want %q", path, ct, wantType)
}
}
}
func TestAssetPathTraversalIsRejected(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
for _, p := range []string{"/assets/../server.go", "/assets/%2e%2e%2fserver.go", "/assets/"} {
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, p, nil))
if rec.Code == http.StatusOK && strings.Contains(rec.Body.String(), "package web") {
t.Errorf("%s hat Quelltext ausgeliefert", p)
}
}
}
func TestLogoIsNotServedWhenUnconfigured(t *testing.T) {
srv, _ := newTestServer(t, Deps{Auth: &fakeAuth{}})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/assets/logo", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("ohne logo_file muss /assets/logo 404 liefern, Code = %d", rec.Code)
}
}

139
internal/web/ratelimit.go Normal file
View file

@ -0,0 +1,139 @@
package web
import (
"math"
"strings"
"sync"
"time"
)
const (
// freeAttempts sind die Fehlversuche ohne Wartezeit — genug für Tippfehler.
freeAttempts = 3
// maxBackoff deckelt die Wartezeit. Ein harter Lockout wäre selbst eine
// Denial-of-Service-Möglichkeit gegen Kollegen.
maxBackoff = 5 * time.Minute
// entryTTL räumt ruhende Zähler ab.
entryTTL = 1 * time.Hour
)
type limitEntry struct {
failures int
lastFail time.Time
}
// Limiter bremst Anmeldeversuche pro Benutzername und pro Quell-IP.
// Beide Schlüssel werden geführt: die IP fängt Passwort-Spraying ab, der
// Benutzername verteilte Angriffe und den NAT-Fall.
type Limiter struct {
clock func() time.Time
mu sync.Mutex
entries map[string]*limitEntry
}
func NewLimiter(clock func() time.Time) *Limiter {
if clock == nil {
clock = time.Now
}
return &Limiter{clock: clock, entries: make(map[string]*limitEntry)}
}
// backoff berechnet die Wartezeit nach n Fehlversuchen.
// Die ersten freeAttempts Versuche sind frei: bei freeAttempts=3 sind die
// Versuche 1 bis 3 ungebremst, ab dem vierten wird gewartet.
func backoff(failures int) time.Duration {
if failures < freeAttempts {
return 0
}
exp := failures - freeAttempts + 1
if exp > 20 { // schützt vor Overflow in math.Pow
return maxBackoff
}
d := time.Duration(math.Pow(2, float64(exp))) * time.Second
if d > maxBackoff {
return maxBackoff
}
return d
}
// keys liefert die beiden Zählerschlüssel. Benutzernamen werden
// normalisiert, damit "MMueller" und "mmueller" derselbe Zähler sind.
func keys(username, ip string) []string {
var out []string
if u := strings.ToLower(strings.TrimSpace(username)); u != "" {
out = append(out, "u:"+u)
}
if ip != "" {
out = append(out, "i:"+ip)
}
return out
}
// Allow meldet, ob ein Versuch erlaubt ist, und andernfalls die Restwartezeit.
// Allow zählt nicht mit — dafür ist RecordFailure zuständig.
func (l *Limiter) Allow(username, ip string) (time.Duration, bool) {
now := l.clock()
l.mu.Lock()
defer l.mu.Unlock()
var worst time.Duration
for _, k := range keys(username, ip) {
e, ok := l.entries[k]
if !ok {
continue
}
wait := backoff(e.failures) - now.Sub(e.lastFail)
if wait > worst {
worst = wait
}
}
if worst > 0 {
return worst, false
}
return 0, true
}
// RecordFailure erhöht beide Zähler.
func (l *Limiter) RecordFailure(username, ip string) {
now := l.clock()
l.mu.Lock()
defer l.mu.Unlock()
for _, k := range keys(username, ip) {
e, ok := l.entries[k]
if !ok {
e = &limitEntry{}
l.entries[k] = e
}
e.failures++
e.lastFail = now
}
}
// RecordSuccess setzt beide Zähler zurück.
func (l *Limiter) RecordSuccess(username, ip string) {
l.mu.Lock()
defer l.mu.Unlock()
for _, k := range keys(username, ip) {
delete(l.entries, k)
}
}
// GC entfernt Zähler, die lange nicht mehr angefasst wurden.
func (l *Limiter) GC() {
now := l.clock()
l.mu.Lock()
defer l.mu.Unlock()
for k, e := range l.entries {
if now.Sub(e.lastFail) > entryTTL {
delete(l.entries, k)
}
}
}
// size ist ein Testhelfer.
func (l *Limiter) size() int {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.entries)
}

View file

@ -0,0 +1,139 @@
package web
import (
"fmt"
"testing"
"time"
)
func TestLimiterAllowsFirstAttempts(t *testing.T) {
l := NewLimiter(nil)
for i := 0; i < 3; i++ {
if _, ok := l.Allow("mmueller", "10.0.0.1"); !ok {
t.Fatalf("Versuch %d muss erlaubt sein", i+1)
}
l.RecordFailure("mmueller", "10.0.0.1")
}
if _, ok := l.Allow("mmueller", "10.0.0.1"); ok {
t.Fatal("nach 3 Fehlversuchen muss gebremst werden")
}
}
func TestBackoffGrowsAndExpires(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
l := NewLimiter(func() time.Time { return now })
for i := 0; i < 4; i++ {
l.RecordFailure("mmueller", "10.0.0.1")
}
wait1, ok := l.Allow("mmueller", "10.0.0.1")
if ok {
t.Fatal("nach 4 Fehlversuchen muss gebremst werden")
}
if wait1 <= 0 {
t.Fatalf("retryAfter = %v, muss positiv sein", wait1)
}
// Wartezeit absitzen -> wieder erlaubt.
now = now.Add(wait1 + time.Second)
if _, ok := l.Allow("mmueller", "10.0.0.1"); !ok {
t.Fatal("nach Ablauf der Wartezeit muss wieder erlaubt sein")
}
// Weiterer Fehlversuch -> längere Wartezeit als vorher.
l.RecordFailure("mmueller", "10.0.0.1")
wait2, ok := l.Allow("mmueller", "10.0.0.1")
if ok {
t.Fatal("erneuter Fehlversuch muss wieder bremsen")
}
if wait2 <= wait1 {
t.Errorf("Backoff muss wachsen: %v -> %v", wait1, wait2)
}
}
func TestBackoffIsCapped(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
l := NewLimiter(func() time.Time { return now })
for i := 0; i < 40; i++ {
l.RecordFailure("mmueller", "10.0.0.1")
}
wait, ok := l.Allow("mmueller", "10.0.0.1")
if ok {
t.Fatal("muss gebremst sein")
}
if wait > maxBackoff {
t.Errorf("retryAfter = %v, Deckel ist %v", wait, maxBackoff)
}
}
func TestIPLimitProtectsAgainstUserSpray(t *testing.T) {
l := NewLimiter(nil)
// Angreifer probiert viele verschiedene Benutzernamen von einer IP.
for i := 0; i < 12; i++ {
l.RecordFailure(fmt.Sprintf("user%d", i), "10.0.0.9")
}
if _, ok := l.Allow("nochNieProbiert", "10.0.0.9"); ok {
t.Fatal("die IP muss trotz wechselnder Benutzernamen gebremst werden")
}
}
func TestUserLimitProtectsAcrossIPs(t *testing.T) {
l := NewLimiter(nil)
// Verteilter Angriff auf einen Benutzer von vielen IPs.
for i := 0; i < 12; i++ {
l.RecordFailure("mmueller", fmt.Sprintf("10.0.1.%d", i))
}
if _, ok := l.Allow("mmueller", "192.168.99.99"); ok {
t.Fatal("der Benutzer muss auch von einer neuen IP gebremst werden")
}
}
func TestUsernameIsCaseInsensitive(t *testing.T) {
l := NewLimiter(nil)
for i := 0; i < 6; i++ {
l.RecordFailure("MMueller", "10.0.0.1")
}
// Andere Schreibweise darf den Zähler nicht umgehen.
if _, ok := l.Allow("mmueller", "192.168.5.5"); ok {
t.Fatal("Schreibweise des Benutzernamens darf das Limit nicht umgehen")
}
}
func TestSuccessResetsBothCounters(t *testing.T) {
l := NewLimiter(nil)
for i := 0; i < 5; i++ {
l.RecordFailure("mmueller", "10.0.0.1")
}
if _, ok := l.Allow("mmueller", "10.0.0.1"); ok {
t.Fatal("Vorbedingung: muss gebremst sein")
}
l.RecordSuccess("mmueller", "10.0.0.1")
if _, ok := l.Allow("mmueller", "10.0.0.1"); !ok {
t.Fatal("erfolgreiche Anmeldung muss die Zähler zurücksetzen")
}
}
func TestNoHardLockout(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
l := NewLimiter(func() time.Time { return now })
for i := 0; i < 100; i++ {
l.RecordFailure("opfer", "10.0.0.1")
}
// Nach dem gedeckelten Backoff darf der Benutzer wieder anklopfen —
// sonst könnte ein Dritter Kollegen dauerhaft aussperren.
now = now.Add(maxBackoff + time.Second)
if _, ok := l.Allow("opfer", "10.0.0.1"); !ok {
t.Fatal("es darf keinen dauerhaften Lockout geben")
}
}
func TestGCDropsIdleEntries(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
l := NewLimiter(func() time.Time { return now })
l.RecordFailure("mmueller", "10.0.0.1")
now = now.Add(2 * entryTTL)
l.GC()
if n := l.size(); n != 0 {
t.Fatalf("GC muss veraltete Einträge entfernen, size = %d", n)
}
}

92
internal/web/render.go Normal file
View file

@ -0,0 +1,92 @@
package web
import (
"bytes"
"embed"
"fmt"
"html/template"
"net/http"
)
//go:embed templates/*.html
var templateFS embed.FS
// AssetsFS enthält CSS und JavaScript; wird vom Asset-Handler ausgeliefert.
//
//go:embed assets
var AssetsFS embed.FS
// Brand bündelt die White-Label-Angaben.
type Brand struct {
Title string
SupportContact string
Version string
HasLogo bool
}
// PageData ist das Wurzelobjekt jedes Templates.
type PageData struct {
Brand Brand
Flash string
FlashKind string // "error" oder "info"
CSRF string
User string
Data any
}
// errorPageData füllt error.html.
type errorPageData struct {
Title string
Message string
ShowLoginLink bool
ShowOverviewLink bool
}
// Renderer hält die beim Start geparsten Templates.
type Renderer struct {
brand Brand
pages map[string]*template.Template
}
// pageNames sind die Inhaltstemplates, die jeweils mit dem Layout kombiniert werden.
var pageNames = []string{"login", "overview", "guides", "error"}
// NewRenderer parst alle Templates beim Start; ein Fehler bricht den Start ab,
// damit ein Tippfehler im Template nicht erst im Betrieb auffällt.
func NewRenderer(brand Brand) (*Renderer, error) {
funcs := template.FuncMap{"t": T}
pages := make(map[string]*template.Template, len(pageNames))
for _, name := range pageNames {
tmpl, err := template.New("layout.html").Funcs(funcs).
ParseFS(templateFS, "templates/layout.html", "templates/"+name+".html")
if err != nil {
return nil, fmt.Errorf("Template %s: %w", name, err)
}
pages[name] = tmpl
}
return &Renderer{brand: brand, pages: pages}, nil
}
// Brand liefert die White-Label-Angaben für Handler.
func (rn *Renderer) Brand() Brand { return rn.brand }
// Render schreibt eine Seite. Es wird zuerst in einen Puffer gerendert, damit
// ein Template-Fehler nicht zu einer halb geschriebenen Antwort führt.
func (rn *Renderer) Render(w http.ResponseWriter, r *http.Request, status int, page string, data PageData) {
tmpl, ok := rn.pages[page]
if !ok {
http.Error(w, T("error_internal"), http.StatusInternalServerError)
return
}
if data.Brand.Title == "" {
data.Brand = rn.brand
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
http.Error(w, T("error_internal"), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
w.Write(buf.Bytes())
}

View file

@ -0,0 +1,89 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func testRenderer(t *testing.T) *Renderer {
t.Helper()
rn, err := NewRenderer(Brand{Title: "VPN-Portal Firma GmbH", Version: "1.2.3",
SupportContact: "it@firma.de"})
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
return rn
}
func TestRenderLoginPage(t *testing.T) {
rn := testRenderer(t)
rec := httptest.NewRecorder()
rn.Render(rec, httptest.NewRequest(http.MethodGet, "/login", nil), http.StatusOK,
"login", PageData{Brand: rn.brand})
if rec.Code != http.StatusOK {
t.Fatalf("Code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"<!DOCTYPE html>", `lang="de"`, "VPN-Portal Firma GmbH",
S["login_submit"], S["login_username_hint"], "1.2.3",
`name="password"`, `type="password"`,
} {
if !strings.Contains(body, want) {
t.Errorf("Login-Seite enthält %q nicht", want)
}
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("Content-Type = %q", ct)
}
}
func TestRenderEscapesUserContent(t *testing.T) {
rn := testRenderer(t)
rec := httptest.NewRecorder()
rn.Render(rec, httptest.NewRequest(http.MethodGet, "/", nil), http.StatusOK,
"error", PageData{
Brand: rn.brand,
Flash: `<script>alert(1)</script>`,
Data: errorPageData{Title: "T", Message: `"><img src=x onerror=alert(1)>`},
})
body := rec.Body.String()
if strings.Contains(body, "<script>alert(1)</script>") {
t.Error("Flash-Text wurde nicht escapt")
}
if strings.Contains(body, "onerror=alert(1)>") {
t.Error("Fehlermeldung wurde nicht escapt")
}
}
func TestRenderSetsStatusCode(t *testing.T) {
rn := testRenderer(t)
rec := httptest.NewRecorder()
rn.Render(rec, httptest.NewRequest(http.MethodGet, "/", nil), http.StatusServiceUnavailable,
"error", PageData{Brand: rn.brand, Data: errorPageData{Title: "x", Message: "y"}})
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("Code = %d, want 503", rec.Code)
}
}
func TestAllTemplatesParse(t *testing.T) {
rn := testRenderer(t)
for _, page := range pageNames {
if rn.pages[page] == nil {
t.Errorf("Template %q wurde nicht geladen", page)
}
}
}
func TestRenderUnknownPageDoesNotPanic(t *testing.T) {
rn := testRenderer(t)
rec := httptest.NewRecorder()
rn.Render(rec, httptest.NewRequest(http.MethodGet, "/", nil), http.StatusOK,
"gibtsnicht", PageData{Brand: rn.brand})
if rec.Code != http.StatusInternalServerError {
t.Fatalf("Code = %d, want 500", rec.Code)
}
}

46
internal/web/security.go Normal file
View file

@ -0,0 +1,46 @@
package web
import (
"net"
"net/http"
"strings"
)
// contentSecurityPolicy ist so restriktiv möglich, weil sämtliche Assets
// eingebettet und gleich-origin ausgeliefert werden. Es gibt keine externen
// Ressourcen und kein Inline-Script.
const contentSecurityPolicy = "default-src 'self'; " +
"script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; " +
"connect-src 'none'; object-src 'none'; base-uri 'none'; " +
"form-action 'self'; frame-ancestors 'none'"
// SecurityHeaders setzt die Sicherheits-Header auf jede Antwort.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Frame-Options", "DENY")
h.Set("Content-Security-Policy", contentSecurityPolicy)
next.ServeHTTP(w, r)
})
}
// NoStore verhindert jegliches Zwischenspeichern.
func NoStore(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
}
// ClientIP liefert die Quell-IP der Verbindung. X-Forwarded-For wird bewusst
// ignoriert: das Portal terminiert TLS selbst, ein gesetzter Header wäre
// fälschbar und würde das Audit-Log entwerten.
func ClientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return strings.TrimSpace(r.RemoteAddr)
}
return host
}

View file

@ -0,0 +1,90 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSecurityHeaders(t *testing.T) {
h := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
want := map[string]string{
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"X-Frame-Options": "DENY",
}
for k, v := range want {
if got := rec.Header().Get(k); got != v {
t.Errorf("%s = %q, want %q", k, got, v)
}
}
if hsts := rec.Header().Get("Strict-Transport-Security"); !strings.Contains(hsts, "max-age=") {
t.Errorf("Strict-Transport-Security = %q", hsts)
}
csp := rec.Header().Get("Content-Security-Policy")
for _, directive := range []string{"default-src 'self'", "frame-ancestors 'none'", "form-action 'self'"} {
if !strings.Contains(csp, directive) {
t.Errorf("CSP %q fehlt %q", csp, directive)
}
}
if strings.Contains(csp, "unsafe-inline") || strings.Contains(csp, "unsafe-eval") {
t.Errorf("CSP darf kein unsafe-* enthalten: %q", csp)
}
}
func TestNoStore(t *testing.T) {
rec := httptest.NewRecorder()
NoStore(rec)
if got := rec.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
t.Errorf("Cache-Control = %q", got)
}
}
func TestClientIPIgnoresForwardedHeader(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "10.1.20.34:51234"
r.Header.Set("X-Forwarded-For", "1.2.3.4")
// Das Portal terminiert TLS selbst; ein gefälschter Header darf das
// Audit-Log nicht verfälschen.
if got := ClientIP(r); got != "10.1.20.34" {
t.Errorf("ClientIP = %q, want 10.1.20.34", got)
}
}
func TestClientIPHandlesIPv6AndMalformed(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "[2001:db8::1]:443"
if got := ClientIP(r); got != "2001:db8::1" {
t.Errorf("ClientIP = %q", got)
}
r.RemoteAddr = "kaputt"
if got := ClientIP(r); got != "kaputt" {
t.Errorf("ClientIP = %q, malformed muss unverändert durchgereicht werden", got)
}
}
func TestStringsAreComplete(t *testing.T) {
required := []string{
"login_title", "login_username", "login_password", "login_submit",
"login_username_hint", "error_generic_login", "error_password_expired",
"error_rate_limited", "error_session_expired", "overview_title",
"overview_no_certs", "overview_expiry_warning", "download_ovpn",
"download_viscosity", "logout", "guides_title", "error_service_unavailable",
"error_forbidden", "logout_reminder", "valid_until", "vpn_instance",
}
for _, k := range required {
v, ok := S[k]
if !ok || strings.TrimSpace(v) == "" {
t.Errorf("String %q fehlt oder ist leer", k)
}
}
if T("gibt_es_nicht") != "gibt_es_nicht" {
t.Error("T muss bei unbekanntem Schlüssel den Schlüssel liefern, nie einen leeren String")
}
}

234
internal/web/server.go Normal file
View file

@ -0,0 +1,234 @@
package web
import (
"context"
"errors"
"io"
"io/fs"
"net/http"
"path"
"strings"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
)
// AuditSink ist die Log-Senke; als Interface, damit Tests mitschreiben können.
type AuditSink interface {
Log(audit.Event)
}
// CertEntry ist ein für den Benutzer freigegebenes Zertifikat, aufbereitet
// für die Oberfläche.
type CertEntry struct {
Token string
InstanceName string
CommonName string
ValidTo time.Time
}
// ExportStream ist eine zum Download bereite Konfiguration.
// Body muss vom Aufrufer geschlossen werden.
type ExportStream struct {
Filename string
ContentType string
Body io.ReadCloser
InstanceName string
CommonName string
ValidTo time.Time
}
// ErrCertNotOwned signalisiert, dass das angeforderte Zertifikat nicht zum
// angemeldeten Benutzer gehört.
var ErrCertNotOwned = errors.New("Zertifikat gehört nicht zum angemeldeten Benutzer")
// ErrBackendUnavailable signalisiert eine nicht erreichbare Firewall.
var ErrBackendUnavailable = errors.New("Firewall nicht erreichbar")
// CertSource liefert die Zertifikate eines Benutzers und exportiert sie.
type CertSource interface {
EntriesFor(ctx context.Context, username string) ([]CertEntry, error)
// Export prüft die Zuordnung serverseitig erneut und liefert sonst
// ErrCertNotOwned. Die Auswahl in der Oberfläche ist keine Autorisierung.
Export(ctx context.Context, username, vpnID, refID, format string) (*ExportStream, error)
}
// HealthChecker liefert den Zustand der Abhängigkeiten für /healthz.
type HealthChecker interface {
Check(ctx context.Context) HealthReport
}
// HealthReport ist die Antwort von /healthz — bewusst ohne sensible Details.
type HealthReport struct {
OK bool `json:"ok"`
OPNsense bool `json:"opnsense"`
Directory bool `json:"directory"`
Sessions int `json:"sessions"`
}
// Deps sind die Abhängigkeiten des Servers.
type Deps struct {
Auth auth.Authenticator
Certs CertSource
Audit AuditSink
Sessions *SessionStore
Limiter *Limiter
Renderer *Renderer
Health HealthChecker
Clock func() time.Time
LogoPath string
SupportContact string
SessionTTL time.Duration
// MinLoginDuration gleicht die Antwortzeit der Anmeldung an.
// 0 = Vorgabewert; Tests setzen sie klein.
MinLoginDuration time.Duration
}
// Server bündelt Routing und Handler.
type Server struct {
d Deps
}
// defaultMinLoginDuration verhindert, dass die Laufzeit verrät, ob ein Konto
// existiert.
const defaultMinLoginDuration = 400 * time.Millisecond
// NewServer prüft die Abhängigkeiten.
func NewServer(d Deps) (*Server, error) {
switch {
case d.Auth == nil:
return nil, errors.New("web: Authenticator fehlt")
case d.Certs == nil:
return nil, errors.New("web: CertSource fehlt")
case d.Audit == nil:
return nil, errors.New("web: Audit-Senke fehlt")
case d.Sessions == nil:
return nil, errors.New("web: SessionStore fehlt")
case d.Limiter == nil:
return nil, errors.New("web: Limiter fehlt")
case d.Renderer == nil:
return nil, errors.New("web: Renderer fehlt")
}
if d.Clock == nil {
d.Clock = time.Now
}
if d.SessionTTL <= 0 {
d.SessionTTL = 10 * time.Minute
}
if d.MinLoginDuration <= 0 {
d.MinLoginDuration = defaultMinLoginDuration
}
return &Server{d: d}, nil
}
// Handler baut den Router samt Middleware.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.requireSession(s.handleLogout))
mux.HandleFunc("GET /{$}", s.requireSession(s.handleOverview))
mux.HandleFunc("POST /download", s.requireSession(s.handleDownload))
mux.HandleFunc("GET /anleitungen", s.requireSession(s.handleGuides))
mux.HandleFunc("GET /healthz", s.handleHealth)
mux.HandleFunc("GET /assets/{file}", s.handleAsset)
mux.HandleFunc("/", s.handleNotFound)
return SecurityHeaders(mux)
}
// log schreibt ein Audit-Ereignis.
func (s *Server) log(e audit.Event) { s.d.Audit.Log(e) }
// renderPage füllt die Standardfelder und rendert.
func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, status int,
page string, sess *Session, data PageData) {
NoStore(w)
data.Brand = s.d.Renderer.Brand()
data.Brand.HasLogo = s.d.LogoPath != ""
if sess != nil {
data.CSRF = sess.CSRF
data.User = sess.Identity.Username
}
s.d.Renderer.Render(w, r, status, page, data)
}
// renderError zeigt die Fehlerseite.
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int,
sess *Session, titleKey, messageKey string) {
s.renderPage(w, r, status, "error", sess, PageData{
Data: errorPageData{
Title: T(titleKey),
Message: T(messageKey),
ShowOverviewLink: sess != nil,
},
})
}
// requireSession schützt eine Route. Ohne gültige Session wird auf /login
// umgeleitet; ein abgelaufenes Cookie erzeugt zusätzlich ein Audit-Ereignis.
func (s *Server) requireSession(next func(http.ResponseWriter, *http.Request, *Session)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
NoStore(w)
cookie, err := r.Cookie(SessionCookieName)
if err != nil || cookie.Value == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
sess, ok := s.d.Sessions.Get(cookie.Value)
if !ok {
s.clearCookie(w)
s.log(audit.Event{
Event: audit.EventSessionExpired,
SrcIP: ClientIP(r),
Session: audit.ShortSession(cookie.Value),
})
http.Redirect(w, r, "/login?expired=1", http.StatusSeeOther)
return
}
next(w, r, sess)
}
}
// handleAsset liefert die eingebetteten Assets und das optionale Logo.
func (s *Server) handleAsset(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("file")
// Verteidigung in der Tiefe: embed.FS erlaubt ohnehin kein Traversal,
// aber ein Pfadtrenner im Namen hat hier nichts zu suchen.
if name == "" || name != path.Base(name) || strings.HasPrefix(name, ".") {
http.NotFound(w, r)
return
}
if name == "logo" {
if s.d.LogoPath == "" {
http.NotFound(w, r)
return
}
// Assets dürfen zwischengespeichert werden; sie enthalten keine
// personenbezogenen Daten.
w.Header().Set("Cache-Control", "public, max-age=3600")
http.ServeFile(w, r, s.d.LogoPath)
return
}
data, err := fs.ReadFile(AssetsFS, "assets/"+name)
if err != nil {
http.NotFound(w, r)
return
}
switch path.Ext(name) {
case ".css":
w.Header().Set("Content-Type", "text/css; charset=utf-8")
case ".js":
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
}
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(data)
}
// handleNotFound zeigt eine schlichte 404-Seite.
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, http.StatusNotFound, nil, "error_not_found", "error_not_found_body")
}

135
internal/web/session.go Normal file
View file

@ -0,0 +1,135 @@
// Package web enthält HTTP-Handler, Templates, Sessions und Schutzmechanismen.
package web
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"sync"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
)
// SessionCookieName ist der Name des Session-Cookies.
const SessionCookieName = "vpnportal_session"
// tokenBytes ergibt nach Base64-URL-Kodierung 43 Zeichen bei 256 Bit Entropie.
const tokenBytes = 32
// Session ist ein angemeldeter Benutzer. Sessions existieren ausschließlich im
// RAM; ein Neustart meldet alle Benutzer ab — das ist beabsichtigt.
type Session struct {
Token string
CSRF string
Identity *auth.Identity
Created time.Time
Expires time.Time
}
// ValidCSRF vergleicht in konstanter Zeit.
func (s *Session) ValidCSRF(token string) bool {
if token == "" || s.CSRF == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(s.CSRF), []byte(token)) == 1
}
// SessionStore hält Sessions im Speicher.
type SessionStore struct {
ttl time.Duration
clock func() time.Time
mu sync.Mutex
sessions map[string]*Session
}
// NewSessionStore erzeugt den Store. clock ist injizierbar; nil = time.Now.
func NewSessionStore(ttl time.Duration, clock func() time.Time) *SessionStore {
if clock == nil {
clock = time.Now
}
if ttl <= 0 {
ttl = 10 * time.Minute
}
return &SessionStore{ttl: ttl, clock: clock, sessions: make(map[string]*Session)}
}
// randomToken liefert einen kryptografisch zufälligen, URL-sicheren Token.
func randomToken() (string, error) {
buf := make([]byte, tokenBytes)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("Zufallszahlengenerator nicht verfügbar: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// Create legt eine neue Session an.
func (s *SessionStore) Create(id *auth.Identity) (*Session, error) {
token, err := randomToken()
if err != nil {
return nil, err
}
csrf, err := randomToken()
if err != nil {
return nil, err
}
now := s.clock()
sess := &Session{
Token: token,
CSRF: csrf,
Identity: id,
Created: now,
Expires: now.Add(s.ttl),
}
s.mu.Lock()
s.sessions[token] = sess
s.mu.Unlock()
return sess, nil
}
// Get liefert eine gültige Session. Abgelaufene Sessions werden entfernt.
// Die TTL ist absolut und wird bewusst nicht durch Aktivität verlängert.
func (s *SessionStore) Get(token string) (*Session, bool) {
if token == "" {
return nil, false
}
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[token]
if !ok {
return nil, false
}
if !s.clock().Before(sess.Expires) {
delete(s.sessions, token)
return nil, false
}
return sess, true
}
// Destroy meldet eine Session ab.
func (s *SessionStore) Destroy(token string) {
s.mu.Lock()
delete(s.sessions, token)
s.mu.Unlock()
}
// Count liefert die Zahl gespeicherter Sessions (Tests, /healthz).
func (s *SessionStore) Count() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.sessions)
}
// GC entfernt abgelaufene Sessions; wird periodisch aufgerufen.
func (s *SessionStore) GC() {
now := s.clock()
s.mu.Lock()
defer s.mu.Unlock()
for token, sess := range s.sessions {
if !now.Before(sess.Expires) {
delete(s.sessions, token)
}
}
}

View file

@ -0,0 +1,130 @@
package web
import (
"testing"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
)
func testIdentity() *auth.Identity {
return &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}}
}
func TestCreateAndGetSession(t *testing.T) {
st := NewSessionStore(10*time.Minute, nil)
sess, err := st.Create(testIdentity())
if err != nil {
t.Fatalf("Create: %v", err)
}
if len(sess.Token) < 32 {
t.Errorf("Token %q ist zu kurz für ausreichende Entropie", sess.Token)
}
if sess.CSRF == "" || sess.CSRF == sess.Token {
t.Error("CSRF-Token muss existieren und vom Session-Token verschieden sein")
}
got, ok := st.Get(sess.Token)
if !ok || got.Identity.Username != "mmueller" {
t.Fatalf("Get = %+v, ok=%v", got, ok)
}
}
func TestTokensAreUnique(t *testing.T) {
st := NewSessionStore(time.Minute, nil)
seen := map[string]bool{}
for i := 0; i < 200; i++ {
s, err := st.Create(testIdentity())
if err != nil {
t.Fatal(err)
}
if seen[s.Token] {
t.Fatalf("doppelter Token %q", s.Token)
}
seen[s.Token] = true
}
}
func TestSessionExpires(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
st := NewSessionStore(10*time.Minute, func() time.Time { return now })
sess, _ := st.Create(testIdentity())
now = now.Add(9 * time.Minute)
if _, ok := st.Get(sess.Token); !ok {
t.Fatal("Session darf nach 9 von 10 Minuten nicht abgelaufen sein")
}
now = now.Add(2 * time.Minute)
if _, ok := st.Get(sess.Token); ok {
t.Fatal("Session muss nach Ablauf der TTL ungültig sein")
}
if st.Count() != 0 {
t.Errorf("abgelaufene Session muss entfernt werden, Count = %d", st.Count())
}
}
func TestTTLIsAbsoluteNotSliding(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
st := NewSessionStore(10*time.Minute, func() time.Time { return now })
sess, _ := st.Create(testIdentity())
// Regelmäßige Zugriffe dürfen die Sitzung nicht verlängern.
for i := 0; i < 5; i++ {
now = now.Add(2 * time.Minute)
st.Get(sess.Token)
}
if _, ok := st.Get(sess.Token); ok {
t.Fatal("die TTL ist absolut; Aktivität darf sie nicht verlängern")
}
}
func TestDestroyRemovesSession(t *testing.T) {
st := NewSessionStore(time.Minute, nil)
sess, _ := st.Create(testIdentity())
st.Destroy(sess.Token)
if _, ok := st.Get(sess.Token); ok {
t.Fatal("zerstörte Session darf nicht mehr auffindbar sein")
}
st.Destroy("existiert-nicht") // darf nicht paniken
}
func TestGetRejectsUnknownAndEmptyToken(t *testing.T) {
st := NewSessionStore(time.Minute, nil)
if _, ok := st.Get(""); ok {
t.Error("leerer Token darf nie gültig sein")
}
if _, ok := st.Get("beliebig"); ok {
t.Error("unbekannter Token darf nie gültig sein")
}
}
func TestValidCSRF(t *testing.T) {
st := NewSessionStore(time.Minute, nil)
sess, _ := st.Create(testIdentity())
if !sess.ValidCSRF(sess.CSRF) {
t.Error("eigenes CSRF-Token muss akzeptiert werden")
}
for _, bad := range []string{"", "falsch", sess.CSRF + "x", sess.CSRF[:len(sess.CSRF)-1]} {
if sess.ValidCSRF(bad) {
t.Errorf("CSRF-Token %q darf nicht akzeptiert werden", bad)
}
}
}
func TestGCRemovesOnlyExpired(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
st := NewSessionStore(10*time.Minute, func() time.Time { return now })
old, _ := st.Create(testIdentity())
now = now.Add(11 * time.Minute)
fresh, _ := st.Create(testIdentity())
st.GC()
if st.Count() != 1 {
t.Fatalf("Count = %d, want 1", st.Count())
}
if _, ok := st.Get(old.Token); ok {
t.Error("alte Session muss weg sein")
}
if _, ok := st.Get(fresh.Token); !ok {
t.Error("frische Session muss bleiben")
}
}

65
internal/web/strings.go Normal file
View file

@ -0,0 +1,65 @@
package web
// S enthält alle Texte der Oberfläche. Die Map-Struktur ist bewusst
// vorbereitet für spätere Mehrsprachigkeit; v1 füllt nur Deutsch.
// Kein deutschsprachiger Text gehört direkt in einen Handler.
var S = map[string]string{
"login_title": "Anmeldung",
"login_username": "Benutzername",
"login_password": "Passwort",
"login_submit": "Anmelden",
"login_username_hint": "z. B. mmueller oder m.mueller@firma.de",
"login_show_password": "Passwort anzeigen",
"error_generic_login": "Anmeldung fehlgeschlagen. Bitte prüfen Sie Benutzername und Passwort.",
"error_password_expired": "Ihr Passwort ist abgelaufen. Bitte ändern Sie es zuerst an Ihrem Arbeitsplatz.",
"error_rate_limited": "Zu viele Anmeldeversuche. Bitte versuchen Sie es in %s erneut.",
"error_session_expired": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
"error_service_unavailable": "Dienst vorübergehend nicht verfügbar",
"error_service_unavailable_body": "Die Firewall ist derzeit nicht erreichbar. " +
"Bitte versuchen Sie es in einigen Minuten erneut.",
"error_forbidden": "Zugriff verweigert",
"error_forbidden_body": "Diese Anfrage ist nicht zulässig. " +
"Bitte starten Sie den Download erneut über die Übersicht.",
"error_not_found": "Seite nicht gefunden",
"error_not_found_body": "Die aufgerufene Adresse gibt es nicht.",
"error_csrf": "Die Anfrage ist abgelaufen oder ungültig. Bitte versuchen Sie es erneut.",
"error_internal": "Unerwarteter Fehler",
"error_internal_body": "Es ist ein interner Fehler aufgetreten. Bitte versuchen Sie es erneut.",
"overview_title": "Ihre VPN-Konfigurationen",
"overview_intro": "Laden Sie hier Ihre persönliche OpenVPN-Konfiguration herunter.",
"overview_no_certs": "Für Ihr Benutzerkonto ist derzeit keine VPN-Konfiguration hinterlegt.",
"overview_no_certs_contact": "Bitte wenden Sie sich an %s.",
"overview_expiry_warning": "Achtung: Dieses Zertifikat läuft in %d Tagen ab.",
"overview_expired_today": "Achtung: Dieses Zertifikat läuft heute ab.",
"vpn_instance": "VPN-Verbindung",
"common_name": "Zertifikat",
"valid_until": "Gültig bis",
"download_ovpn": "OpenVPN-Datei (.ovpn)",
"download_viscosity": "Viscosity-Paket",
"logout": "Abmelden",
"logout_reminder": "Bitte melden Sie sich nach dem Herunterladen ab.",
"logout_done": "Sie wurden abgemeldet.",
"guides_title": "Anleitungen",
"guides_intro": "Kurzanleitungen zum Einrichten der VPN-Verbindung auf Ihrem Gerät.",
"guides_link": "Anleitungen",
"guides_suggested": "Passt vermutlich zu Ihrem Gerät",
"back_to_overview": "Zurück zur Übersicht",
"footer_version": "Version",
}
// T liefert den Text zu einem Schlüssel. Fehlt der Schlüssel, wird er selbst
// zurückgegeben — eine leere Fläche in der Oberfläche wäre schlimmer.
func T(key string) string {
if v, ok := S[key]; ok {
return v
}
return key
}

View file

@ -0,0 +1,8 @@
{{define "content"}}
<h1>{{.Data.Title}}</h1>
<div class="card error">
<p>{{.Data.Message}}</p>
{{if .Data.ShowLoginLink}}<p><a href="/login">{{t "login_submit"}}</a></p>{{end}}
{{if .Data.ShowOverviewLink}}<p><a href="/">{{t "back_to_overview"}}</a></p>{{end}}
</div>
{{end}}

View file

@ -0,0 +1,12 @@
{{define "content"}}
<h1>{{t "guides_title"}}</h1>
<p>{{t "guides_intro"}}</p>
{{range .Data.Guides}}
<section class="card{{if .Suggested}} suggested{{end}}">
<h2>{{.Platform}}{{if .Suggested}} <span class="badge">{{t "guides_suggested"}}</span>{{end}}</h2>
<p class="client">{{.Client}}</p>
<ol>{{range .Steps}}<li>{{.}}</li>{{end}}</ol>
</section>
{{end}}
<p><a href="/">{{t "back_to_overview"}}</a></p>
{{end}}

View file

@ -0,0 +1,37 @@
{{define "layout"}}<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{{.Brand.Title}}</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<header class="topbar">
<div class="brand">
{{if .Brand.HasLogo}}<img src="/assets/logo" alt="" class="logo">{{end}}
<span class="brand-title">{{.Brand.Title}}</span>
</div>
{{if .User}}
<nav class="topnav">
<a href="/anleitungen">{{t "guides_link"}}</a>
<span class="user">{{.User}}</span>
<form method="post" action="/logout" class="inline">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<button type="submit" class="secondary">{{t "logout"}}</button>
</form>
</nav>
{{end}}
</header>
<main>
{{if .Flash}}<div class="flash flash-{{.FlashKind}}" role="status">{{.Flash}}</div>{{end}}
{{block "content" .}}{{end}}
</main>
<footer class="footer">
<span>{{t "footer_version"}} {{.Brand.Version}}</span>
</footer>
</body>
</html>{{end}}

View file

@ -0,0 +1,20 @@
{{define "content"}}
<h1>{{t "login_title"}}</h1>
<form method="post" action="/login" class="card form" autocomplete="on">
<label for="username">{{t "login_username"}}</label>
<input type="text" id="username" name="username" required autofocus
autocomplete="username" autocapitalize="none" spellcheck="false"
aria-describedby="username-hint">
<small id="username-hint">{{t "login_username_hint"}}</small>
<label for="password">{{t "login_password"}}</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
<div class="toggle-wrap" hidden id="toggle-wrap">
<input type="checkbox" id="show-password">
<label for="show-password">{{t "login_show_password"}}</label>
</div>
<button type="submit">{{t "login_submit"}}</button>
</form>
<script src="/assets/app.js" defer></script>
{{end}}

View file

@ -0,0 +1,34 @@
{{define "content"}}
<h1>{{t "overview_title"}}</h1>
{{if .Data.Entries}}
<p>{{t "overview_intro"}}</p>
<ul class="cards">
{{range .Data.Entries}}
<li class="card">
<h2>{{.InstanceName}}</h2>
<dl>
<dt>{{t "common_name"}}</dt><dd>{{.CommonName}}</dd>
{{if .ValidUntil}}<dt>{{t "valid_until"}}</dt><dd>{{.ValidUntil}}</dd>{{end}}
</dl>
{{if .ExpiryWarning}}<p class="warn">{{.ExpiryWarning}}</p>{{end}}
<div class="actions">
{{range .Formats}}
<form method="post" action="/download" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<input type="hidden" name="cert" value="{{.Token}}">
<input type="hidden" name="format" value="{{.Format}}">
<button type="submit">{{.Label}}</button>
</form>
{{end}}
</div>
</li>
{{end}}
</ul>
<p class="logout-hint" role="note">{{t "logout_reminder"}}</p>
{{else}}
<div class="card empty">
<p>{{t "overview_no_certs"}}</p>
{{if .Data.SupportHint}}<p>{{.Data.SupportHint}}</p>{{end}}
</div>
{{end}}
{{end}}