opnsense-portal/internal/opnsense/client.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

170 lines
5 KiB
Go

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
}