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:
commit
f202ab811b
74 changed files with 8867 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/dist/
|
||||||
|
/vpnportal
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
44
Makefile
Normal file
44
Makefile
Normal 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
422
README.md
Normal file
|
|
@ -0,0 +1,422 @@
|
||||||
|
# vpnportal — VPN-Konfig-Portal für OPNsense
|
||||||
|
|
||||||
|
Self-Service-Webportal, über das Mitarbeitende ihre persönliche
|
||||||
|
OpenVPN-Client-Konfiguration von einer OPNsense-Firewall herunterladen.
|
||||||
|
Anmeldung ausschließlich gegen das Active Directory des Kunden.
|
||||||
|
|
||||||
|
Das Portal ist ein reiner **Read-only-Verteiler**: Es erstellt, ändert oder
|
||||||
|
widerruft keine Zertifikate und schreibt nichts auf die Firewall. Ein einzelnes
|
||||||
|
statisch gelinktes Binary plus eine YAML-Datei, keine Datenbank.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Betriebssystem | Linux, `amd64` oder `arm64` (Raspberry Pi) |
|
||||||
|
| Firewall | OPNsense mit Plugin `os-openvpn-client-export` (siehe `docs/opnsense-api.md`) |
|
||||||
|
| Verzeichnis | Active Directory mit LDAPS (636) oder StartTLS (389) |
|
||||||
|
| Zeit | NTP ist **Pflicht** — ohne korrekte Uhr werden Zertifikatslaufzeiten falsch bewertet |
|
||||||
|
| TLS | Serverzertifikat für das Portal (eigene CA genügt) |
|
||||||
|
|
||||||
|
Nicht unterstützt: Windows, Multi-Domain-Forests, mehrere OPNsense-Instanzen
|
||||||
|
pro Portal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation in unter 30 Minuten
|
||||||
|
|
||||||
|
### 1. OPNsense vorbereiten
|
||||||
|
|
||||||
|
1. Plugin `os-openvpn-client-export` installieren
|
||||||
|
(*System → Firmware → Plugins*).
|
||||||
|
2. Gruppe anlegen (*System → Access → Groups*), z. B. `vpnportal-api`, und ihr
|
||||||
|
**ausschließlich** das Privileg **„VPN: OpenVPN Client Export"** zuweisen.
|
||||||
|
3. Benutzer anlegen (*System → Access → Users*), z. B. `svc-vpnportal`, dieser
|
||||||
|
Gruppe zuordnen, kein Shell-Zugang.
|
||||||
|
4. Für diesen Benutzer einen API-Key erzeugen; Key und Secret notieren.
|
||||||
|
|
||||||
|
> **Niemals einen Admin-Key verwenden.** Das Portal braucht genau ein Privileg.
|
||||||
|
> `vpnportal check` meldet ausdrücklich, wenn die Berechtigung fehlt.
|
||||||
|
|
||||||
|
### 2. Active Directory vorbereiten
|
||||||
|
|
||||||
|
1. Unprivilegierten Dienstbenutzer anlegen, z. B.
|
||||||
|
`svc-vpnportal@firma.local`. Lesezugriff genügt; keine besonderen Rechte.
|
||||||
|
2. Sicherheitsgruppe für den VPN-Zugang anlegen oder benennen, z. B.
|
||||||
|
`VPN-Users`. Verschachtelte Gruppen werden aufgelöst.
|
||||||
|
3. Sicherstellen, dass die Domain Controller **über ihre Hostnamen**
|
||||||
|
erreichbar sind — das LDAPS-Zertifikat wird gegen den Hostnamen geprüft.
|
||||||
|
IP-Adressen in `ad.servers` werden abgelehnt.
|
||||||
|
4. Wird eine interne CA verwendet, deren Zertifikat als PEM bereitstellen.
|
||||||
|
|
||||||
|
### 3. Portal installieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Archiv entpacken
|
||||||
|
tar -xzf vpnportal-<version>-linux-amd64.tar.gz -C /tmp/vpnportal
|
||||||
|
install -m 0755 /tmp/vpnportal/vpnportal /usr/local/bin/vpnportal
|
||||||
|
|
||||||
|
# Konfiguration anlegen
|
||||||
|
install -d -m 0750 /etc/vpnportal
|
||||||
|
install -m 0640 /tmp/vpnportal/config.example.yaml /etc/vpnportal/config.yaml
|
||||||
|
$EDITOR /etc/vpnportal/config.yaml
|
||||||
|
|
||||||
|
# TLS-Material ablegen
|
||||||
|
install -m 0644 portal.crt /etc/vpnportal/portal.crt
|
||||||
|
install -m 0600 portal.key /etc/vpnportal/portal.key
|
||||||
|
```
|
||||||
|
|
||||||
|
Dateirechte werden **erzwungen** — das Portal startet nicht, wenn sie zu weit
|
||||||
|
gefasst sind:
|
||||||
|
|
||||||
|
| Datei | Höchstens |
|
||||||
|
|---|---|
|
||||||
|
| `config.yaml` | `0640` |
|
||||||
|
| Secret-Dateien | `0640` |
|
||||||
|
| `portal.key` | `0600` |
|
||||||
|
| Log-Verzeichnis | `0750` |
|
||||||
|
|
||||||
|
### 4. Prüfen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vpnportal check --config /etc/vpnportal/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Gibt eine Checkliste aus und benennt jeden Fehler konkret:
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ Konfiguration
|
||||||
|
gelesen und plausibel
|
||||||
|
✓ Dateirechte
|
||||||
|
Konfiguration, Secrets und Log-Verzeichnis sind ausreichend geschützt
|
||||||
|
✗ OPNsense-Verbindung
|
||||||
|
der API-Benutzer hat keine Berechtigung — auf der OPNsense das Privileg
|
||||||
|
"VPN: OpenVPN Client Export" zuweisen
|
||||||
|
```
|
||||||
|
|
||||||
|
Einen konkreten Benutzer durchspielen (ohne dessen Passwort):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vpnportal check --test-auth mmueller
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Als Dienst einrichten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install -m 0644 /tmp/vpnportal/vpnportal.service /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now vpnportal
|
||||||
|
systemctl status vpnportal
|
||||||
|
```
|
||||||
|
|
||||||
|
Die mitgelieferte Unit ist gehärtet (`DynamicUser`, `ProtectSystem=strict`,
|
||||||
|
leeres `CapabilityBoundingSet`, `SystemCallFilter`). Wer einen festen
|
||||||
|
Systembenutzer bevorzugt, ersetzt `DynamicUser=yes` durch:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
User=vpnportal
|
||||||
|
Group=vpnportal
|
||||||
|
```
|
||||||
|
|
||||||
|
und legt den Benutzer mit
|
||||||
|
`useradd --system --no-create-home --shell /usr/sbin/nologin vpnportal` an.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
Jedes Secret lässt sich auf drei Wegen setzen. **Rangfolge: Umgebungsvariable
|
||||||
|
> `*_file` > Wert in der Konfigurationsdatei.**
|
||||||
|
|
||||||
|
| Secret | Feld | Datei-Variante | Umgebungsvariable |
|
||||||
|
|---|---|---|---|
|
||||||
|
| OPNsense-Key | `opnsense.api_key` | `api_key_file` | `VPNPORTAL_OPNSENSE_API_KEY` |
|
||||||
|
| OPNsense-Secret | `opnsense.api_secret` | `api_secret_file` | `VPNPORTAL_OPNSENSE_API_SECRET` |
|
||||||
|
| AD-Passwort | `ad.bind_password` | `bind_password_file` | `VPNPORTAL_AD_BIND_PASSWORD` |
|
||||||
|
|
||||||
|
Werden Feld und `*_file` gleichzeitig gesetzt, bricht der Start mit einer
|
||||||
|
Fehlermeldung ab — stillschweigendes Übergehen wäre gefährlicher.
|
||||||
|
|
||||||
|
Mit systemd empfiehlt sich `LoadCredential` (schon in der Unit vorbereitet):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install -d -m 0700 /etc/vpnportal/secrets
|
||||||
|
printf '%s' 'DAS-AD-PASSWORT' > /etc/vpnportal/secrets/ad-bind-password
|
||||||
|
chmod 600 /etc/vpnportal/secrets/ad-bind-password
|
||||||
|
```
|
||||||
|
|
||||||
|
In der `config.yaml` dann:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ad:
|
||||||
|
bind_password_file: "/run/credentials/vpnportal.service/ad-bind-password"
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Konfiguration wird bewusst **nicht** verschlüsselt: der Schlüssel läge
|
||||||
|
daneben, das wäre Sicherheitstheater. Schutz sind Dateirechte und die
|
||||||
|
Härtung der Unit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zertifikatszuordnung
|
||||||
|
|
||||||
|
Welches Firewall-Zertifikat zu welchem Benutzer gehört, entscheidet
|
||||||
|
`matching`. `{username}` ist der **aus dem AD gelesene** `sAMAccountName` in
|
||||||
|
Kleinschreibung — nie die Eingabe des Benutzers. Der Vergleich ignoriert
|
||||||
|
Groß- und Kleinschreibung.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
matching:
|
||||||
|
cn_pattern: "{username}" # CN == sAMAccountName (Standard)
|
||||||
|
# cn_pattern: "{username}@firma.de" # CN ist eine E-Mail-Adresse
|
||||||
|
```
|
||||||
|
|
||||||
|
Für Bestandsumgebungen, die sich nicht in ein Template fassen lassen, gibt es
|
||||||
|
`cn_regex` als Notnagel. Er schließt `cn_pattern` aus:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
matching:
|
||||||
|
cn_regex: "^(vpn-)?{username}(-[0-9]+)?$"
|
||||||
|
```
|
||||||
|
|
||||||
|
`{username}` wird vor dem Kompilieren quotiert eingesetzt, ein Benutzername
|
||||||
|
mit Sonderzeichen kann das Muster also nicht verändern.
|
||||||
|
|
||||||
|
> **Vorsicht:** Eine zu weit gefasste Regex kann fremde Zertifikate zuordnen.
|
||||||
|
> Nach jeder Änderung mit `check --test-auth` und einem Testbenutzer prüfen.
|
||||||
|
|
||||||
|
Zusätzlich zur Zuordnung gilt immer: **revozierte und abgelaufene Zertifikate
|
||||||
|
werden nie ausgeliefert.** Die Zertifikatsliste wird bei jeder Anfrage frisch
|
||||||
|
von der Firewall geholt, eine Revozierung greift also sofort.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
`GET /healthz` antwortet ohne Anmeldung und ohne sensible Details:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ok":true,"opnsense":true,"directory":true,"sessions":3}
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 200 wenn `ok`, sonst 503. Ergebnisse werden 30 Sekunden gecacht, häufiges
|
||||||
|
Abfragen belastet die Backends also nicht.
|
||||||
|
|
||||||
|
Für Zabbix/CheckMK genügt der HTTP-Status; wer feiner prüfen will, wertet
|
||||||
|
`opnsense` und `directory` einzeln aus.
|
||||||
|
|
||||||
|
### Logs lesen
|
||||||
|
|
||||||
|
Alles geht parallel nach stdout (systemd-Journal) und in die Audit-Datei:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u vpnportal -f
|
||||||
|
|
||||||
|
# Wer hat wann welche Konfiguration bezogen?
|
||||||
|
jq -r 'select(.event=="config_download") | "\(.ts) \(.user) \(.vpn_instance)"' \
|
||||||
|
/var/log/vpnportal/audit.log
|
||||||
|
|
||||||
|
# Fehlanmeldungen einer IP zählen
|
||||||
|
jq -r 'select(.event=="login_failed") | .src_ip' /var/log/vpnportal/audit.log \
|
||||||
|
| sort | uniq -c | sort -rn
|
||||||
|
```
|
||||||
|
|
||||||
|
### Neustart
|
||||||
|
|
||||||
|
Ein Neustart beendet **alle** Sitzungen — Sessions liegen ausschließlich im
|
||||||
|
RAM. Das ist beabsichtigt; weitere Daten gehen dabei nicht verloren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit-Log
|
||||||
|
|
||||||
|
JSON Lines, eine Zeile pro Ereignis, Zeitstempel RFC 3339 mit Zeitzone:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ts":"2026-08-14T09:32:11+02:00","event":"login_success","user":"mmueller","src_ip":"10.1.20.34","session":"a3f9"}
|
||||||
|
{"ts":"2026-08-14T09:32:40+02:00","event":"config_download","user":"mmueller","src_ip":"10.1.20.34","session":"a3f9","vpn_instance":"VPN Homeoffice","cert_cn":"mmueller","cert_expiry":"2027-03-01","format":"ovpn"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Audit-Ereignisse:** `login_success`, `login_failed` (mit `reason`), `logout`,
|
||||||
|
`session_expired`, `config_download`, `download_denied`
|
||||||
|
|
||||||
|
**Betriebsereignisse:** `no_cert_found`, `opnsense_unreachable`,
|
||||||
|
`ldap_failover`, `rate_limited`, `startup`
|
||||||
|
|
||||||
|
**`reason` bei `login_failed`:** `invalid_credentials`, `account_disabled`,
|
||||||
|
`account_locked`, `password_expired`, `password_change_required`,
|
||||||
|
`not_in_vpn_group`, `user_not_found`, `backend_unavailable`
|
||||||
|
|
||||||
|
### Was niemals im Log steht
|
||||||
|
|
||||||
|
- **Passwörter.** Wird versehentlich ein Passwort ins Benutzernamenfeld
|
||||||
|
getippt, erscheint statt des Namens `"<unknown>"` — der Klartextname wird
|
||||||
|
nur protokolliert, wenn der Benutzer im AD tatsächlich gefunden wurde.
|
||||||
|
- Konfigurationsinhalte, Zertifikate, private Schlüssel.
|
||||||
|
- Vollständige Session-Tokens (nur eine 4-stellige Hash-Korrelations-ID).
|
||||||
|
- Vollständige CN-Listen der Firewall (nur auf `debug`-Level, das im
|
||||||
|
Normalbetrieb aus bleibt).
|
||||||
|
|
||||||
|
### Rotation und Weiterleitung
|
||||||
|
|
||||||
|
Die Rotation ist eingebaut und größenbasiert (`max_size_mb`, `max_backups`,
|
||||||
|
`compress`) — das schont SD-Karten. Wer stattdessen externes `logrotate`
|
||||||
|
nutzt, schickt danach ein `SIGHUP`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/var/log/vpnportal/audit.log {
|
||||||
|
daily
|
||||||
|
rotate 90
|
||||||
|
compress
|
||||||
|
postrotate
|
||||||
|
systemctl kill -s HUP vpnportal
|
||||||
|
endscript
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Für revisionssichere Aufbewahrung das Log an ein zentrales Logsystem
|
||||||
|
> weiterleiten** (über das Journal oder Syslog). Empfohlene Aufbewahrung:
|
||||||
|
> 90 Tage, kundenseitig anpassbar.
|
||||||
|
|
||||||
|
Das Log enthält personenbezogene Daten und ist entsprechend zu schützen. Die
|
||||||
|
Datei wird mit `0600` angelegt, das Verzeichnis darf höchstens `0750` haben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Raspberry Pi
|
||||||
|
|
||||||
|
- **SSD statt SD-Karte.** Das Portal schreibt zwar nur das Audit-Log, aber
|
||||||
|
SD-Karten sterben trotzdem.
|
||||||
|
- **Gerät in einen abschließbaren Netzwerkschrank.** Das Speichermedium trägt
|
||||||
|
die Zugangsdaten zu Firewall und Verzeichnis.
|
||||||
|
- **NTP prüfen** (`timedatectl status`). `vpnportal check` vergleicht die
|
||||||
|
Systemzeit mit der Firewall und warnt ab zwei Minuten Abweichung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fehlersuche
|
||||||
|
|
||||||
|
| Symptom | Wahrscheinliche Ursache | Kommando |
|
||||||
|
|---|---|---|
|
||||||
|
| Start bricht mit `chmod`-Hinweis ab | Datei- oder Verzeichnisrechte zu weit | Der Hinweis nennt den nötigen Befehl |
|
||||||
|
| Start bricht mit unbekanntem Feld ab | Tippfehler in der `config.yaml` | Die Meldung nennt das Feld |
|
||||||
|
| Niemand kann sich anmelden | Dienstkonto, Gruppe oder DC-Erreichbarkeit | `vpnportal check` |
|
||||||
|
| Ein einzelner Benutzer nicht | Gruppenmitgliedschaft oder Schreibweise | `vpnportal check --test-auth <benutzer>` |
|
||||||
|
| „Keine VPN-Konfiguration hinterlegt" | `cn_pattern` passt nicht zum CN | Audit-Feld `pattern` im Ereignis `no_cert_found` lesen |
|
||||||
|
| „Dienst vorübergehend nicht verfügbar" | Firewall nicht erreichbar oder CA falsch | `vpnportal check` |
|
||||||
|
| Anmeldung dauert lang und schlägt fehl | Erster DC tot, Failover greift | Ereignis `ldap_failover` im Log |
|
||||||
|
| „Zu viele Anmeldeversuche" | Rate-Limit greift | Ereignis `rate_limited`; die Wartezeit läuft von allein ab |
|
||||||
|
|
||||||
|
Für die Fehlersuche lässt sich `logging.level` vorübergehend auf `debug`
|
||||||
|
setzen — dann werden auch die verglichenen CNs protokolliert. **Im
|
||||||
|
Normalbetrieb wieder auf `info` zurückstellen.**
|
||||||
|
|
||||||
|
### Passwort abgelaufen
|
||||||
|
|
||||||
|
Das Portal kann keine Passwörter ändern. Ist ein AD-Passwort abgelaufen
|
||||||
|
(AD-Code 532), sieht der Benutzer als einzige Ausnahme eine konkrete Meldung
|
||||||
|
und muss das Passwort an seinem Arbeitsplatz ändern. Verlangt das AD eine
|
||||||
|
Passwortänderung bei der nächsten Anmeldung (Code 773), erscheint die
|
||||||
|
generische Meldung — dieser Fall lässt sich am Portal nicht unterscheiden
|
||||||
|
kommunizieren, ohne Kontoinformationen preiszugeben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bekannte Grenzen
|
||||||
|
|
||||||
|
- **Kein WireGuard** (für v2 geplant, braucht ein eigenes Schlüsselkonzept).
|
||||||
|
- **Kein Self-Service für Zertifikate** — Erstellen, Erneuern und Widerrufen
|
||||||
|
bleiben auf der Firewall.
|
||||||
|
- **Keine Admin-Oberfläche.** Logs per SSH/Monitoring, `/healthz` für
|
||||||
|
externe Überwachung.
|
||||||
|
- **Keine Mandantenfähigkeit**, eine OPNsense-Instanz pro Portal.
|
||||||
|
- **Single-Domain.** Referrals werden ignoriert, Multi-Domain-Forests sind
|
||||||
|
nicht unterstützt.
|
||||||
|
- **Kein RADIUS und keine lokalen Portalbenutzer.**
|
||||||
|
- **Nur Deutsch.** Die Textstruktur ist für i18n vorbereitet, aber nur
|
||||||
|
Deutsch ist befüllt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl stop vpnportal
|
||||||
|
install -m 0755 vpnportal /usr/local/bin/vpnportal
|
||||||
|
vpnportal check --config /etc/vpnportal/config.yaml
|
||||||
|
systemctl start vpnportal
|
||||||
|
```
|
||||||
|
|
||||||
|
Sitzungen gehen dabei verloren, das ist beabsichtigt.
|
||||||
|
|
||||||
|
Es gibt **kein Auto-Update**. `portal.update_check` ist standardmäßig
|
||||||
|
**aus**; eingeschaltet baut das Portal beim Start eine ausgehende
|
||||||
|
HTTPS-Verbindung zur Release-URL auf und meldet abweichende Versionen im
|
||||||
|
Log. Es lädt nichts herunter und installiert nichts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bauen aus dem Quelltext
|
||||||
|
|
||||||
|
Voraussetzung: Go 1.26 oder neuer. Keine CGO-Abhängigkeiten, kein npm,
|
||||||
|
kein Build-Step für das Frontend — Templates und Assets liegen per `go:embed`
|
||||||
|
im Binary.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # alle Tests
|
||||||
|
make race # Tests mit Race-Detector
|
||||||
|
make lint # go vet + gofmt
|
||||||
|
make build # Binary nach dist/
|
||||||
|
make release # Archive für linux-amd64 und linux-arm64 inkl. SHA256SUMS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integrationstests gegen eine echte OPNsense
|
||||||
|
|
||||||
|
Die Feldnamen der Export-API sind versionsabhängig. Vor einer Auslieferung
|
||||||
|
sind die in `docs/opnsense-api.md` dokumentierten Annahmen einmal gegen eine
|
||||||
|
echte Instanz zu bestätigen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export OPNSENSE_URL="https://fw01.firma.local"
|
||||||
|
export OPNSENSE_KEY="..." OPNSENSE_SECRET="..."
|
||||||
|
export OPNSENSE_REVOKED_CN="testuser-revoked" # optional
|
||||||
|
go test -tags integration ./internal/opnsense/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne gesetzte Variablen überspringen sich diese Tests selbst.
|
||||||
|
|
||||||
|
### Oberfläche ohne Backends ansehen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -tags uidemo ./cmd/vpnportal/ -run TestUIDemo -v -timeout 0
|
||||||
|
# http://127.0.0.1:8080 — Anmeldung mit beliebigen Daten
|
||||||
|
# VPNPORTAL_UIDEMO_EMPTY=1 zeigt den Zustand "kein Zertifikat"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sicherheitsdesign in Kürze
|
||||||
|
|
||||||
|
- HTTPS-Pflicht am Portal; TLS-Verifikation gegen Firewall und Verzeichnis
|
||||||
|
immer aktiv. Die Insecure-Option existiert nur für die Firewall und warnt
|
||||||
|
bei jedem Start; für LDAP gibt es sie gar nicht.
|
||||||
|
- LDAP-Injection-Schutz durch konsequentes `ldap.EscapeFilter`.
|
||||||
|
- CSRF-Token auf allen zustandsändernden Anfragen **einschließlich Downloads**.
|
||||||
|
Downloads sind ausschließlich `POST`, es gibt keine GET-Download-Links.
|
||||||
|
- **Jeder Download wird serverseitig neu autorisiert.** Die Auswahl in der
|
||||||
|
Oberfläche ist keine Berechtigung: zwischen Anzeige und Klick kann ein
|
||||||
|
Zertifikat revoziert worden sein.
|
||||||
|
- Konfigurationen werden gestreamt, nie zwischengespeichert; alle Seiten nach
|
||||||
|
der Anmeldung tragen `Cache-Control: no-store`.
|
||||||
|
- Rate-Limiting pro Benutzername **und** pro Quell-IP mit exponentiellem
|
||||||
|
Backoff, gedeckelt bei fünf Minuten. Bewusst **kein harter Lockout** —
|
||||||
|
sonst könnte ein Dritter Kollegen dauerhaft aussperren.
|
||||||
|
- Keine Benutzer-Enumeration: gleiche Meldung und angeglichene Antwortzeit für
|
||||||
|
„Benutzer existiert nicht" und „Passwort falsch".
|
||||||
|
- Security-Header auf jeder Antwort: HSTS, `nosniff`, `no-referrer`,
|
||||||
|
`X-Frame-Options: DENY` und eine CSP ohne jedes `unsafe-*`.
|
||||||
|
- Die Oberfläche ist ohne JavaScript vollständig bedienbar.
|
||||||
14
cmd/vpnportal/buildinfo.go
Normal file
14
cmd/vpnportal/buildinfo.go
Normal 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
80
cmd/vpnportal/check.go
Normal 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
62
cmd/vpnportal/health.go
Normal 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
46
cmd/vpnportal/main.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
43
cmd/vpnportal/main_test.go
Normal file
43
cmd/vpnportal/main_test.go
Normal 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
238
cmd/vpnportal/serve.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
82
cmd/vpnportal/serve_test.go
Normal file
82
cmd/vpnportal/serve_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
94
cmd/vpnportal/uidemo_test.go
Normal file
94
cmd/vpnportal/uidemo_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
61
cmd/vpnportal/updatecheck.go
Normal file
61
cmd/vpnportal/updatecheck.go
Normal 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)
|
||||||
|
}
|
||||||
78
cmd/vpnportal/updatecheck_test.go
Normal file
78
cmd/vpnportal/updatecheck_test.go
Normal 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
121
deploy/config.example.yaml
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# Beispielkonfiguration für das VPN-Konfig-Portal.
|
||||||
|
#
|
||||||
|
# Diese Datei enthält Zugangsdaten. Rechte setzen:
|
||||||
|
# chown root:vpnportal /etc/vpnportal/config.yaml
|
||||||
|
# chmod 640 /etc/vpnportal/config.yaml
|
||||||
|
# Das Portal startet nicht, wenn die Rechte weiter gefasst sind.
|
||||||
|
#
|
||||||
|
# Die Datei wird strikt gelesen: ein Tippfehler in einem Feldnamen führt zum
|
||||||
|
# Startabbruch mit klarer Meldung. Prüfen mit:
|
||||||
|
# vpnportal check --config /etc/vpnportal/config.yaml
|
||||||
|
|
||||||
|
portal:
|
||||||
|
# Adresse, auf der das Portal lauscht. HTTPS ist Pflicht.
|
||||||
|
listen: "0.0.0.0:8443"
|
||||||
|
|
||||||
|
# Serverzertifikat und privater Schlüssel des Portals.
|
||||||
|
# Der Schlüssel muss 0600 sein, sonst startet das Portal nicht.
|
||||||
|
tls_cert: "/etc/vpnportal/portal.crt"
|
||||||
|
tls_key: "/etc/vpnportal/portal.key"
|
||||||
|
|
||||||
|
# Lebensdauer einer Anmeldung. Absolut, nicht verlängerbar. Default: 10m
|
||||||
|
session_ttl: "10m"
|
||||||
|
|
||||||
|
# White-Label: Titel im Browser-Tab und in der Kopfzeile.
|
||||||
|
title: "VPN-Portal Firma GmbH"
|
||||||
|
|
||||||
|
# Optionales Logo in der Kopfzeile (PNG oder SVG).
|
||||||
|
# logo_file: "/etc/vpnportal/logo.png"
|
||||||
|
|
||||||
|
# Wird angezeigt, wenn für einen Benutzer kein Zertifikat hinterlegt ist.
|
||||||
|
support_contact: "it-helpdesk@firma.de"
|
||||||
|
|
||||||
|
# Optionaler Versionscheck. Default: false.
|
||||||
|
# Eingeschaltet baut das Portal beim Start eine ausgehende HTTPS-Verbindung
|
||||||
|
# zur Release-URL auf ("telefoniert nach Hause"). Read-only, kein Auto-Update.
|
||||||
|
# update_check: false
|
||||||
|
|
||||||
|
opnsense:
|
||||||
|
url: "https://fw01.firma.local"
|
||||||
|
|
||||||
|
# API-Zugangsdaten eines dedizierten Benutzers, der AUSSCHLIESSLICH das
|
||||||
|
# Privileg "VPN: OpenVPN Client Export" hat. Niemals einen Admin-Key.
|
||||||
|
api_key: "BITTE-ERSETZEN"
|
||||||
|
api_secret: "BITTE-ERSETZEN"
|
||||||
|
# alternativ als Datei (kompatibel mit systemd LoadCredential, Ansible Vault):
|
||||||
|
# api_key_file: "/run/credentials/vpnportal.service/opnsense-api-key"
|
||||||
|
# api_secret_file: "/run/credentials/vpnportal.service/opnsense-api-secret"
|
||||||
|
# oder als Umgebungsvariable:
|
||||||
|
# VPNPORTAL_OPNSENSE_API_KEY, VPNPORTAL_OPNSENSE_API_SECRET
|
||||||
|
# Rangfolge: Umgebungsvariable > *_file > Wert in dieser Datei.
|
||||||
|
|
||||||
|
# Interne CA, falls die Firewall ein eigenes Zertifikat verwendet.
|
||||||
|
# ca_file: "/etc/vpnportal/firma-ca.pem"
|
||||||
|
|
||||||
|
# NUR für Tests: deaktiviert die TLS-Prüfung zur Firewall.
|
||||||
|
# Erzeugt bei jedem Start eine Warnung. Für LDAP gibt es das bewusst nicht.
|
||||||
|
# insecure_skip_verify: false
|
||||||
|
|
||||||
|
ad:
|
||||||
|
# AD-Domäne. Der Base-DN wird daraus abgeleitet (DC=firma,DC=local).
|
||||||
|
domain: "firma.local"
|
||||||
|
|
||||||
|
# Optionaler Override, z. B. um auf eine OU einzuschränken.
|
||||||
|
# base_dn: "OU=Mitarbeiter,DC=firma,DC=local"
|
||||||
|
|
||||||
|
# Domain Controller mit Failover in dieser Reihenfolge.
|
||||||
|
# HOSTNAMEN, keine IP-Adressen: das LDAPS-Zertifikat wird gegen den
|
||||||
|
# Hostnamen geprüft. IP-Adressen werden abgelehnt.
|
||||||
|
servers:
|
||||||
|
- "dc01.firma.local"
|
||||||
|
- "dc02.firma.local"
|
||||||
|
|
||||||
|
# LDAPS ist Default. Für StartTLS auf 389:
|
||||||
|
# port: 389
|
||||||
|
# tls_mode: "starttls"
|
||||||
|
# port: 636
|
||||||
|
# tls_mode: "ldaps"
|
||||||
|
|
||||||
|
# Unprivilegierter Domain-Benutzer genügt — er muss nur lesen dürfen.
|
||||||
|
bind_user: "svc-vpnportal@firma.local"
|
||||||
|
bind_password: "BITTE-ERSETZEN"
|
||||||
|
# alternativ: bind_password_file oder VPNPORTAL_AD_BIND_PASSWORD
|
||||||
|
# bind_password_file: "/run/credentials/vpnportal.service/ad-bind-password"
|
||||||
|
|
||||||
|
# Nur Mitglieder dieser Gruppe erhalten Zugang. Verschachtelte Gruppen
|
||||||
|
# werden aufgelöst. Der Gruppen-DN wird beim Start ermittelt und gecacht.
|
||||||
|
vpn_group: "VPN-Users"
|
||||||
|
|
||||||
|
# Interne CA für LDAPS. Für LDAP gibt es keine Insecure-Option.
|
||||||
|
# ca_file: "/etc/vpnportal/firma-ca.pem"
|
||||||
|
|
||||||
|
# Zeitlimit pro Domain Controller, danach wird der nächste versucht.
|
||||||
|
# timeout: "8s"
|
||||||
|
|
||||||
|
matching:
|
||||||
|
# Wie der Zertifikats-CN dem Benutzer zugeordnet wird.
|
||||||
|
# {username} ist der aus dem AD gelesene sAMAccountName in Kleinschreibung.
|
||||||
|
# Der Vergleich ignoriert Groß-/Kleinschreibung.
|
||||||
|
cn_pattern: "{username}"
|
||||||
|
# Beispiel für Bestandsumgebungen mit E-Mail-artigen CNs:
|
||||||
|
# cn_pattern: "{username}@firma.de"
|
||||||
|
|
||||||
|
# Notnagel für Bestandsumgebungen, die sich nicht in ein Template fassen
|
||||||
|
# lassen. Schließt cn_pattern aus. {username} wird vor dem Kompilieren
|
||||||
|
# quotiert eingesetzt.
|
||||||
|
# ACHTUNG: eine zu weit gefasste Regex kann fremde Zertifikate zuordnen.
|
||||||
|
# cn_regex: "^(vpn-)?{username}(-[0-9]+)?$"
|
||||||
|
|
||||||
|
logging:
|
||||||
|
# debug, info, warn, error. Im Normalbetrieb NICHT debug: auf debug werden
|
||||||
|
# die verglichenen CNs protokolliert.
|
||||||
|
level: "info"
|
||||||
|
|
||||||
|
# Das Audit-Log enthält personenbezogene Daten. Verzeichnis höchstens 0750,
|
||||||
|
# Datei wird mit 0600 angelegt.
|
||||||
|
audit_log: "/var/log/vpnportal/audit.log"
|
||||||
|
|
||||||
|
# Eingebaute größenbasierte Rotation (schont SD-Karten).
|
||||||
|
max_size_mb: 50
|
||||||
|
max_backups: 5
|
||||||
|
compress: true
|
||||||
65
deploy/vpnportal.service
Normal file
65
deploy/vpnportal.service
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
[Unit]
|
||||||
|
Description=VPN-Konfig-Portal für OPNsense
|
||||||
|
Documentation=file:/usr/share/doc/vpnportal/README.md
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/bin/vpnportal serve --config /etc/vpnportal/config.yaml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
# Dedizierter, zustandsloser Dienstbenutzer. Das Portal hält keinen Zustand
|
||||||
|
# auf der Platte außer dem Audit-Log, deshalb genügt DynamicUser.
|
||||||
|
# Alternativ einen festen Systembenutzer anlegen und User=/Group= setzen —
|
||||||
|
# siehe README, Abschnitt "Installation".
|
||||||
|
DynamicUser=yes
|
||||||
|
|
||||||
|
# Audit-Log: systemd legt /var/log/vpnportal an und übergibt es dem Dienst.
|
||||||
|
# In der config.yaml dann: audit_log: "/var/log/vpnportal/audit.log"
|
||||||
|
LogsDirectory=vpnportal
|
||||||
|
LogsDirectoryMode=0750
|
||||||
|
|
||||||
|
# Secrets werden nicht als Umgebungsvariablen übergeben, sondern als Dateien
|
||||||
|
# unter $CREDENTIALS_DIRECTORY. In der config.yaml dann z. B.:
|
||||||
|
# ad:
|
||||||
|
# bind_password_file: "/run/credentials/vpnportal.service/ad-bind-password"
|
||||||
|
# opnsense:
|
||||||
|
# api_secret_file: "/run/credentials/vpnportal.service/opnsense-api-secret"
|
||||||
|
LoadCredential=ad-bind-password:/etc/vpnportal/secrets/ad-bind-password
|
||||||
|
LoadCredential=opnsense-api-secret:/etc/vpnportal/secrets/opnsense-api-secret
|
||||||
|
|
||||||
|
# ---------- Härtung ----------
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
PrivateDevices=yes
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectKernelLogs=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
ProtectClock=yes
|
||||||
|
ProtectProc=invisible
|
||||||
|
ProcSubset=pid
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
RestrictRealtime=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6
|
||||||
|
LockPersonality=yes
|
||||||
|
MemoryDenyWriteExecute=yes
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
SystemCallFilter=@system-service
|
||||||
|
SystemCallFilter=~@privileged @resources
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
ReadOnlyPaths=/etc/vpnportal
|
||||||
|
UMask=0077
|
||||||
|
|
||||||
|
# Soll das Portal direkt auf 443 lauschen, stattdessen:
|
||||||
|
# AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||||
|
# CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||||
|
# Besser: auf 8443 lauschen lassen und davor per nftables umleiten.
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
102
docs/opnsense-api.md
Normal file
102
docs/opnsense-api.md
Normal 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
12
go.mod
Normal 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
13
go.sum
Normal 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
60
internal/audit/event.go
Normal 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
77
internal/audit/logger.go
Normal 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()
|
||||||
|
}
|
||||||
124
internal/audit/logger_test.go
Normal file
124
internal/audit/logger_test.go
Normal 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
159
internal/audit/rotate.go
Normal 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:]
|
||||||
|
}
|
||||||
|
}
|
||||||
106
internal/audit/rotate_test.go
Normal file
106
internal/audit/rotate_test.go
Normal 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
330
internal/auth/ad.go
Normal 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
296
internal/auth/ad_test.go
Normal 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
47
internal/auth/addata.go
Normal 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
|
||||||
|
}
|
||||||
72
internal/auth/addata_test.go
Normal file
72
internal/auth/addata_test.go
Normal 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
67
internal/auth/auth.go
Normal 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
78
internal/auth/dial.go
Normal 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
122
internal/certmatch/match.go
Normal 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
|
||||||
|
}
|
||||||
141
internal/certmatch/match_test.go
Normal file
141
internal/certmatch/match_test.go
Normal 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
239
internal/check/check.go
Normal 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
|
||||||
|
}
|
||||||
260
internal/check/check_test.go
Normal file
260
internal/check/check_test.go
Normal 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
109
internal/config/config.go
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
25
internal/config/example_test.go
Normal file
25
internal/config/example_test.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Die ausgelieferte Beispielkonfiguration muss immer parsen und validieren.
|
||||||
|
// Sonst laufen Kunden beim ersten Start in einen Fehler — und der Strict-Parser
|
||||||
|
// würde jeden Tippfehler in der Vorlage gnadenlos melden.
|
||||||
|
func TestExampleConfigParsesAndValidates(t *testing.T) {
|
||||||
|
f, err := os.Open("../../deploy/config.example.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Beispielkonfiguration fehlt: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
cfg, err := Parse(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Beispielkonfiguration parst nicht: %v", err)
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Fatalf("Beispielkonfiguration ist nicht gültig: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
116
internal/config/load.go
Normal file
116
internal/config/load.go
Normal 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
|
||||||
|
}
|
||||||
87
internal/config/load_test.go
Normal file
87
internal/config/load_test.go
Normal 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
40
internal/config/perms.go
Normal 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
|
||||||
|
}
|
||||||
65
internal/config/perms_test.go
Normal file
65
internal/config/perms_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
96
internal/config/secrets_test.go
Normal file
96
internal/config/secrets_test.go
Normal 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
144
internal/config/validate.go
Normal 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 1–65535", 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...)
|
||||||
|
}
|
||||||
115
internal/config/validate_test.go
Normal file
115
internal/config/validate_test.go
Normal 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
170
internal/opnsense/client.go
Normal 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
|
||||||
|
}
|
||||||
234
internal/opnsense/client_test.go
Normal file
234
internal/opnsense/client_test.go
Normal 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
144
internal/opnsense/export.go
Normal 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"]
|
||||||
|
}
|
||||||
133
internal/opnsense/integration_test.go
Normal file
133
internal/opnsense/integration_test.go
Normal 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
161
internal/opnsense/types.go
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
73
internal/opnsense/types_test.go
Normal file
73
internal/opnsense/types_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
190
internal/portal/certsource.go
Normal file
190
internal/portal/certsource.go
Normal 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)
|
||||||
296
internal/portal/certsource_test.go
Normal file
296
internal/portal/certsource_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
internal/web/assets/app.js
Normal file
14
internal/web/assets/app.js
Normal 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";
|
||||||
|
});
|
||||||
|
})();
|
||||||
242
internal/web/assets/style.css
Normal file
242
internal/web/assets/style.css
Normal 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
97
internal/web/guides.go
Normal 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
|
||||||
|
}
|
||||||
208
internal/web/handlers_certs.go
Normal file
208
internal/web/handlers_certs.go
Normal 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
|
||||||
|
}
|
||||||
305
internal/web/handlers_certs_test.go
Normal file
305
internal/web/handlers_certs_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
183
internal/web/handlers_login.go
Normal file
183
internal/web/handlers_login.go
Normal 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)
|
||||||
|
}
|
||||||
363
internal/web/handlers_login_test.go
Normal file
363
internal/web/handlers_login_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
internal/web/handlers_static.go
Normal file
35
internal/web/handlers_static.go
Normal 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)
|
||||||
|
}
|
||||||
193
internal/web/handlers_static_test.go
Normal file
193
internal/web/handlers_static_test.go
Normal 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
139
internal/web/ratelimit.go
Normal 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)
|
||||||
|
}
|
||||||
139
internal/web/ratelimit_test.go
Normal file
139
internal/web/ratelimit_test.go
Normal 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
92
internal/web/render.go
Normal 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())
|
||||||
|
}
|
||||||
89
internal/web/render_test.go
Normal file
89
internal/web/render_test.go
Normal 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
46
internal/web/security.go
Normal 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
|
||||||
|
}
|
||||||
90
internal/web/security_test.go
Normal file
90
internal/web/security_test.go
Normal 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
234
internal/web/server.go
Normal 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
135
internal/web/session.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
130
internal/web/session_test.go
Normal file
130
internal/web/session_test.go
Normal 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
65
internal/web/strings.go
Normal 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
|
||||||
|
}
|
||||||
8
internal/web/templates/error.html
Normal file
8
internal/web/templates/error.html
Normal 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}}
|
||||||
12
internal/web/templates/guides.html
Normal file
12
internal/web/templates/guides.html
Normal 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}}
|
||||||
37
internal/web/templates/layout.html
Normal file
37
internal/web/templates/layout.html
Normal 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}}
|
||||||
20
internal/web/templates/login.html
Normal file
20
internal/web/templates/login.html
Normal 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}}
|
||||||
34
internal/web/templates/overview.html
Normal file
34
internal/web/templates/overview.html
Normal 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}}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue