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