// 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)