feat(opnsense,certmatch): read-only Export-Client mit Streaming und CN-Zuordnung

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
Carsten Abele 2026-08-14 09:15:53 +02:00
parent 99ee8758cc
commit e49882b8a8
9 changed files with 1280 additions and 0 deletions

122
internal/certmatch/match.go Normal file
View file

@ -0,0 +1,122 @@
// Package certmatch ordnet Firewall-Zertifikate einem Portalbenutzer zu.
package certmatch
import (
"errors"
"fmt"
"regexp"
"strings"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
)
// Placeholder ist der Platzhalter für den kanonischen Benutzernamen.
const Placeholder = "{username}"
// Matcher entscheidet, ob ein Zertifikats-CN zu einem Benutzer gehört.
// Entweder Template- oder Regex-Modus, nie beides.
type Matcher struct {
pattern string
regex string
}
// NewMatcher baut den Matcher aus der Konfiguration.
func NewMatcher(pattern, regex string) (*Matcher, error) {
pattern, regex = strings.TrimSpace(pattern), strings.TrimSpace(regex)
switch {
case pattern == "" && regex == "":
return nil, errors.New("certmatch: cn_pattern oder cn_regex muss gesetzt sein")
case pattern != "" && regex != "":
return nil, errors.New("certmatch: cn_pattern und cn_regex schließen sich aus")
case pattern != "":
if !strings.Contains(pattern, Placeholder) {
return nil, fmt.Errorf("certmatch: cn_pattern %q enthält keinen %s-Platzhalter", pattern, Placeholder)
}
return &Matcher{pattern: pattern}, nil
default:
// Probeweise kompilieren, damit Konfigurationsfehler beim Start
// auffallen und nicht erst bei der ersten Anmeldung.
if _, err := regexp.Compile("(?i)" + strings.ReplaceAll(regex, Placeholder, "x")); err != nil {
return nil, fmt.Errorf("certmatch: cn_regex ist nicht kompilierbar: %w", err)
}
return &Matcher{regex: regex}, nil
}
}
// expand setzt den Benutzernamen in Template bzw. Regex ein.
// Im Regex-Modus wird der Name quotiert, damit Metazeichen im Namen
// nicht als Muster wirken.
func (m *Matcher) expand(username string) string {
if m.regex != "" {
return strings.ReplaceAll(m.regex, Placeholder, regexp.QuoteMeta(username))
}
return strings.ReplaceAll(m.pattern, Placeholder, username)
}
// Matches vergleicht einen Zertifikats-CN mit dem Benutzernamen.
func (m *Matcher) Matches(cn, username string) bool {
cn, username = strings.TrimSpace(cn), strings.TrimSpace(username)
if cn == "" || username == "" {
return false
}
if m.regex != "" {
// (?i) macht den Vergleich unabhängig von der Schreibweise, analog zum
// Template-Modus. Kompilierfehler wurden in NewMatcher ausgeschlossen.
re, err := regexp.Compile("(?i)" + m.expand(username))
if err != nil {
return false
}
return re.MatchString(cn)
}
return strings.EqualFold(cn, m.expand(username))
}
// Describe liefert die für diesen Benutzer angewendete Regel — geht als Feld
// pattern ins Audit-Log, damit no_cert_found nachvollziehbar bleibt.
func (m *Matcher) Describe(username string) string {
if m.regex != "" {
return "regex:" + m.expand(username)
}
return m.expand(username)
}
// Entry ist ein für den Benutzer freigegebenes Zertifikat samt VPN-Instanz.
type Entry struct {
Provider opnsense.Provider
Account opnsense.Account
}
// Token ist der undurchsichtige Bezeichner für die Auswahl in der UI.
// Er ist ausdrücklich KEINE Autorisierung — vor jedem Download wird die
// Zuordnung serverseitig neu geprüft.
func (e Entry) Token() string {
return e.Provider.VPNID + ":" + e.Account.RefID
}
// ParseToken zerlegt einen Token wieder in seine Bestandteile.
func ParseToken(s string) (vpnID, refID string, ok bool) {
vpnID, refID, found := strings.Cut(s, ":")
if !found || vpnID == "" || refID == "" || strings.Contains(refID, ":") {
return "", "", false
}
return vpnID, refID, true
}
// Filter liefert alle Zertifikate einer Instanz, die dem Benutzer gehören,
// nicht revoziert und nicht abgelaufen sind.
func (m *Matcher) Filter(username string, provider opnsense.Provider,
accounts []opnsense.Account, now time.Time) []Entry {
var out []Entry
for _, a := range accounts {
if !m.Matches(a.CommonName, username) {
continue
}
if !a.IsUsable(now) {
continue
}
out = append(out, Entry{Provider: provider, Account: a})
}
return out
}

View file

