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