opnsense-portal/internal/opnsense/export.go
Carsten Abele e49882b8a8 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
2026-08-14 09:15:53 +02:00

144 lines
4.5 KiB
Go

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