feat(portal): Zertifikatssicht mit serverseitig autorisiertem Streaming-Export
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
parent
eb89d78ce2
commit
7e23df9fec
2 changed files with 486 additions and 0 deletions
190
internal/portal/certsource.go
Normal file
190
internal/portal/certsource.go
Normal file
|
|
@ -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)
|
||||
296
internal/portal/certsource_test.go
Normal file
296
internal/portal/certsource_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue