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:
Carsten Abele 2026-08-14 09:15:53 +02:00
parent 99ee8758cc
commit e49882b8a8
9 changed files with 1280 additions and 0 deletions

102
docs/opnsense-api.md Normal file
View file

@ -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 <id>` |
**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.

122
internal/certmatch/match.go Normal file
View 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
}

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

View 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
View 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"]
}

View 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
View 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),
}
}

View 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")
}
}