diff --git a/docs/opnsense-api.md b/docs/opnsense-api.md new file mode 100644 index 0000000..ae4a2e2 --- /dev/null +++ b/docs/opnsense-api.md @@ -0,0 +1,102 @@ +# OPNsense-API: genutzte Endpunkte und Annahmen + +Das Portal spricht ausschließlich das Plugin **`os-openvpn-client-export`** an +und ausschließlich lesend. + +> **Diese Annahmen sind mit `go test -tags integration ./internal/opnsense/` +> gegen eine echte Instanz zu bestätigen, bevor v1 ausgeliefert wird.** +> Die exakten Feldnamen und Antwortformen wechseln zwischen OPNsense-Versionen; +> der Client ist deshalb bewusst tolerant gebaut. + +## Genutzte Endpunkte + +| Methode | Pfad | Zweck | +|---|---|---| +| `GET` | `/api/openvpn/export/providers` | Liste der exportierbaren VPN-Instanzen | +| `GET` | `/api/openvpn/export/accounts/{vpnid}` | Zertifikate einer Instanz | +| `GET` | `/api/openvpn/export/download/{vpnid}/{format}/{certref}` | Konfiguration herunterladen | + +Alle drei sind `GET`. Das Portal ruft **keinen** schreibenden Endpunkt auf und +übergibt **keine** Exportoptionen — die auf der Firewall hinterlegten +Einstellungen sind die Quelle der Wahrheit. + +## Authentifizierung + +HTTP Basic Auth mit API-Key als Benutzername und API-Secret als Passwort. +Der API-Benutzer braucht ausschließlich das Privileg +**„VPN: OpenVPN Client Export"** — niemals einen Admin-Key. + +Bei ungültigem Key antwortet OPNsense je nach Version mit `401` oder mit +`200` und einer HTML-Loginseite. Der Client behandelt beides als +`ErrUnauthorized`; die HTML-Erkennung prüft sowohl den `Content-Type` als +auch ein führendes `<` im Body. + +## Akzeptierte Feldnamen + +Der Client akzeptiert mehrere Schreibweisen und nimmt den ersten +nicht-leeren Treffer (`internal/opnsense/types.go`, `rawAccount`): + +| Bedeutung | Akzeptierte JSON-Felder | +|---|---| +| Common Name | `commonName`, `common_name` | +| Beschreibung | `description`, `descr` | +| Ablaufdatum | `validTo`, `valid_to`, `validto` | +| Revoziert | `isRevoked`, `revoked`, `is_revoked` | +| VPN-ID (Provider) | `vpnid`, sonst der Map-Schlüssel | +| Instanzname | `name`, `description`, sonst `VPN ` | + +**Revoziert** gilt, sobald *eines* der drei Felder wahr meldet — im Zweifel +restriktiv. Als „wahr" zählen `true`, `1`, `"1"`, `"true"`, `"yes"`, `"on"`. + +## Akzeptierte Datumsformate + +`time.RFC3339`, `2006-01-02T15:04:05`, `2006-01-02 15:04:05`, `2006-01-02`, +`Jan _2 15:04:05 2006 MST` (OpenSSL-Stil), `060102150405Z` (ASN.1 UTCTime) +sowie Unix-Zeitstempel als Zahl. + +Ein **nicht auswertbares oder fehlendes** Ablaufdatum führt *nicht* zum +Ausschluss des Zertifikats — die Revocation-Prüfung bleibt maßgeblich, und ein +unbekanntes Datum darf keine gültige Konfiguration blockieren. Der +Integrationstest `TestIntegrationAccountsExpiryIsParsed` schlägt fehl, wenn +*kein* Zertifikat ein auswertbares Datum liefert; das ist das Signal, die +Layout-Liste zu ergänzen. + +## Antwortformen des Downloads + +Zwei Varianten sind implementiert und werden am `Content-Type` unterschieden: + +1. **Rohdaten** (`application/octet-stream` o. ä.) — wird direkt + durchgestreamt, nichts wird gepuffert oder auf Platte geschrieben. + Der Dateiname kommt aus `Content-Disposition`. +2. **Base64 in JSON** (`application/json`) — Feld `content` oder `data`, + optional `filename`. Lässt sich der Inhalt nicht Base64-dekodieren, wird + er als Klartext behandelt. + +## Unterstützte Exportformate + +Das Portal bietet genau zwei Formate an: `ovpn` (inline `.ovpn`) und +`viscosity`. Weitere Formate sind bewusst nicht wählbar. + +## Mindestversion + +**Beim ersten erfolgreichen Integrationstestlauf hier eintragen:** + +- OPNsense: _(zu ermitteln)_ +- `os-openvpn-client-export`: _(zu ermitteln)_ + +## Integrationstests ausführen + +```bash +export OPNSENSE_URL="https://fw01.firma.local" +export OPNSENSE_KEY="..." +export OPNSENSE_SECRET="..." +export OPNSENSE_CA="/pfad/zur/firma-ca.pem" # optional +export OPNSENSE_REVOKED_CN="testuser-revoked" # optional, für den Revocation-Test +export OPNSENSE_VPNID="1" # optional, für den Export-Test +export OPNSENSE_CERT_REF="abc123" # optional, für den Export-Test + +go test -tags integration ./internal/opnsense/ -v +``` + +Ohne gesetzte Variablen überspringen sich die Tests selbst; sie laufen +deshalb nie versehentlich in der normalen Testsuite mit. diff --git a/internal/certmatch/match.go b/internal/certmatch/match.go new file mode 100644 index 0000000..63fd520 --- /dev/null +++ b/internal/certmatch/match.go @@ -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 +} diff --git a/internal/certmatch/match_test.go b/internal/certmatch/match_test.go new file mode 100644 index 0000000..dfba065 --- /dev/null +++ b/internal/certmatch/match_test.go @@ -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) + } +} diff --git a/internal/opnsense/client.go b/internal/opnsense/client.go new file mode 100644 index 0000000..250bef3 --- /dev/null +++ b/internal/opnsense/client.go @@ -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 +} diff --git a/internal/opnsense/client_test.go b/internal/opnsense/client_test.go new file mode 100644 index 0000000..fd596cd --- /dev/null +++ b/internal/opnsense/client_test.go @@ -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, "Login") + }) + 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") + } +} diff --git a/internal/opnsense/export.go b/internal/opnsense/export.go new file mode 100644 index 0000000..c43f107 --- /dev/null +++ b/internal/opnsense/export.go @@ -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"] +} diff --git a/internal/opnsense/integration_test.go b/internal/opnsense/integration_test.go new file mode 100644 index 0000000..f28cd98 --- /dev/null +++ b/internal/opnsense/integration_test.go @@ -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) + } + } +} diff --git a/internal/opnsense/types.go b/internal/opnsense/types.go new file mode 100644 index 0000000..4004e96 --- /dev/null +++ b/internal/opnsense/types.go @@ -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), + } +} diff --git a/internal/opnsense/types_test.go b/internal/opnsense/types_test.go new file mode 100644 index 0000000..824ab3c --- /dev/null +++ b/internal/opnsense/types_test.go @@ -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") + } +}