Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
144 lines
4.5 KiB
Go
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"]
|
|
}
|