mirror of https://github.com/h44z/wg-portal.git
				
				
				
			
		
			
				
	
	
		
			436 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Go
		
	
	
	
			
		
		
	
	
			436 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Go
		
	
	
	
| package lowlevel
 | |
| 
 | |
| import (
 | |
| 	"bytes"
 | |
| 	"context"
 | |
| 	"crypto/tls"
 | |
| 	"encoding/json"
 | |
| 	"fmt"
 | |
| 	"io"
 | |
| 	"log/slog"
 | |
| 	"net/http"
 | |
| 	"net/url"
 | |
| 	"strconv"
 | |
| 	"strings"
 | |
| 	"time"
 | |
| 
 | |
| 	"github.com/h44z/wg-portal/internal"
 | |
| 	"github.com/h44z/wg-portal/internal/config"
 | |
| )
 | |
| 
 | |
| // region models
 | |
| 
 | |
| const (
 | |
| 	MikrotikApiStatusOk    = "success"
 | |
| 	MikrotikApiStatusError = "error"
 | |
| )
 | |
| 
 | |
| const (
 | |
| 	MikrotikApiErrorCodeUnknown = iota + 600
 | |
| 	MikrotikApiErrorCodeRequestPreparationFailed
 | |
| 	MikrotikApiErrorCodeRequestFailed
 | |
| 	MikrotikApiErrorCodeResponseDecodeFailed
 | |
| )
 | |
| 
 | |
| type MikrotikApiResponse[T any] struct {
 | |
| 	Status string
 | |
| 	Code   int
 | |
| 	Data   T                 `json:"data,omitempty"`
 | |
| 	Error  *MikrotikApiError `json:"error,omitempty"`
 | |
| }
 | |
| 
 | |
| type MikrotikApiError struct {
 | |
| 	Code    int    `json:"error,omitempty"`
 | |
| 	Message string `json:"message,omitempty"`
 | |
| 	Details string `json:"detail,omitempty"`
 | |
| }
 | |
| 
 | |
| func (e *MikrotikApiError) String() string {
 | |
| 	if e == nil {
 | |
| 		return "no error"
 | |
| 	}
 | |
| 	return fmt.Sprintf("API error %d: %s - %s", e.Code, e.Message, e.Details)
 | |
| }
 | |
| 
 | |
| type GenericJsonObject map[string]any
 | |
| type EmptyResponse struct{}
 | |
| 
 | |
| func (JsonObject GenericJsonObject) GetString(key string) string {
 | |
| 	if value, ok := JsonObject[key]; ok {
 | |
| 		if strValue, ok := value.(string); ok {
 | |
| 			return strValue
 | |
| 		} else {
 | |
| 			return fmt.Sprintf("%v", value) // Convert to string if not already
 | |
| 		}
 | |
| 	}
 | |
| 	return ""
 | |
| }
 | |
| 
 | |
| func (JsonObject GenericJsonObject) GetInt(key string) int {
 | |
| 	if value, ok := JsonObject[key]; ok {
 | |
| 		if intValue, ok := value.(int); ok {
 | |
| 			return intValue
 | |
| 		} else {
 | |
| 			if floatValue, ok := value.(float64); ok {
 | |
| 				return int(floatValue) // Convert float64 to int
 | |
| 			}
 | |
| 			if strValue, ok := value.(string); ok {
 | |
| 				if intValue, err := strconv.Atoi(strValue); err == nil {
 | |
| 					return intValue // Convert string to int if possible
 | |
| 				}
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 	return 0
 | |
| }
 | |
| 
 | |
| func (JsonObject GenericJsonObject) GetBool(key string) bool {
 | |
| 	if value, ok := JsonObject[key]; ok {
 | |
| 		if boolValue, ok := value.(bool); ok {
 | |
| 			return boolValue
 | |
| 		} else {
 | |
| 			if intValue, ok := value.(int); ok {
 | |
| 				return intValue == 1 // Convert int to bool (1 is true, 0 is false)
 | |
| 			}
 | |
| 			if floatValue, ok := value.(float64); ok {
 | |
| 				return int(floatValue) == 1 // Convert float64 to bool (1.0 is true, 0.0 is false)
 | |
| 			}
 | |
| 			if strValue, ok := value.(string); ok {
 | |
| 				boolValue, err := strconv.ParseBool(strValue)
 | |
| 				if err == nil {
 | |
| 					return boolValue
 | |
| 				}
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 	return false
 | |
| }
 | |
| 
 | |
| type MikrotikRequestOptions struct {
 | |
| 	Filters  map[string]string `json:"filters,omitempty"`
 | |
| 	PropList []string          `json:"proplist,omitempty"`
 | |
| }
 | |
| 
 | |
| func (o *MikrotikRequestOptions) GetPath(base string) string {
 | |
| 	if o == nil {
 | |
| 		return base
 | |
| 	}
 | |
| 
 | |
| 	path, err := url.Parse(base)
 | |
| 	if err != nil {
 | |
| 		return base
 | |
| 	}
 | |
| 
 | |
| 	query := path.Query()
 | |
| 	for k, v := range o.Filters {
 | |
| 		query.Set(k, v)
 | |
| 	}
 | |
| 	if len(o.PropList) > 0 {
 | |
| 		query.Set(".proplist", strings.Join(o.PropList, ","))
 | |
| 	}
 | |
| 	path.RawQuery = query.Encode()
 | |
| 	return path.String()
 | |
| }
 | |
| 
 | |
| // region models
 | |
| 
 | |
| // region API-client
 | |
| 
 | |
| type MikrotikApiClient struct {
 | |
| 	coreCfg *config.Config
 | |
| 	cfg     *config.BackendMikrotik
 | |
| 
 | |
| 	client *http.Client
 | |
| 	log    *slog.Logger
 | |
| }
 | |
| 
 | |
| func NewMikrotikApiClient(coreCfg *config.Config, cfg *config.BackendMikrotik) (*MikrotikApiClient, error) {
 | |
| 	c := &MikrotikApiClient{
 | |
| 		coreCfg: coreCfg,
 | |
| 		cfg:     cfg,
 | |
| 	}
 | |
| 
 | |
| 	err := c.setup()
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	c.debugLog("Mikrotik api client created", "api_url", cfg.ApiUrl)
 | |
| 
 | |
| 	return c, nil
 | |
| }
 | |
| 
 | |
| func (m *MikrotikApiClient) setup() error {
 | |
| 	m.client = &http.Client{
 | |
| 		Transport: &http.Transport{
 | |
| 			TLSClientConfig: &tls.Config{
 | |
| 				InsecureSkipVerify: !m.cfg.ApiVerifyTls,
 | |
| 			},
 | |
| 		},
 | |
| 		Timeout: m.cfg.GetApiTimeout(),
 | |
| 	}
 | |
| 
 | |
| 	if m.cfg.Debug {
 | |
| 		m.log = slog.New(internal.GetLoggingHandler("debug",
 | |
| 			m.coreCfg.Advanced.LogPretty,
 | |
| 			m.coreCfg.Advanced.LogJson).
 | |
| 			WithAttrs([]slog.Attr{
 | |
| 				{
 | |
| 					Key: "mikrotik-bid", Value: slog.StringValue(m.cfg.Id),
 | |
| 				},
 | |
| 			}))
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (m *MikrotikApiClient) debugLog(msg string, args ...any) {
 | |
| 	if m.log != nil {
 | |
| 		m.log.Debug("[MT-API] "+msg, args...)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (m *MikrotikApiClient) getFullPath(command string) string {
 | |
| 	path, err := url.JoinPath(m.cfg.ApiUrl, command)
 | |
| 	if err != nil {
 | |
| 		return ""
 | |
| 	}
 | |
| 	return path
 | |
| }
 | |
| 
 | |
| func (m *MikrotikApiClient) 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 m.cfg.ApiUser != "" && m.cfg.ApiPassword != "" {
 | |
| 		req.SetBasicAuth(m.cfg.ApiUser, m.cfg.ApiPassword)
 | |
| 	}
 | |
| 
 | |
| 	return req, nil
 | |
| }
 | |
| 
 | |
| func (m *MikrotikApiClient) 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 m.cfg.ApiUser != "" && m.cfg.ApiPassword != "" {
 | |
| 		req.SetBasicAuth(m.cfg.ApiUser, m.cfg.ApiPassword)
 | |
| 	}
 | |
| 
 | |
| 	return req, nil
 | |
| }
 | |
| 
 | |
| func (m *MikrotikApiClient) 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 m.cfg.ApiUser != "" && m.cfg.ApiPassword != "" {
 | |
| 		req.SetBasicAuth(m.cfg.ApiUser, m.cfg.ApiPassword)
 | |
| 	}
 | |
| 
 | |
| 	return req, nil
 | |
| }
 | |
| 
 | |
| func errToApiResponse[T any](code int, message string, err error) MikrotikApiResponse[T] {
 | |
| 	return MikrotikApiResponse[T]{
 | |
| 		Status: MikrotikApiStatusError,
 | |
| 		Code:   code,
 | |
| 		Error: &MikrotikApiError{
 | |
| 			Code:    code,
 | |
| 			Message: message,
 | |
| 			Details: err.Error(),
 | |
| 		},
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func parseHttpResponse[T any](resp *http.Response, err error) MikrotikApiResponse[T] {
 | |
| 	if err != nil {
 | |
| 		return errToApiResponse[T](MikrotikApiErrorCodeRequestFailed, "failed to execute request", err)
 | |
| 	}
 | |
| 
 | |
| 	defer func(Body io.ReadCloser) {
 | |
| 		err := Body.Close()
 | |
| 		if err != nil {
 | |
| 			slog.Error("failed to close response body", "error", err)
 | |
| 		}
 | |
| 	}(resp.Body)
 | |
| 
 | |
| 	if resp.StatusCode >= 200 && resp.StatusCode < 300 {
 | |
| 		var data T
 | |
| 
 | |
| 		// if the type of T is EmptyResponse, we can return an empty response with just the status
 | |
| 		if _, ok := any(data).(EmptyResponse); ok {
 | |
| 			return MikrotikApiResponse[T]{Status: MikrotikApiStatusOk, Code: resp.StatusCode}
 | |
| 		}
 | |
| 
 | |
| 		if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
 | |
| 			return errToApiResponse[T](MikrotikApiErrorCodeResponseDecodeFailed, "failed to decode response", err)
 | |
| 		}
 | |
| 		return MikrotikApiResponse[T]{Status: MikrotikApiStatusOk, Code: resp.StatusCode, Data: data}
 | |
| 	}
 | |
| 
 | |
| 	var apiErr MikrotikApiError
 | |
| 	if err := json.NewDecoder(resp.Body).Decode(&apiErr); err != nil {
 | |
| 		return errToApiResponse[T](resp.StatusCode, "unknown error, unparsable response", err)
 | |
| 	} else {
 | |
| 		return MikrotikApiResponse[T]{Status: MikrotikApiStatusError, Code: resp.StatusCode, Error: &apiErr}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (m *MikrotikApiClient) Query(
 | |
| 	ctx context.Context,
 | |
| 	command string,
 | |
| 	opts *MikrotikRequestOptions,
 | |
| ) MikrotikApiResponse[[]GenericJsonObject] {
 | |
| 	apiCtx, cancel := context.WithTimeout(ctx, m.cfg.GetApiTimeout())
 | |
| 	defer cancel()
 | |
| 
 | |
| 	fullUrl := opts.GetPath(m.getFullPath(command))
 | |
| 
 | |
| 	req, err := m.prepareGetRequest(apiCtx, fullUrl)
 | |
| 	if err != nil {
 | |
| 		return errToApiResponse[[]GenericJsonObject](MikrotikApiErrorCodeRequestPreparationFailed,
 | |
| 			"failed to create request", err)
 | |
| 	}
 | |
| 
 | |
| 	start := time.Now()
 | |
| 	m.debugLog("executing API query", "url", fullUrl)
 | |
| 	response := parseHttpResponse[[]GenericJsonObject](m.client.Do(req))
 | |
| 	m.debugLog("retrieved API query result", "url", fullUrl, "duration", time.Since(start).String())
 | |
| 	return response
 | |
| }
 | |
| 
 | |
| func (m *MikrotikApiClient) Get(
 | |
| 	ctx context.Context,
 | |
| 	command string,
 | |
| 	opts *MikrotikRequestOptions,
 | |
| ) MikrotikApiResponse[GenericJsonObject] {
 | |
| 	apiCtx, cancel := context.WithTimeout(ctx, m.cfg.GetApiTimeout())
 | |
| 	defer cancel()
 | |
| 
 | |
| 	fullUrl := opts.GetPath(m.getFullPath(command))
 | |
| 
 | |
| 	req, err := m.prepareGetRequest(apiCtx, fullUrl)
 | |
| 	if err != nil {
 | |
| 		return errToApiResponse[GenericJsonObject](MikrotikApiErrorCodeRequestPreparationFailed,
 | |
| 			"failed to create request", err)
 | |
| 	}
 | |
| 
 | |
| 	start := time.Now()
 | |
| 	m.debugLog("executing API get", "url", fullUrl)
 | |
| 	response := parseHttpResponse[GenericJsonObject](m.client.Do(req))
 | |
| 	m.debugLog("retrieved API get result", "url", fullUrl, "duration", time.Since(start).String())
 | |
| 	return response
 | |
| }
 | |
| 
 | |
| func (m *MikrotikApiClient) Create(
 | |
| 	ctx context.Context,
 | |
| 	command string,
 | |
| 	payload GenericJsonObject,
 | |
| ) MikrotikApiResponse[GenericJsonObject] {
 | |
| 	apiCtx, cancel := context.WithTimeout(ctx, m.cfg.GetApiTimeout())
 | |
| 	defer cancel()
 | |
| 
 | |
| 	fullUrl := m.getFullPath(command)
 | |
| 
 | |
| 	req, err := m.preparePayloadRequest(apiCtx, http.MethodPut, fullUrl, payload)
 | |
| 	if err != nil {
 | |
| 		return errToApiResponse[GenericJsonObject](MikrotikApiErrorCodeRequestPreparationFailed,
 | |
| 			"failed to create request", err)
 | |
| 	}
 | |
| 
 | |
| 	start := time.Now()
 | |
| 	m.debugLog("executing API put", "url", fullUrl)
 | |
| 	response := parseHttpResponse[GenericJsonObject](m.client.Do(req))
 | |
| 	m.debugLog("retrieved API put result", "url", fullUrl, "duration", time.Since(start).String())
 | |
| 	return response
 | |
| }
 | |
| 
 | |
| func (m *MikrotikApiClient) Update(
 | |
| 	ctx context.Context,
 | |
| 	command string,
 | |
| 	payload GenericJsonObject,
 | |
| ) MikrotikApiResponse[GenericJsonObject] {
 | |
| 	apiCtx, cancel := context.WithTimeout(ctx, m.cfg.GetApiTimeout())
 | |
| 	defer cancel()
 | |
| 
 | |
| 	fullUrl := m.getFullPath(command)
 | |
| 
 | |
| 	req, err := m.preparePayloadRequest(apiCtx, http.MethodPatch, fullUrl, payload)
 | |
| 	if err != nil {
 | |
| 		return errToApiResponse[GenericJsonObject](MikrotikApiErrorCodeRequestPreparationFailed,
 | |
| 			"failed to create request", err)
 | |
| 	}
 | |
| 
 | |
| 	start := time.Now()
 | |
| 	m.debugLog("executing API patch", "url", fullUrl)
 | |
| 	response := parseHttpResponse[GenericJsonObject](m.client.Do(req))
 | |
| 	m.debugLog("retrieved API patch result", "url", fullUrl, "duration", time.Since(start).String())
 | |
| 	return response
 | |
| }
 | |
| 
 | |
| func (m *MikrotikApiClient) Delete(
 | |
| 	ctx context.Context,
 | |
| 	command string,
 | |
| ) MikrotikApiResponse[EmptyResponse] {
 | |
| 	apiCtx, cancel := context.WithTimeout(ctx, m.cfg.GetApiTimeout())
 | |
| 	defer cancel()
 | |
| 
 | |
| 	fullUrl := m.getFullPath(command)
 | |
| 
 | |
| 	req, err := m.prepareDeleteRequest(apiCtx, fullUrl)
 | |
| 	if err != nil {
 | |
| 		return errToApiResponse[EmptyResponse](MikrotikApiErrorCodeRequestPreparationFailed,
 | |
| 			"failed to create request", err)
 | |
| 	}
 | |
| 
 | |
| 	start := time.Now()
 | |
| 	m.debugLog("executing API delete", "url", fullUrl)
 | |
| 	response := parseHttpResponse[EmptyResponse](m.client.Do(req))
 | |
| 	m.debugLog("retrieved API delete result", "url", fullUrl, "duration", time.Since(start).String())
 | |
| 	return response
 | |
| }
 | |
| 
 | |
| func (m *MikrotikApiClient) ExecList(
 | |
| 	ctx context.Context,
 | |
| 	command string,
 | |
| 	payload GenericJsonObject,
 | |
| ) MikrotikApiResponse[[]GenericJsonObject] {
 | |
| 	apiCtx, cancel := context.WithTimeout(ctx, m.cfg.GetApiTimeout())
 | |
| 	defer cancel()
 | |
| 
 | |
| 	fullUrl := m.getFullPath(command)
 | |
| 
 | |
| 	req, err := m.preparePayloadRequest(apiCtx, http.MethodPost, fullUrl, payload)
 | |
| 	if err != nil {
 | |
| 		return errToApiResponse[[]GenericJsonObject](MikrotikApiErrorCodeRequestPreparationFailed,
 | |
| 			"failed to create request", err)
 | |
| 	}
 | |
| 
 | |
| 	start := time.Now()
 | |
| 	m.debugLog("executing API post", "url", fullUrl)
 | |
| 	response := parseHttpResponse[[]GenericJsonObject](m.client.Do(req))
 | |
| 	m.debugLog("retrieved API post result", "url", fullUrl, "duration", time.Since(start).String())
 | |
| 	return response
 | |
| }
 | |
| 
 | |
| // endregion API-client
 |