From 2d223c58232e3ffdc122860be7162a3889ab54fc Mon Sep 17 00:00:00 2001 From: Carsten Abele Date: Fri, 14 Aug 2026 09:34:22 +0200 Subject: [PATCH] feat(check,serve): Startvalidierung, HTTPS-Server, Signal-Handling und Versionscheck Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68 --- cmd/vpnportal/check.go | 71 +++++++- cmd/vpnportal/health.go | 62 +++++++ cmd/vpnportal/serve.go | 217 ++++++++++++++++++++++++- cmd/vpnportal/serve_test.go | 82 ++++++++++ cmd/vpnportal/uidemo_test.go | 94 +++++++++++ cmd/vpnportal/updatecheck.go | 61 +++++++ cmd/vpnportal/updatecheck_test.go | 78 +++++++++ internal/check/check.go | 239 +++++++++++++++++++++++++++ internal/check/check_test.go | 260 ++++++++++++++++++++++++++++++ 9 files changed, 1158 insertions(+), 6 deletions(-) create mode 100644 cmd/vpnportal/health.go create mode 100644 cmd/vpnportal/serve_test.go create mode 100644 cmd/vpnportal/uidemo_test.go create mode 100644 cmd/vpnportal/updatecheck.go create mode 100644 cmd/vpnportal/updatecheck_test.go create mode 100644 internal/check/check.go create mode 100644 internal/check/check_test.go diff --git a/cmd/vpnportal/check.go b/cmd/vpnportal/check.go index ec6b1a1..a662224 100644 --- a/cmd/vpnportal/check.go +++ b/cmd/vpnportal/check.go @@ -1,17 +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 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 { return 2 } - _ = testAuth - fmt.Fprintf(stderr, "Fehler: config %q kann noch nicht geladen werden (nicht implementiert)\n", *path) - return 1 + + 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 } diff --git a/cmd/vpnportal/health.go b/cmd/vpnportal/health.go new file mode 100644 index 0000000..6ad4361 --- /dev/null +++ b/cmd/vpnportal/health.go @@ -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) diff --git a/cmd/vpnportal/serve.go b/cmd/vpnportal/serve.go index bcc9983..318c5ac 100644 --- a/cmd/vpnportal/serve.go +++ b/cmd/vpnportal/serve.go @@ -1,13 +1,34 @@ 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) @@ -15,11 +36,203 @@ func configFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) { 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 } - fmt.Fprintf(stderr, "Fehler: config %q kann noch nicht geladen werden (nicht implementiert)\n", *path) - return 1 + + 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 + } } diff --git a/cmd/vpnportal/serve_test.go b/cmd/vpnportal/serve_test.go new file mode 100644 index 0000000..692c3d5 --- /dev/null +++ b/cmd/vpnportal/serve_test.go @@ -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") + } +} diff --git a/cmd/vpnportal/uidemo_test.go b/cmd/vpnportal/uidemo_test.go new file mode 100644 index 0000000..56f3e72 --- /dev/null +++ b/cmd/vpnportal/uidemo_test.go @@ -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) + } +} diff --git a/cmd/vpnportal/updatecheck.go b/cmd/vpnportal/updatecheck.go new file mode 100644 index 0000000..17ea954 --- /dev/null +++ b/cmd/vpnportal/updatecheck.go @@ -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) +} diff --git a/cmd/vpnportal/updatecheck_test.go b/cmd/vpnportal/updatecheck_test.go new file mode 100644 index 0000000..bad5109 --- /dev/null +++ b/cmd/vpnportal/updatecheck_test.go @@ -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() + } +} diff --git a/internal/check/check.go b/internal/check/check.go new file mode 100644 index 0000000..ba39c42 --- /dev/null +++ b/internal/check/check.go @@ -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 +} diff --git a/internal/check/check_test.go b/internal/check/check_test.go new file mode 100644 index 0000000..039324d --- /dev/null +++ b/internal/check/check_test.go @@ -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) + } +}