From 7e23df9fec06bd5d0dda935e71e24765f890696a Mon Sep 17 00:00:00 2001 From: Carsten Abele Date: Fri, 14 Aug 2026 09:27:31 +0200 Subject: [PATCH] feat(portal): Zertifikatssicht mit serverseitig autorisiertem Streaming-Export Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68 --- internal/portal/certsource.go | 190 ++++++++++++++++++ internal/portal/certsource_test.go | 296 +++++++++++++++++++++++++++++ 2 files changed, 486 insertions(+) create mode 100644 internal/portal/certsource.go create mode 100644 internal/portal/certsource_test.go diff --git a/internal/portal/certsource.go b/internal/portal/certsource.go new file mode 100644 index 0000000..6884e19 --- /dev/null +++ b/internal/portal/certsource.go @@ -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) diff --git a/internal/portal/certsource_test.go b/internal/portal/certsource_test.go new file mode 100644 index 0000000..bacabef --- /dev/null +++ b/internal/portal/certsource_test.go @@ -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) + } +}