@ -0,0 +1,141 @@
package certmatch
import (
"testing"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
)
func TestPatternMatching(t *testing.T) {
m, err := NewMatcher("{username}", "")
if err != nil {
t.Fatal(err)
}
if !m.Matches("mmueller", "mmueller") {
t.Error("identischer CN muss matchen")
}
if !m.Matches("MMueller", "mmueller") {
t.Error("Vergleich muss Groß-/Kleinschreibung ignorieren")
}
if m.Matches("jdoe", "mmueller") {
t.Error("fremder CN darf nicht matchen")
}
if m.Matches("mmueller2", "mmueller") {
t.Error("Präfix-Treffer darf nicht als Match gelten")
}
if m.Matches("", "mmueller") || m.Matches("mmueller", "") {
t.Error("leere Werte dürfen nie matchen")
}
}
func TestPatternWithSuffix(t *testing.T) {
m, err := NewMatcher("{username}@firma.de", "")
if err != nil {
t.Fatal(err)
}
if !m.Matches("mmueller@firma.de", "mmueller") {
t.Error("Template mit Suffix muss matchen")
}
if m.Matches("mmueller", "mmueller") {
t.Error("CN ohne Suffix darf bei diesem Template nicht matchen")
}
}
func TestRegexMatching(t *testing.T) {
// Regex mit {username}-Platzhalter: wird vor dem Kompilieren ersetzt und
// dabei quotiert, damit Sonderzeichen im Namen nicht zur Injection werden.
m, err := NewMatcher("", `^(vpn-)?{username}(-\d+)?$`)
if err != nil {
t.Fatal(err)
}
for _, cn := range []string{"mmueller", "vpn-mmueller", "mmueller-2"} {
if !m.Matches(cn, "mmueller") {
t.Errorf("CN %q muss matchen", cn)
}
}
for _, cn := range []string{"jdoe", "mmuellerX", "vpn-jdoe"} {
if m.Matches(cn, "mmueller") {
t.Errorf("CN %q darf nicht matchen", cn)
}
}
}
func TestRegexQuotesUsername(t *testing.T) {
m, err := NewMatcher("", `^{username}$`)
if err != nil {
t.Fatal(err)
}
// Ein Benutzername mit Regex-Metazeichen darf nicht als Muster wirken.
if m.Matches("mmueller", "m.*") {
t.Error("Benutzername muss vor dem Einsetzen quotiert werden")
}
if !m.Matches("m.*", "m.*") {
t.Error("literaler Vergleich muss weiterhin funktionieren")
}
}
func TestNewMatcherValidation(t *testing.T) {
if _, err := NewMatcher("", ""); err == nil {
t.Error("weder Pattern noch Regex muss abgelehnt werden")
}
if _, err := NewMatcher("{username}", "^x$"); err == nil {
t.Error("beides gleichzeitig muss abgelehnt werden")
}
if _, err := NewMatcher("", "([unbalanced"); err == nil {
t.Error("unkompilierbare Regex muss abgelehnt werden")
}
if _, err := NewMatcher("kein-platzhalter", ""); err == nil {
t.Error("Pattern ohne {username} muss abgelehnt werden")
}
}
func TestFilterExcludesRevokedAndExpired(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
m, _ := NewMatcher("{username}", "")
prov := opnsense.Provider{VPNID: "1", Name: "VPN Homeoffice"}
accounts := []opnsense.Account{
{RefID: "ok", CommonName: "mmueller", ValidTo: now.AddDate(1, 0, 0)},
{RefID: "revoked", CommonName: "mmueller", ValidTo: now.AddDate(1, 0, 0), Revoked: true},
{RefID: "expired", CommonName: "mmueller", ValidTo: now.AddDate(0, 0, -1)},
{RefID: "fremd", CommonName: "jdoe", ValidTo: now.AddDate(1, 0, 0)},
}
got := m.Filter("mmueller", prov, accounts, now)
if len(got) != 1 {
t.Fatalf("got %d Treffer, want 1: %+v", len(got), got)
}
if got[0].Account.RefID != "ok" {
t.Errorf("falscher Treffer: %+v", got[0])
}
if got[0].Provider.Name != "VPN Homeoffice" {
t.Errorf("Provider muss durchgereicht werden: %+v", got[0].Provider)
}
}
func TestTokenRoundTrip(t *testing.T) {
e := Entry{
Provider: opnsense.Provider{VPNID: "1"},
Account: opnsense.Account{RefID: "abc123"},
}
vpnID, refID, ok := ParseToken(e.Token())
if !ok || vpnID != "1" || refID != "abc123" {
t.Fatalf("ParseToken(%q) = %q,%q,%v", e.Token(), vpnID, refID, ok)
}
for _, bad := range []string{"", "nurEins", "a:b:c", ":x", "x:"} {
if _, _, ok := ParseToken(bad); ok {
t.Errorf("ParseToken(%q) darf nicht gelingen", bad)
}
}
}
func TestDescribeForAudit(t *testing.T) {
m, _ := NewMatcher("{username}@firma.de", "")
if got := m.Describe("mmueller"); got != "mmueller@firma.de" {
t.Errorf("Describe = %q", got)
}
rx, _ := NewMatcher("", `^{username}$`)
if got := rx.Describe("mmueller"); got != `regex:^mmueller$` {
t.Errorf("Describe = %q", got)
}
}