feat(check,serve): Startvalidierung, HTTPS-Server, Signal-Handling und Versionscheck

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
Carsten Abele 2026-08-14 09:34:22 +02:00
parent 7e23df9fec
commit 2d223c5823
9 changed files with 1158 additions and 6 deletions

View file

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

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

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

View file

@ -1,13 +1,34 @@
package main package main
import ( import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "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" 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) { func configFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) {
fs := flag.NewFlagSet(name, flag.ContinueOnError) fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(stderr) fs.SetOutput(stderr)
@ -15,11 +36,203 @@ func configFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) {
return fs, path 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 { func runServe(args []string, stdout, stderr io.Writer) int {
fs, path := configFlags("serve", stderr) fs, path := configFlags("serve", stderr)
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return 2 return 2
} }
fmt.Fprintf(stderr, "Fehler: config %q kann noch nicht geladen werden (nicht implementiert)\n", *path)
cfg, err := config.Load(*path, nil)
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1 return 1
} }
if cfg.OPNsense.InsecureSkipVerify {
// Bewusst bei jedem Start, nicht nur einmalig.
fmt.Fprintln(stderr,
"WARNUNG: opnsense.insecure_skip_verify ist aktiv — "+
"die TLS-Verbindung zur Firewall wird NICHT geprüft. Nur für Tests verwenden.")
}
fw, ad, err := buildBackends(cfg)
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
// Startprüfung: das Portal startet nicht mit kaputter Umgebung.
startCtx, startCancel := context.WithTimeout(context.Background(), 60*time.Second)
report := (&check.Checker{Cfg: cfg, ConfigPath: *path, FW: fw, Dir: ad}).Run(startCtx, "")
startCancel()
report.WriteTo(stdout)
if !report.OK() {
fmt.Fprintln(stderr, "\nStart abgebrochen: die Startprüfung ist fehlgeschlagen.")
return 1
}
auditLog, err := audit.New(cfg.Logging.AuditLog, audit.Options{
MaxSizeMB: cfg.Logging.MaxSizeMB,
MaxBackups: cfg.Logging.MaxBackups,
Compress: cfg.Logging.Compress,
Stdout: stdout,
})
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
defer auditLog.Close()
// Der Failover-Hook kann erst jetzt gesetzt werden — vorher gab es noch
// kein Audit-Log, in das er hätte schreiben können.
ad.SetFailoverHook(func(server string, err error) {
auditLog.Log(audit.Event{Event: audit.EventLDAPFailover, Server: server})
})
matcher, err := certmatch.NewMatcher(cfg.Matching.CNPattern, cfg.Matching.CNRegex)
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
certs := portal.NewSource(fw, matcher, providerCacheTTL, time.Now)
renderer, err := web.NewRenderer(web.Brand{
Title: cfg.Portal.Title,
SupportContact: cfg.Portal.SupportContact,
Version: version,
})
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
sessions := web.NewSessionStore(time.Duration(cfg.Portal.SessionTTL), time.Now)
limiter := web.NewLimiter(time.Now)
srv, err := web.NewServer(web.Deps{
Auth: ad,
Certs: certs,
Audit: auditLog,
Sessions: sessions,
Limiter: limiter,
Renderer: renderer,
Health: newHealthChecker(fw, ad),
Clock: time.Now,
LogoPath: cfg.Portal.LogoFile,
SupportContact: cfg.Portal.SupportContact,
SessionTTL: time.Duration(cfg.Portal.SessionTTL),
})
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
tlsCfg, err := newTLSConfig(cfg.Portal.TLSCert, cfg.Portal.TLSKey)
if err != nil {
fmt.Fprintf(stderr, "Fehler: %v\n", err)
return 1
}
sum, _ := configChecksum(*path)
auditLog.Log(audit.Event{
Event: audit.EventStartup, Version: versionString(), ConfigSum: sum,
Detail: "listen=" + cfg.Portal.Listen,
})
if msg := checkForUpdate(context.Background(), cfg.Portal.UpdateCheck, releaseURL, version, nil); msg != "" {
fmt.Fprintln(stdout, msg)
}
httpSrv := &http.Server{
Addr: cfg.Portal.Listen,
Handler: srv.Handler(),
TLSConfig: tlsCfg,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
// WriteTimeout bleibt großzügig, weil Downloads gestreamt werden.
WriteTimeout: 5 * time.Minute,
IdleTimeout: 60 * time.Second,
}
// Hintergrundaufräumen für Sessions und Rate-Limiter.
stopGC := make(chan struct{})
go func() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
sessions.GC()
limiter.GC()
case <-stopGC:
return
}
}
}()
defer close(stopGC)
// SIGHUP öffnet das Audit-Log neu (logrotate-Umgebungen).
hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
defer signal.Stop(hup)
go func() {
for range hup {
if err := auditLog.Reopen(); err != nil {
fmt.Fprintf(stderr, "Audit-Log konnte nicht neu geöffnet werden: %v\n", err)
}
}
}()
// Geordnetes Herunterfahren.
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(shutdown)
errCh := make(chan error, 1)
go func() {
fmt.Fprintf(stdout, "%s lauscht auf https://%s\n", versionString(), cfg.Portal.Listen)
errCh <- httpSrv.ListenAndServeTLS("", "")
}()
select {
case err := <-errCh:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Fprintf(stderr, "Fehler: Server beendet: %v\n", err)
return 1
}
return 0
case sig := <-shutdown:
fmt.Fprintf(stdout, "Signal %s empfangen, fahre herunter…\n", sig)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpSrv.Shutdown(ctx); err != nil {
fmt.Fprintf(stderr, "Herunterfahren nicht sauber beendet: %v\n", err)
return 1
}
return 0
}
}

View file

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

View file

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

View file

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

View file

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

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

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

View file

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