wg-portal/internal/lowlevel/pfsense.go

429 lines
12 KiB
Go

package lowlevel
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"time"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/config"
)
// PfsenseApiClient provides HTTP client functionality for interacting with the pfSense REST API.
// Documentation: https://pfrest.org/
// Swagger UI: https://pfrest.org/api-docs/
// region models
const (
PfsenseApiStatusOk = "ok" // pfSense REST API uses "ok" in response
PfsenseApiStatusError = "error"
)
const (
PfsenseApiErrorCodeUnknown = iota + 700
PfsenseApiErrorCodeRequestPreparationFailed
PfsenseApiErrorCodeRequestFailed
PfsenseApiErrorCodeResponseDecodeFailed
)
type PfsenseApiResponse[T any] struct {
Status string
Code int
Data T `json:"data,omitempty"`
Error *PfsenseApiError `json:"error,omitempty"`
}
type PfsenseApiError struct {
Code int `json:"error,omitempty"`
Message string `json:"message,omitempty"`
Details string `json:"detail,omitempty"`
}
func (e *PfsenseApiError) String() string {
if e == nil {
return "no error"
}
return fmt.Sprintf("API error %d: %s - %s", e.Code, e.Message, e.Details)
}
type PfsenseRequestOptions struct {
Filters map[string]string `json:"filters,omitempty"`
PropList []string `json:"proplist,omitempty"`
}
func (o *PfsenseRequestOptions) GetPath(base string) string {
if o == nil {
return base
}
path, err := url.Parse(base)
if err != nil {
return base
}
query := path.Query()
// pfSense REST API uses standard query parameters for filtering
for k, v := range o.Filters {
query.Set(k, v)
}
// Note: PropList may not be supported by pfSense REST API in the same way as Mikrotik
// pfSense typically returns all fields by default, but we keep this for potential future use
// Verify the correct parameter name in Swagger docs if field selection is needed
if len(o.PropList) > 0 {
// pfSense might use different parameter name - verify in Swagger docs
// For now, we'll skip it as pfSense may return all fields by default
// query.Set("fields", strings.Join(o.PropList, ","))
}
path.RawQuery = query.Encode()
return path.String()
}
// endregion models
// region API-client
type PfsenseApiClient struct {
coreCfg *config.Config
cfg *config.BackendPfsense
client *http.Client
log *slog.Logger
}
func NewPfsenseApiClient(coreCfg *config.Config, cfg *config.BackendPfsense) (*PfsenseApiClient, error) {
c := &PfsenseApiClient{
coreCfg: coreCfg,
cfg: cfg,
}
err := c.setup()
if err != nil {
return nil, err
}
c.debugLog("pfSense api client created", "api_url", cfg.ApiUrl)
return c, nil
}
func (p *PfsenseApiClient) setup() error {
p.client = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: !p.cfg.ApiVerifyTls,
},
},
Timeout: p.cfg.GetApiTimeout(),
}
if p.cfg.Debug {
p.log = slog.New(internal.GetLoggingHandler("debug",
p.coreCfg.Advanced.LogPretty,
p.coreCfg.Advanced.LogJson).
WithAttrs([]slog.Attr{
{
Key: "pfsense-bid", Value: slog.StringValue(p.cfg.Id),
},
}))
}
return nil
}
func (p *PfsenseApiClient) debugLog(msg string, args ...any) {
if p.log != nil {
p.log.Debug("[PFS-API] "+msg, args...)
}
}
func (p *PfsenseApiClient) getFullPath(command string) string {
path, err := url.JoinPath(p.cfg.ApiUrl, command)
if err != nil {
return ""
}
return path
}
func (p *PfsenseApiClient) prepareGetRequest(ctx context.Context, fullUrl string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullUrl, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
if p.cfg.ApiKey != "" {
// pfSense REST API API Key authentication (https://pfrest.org/AUTHENTICATION_AND_AUTHORIZATION/)
// Uses X-API-Key header for API key authentication
req.Header.Set("X-API-Key", p.cfg.ApiKey)
}
return req, nil
}
func (p *PfsenseApiClient) prepareDeleteRequest(ctx context.Context, fullUrl string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, fullUrl, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
if p.cfg.ApiKey != "" {
// pfSense REST API API Key authentication (https://pfrest.org/AUTHENTICATION_AND_AUTHORIZATION/)
// Uses X-API-Key header for API key authentication
req.Header.Set("X-API-Key", p.cfg.ApiKey)
}
return req, nil
}
func (p *PfsenseApiClient) preparePayloadRequest(
ctx context.Context,
method string,
fullUrl string,
payload GenericJsonObject,
) (*http.Request, error) {
// marshal the payload to JSON
payloadBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, method, fullUrl, bytes.NewReader(payloadBytes))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
if p.cfg.ApiKey != "" {
// pfSense REST API API Key authentication (https://pfrest.org/AUTHENTICATION_AND_AUTHORIZATION/)
// Uses X-API-Key header for API key authentication
req.Header.Set("X-API-Key", p.cfg.ApiKey)
}
return req, nil
}
func errToPfsenseApiResponse[T any](code int, message string, err error) PfsenseApiResponse[T] {
return PfsenseApiResponse[T]{
Status: PfsenseApiStatusError,
Code: code,
Error: &PfsenseApiError{
Code: code,
Message: message,
Details: err.Error(),
},
}
}
func parsePfsenseHttpResponse[T any](resp *http.Response, err error) PfsenseApiResponse[T] {
if err != nil {
return errToPfsenseApiResponse[T](PfsenseApiErrorCodeRequestFailed, "failed to execute request", err)
}
// pfSense REST API wraps responses in {code, status, data} or {code, status, error} structure
var wrapper struct {
Code int `json:"code"`
Status string `json:"status"`
Data T `json:"data,omitempty"`
Error *struct {
Code int `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Detail string `json:"detail,omitempty"`
} `json:"error,omitempty"`
}
// Read the entire body first
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return errToPfsenseApiResponse[T](PfsenseApiErrorCodeResponseDecodeFailed, "failed to read response body", err)
}
// Close the body after reading
defer func() {
if err := resp.Body.Close(); err != nil {
slog.Error("failed to close response body", "error", err)
}
}()
if len(bodyBytes) == 0 {
// Empty response for DELETE operations
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return PfsenseApiResponse[T]{Status: PfsenseApiStatusOk, Code: resp.StatusCode}
}
return errToPfsenseApiResponse[T](resp.StatusCode, "empty error response", fmt.Errorf("HTTP %d", resp.StatusCode))
}
if err := json.Unmarshal(bodyBytes, &wrapper); err != nil {
// Log the actual response for debugging when JSON parsing fails
contentType := resp.Header.Get("Content-Type")
bodyPreview := string(bodyBytes)
if len(bodyPreview) > 500 {
bodyPreview = bodyPreview[:500] + "..."
}
slog.Error("failed to decode pfSense API response",
"status_code", resp.StatusCode,
"content_type", contentType,
"url", resp.Request.URL.String(),
"method", resp.Request.Method,
"body_preview", bodyPreview,
"error", err)
return errToPfsenseApiResponse[T](PfsenseApiErrorCodeResponseDecodeFailed,
fmt.Sprintf("failed to decode response (status %d, content-type: %s): %v", resp.StatusCode, contentType, err), err)
}
// Check if response indicates success
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
// Map pfSense status to our status
status := PfsenseApiStatusOk
if wrapper.Status != "ok" && wrapper.Status != "success" {
status = PfsenseApiStatusError
}
// Handle EmptyResponse type
if _, ok := any(wrapper.Data).(EmptyResponse); ok {
return PfsenseApiResponse[T]{Status: status, Code: wrapper.Code}
}
return PfsenseApiResponse[T]{Status: status, Code: wrapper.Code, Data: wrapper.Data}
}
// Handle error response
if wrapper.Error != nil {
return PfsenseApiResponse[T]{
Status: PfsenseApiStatusError,
Code: wrapper.Code,
Error: &PfsenseApiError{
Code: wrapper.Error.Code,
Message: wrapper.Error.Message,
Details: wrapper.Error.Detail,
},
}
}
// Fallback error response
return errToPfsenseApiResponse[T](wrapper.Code, "unknown error", fmt.Errorf("HTTP %d: %s", wrapper.Code, wrapper.Status))
}
func (p *PfsenseApiClient) Query(
ctx context.Context,
command string,
opts *PfsenseRequestOptions,
) PfsenseApiResponse[[]GenericJsonObject] {
apiCtx, cancel := context.WithTimeout(ctx, p.cfg.GetApiTimeout())
defer cancel()
fullUrl := opts.GetPath(p.getFullPath(command))
req, err := p.prepareGetRequest(apiCtx, fullUrl)
if err != nil {
return errToPfsenseApiResponse[[]GenericJsonObject](PfsenseApiErrorCodeRequestPreparationFailed,
"failed to create request", err)
}
start := time.Now()
p.debugLog("executing API query", "url", fullUrl)
response := parsePfsenseHttpResponse[[]GenericJsonObject](p.client.Do(req))
p.debugLog("retrieved API query result", "url", fullUrl, "duration", time.Since(start).String())
return response
}
func (p *PfsenseApiClient) Get(
ctx context.Context,
command string,
opts *PfsenseRequestOptions,
) PfsenseApiResponse[GenericJsonObject] {
apiCtx, cancel := context.WithTimeout(ctx, p.cfg.GetApiTimeout())
defer cancel()
fullUrl := opts.GetPath(p.getFullPath(command))
req, err := p.prepareGetRequest(apiCtx, fullUrl)
if err != nil {
return errToPfsenseApiResponse[GenericJsonObject](PfsenseApiErrorCodeRequestPreparationFailed,
"failed to create request", err)
}
start := time.Now()
p.debugLog("executing API get", "url", fullUrl)
response := parsePfsenseHttpResponse[GenericJsonObject](p.client.Do(req))
p.debugLog("retrieved API get result", "url", fullUrl, "duration", time.Since(start).String())
return response
}
func (p *PfsenseApiClient) Create(
ctx context.Context,
command string,
payload GenericJsonObject,
) PfsenseApiResponse[GenericJsonObject] {
apiCtx, cancel := context.WithTimeout(ctx, p.cfg.GetApiTimeout())
defer cancel()
fullUrl := p.getFullPath(command)
req, err := p.preparePayloadRequest(apiCtx, http.MethodPost, fullUrl, payload)
if err != nil {
return errToPfsenseApiResponse[GenericJsonObject](PfsenseApiErrorCodeRequestPreparationFailed,
"failed to create request", err)
}
start := time.Now()
p.debugLog("executing API post", "url", fullUrl)
response := parsePfsenseHttpResponse[GenericJsonObject](p.client.Do(req))
p.debugLog("retrieved API post result", "url", fullUrl, "duration", time.Since(start).String())
return response
}
func (p *PfsenseApiClient) Update(
ctx context.Context,
command string,
payload GenericJsonObject,
) PfsenseApiResponse[GenericJsonObject] {
apiCtx, cancel := context.WithTimeout(ctx, p.cfg.GetApiTimeout())
defer cancel()
fullUrl := p.getFullPath(command)
req, err := p.preparePayloadRequest(apiCtx, http.MethodPatch, fullUrl, payload)
if err != nil {
return errToPfsenseApiResponse[GenericJsonObject](PfsenseApiErrorCodeRequestPreparationFailed,
"failed to create request", err)
}
start := time.Now()
p.debugLog("executing API patch", "url", fullUrl)
response := parsePfsenseHttpResponse[GenericJsonObject](p.client.Do(req))
p.debugLog("retrieved API patch result", "url", fullUrl, "duration", time.Since(start).String())
return response
}
func (p *PfsenseApiClient) Delete(
ctx context.Context,
command string,
) PfsenseApiResponse[EmptyResponse] {
apiCtx, cancel := context.WithTimeout(ctx, p.cfg.GetApiTimeout())
defer cancel()
fullUrl := p.getFullPath(command)
req, err := p.prepareDeleteRequest(apiCtx, fullUrl)
if err != nil {
return errToPfsenseApiResponse[EmptyResponse](PfsenseApiErrorCodeRequestPreparationFailed,
"failed to create request", err)
}
start := time.Now()
p.debugLog("executing API delete", "url", fullUrl)
response := parsePfsenseHttpResponse[EmptyResponse](p.client.Do(req))
p.debugLog("retrieved API delete result", "url", fullUrl, "duration", time.Since(start).String())
return response
}
// endregion API-client