// 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 }