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 }