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)
|
||||
}
|
||||
}
|
||||
170
internal/opnsense/client.go
Normal file
170
internal/opnsense/client.go
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
package opnsense
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sentinel-Fehler, damit Aufrufer Ursachen unterscheiden können.
|
||||
var (
|
||||
ErrUnauthorized = errors.New("OPNsense: Zugangsdaten abgelehnt (API-Key/Secret prüfen)")
|
||||
ErrForbidden = errors.New("OPNsense: keine Berechtigung (API-User braucht \"VPN: OpenVPN Client Export\")")
|
||||
ErrUnreachable = errors.New("OPNsense nicht erreichbar")
|
||||
ErrUnexpected = errors.New("OPNsense: unerwartete Antwort")
|
||||
)
|
||||
|
||||
// maxJSONBytes begrenzt JSON-Antworten; Exportdaten werden gestreamt und
|
||||
// unterliegen dieser Grenze nicht.
|
||||
const maxJSONBytes = 8 << 20
|
||||
|
||||
// Options konfiguriert den Client.
|
||||
type Options struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
APISecret string
|
||||
CAFile string
|
||||
InsecureSkipVerify bool
|
||||
Timeout time.Duration
|
||||
// HTTPClient überschreibt den intern gebauten Client (Tests).
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// Client ist ein read-only Client für die OPNsense-Export-API.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
key string
|
||||
secret string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New baut den Client und die TLS-Konfiguration.
|
||||
func New(opts Options) (*Client, error) {
|
||||
if strings.TrimSpace(opts.BaseURL) == "" {
|
||||
return nil, errors.New("opnsense: url fehlt")
|
||||
}
|
||||
if _, err := url.Parse(opts.BaseURL); err != nil {
|
||||
return nil, fmt.Errorf("opnsense: url ist ungültig: %w", err)
|
||||
}
|
||||
if opts.APIKey == "" || opts.APISecret == "" {
|
||||
return nil, errors.New("opnsense: api_key und api_secret sind erforderlich")
|
||||
}
|
||||
if opts.Timeout <= 0 {
|
||||
opts.Timeout = 15 * time.Second
|
||||
}
|
||||
|
||||
httpClient := opts.HTTPClient
|
||||
if httpClient == nil {
|
||||
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
if opts.InsecureSkipVerify {
|
||||
// Nur für Tests; serve gibt bei jedem Start eine Warnung aus.
|
||||
tlsCfg.InsecureSkipVerify = true
|
||||
}
|
||||
if opts.CAFile != "" {
|
||||
pem, err := os.ReadFile(opts.CAFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opnsense.ca_file %s: %w", opts.CAFile, err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("opnsense.ca_file %s enthält kein gültiges PEM-Zertifikat", opts.CAFile)
|
||||
}
|
||||
tlsCfg.RootCAs = pool
|
||||
}
|
||||
httpClient = &http.Client{
|
||||
Timeout: opts.Timeout,
|
||||
Transport: &http.Transport{TLSClientConfig: tlsCfg, ForceAttemptHTTP2: true},
|
||||
}
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(opts.BaseURL, "/"),
|
||||
key: opts.APIKey,
|
||||
secret: opts.APISecret,
|
||||
http: httpClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// get führt einen authentifizierten GET aus und normalisiert Fehlerstatus.
|
||||
// Der Aufrufer muss den Body schließen.
|
||||
func (c *Client) get(ctx context.Context, path string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(c.key, c.secret)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s: %v", ErrUnreachable, path, err)
|
||||
}
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
return resp, nil
|
||||
case http.StatusUnauthorized:
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("%w (%s)", ErrUnauthorized, path)
|
||||
case http.StatusForbidden:
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("%w (%s)", ErrForbidden, path)
|
||||
case http.StatusNotFound:
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("%w: Endpunkt %s existiert nicht — ist das Plugin "+
|
||||
"os-openvpn-client-export installiert?", ErrUnexpected, path)
|
||||
default:
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("%w: %s antwortete mit HTTP %d", ErrUnexpected, path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// getJSON liest eine JSON-Antwort und weist HTML-Loginseiten zurück.
|
||||
func (c *Client) getJSON(ctx context.Context, path string, into any) error {
|
||||
resp, err := c.get(ctx, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") {
|
||||
return fmt.Errorf("%w: %s lieferte HTML statt JSON — meist ein ungültiger API-Key", ErrUnauthorized, path)
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxJSONBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s: %v", ErrUnreachable, path, err)
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(string(raw)), "<") {
|
||||
return fmt.Errorf("%w: %s lieferte HTML statt JSON — meist ein ungültiger API-Key", ErrUnauthorized, path)
|
||||
}
|
||||
if err := json.Unmarshal(raw, into); err != nil {
|
||||
return fmt.Errorf("%w: %s lieferte kein verwertbares JSON: %v", ErrUnexpected, path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ping prüft Erreichbarkeit und Berechtigung und liefert die Serverzeit aus
|
||||
// dem Date-Header (Grundlage der NTP-Plausibilitätsprüfung in check).
|
||||
func (c *Client) Ping(ctx context.Context) (time.Time, error) {
|
||||
resp, err := c.get(ctx, pathProviders)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, maxJSONBytes))
|
||||
|
||||
if d := resp.Header.Get("Date"); d != "" {
|
||||
if t, err := http.ParseTime(d); err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, nil
|
||||
}
|
||||
234
internal/opnsense/client_test.go
Normal file
234
internal/opnsense/client_test.go
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
package opnsense
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestClient(t *testing.T, h http.HandlerFunc) (*Client, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
c, err := New(Options{
|
||||
BaseURL: srv.URL,
|
||||
APIKey: "KEY",
|
||||
APISecret: "SECRET",
|
||||
Timeout: 2 * time.Second,
|
||||
HTTPClient: srv.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return c, srv
|
||||
}
|
||||
|
||||
func TestProvidersSendsBasicAuthAndParsesMap(t *testing.T) {
|
||||
var gotPath string
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != "KEY" || pass != "SECRET" {
|
||||
t.Errorf("BasicAuth = %q/%q ok=%v", user, pass, ok)
|
||||
}
|
||||
io.WriteString(w, `{
|
||||
"1": {"vpnid":"1","name":"VPN Homeoffice"},
|
||||
"2": {"vpnid":"2","name":"VPN Aussendienst"}
|
||||
}`)
|
||||
})
|
||||
|
||||
ps, err := c.Providers(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Providers: %v", err)
|
||||
}
|
||||
if gotPath != "/api/openvpn/export/providers" {
|
||||
t.Errorf("Pfad = %q", gotPath)
|
||||
}
|
||||
if len(ps) != 2 {
|
||||
t.Fatalf("got %d Provider, want 2", len(ps))
|
||||
}
|
||||
// Stabile Sortierung nach VPNID, damit die UI-Reihenfolge deterministisch ist.
|
||||
if ps[0].VPNID != "1" || ps[0].Name != "VPN Homeoffice" {
|
||||
t.Errorf("ps[0] = %+v", ps[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvidersFallsBackToMapKeyAsVPNID(t *testing.T) {
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, `{"7": {"name":"VPN Sieben"}}`)
|
||||
})
|
||||
ps, err := c.Providers(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Providers: %v", err)
|
||||
}
|
||||
if len(ps) != 1 || ps[0].VPNID != "7" {
|
||||
t.Fatalf("ps = %+v, VPNID muss aus dem Map-Schlüssel kommen", ps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountsParsesRevokedAndExpiry(t *testing.T) {
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/accounts/1") {
|
||||
t.Errorf("Pfad = %q", r.URL.Path)
|
||||
}
|
||||
io.WriteString(w, `{
|
||||
"abc123": {"commonName":"mmueller","description":"Max","validTo":"2027-03-01","isRevoked":"0"},
|
||||
"def456": {"commonName":"jdoe","validTo":"2027-03-01","isRevoked":"1"}
|
||||
}`)
|
||||
})
|
||||
|
||||
accs, err := c.Accounts(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("Accounts: %v", err)
|
||||
}
|
||||
if len(accs) != 2 {
|
||||
t.Fatalf("got %d Accounts, want 2", len(accs))
|
||||
}
|
||||
byCN := map[string]Account{}
|
||||
for _, a := range accs {
|
||||
byCN[a.CommonName] = a
|
||||
}
|
||||
if byCN["mmueller"].RefID != "abc123" || byCN["mmueller"].Revoked {
|
||||
t.Errorf("mmueller = %+v", byCN["mmueller"])
|
||||
}
|
||||
if !byCN["jdoe"].Revoked {
|
||||
t.Error("jdoe muss als revoziert erkannt werden")
|
||||
}
|
||||
if byCN["mmueller"].ValidTo.Year() != 2027 {
|
||||
t.Errorf("ValidTo = %v", byCN["mmueller"].ValidTo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportStreamsRawBody(t *testing.T) {
|
||||
const cfg = "client\nremote fw01.firma.local 1194\n"
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="mmueller.ovpn"`)
|
||||
io.WriteString(w, cfg)
|
||||
})
|
||||
|
||||
res, err := c.Export(context.Background(), "1", "abc123", FormatOVPN)
|
||||
if err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if string(body) != cfg {
|
||||
t.Errorf("Body = %q", body)
|
||||
}
|
||||
if res.Filename != "mmueller.ovpn" {
|
||||
t.Errorf("Filename = %q", res.Filename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportDecodesBase64JSONBody(t *testing.T) {
|
||||
const cfg = "client\nremote fw01 1194\n"
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(w, `{"status":"ok","filename":"x.ovpn","content":"`+
|
||||
base64.StdEncoding.EncodeToString([]byte(cfg))+`"}`)
|
||||
})
|
||||
|
||||
res, err := c.Export(context.Background(), "1", "abc123", FormatOVPN)
|
||||
if err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if string(body) != cfg {
|
||||
t.Errorf("Body = %q, want dekodierte Konfiguration", body)
|
||||
}
|
||||
if res.Filename != "x.ovpn" {
|
||||
t.Errorf("Filename = %q", res.Filename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthorizedAndForbiddenAreDistinct(t *testing.T) {
|
||||
for status, want := range map[int]error{
|
||||
http.StatusUnauthorized: ErrUnauthorized,
|
||||
http.StatusForbidden: ErrForbidden,
|
||||
} {
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
})
|
||||
_, err := c.Providers(context.Background())
|
||||
if !errors.Is(err, want) {
|
||||
t.Errorf("Status %d: err = %v, want %v", status, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreachableIsWrapped(t *testing.T) {
|
||||
c, srv := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {})
|
||||
srv.Close() // Server abschalten, um Verbindungsfehler zu erzwingen
|
||||
_, err := c.Providers(context.Background())
|
||||
if !errors.Is(err, ErrUnreachable) {
|
||||
t.Fatalf("err = %v, want ErrUnreachable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLLoginPageIsRejected(t *testing.T) {
|
||||
// OPNsense liefert bei ungültigem Key manchmal 200 mit HTML-Loginseite.
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
io.WriteString(w, "<html><body>Login</body></html>")
|
||||
})
|
||||
if _, err := c.Providers(context.Background()); err == nil {
|
||||
t.Fatal("HTML-Antwort muss als Fehler erkannt werden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingPluginIsNamedInError(t *testing.T) {
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
_, err := c.Providers(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "os-openvpn-client-export") {
|
||||
t.Fatalf("404 muss auf das fehlende Plugin hinweisen, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingReturnsServerTime(t *testing.T) {
|
||||
want := time.Date(2026, 8, 14, 7, 32, 11, 0, time.UTC)
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Date", want.Format(http.TimeFormat))
|
||||
io.WriteString(w, `{}`)
|
||||
})
|
||||
got, err := c.Ping(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Ping: %v", err)
|
||||
}
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("Ping-Zeit = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewValidatesOptions(t *testing.T) {
|
||||
if _, err := New(Options{BaseURL: "https://fw", APIKey: "k", APISecret: "s"}); err != nil {
|
||||
t.Fatalf("Standardfall muss funktionieren: %v", err)
|
||||
}
|
||||
if _, err := New(Options{BaseURL: "", APIKey: "k", APISecret: "s"}); err == nil {
|
||||
t.Fatal("leere BaseURL muss abgelehnt werden")
|
||||
}
|
||||
if _, err := New(Options{BaseURL: "https://fw", APIKey: "", APISecret: "s"}); err == nil {
|
||||
t.Fatal("fehlender API-Key muss abgelehnt werden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportRejectsEmptyIdentifiers(t *testing.T) {
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("bei leeren Bezeichnern darf kein Request abgehen")
|
||||
})
|
||||
if _, err := c.Export(context.Background(), "", "abc", FormatOVPN); err == nil {
|
||||
t.Error("leere vpnid muss abgelehnt werden")
|
||||
}
|
||||
if _, err := c.Export(context.Background(), "1", "", FormatOVPN); err == nil {
|
||||
t.Error("leere Zertifikatsreferenz muss abgelehnt werden")
|
||||
}
|
||||
}
|
||||
144
internal/opnsense/export.go
Normal file
144
internal/opnsense/export.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package opnsense
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// API-Pfade des Plugins os-openvpn-client-export. Alle drei sind GET und
|
||||
// damit read-only — das Portal schreibt niemals auf die Firewall.
|
||||
const (
|
||||
pathProviders = "/api/openvpn/export/providers"
|
||||
pathAccounts = "/api/openvpn/export/accounts/"
|
||||
pathDownload = "/api/openvpn/export/download/"
|
||||
)
|
||||
|
||||
// Providers listet die exportierbaren OpenVPN-Instanzen.
|
||||
// Das Ergebnis darf vom Aufrufer kurz gecacht werden (Minuten).
|
||||
func (c *Client) Providers(ctx context.Context) ([]Provider, error) {
|
||||
var raw map[string]rawProvider
|
||||
if err := c.getJSON(ctx, pathProviders, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Provider, 0, len(raw))
|
||||
for key, rp := range raw {
|
||||
id := rp.VPNID
|
||||
if id == "" {
|
||||
id = key // ältere Versionen führen die vpnid nur als Map-Schlüssel
|
||||
}
|
||||
name := firstNonEmpty(rp.Name, rp.Descr, "VPN "+id)
|
||||
out = append(out, Provider{VPNID: id, Name: name})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].VPNID < out[j].VPNID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Accounts listet die Zertifikate einer Instanz.
|
||||
// Diese Antwort darf NIEMALS gecacht werden — eine Revozierung auf der
|
||||
// Firewall muss ohne Verzögerung greifen.
|
||||
func (c *Client) Accounts(ctx context.Context, vpnID string) ([]Account, error) {
|
||||
if strings.TrimSpace(vpnID) == "" {
|
||||
return nil, fmt.Errorf("%w: leere vpnid", ErrUnexpected)
|
||||
}
|
||||
var raw map[string]rawAccount
|
||||
if err := c.getJSON(ctx, pathAccounts+url.PathEscape(vpnID), &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Account, 0, len(raw))
|
||||
for refID, ra := range raw {
|
||||
out = append(out, ra.toAccount(refID))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].RefID < out[j].RefID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ExportResult trägt den Datenstrom der Konfiguration.
|
||||
// Der Aufrufer muss Body schließen und darf ihn nicht auf Platte zwischenspeichern.
|
||||
type ExportResult struct {
|
||||
Filename string
|
||||
ContentType string
|
||||
Body io.ReadCloser
|
||||
}
|
||||
|
||||
// jsonExport ist die Antwortform älterer Plugin-Versionen: Base64 im JSON.
|
||||
type jsonExport struct {
|
||||
Status string `json:"status"`
|
||||
Filename string `json:"filename"`
|
||||
Content string `json:"content"`
|
||||
Data string `json:"data"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Export lädt eine Client-Konfiguration und liefert sie als Stream.
|
||||
// Es werden keine Exportoptionen übergeben — die auf der Firewall
|
||||
// hinterlegten Einstellungen sind die Quelle der Wahrheit.
|
||||
func (c *Client) Export(ctx context.Context, vpnID, certRefID, format string) (*ExportResult, error) {
|
||||
if strings.TrimSpace(vpnID) == "" || strings.TrimSpace(certRefID) == "" {
|
||||
return nil, fmt.Errorf("%w: vpnid oder Zertifikatsreferenz fehlt", ErrUnexpected)
|
||||
}
|
||||
path := fmt.Sprintf("%s%s/%s/%s", pathDownload,
|
||||
url.PathEscape(vpnID), url.PathEscape(format), url.PathEscape(certRefID))
|
||||
|
||||
resp, err := c.get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
filename := filenameFromDisposition(resp.Header.Get("Content-Disposition"))
|
||||
|
||||
// JSON-Variante: Inhalt steckt Base64-kodiert in der Antwort.
|
||||
if strings.Contains(ct, "application/json") {
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxJSONBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: Export konnte nicht gelesen werden: %v", ErrUnreachable, err)
|
||||
}
|
||||
var je jsonExport
|
||||
if err := json.Unmarshal(raw, &je); err != nil {
|
||||
return nil, fmt.Errorf("%w: Export lieferte kein verwertbares JSON: %v", ErrUnexpected, err)
|
||||
}
|
||||
payload := firstNonEmpty(je.Content, je.Data)
|
||||
if payload == "" {
|
||||
return nil, fmt.Errorf("%w: Export ohne Inhalt (status %q, message %q)",
|
||||
ErrUnexpected, je.Status, je.Message)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
// Manche Versionen liefern den Klartext direkt.
|
||||
decoded = []byte(payload)
|
||||
}
|
||||
return &ExportResult{
|
||||
Filename: firstNonEmpty(je.Filename, filename),
|
||||
ContentType: "application/x-openvpn-profile",
|
||||
Body: io.NopCloser(bytes.NewReader(decoded)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Rohvariante: direkt durchstreamen, nichts puffern.
|
||||
return &ExportResult{
|
||||
Filename: filename,
|
||||
ContentType: firstNonEmpty(ct, "application/octet-stream"),
|
||||
Body: resp.Body,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// filenameFromDisposition liest den Dateinamen aus dem Content-Disposition-Header.
|
||||
func filenameFromDisposition(v string) string {
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
_, params, err := mime.ParseMediaType(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return params["filename"]
|
||||
}
|
||||
133
internal/opnsense/integration_test.go
Normal file
133
internal/opnsense/integration_test.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
//go:build integration
|
||||
|
||||
// Diese Tests laufen nur mit `go test -tags integration ./internal/opnsense/`
|
||||
// gegen eine echte OPNsense-Testinstanz. Sie bestätigen die in
|
||||
// docs/opnsense-api.md dokumentierten Annahmen über Feldnamen und Formate.
|
||||
package opnsense
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func integrationClient(t *testing.T) *Client {
|
||||
t.Helper()
|
||||
base, key, secret := os.Getenv("OPNSENSE_URL"), os.Getenv("OPNSENSE_KEY"), os.Getenv("OPNSENSE_SECRET")
|
||||
if base == "" || key == "" || secret == "" {
|
||||
t.Skip("OPNSENSE_URL/OPNSENSE_KEY/OPNSENSE_SECRET nicht gesetzt")
|
||||
}
|
||||
c, err := New(Options{BaseURL: base, APIKey: key, APISecret: secret,
|
||||
CAFile: os.Getenv("OPNSENSE_CA"), Timeout: 20 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// TestIntegrationProviders bestätigt Feldnamen und Map-Form der Provider-Antwort.
|
||||
func TestIntegrationProviders(t *testing.T) {
|
||||
ps, err := integrationClient(t).Providers(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Providers: %v", err)
|
||||
}
|
||||
if len(ps) == 0 {
|
||||
t.Fatal("keine Provider — mindestens eine OpenVPN-Instanz muss exportierbar sein")
|
||||
}
|
||||
for _, p := range ps {
|
||||
if p.VPNID == "" || p.Name == "" {
|
||||
t.Errorf("unvollständiger Provider: %+v", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationAccountsExpiryIsParsed bestätigt, dass das Ablaufdatum in
|
||||
// einem der unterstützten Formate ankommt und nicht stillschweigend leer bleibt.
|
||||
func TestIntegrationAccountsExpiryIsParsed(t *testing.T) {
|
||||
c := integrationClient(t)
|
||||
ps, err := c.Providers(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var seen, withDate int
|
||||
for _, p := range ps {
|
||||
accs, err := c.Accounts(context.Background(), p.VPNID)
|
||||
if err != nil {
|
||||
t.Fatalf("Accounts(%s): %v", p.VPNID, err)
|
||||
}
|
||||
for _, a := range accs {
|
||||
seen++
|
||||
if a.CommonName == "" {
|
||||
t.Errorf("Zertifikat %s ohne CommonName — Feldname prüfen: %+v", a.RefID, a)
|
||||
}
|
||||
if !a.ValidTo.IsZero() {
|
||||
withDate++
|
||||
}
|
||||
}
|
||||
}
|
||||
if seen == 0 {
|
||||
t.Skip("keine Zertifikate auf der Testinstanz")
|
||||
}
|
||||
if withDate == 0 {
|
||||
t.Fatalf("kein einziges der %d Zertifikate hat ein auswertbares Ablaufdatum — "+
|
||||
"flexTimeLayouts bzw. die Feldnamen in rawAccount müssen ergänzt werden", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationAccountsRevokedField bestätigt, dass revozierte Zertifikate
|
||||
// als solche erkennbar sind. Voraussetzung: auf der Testinstanz existiert ein
|
||||
// revoziertes Zertifikat mit CN aus OPNSENSE_REVOKED_CN.
|
||||
func TestIntegrationAccountsRevokedField(t *testing.T) {
|
||||
wantCN := os.Getenv("OPNSENSE_REVOKED_CN")
|
||||
if wantCN == "" {
|
||||
t.Skip("OPNSENSE_REVOKED_CN nicht gesetzt")
|
||||
}
|
||||
c := integrationClient(t)
|
||||
ps, err := c.Providers(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range ps {
|
||||
accs, err := c.Accounts(context.Background(), p.VPNID)
|
||||
if err != nil {
|
||||
t.Fatalf("Accounts(%s): %v", p.VPNID, err)
|
||||
}
|
||||
for _, a := range accs {
|
||||
if a.CommonName == wantCN {
|
||||
if !a.Revoked {
|
||||
t.Fatalf("Zertifikat %q wird nicht als revoziert gemeldet: %+v", wantCN, a)
|
||||
}
|
||||
if a.IsUsable(time.Now()) {
|
||||
t.Fatalf("revoziertes Zertifikat %q gilt als nutzbar", wantCN)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("CN %q auf keiner Instanz gefunden", wantCN)
|
||||
}
|
||||
|
||||
// TestIntegrationExportFormats bestätigt, dass beide angebotenen Formate
|
||||
// nicht-leere Konfigurationen liefern.
|
||||
func TestIntegrationExportFormats(t *testing.T) {
|
||||
refID := os.Getenv("OPNSENSE_CERT_REF")
|
||||
vpnID := os.Getenv("OPNSENSE_VPNID")
|
||||
if refID == "" || vpnID == "" {
|
||||
t.Skip("OPNSENSE_CERT_REF/OPNSENSE_VPNID nicht gesetzt")
|
||||
}
|
||||
c := integrationClient(t)
|
||||
for _, format := range []string{FormatOVPN, FormatViscosity} {
|
||||
res, err := c.Export(context.Background(), vpnID, refID, format)
|
||||
if err != nil {
|
||||
t.Errorf("Export(%s): %v", format, err)
|
||||
continue
|
||||
}
|
||||
n, _ := io.Copy(io.Discard, res.Body)
|
||||
res.Body.Close()
|
||||
if n == 0 {
|
||||
t.Errorf("Export(%s) lieferte 0 Bytes", format)
|
||||
}
|
||||
}
|
||||
}
|
||||
161
internal/opnsense/types.go
Normal file
161
internal/opnsense/types.go
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
// Package opnsense spricht die read-only Export-API des Plugins
|
||||
// os-openvpn-client-export an.
|
||||
package opnsense
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Exportformate, die das Portal anbietet. Alle weiteren Einstellungen kommen
|
||||
// aus der Firewall-Konfiguration — das Portal überschreibt nichts.
|
||||
const (
|
||||
FormatOVPN = "ovpn"
|
||||
FormatViscosity = "viscosity"
|
||||
)
|
||||
|
||||
// flexBool akzeptiert die verschiedenen Boolean-Darstellungen der API
|
||||
// (true, 1, "1", "yes"). Die exakte Variante ist versionsabhängig.
|
||||
type flexBool bool
|
||||
|
||||
func (b *flexBool) UnmarshalJSON(data []byte) error {
|
||||
s := strings.Trim(strings.TrimSpace(string(data)), `"`)
|
||||
switch strings.ToLower(s) {
|
||||
case "1", "true", "yes", "on":
|
||||
*b = true
|
||||
case "", "0", "false", "no", "off", "null":
|
||||
*b = false
|
||||
default:
|
||||
return fmt.Errorf("unerwarteter Boolean-Wert %q", s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// flexTimeLayouts deckt die von OPNsense beobachteten Datumsformate ab.
|
||||
var flexTimeLayouts = []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
"Jan _2 15:04:05 2006 MST",
|
||||
"060102150405Z", // ASN.1 UTCTime
|
||||
}
|
||||
|
||||
// flexTime parst Ablaufdaten in mehreren Formaten.
|
||||
type flexTime struct{ Time time.Time }
|
||||
|
||||
func (t *flexTime) UnmarshalJSON(data []byte) error {
|
||||
var raw any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case float64: // Unix-Zeitstempel
|
||||
t.Time = time.Unix(int64(v), 0).UTC()
|
||||
return nil
|
||||
case string:
|
||||
s := strings.TrimSpace(v)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
for _, layout := range flexTimeLayouts {
|
||||
if parsed, err := time.Parse(layout, s); err == nil {
|
||||
t.Time = parsed.UTC()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unbekanntes Datumsformat %q", s)
|
||||
default:
|
||||
return fmt.Errorf("unerwarteter Datumstyp %T", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Provider ist eine exportierbare OpenVPN-Instanz der Firewall.
|
||||
type Provider struct {
|
||||
VPNID string
|
||||
Name string
|
||||
}
|
||||
|
||||
// Account ist ein exportierbares Client-Zertifikat.
|
||||
type Account struct {
|
||||
RefID string
|
||||
CommonName string
|
||||
Description string
|
||||
ValidTo time.Time
|
||||
Revoked bool
|
||||
}
|
||||
|
||||
// IsUsable meldet, ob das Zertifikat ausgeliefert werden darf.
|
||||
// Ein fehlendes Ablaufdatum blockiert nicht — die Revocation-Prüfung greift
|
||||
// weiterhin, und ein unbekanntes Datum darf keine gültige Config verhindern.
|
||||
func (a Account) IsUsable(now time.Time) bool {
|
||||
if a.Revoked {
|
||||
return false
|
||||
}
|
||||
if !a.ValidTo.IsZero() && !a.ValidTo.After(now) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// DaysUntilExpiry liefert die Restlaufzeit in Tagen; -1 bei unbekanntem Datum.
|
||||
func (a Account) DaysUntilExpiry(now time.Time) int {
|
||||
if a.ValidTo.IsZero() {
|
||||
return -1
|
||||
}
|
||||
return int(a.ValidTo.Sub(now).Hours() / 24)
|
||||
}
|
||||
|
||||
// rawProvider und rawAccount bilden die API-Antworten ab. Mehrere Feldnamen
|
||||
// werden akzeptiert, weil die Benennung zwischen OPNsense-Versionen wechselt
|
||||
// (siehe docs/opnsense-api.md).
|
||||
type rawProvider struct {
|
||||
VPNID string `json:"vpnid"`
|
||||
Name string `json:"name"`
|
||||
Descr string `json:"description"`
|
||||
}
|
||||
|
||||
type rawAccount struct {
|
||||
CommonName string `json:"commonName"`
|
||||
CommonName2 string `json:"common_name"`
|
||||
Description string `json:"description"`
|
||||
Descr string `json:"descr"`
|
||||
ValidTo flexTime `json:"validTo"`
|
||||
ValidTo2 flexTime `json:"valid_to"`
|
||||
ValidTo3 flexTime `json:"validto"`
|
||||
Revoked flexBool `json:"isRevoked"`
|
||||
Revoked2 flexBool `json:"revoked"`
|
||||
Revoked3 flexBool `json:"is_revoked"`
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if s := strings.TrimSpace(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r rawAccount) toAccount(refID string) Account {
|
||||
validTo := r.ValidTo.Time
|
||||
if validTo.IsZero() {
|
||||
validTo = r.ValidTo2.Time
|
||||
}
|
||||
if validTo.IsZero() {
|
||||
validTo = r.ValidTo3.Time
|
||||
}
|
||||
return Account{
|
||||
RefID: refID,
|
||||
CommonName: firstNonEmpty(r.CommonName, r.CommonName2),
|
||||
Description: firstNonEmpty(r.Description, r.Descr),
|
||||
ValidTo: validTo,
|
||||
// Meldet irgendeines der bekannten Felder eine Revozierung, gilt das
|
||||
// Zertifikat als revoziert — im Zweifel restriktiv.
|
||||
Revoked: bool(r.Revoked) || bool(r.Revoked2) || bool(r.Revoked3),
|
||||
}
|
||||
}
|
||||
73
internal/opnsense/types_test.go
Normal file
73
internal/opnsense/types_test.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package opnsense
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFlexBoolAcceptsAPIVariants(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
`true`: true, `false`: false,
|
||||
`1`: true, `0`: false,
|
||||
`"1"`: true, `"0"`: false,
|
||||
`"true"`: true, `"false"`: false,
|
||||
`"yes"`: true, `"no"`: false,
|
||||
`""`: false, `null`: false,
|
||||
}
|
||||
for raw, want := range cases {
|
||||
var b flexBool
|
||||
if err := json.Unmarshal([]byte(raw), &b); err != nil {
|
||||
t.Errorf("Unmarshal(%s): %v", raw, err)
|
||||
continue
|
||||
}
|
||||
if bool(b) != want {
|
||||
t.Errorf("Unmarshal(%s) = %v, want %v", raw, bool(b), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlexTimeAcceptsAPIVariants(t *testing.T) {
|
||||
want := time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||
for _, raw := range []string{
|
||||
`"2027-03-01"`,
|
||||
`"2027-03-01T00:00:00Z"`,
|
||||
`"Mar 1 00:00:00 2027 GMT"`,
|
||||
} {
|
||||
var ft flexTime
|
||||
if err := json.Unmarshal([]byte(raw), &ft); err != nil {
|
||||
t.Errorf("Unmarshal(%s): %v", raw, err)
|
||||
continue
|
||||
}
|
||||
if !ft.Time.Equal(want) {
|
||||
t.Errorf("Unmarshal(%s) = %v, want %v", raw, ft.Time, want)
|
||||
}
|
||||
}
|
||||
var empty flexTime
|
||||
if err := json.Unmarshal([]byte(`""`), &empty); err != nil || !empty.Time.IsZero() {
|
||||
t.Errorf("leerer String muss Nullzeit ergeben, got %v (%v)", empty.Time, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountIsUsable(t *testing.T) {
|
||||
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
|
||||
valid := Account{CommonName: "mmueller", ValidTo: now.AddDate(0, 6, 0)}
|
||||
if !valid.IsUsable(now) {
|
||||
t.Error("gültiges Zertifikat muss nutzbar sein")
|
||||
}
|
||||
revoked := valid
|
||||
revoked.Revoked = true
|
||||
if revoked.IsUsable(now) {
|
||||
t.Error("revoziertes Zertifikat darf nie nutzbar sein")
|
||||
}
|
||||
expired := Account{CommonName: "mmueller", ValidTo: now.AddDate(0, 0, -1)}
|
||||
if expired.IsUsable(now) {
|
||||
t.Error("abgelaufenes Zertifikat darf nie nutzbar sein")
|
||||
}
|
||||
// Fehlendes Ablaufdatum: als unbekannt behandeln, aber nicht blockieren —
|
||||
// die Revocation-Prüfung bleibt maßgeblich.
|
||||
noDate := Account{CommonName: "mmueller"}
|
||||
if !noDate.IsUsable(now) {
|
||||
t.Error("fehlendes Ablaufdatum darf nicht zum Ausschluss führen")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue