mirror of
https://github.com/h44z/wg-portal.git
synced 2025-12-14 10:36:18 +00:00
429 lines
12 KiB
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
|
||
|
|
|