Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
190 lines
5.6 KiB
Go
190 lines
5.6 KiB
Go
// 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)
|