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:
parent
99ee8758cc
commit
e49882b8a8
9 changed files with 1280 additions and 0 deletions
122
internal/certmatch/match.go
Normal file
122
internal/certmatch/match.go
Normal 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
|
||||
}
|
||||
141
internal/certmatch/match_test.go
Normal file
141
internal/certmatch/match_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue