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