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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue