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:
parent
99ee8758cc
commit
e49882b8a8
9 changed files with 1280 additions and 0 deletions
144
internal/opnsense/export.go
Normal file
144
internal/opnsense/export.go
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue