From e934232e0b73e673852d9294562fa77a0dc3670c Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Sat, 31 May 2025 17:17:08 +0200 Subject: [PATCH] wip: implement mikrotik rest api client (#426) --- internal/adapters/wgcontroller/local.go | 4 + internal/adapters/wgcontroller/mikrotik.go | 253 ++++++++++++++- internal/app/wireguard/controller_manager.go | 3 +- .../app/wireguard/wireguard_interfaces.go | 22 +- internal/config/backend.go | 11 +- internal/domain/peer.go | 2 + internal/logger.go | 11 +- internal/lowlevel/mikrotik.go | 291 ++++++++++++++++++ 8 files changed, 574 insertions(+), 23 deletions(-) create mode 100644 internal/lowlevel/mikrotik.go diff --git a/internal/adapters/wgcontroller/local.go b/internal/adapters/wgcontroller/local.go index b1541aa..b47db59 100644 --- a/internal/adapters/wgcontroller/local.go +++ b/internal/adapters/wgcontroller/local.go @@ -89,6 +89,10 @@ func NewLocalController(cfg *config.Config) (*LocalController, error) { return repo, nil } +func (c LocalController) GetId() domain.InterfaceBackend { + return config.LocalBackendName +} + // region wireguard-related func (c LocalController) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) { diff --git a/internal/adapters/wgcontroller/mikrotik.go b/internal/adapters/wgcontroller/mikrotik.go index c01fae7..356ebf2 100644 --- a/internal/adapters/wgcontroller/mikrotik.go +++ b/internal/adapters/wgcontroller/mikrotik.go @@ -2,38 +2,261 @@ package wgcontroller import ( "context" + "fmt" + "time" + "github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/domain" + "github.com/h44z/wg-portal/internal/lowlevel" ) type MikrotikController struct { + coreCfg *config.Config + cfg *config.BackendMikrotik + + client *lowlevel.MikrotikApiClient } -func NewMikrotikController() (*MikrotikController, error) { - return &MikrotikController{}, nil +func NewMikrotikController(coreCfg *config.Config, cfg *config.BackendMikrotik) (*MikrotikController, error) { + client, err := lowlevel.NewMikrotikApiClient(coreCfg, cfg) + if err != nil { + return nil, fmt.Errorf("failed to create Mikrotik API client: %w", err) + } + + return &MikrotikController{ + coreCfg: coreCfg, + cfg: cfg, + + client: client, + }, nil +} + +func (c MikrotikController) GetId() domain.InterfaceBackend { + return domain.InterfaceBackend(c.cfg.Id) } // region wireguard-related -func (c MikrotikController) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) { - // TODO implement me - panic("implement me") +func (c MikrotikController) GetInterfaces(ctx context.Context) ([]domain.PhysicalInterface, error) { + wgReply := c.client.Query(ctx, "/interface/wireguard", &lowlevel.MikrotikRequestOptions{ + PropList: []string{ + ".id", "name", "public-key", "private-key", "listen-port", "mtu", "disabled", "running", + }, + }) + if wgReply.Status != lowlevel.MikrotikApiStatusOk { + return nil, fmt.Errorf("failed to query interfaces: %v", wgReply.Error) + } + + interfaces := make([]domain.PhysicalInterface, 0, len(wgReply.Data)) + for _, wg := range wgReply.Data { + physicalInterface, err := c.loadInterfaceData(ctx, wg) + if err != nil { + return nil, err + } + interfaces = append(interfaces, *physicalInterface) + } + + return interfaces, nil } -func (c MikrotikController) GetInterface(_ context.Context, id domain.InterfaceIdentifier) ( +func (c MikrotikController) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) ( *domain.PhysicalInterface, error, ) { - // TODO implement me - panic("implement me") + wgReply := c.client.Query(ctx, "/interface/wireguard", &lowlevel.MikrotikRequestOptions{ + PropList: []string{ + ".id", "name", "public-key", "private-key", "listen-port", "mtu", "disabled", "running", + }, + Filters: map[string]string{ + "name": string(id), + }, + }) + if wgReply.Status != lowlevel.MikrotikApiStatusOk { + return nil, fmt.Errorf("failed to query interface %s: %v", id, wgReply.Error) + } + + if len(wgReply.Data) == 0 { + return nil, fmt.Errorf("interface %s not found", id) + } + + return c.loadInterfaceData(ctx, wgReply.Data[0]) } -func (c MikrotikController) GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ( +func (c MikrotikController) loadInterfaceData( + ctx context.Context, + wireGuardObj lowlevel.GenericJsonObject, +) (*domain.PhysicalInterface, error) { + deviceId := wireGuardObj.GetString(".id") + deviceName := wireGuardObj.GetString("name") + ifaceReply := c.client.Get(ctx, "/interface/"+deviceId, &lowlevel.MikrotikRequestOptions{ + PropList: []string{ + "name", "rx-byte", "tx-byte", + }, + }) + if ifaceReply.Status != lowlevel.MikrotikApiStatusOk { + return nil, fmt.Errorf("failed to query interface %s: %v", deviceId, ifaceReply.Error) + } + + addrV4Reply := c.client.Query(ctx, "/ip/address", &lowlevel.MikrotikRequestOptions{ + PropList: []string{ + "address", "network", + }, + Filters: map[string]string{ + "interface": deviceName, + "dynamic": "false", // we only want static addresses + "disabled": "false", // we only want addresses that are not disabled + }, + }) + if addrV4Reply.Status != lowlevel.MikrotikApiStatusOk { + return nil, fmt.Errorf("failed to query IPv4 addresses for interface %s: %v", deviceId, addrV4Reply.Error) + } + + addrV6Reply := c.client.Query(ctx, "/ipv6/address", &lowlevel.MikrotikRequestOptions{ + PropList: []string{ + "address", "network", + }, + Filters: map[string]string{ + "interface": deviceName, + "dynamic": "false", // we only want static addresses + "disabled": "false", // we only want addresses that are not disabled + }, + }) + if addrV6Reply.Status != lowlevel.MikrotikApiStatusOk { + return nil, fmt.Errorf("failed to query IPv6 addresses for interface %s: %v", deviceId, addrV6Reply.Error) + } + + interfaceModel, err := c.convertWireGuardInterface(wireGuardObj, ifaceReply.Data, addrV4Reply.Data, + addrV6Reply.Data) + if err != nil { + return nil, fmt.Errorf("interface convert failed for %s: %w", deviceName, err) + } + return &interfaceModel, nil +} + +func (c MikrotikController) convertWireGuardInterface( + wg, iface lowlevel.GenericJsonObject, + ipv4, ipv6 []lowlevel.GenericJsonObject, +) ( + domain.PhysicalInterface, + error, +) { + // read data from wgctrl interface + + addresses := make([]domain.Cidr, 0, len(ipv4)+len(ipv6)) + for _, addr := range append(ipv4, ipv6...) { + addrStr := addr.GetString("address") + if addrStr == "" { + continue + } + cidr, err := domain.CidrFromString(addrStr) + if err != nil { + continue + } + + addresses = append(addresses, cidr) + } + + pi := domain.PhysicalInterface{ + Identifier: domain.InterfaceIdentifier(wg.GetString("name")), + KeyPair: domain.KeyPair{ + PrivateKey: wg.GetString("private-key"), + PublicKey: wg.GetString("public-key"), + }, + ListenPort: wg.GetInt("listen-port"), + Addresses: addresses, + Mtu: wg.GetInt("mtu"), + FirewallMark: 0, + DeviceUp: wg.GetBool("running"), + ImportSource: "mikrotik", + DeviceType: "Mikrotik", + BytesUpload: uint64(iface.GetInt("tx-byte")), + BytesDownload: uint64(iface.GetInt("rx-byte")), + } + + return pi, nil +} + +func (c MikrotikController) GetPeers(ctx context.Context, deviceId domain.InterfaceIdentifier) ( []domain.PhysicalPeer, error, ) { - // TODO implement me - panic("implement me") + wgReply := c.client.Query(ctx, "/interface/wireguard/peers", &lowlevel.MikrotikRequestOptions{ + PropList: []string{ + ".id", "name", "allowed-address", "client-address", "client-endpoint", "client-keepalive", "comment", + "current-endpoint-address", "current-endpoint-port", "last-handshake", "persistent-keepalive", + "public-key", "private-key", "preshared-key", "mtu", "disabled", "rx", "tx", "responder", + }, + Filters: map[string]string{ + "interface": string(deviceId), + }, + }) + if wgReply.Status != lowlevel.MikrotikApiStatusOk { + return nil, fmt.Errorf("failed to query peers for %s: %v", deviceId, wgReply.Error) + } + + if len(wgReply.Data) == 0 { + return nil, nil + } + + peers := make([]domain.PhysicalPeer, 0, len(wgReply.Data)) + for _, peer := range wgReply.Data { + peerModel, err := c.convertWireGuardPeer(peer) + if err != nil { + return nil, fmt.Errorf("peer convert failed for %v: %w", peer.GetString("name"), err) + } + peers = append(peers, peerModel) + } + + return peers, nil +} + +func (c MikrotikController) convertWireGuardPeer(peer lowlevel.GenericJsonObject) (domain.PhysicalPeer, error) { + keepAliveSeconds := 0 + duration, err := time.ParseDuration(peer.GetString("client-keepalive")) + if err == nil { + keepAliveSeconds = int(duration.Seconds()) + } + + currentEndpoint := "" + if peer.GetString("current-endpoint-address") != "" && peer.GetString("current-endpoint-port") != "" { + currentEndpoint = peer.GetString("current-endpoint-address") + ":" + peer.GetString("current-endpoint-port") + } + + lastHandshakeTime := time.Time{} + if peer.GetString("last-handshake") != "" { + relDuration, err := time.ParseDuration(peer.GetString("last-handshake")) + if err == nil { + lastHandshakeTime = time.Now().Add(-relDuration) + } + } + + allowedAddresses, _ := domain.CidrsFromString(peer.GetString("allowed-address")) + + peerModel := domain.PhysicalPeer{ + Identifier: domain.PeerIdentifier(peer.GetString("public-key")), + Endpoint: currentEndpoint, + AllowedIPs: allowedAddresses, + KeyPair: domain.KeyPair{ + PublicKey: peer.GetString("public-key"), + PrivateKey: peer.GetString("private-key"), + }, + PresharedKey: domain.PreSharedKey(peer.GetString("preshared-key")), + PersistentKeepalive: keepAliveSeconds, + LastHandshake: lastHandshakeTime, + ProtocolVersion: 0, // Mikrotik does not support protocol versioning, so we set it to 0 + BytesUpload: uint64(peer.GetInt("rx")), + BytesDownload: uint64(peer.GetInt("tx")), + + BackendExtras: make(map[string]interface{}), + } + + peerModel.BackendExtras["MT-NAME"] = peer.GetString("name") + peerModel.BackendExtras["MT-COMMENT"] = peer.GetString("comment") + peerModel.BackendExtras["MT-RESPONDER"] = peer.GetString("responder") + peerModel.BackendExtras["MT-ENDPOINT"] = peer.GetString("client-endpoint") + peerModel.BackendExtras["MT-IP"] = peer.GetString("client-address") + + return peerModel, nil } func (c MikrotikController) SaveInterface( @@ -42,12 +265,12 @@ func (c MikrotikController) SaveInterface( updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error), ) error { // TODO implement me - panic("implement me") + return nil } func (c MikrotikController) DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error { // TODO implement me - panic("implement me") + return nil } func (c MikrotikController) SavePeer( @@ -57,7 +280,7 @@ func (c MikrotikController) SavePeer( updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error), ) error { // TODO implement me - panic("implement me") + return nil } func (c MikrotikController) DeletePeer( @@ -66,7 +289,7 @@ func (c MikrotikController) DeletePeer( id domain.PeerIdentifier, ) error { // TODO implement me - panic("implement me") + return nil } // endregion wireguard-related diff --git a/internal/app/wireguard/controller_manager.go b/internal/app/wireguard/controller_manager.go index 7a5ccd5..0211d86 100644 --- a/internal/app/wireguard/controller_manager.go +++ b/internal/app/wireguard/controller_manager.go @@ -13,6 +13,7 @@ import ( ) type InterfaceController interface { + GetId() domain.InterfaceBackend GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error) @@ -92,7 +93,7 @@ func (c *ControllerManager) registerMikrotikControllers() error { continue } - controller, err := wgcontroller.NewMikrotikController() // TODO: Pass backendConfig to the controller constructor + controller, err := wgcontroller.NewMikrotikController(c.cfg, &backendConfig) if err != nil { return fmt.Errorf("failed to create Mikrotik controller for backend %s: %w", backendConfig.Id, err) } diff --git a/internal/app/wireguard/wireguard_interfaces.go b/internal/app/wireguard/wireguard_interfaces.go index a2a727f..5ae95ae 100644 --- a/internal/app/wireguard/wireguard_interfaces.go +++ b/internal/app/wireguard/wireguard_interfaces.go @@ -149,7 +149,7 @@ func (m Manager) ImportNewInterfaces(ctx context.Context, filter ...domain.Inter return 0, err } - err = m.importInterface(ctx, &physicalInterface, physicalPeers) + err = m.importInterface(ctx, wgBackend, &physicalInterface, physicalPeers) if err != nil { return 0, fmt.Errorf("import of %s failed: %w", physicalInterface.Identifier, err) } @@ -770,7 +770,12 @@ func (m Manager) getFreshListenPort(ctx context.Context) (port int, err error) { return } -func (m Manager) importInterface(ctx context.Context, in *domain.PhysicalInterface, peers []domain.PhysicalPeer) error { +func (m Manager) importInterface( + ctx context.Context, + backend InterfaceController, + in *domain.PhysicalInterface, + peers []domain.PhysicalPeer, +) error { now := time.Now() iface := domain.ConvertPhysicalInterface(in) iface.BaseModel = domain.BaseModel{ @@ -779,6 +784,7 @@ func (m Manager) importInterface(ctx context.Context, in *domain.PhysicalInterfa CreatedAt: now, UpdatedAt: now, } + iface.Backend = backend.GetId() iface.PeerDefAllowedIPsStr = iface.AddressStr() existingInterface, err := m.db.GetInterface(ctx, iface.Identifier) @@ -843,6 +849,18 @@ func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain peer.DisplayName = "Autodetected Client (" + peer.Interface.PublicKey[0:8] + ")" } + if p.BackendExtras != nil { + if val, ok := p.BackendExtras["MT-NAME"]; ok { + peer.DisplayName = val.(string) + } + if val, ok := p.BackendExtras["MT-COMMENT"]; ok { + peer.Notes = val.(string) + } + if val, ok := p.BackendExtras["MT-ENDPOINT"]; ok { + peer.Endpoint = domain.NewConfigOption(val.(string), true) + } + } + err := m.db.SavePeer(ctx, peer.Identifier, func(_ *domain.Peer) (*domain.Peer, error) { return peer, nil }) diff --git a/internal/config/backend.go b/internal/config/backend.go index 714568f..5508e1c 100644 --- a/internal/config/backend.go +++ b/internal/config/backend.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "time" ) const LocalBackendName = "local" @@ -46,7 +47,11 @@ type BackendBase struct { type BackendMikrotik struct { BackendBase `yaml:",inline"` // Embed the base fields - ApiUrl string `yaml:"api_url"` // The base URL of the Mikrotik API (e.g., "https://10.10.10.10:8729/rest") - ApiUser string `yaml:"api_user"` - ApiPassword string `yaml:"api_password"` + ApiUrl string `yaml:"api_url"` // The base URL of the Mikrotik API (e.g., "https://10.10.10.10:8729/rest") + ApiUser string `yaml:"api_user"` + ApiPassword string `yaml:"api_password"` + ApiVerifyTls bool `yaml:"api_verify_tls"` // Whether to verify the TLS certificate of the Mikrotik API + ApiTimeout time.Duration `yaml:"api_timeout"` // Timeout for API requests (default: 30 seconds) + + Debug bool `yaml:"debug"` // Enable debug logging for the Mikrotik backend } diff --git a/internal/domain/peer.go b/internal/domain/peer.go index 4e40a83..6e550ad 100644 --- a/internal/domain/peer.go +++ b/internal/domain/peer.go @@ -181,6 +181,8 @@ type PhysicalPeer struct { BytesUpload uint64 // upload bytes are the number of bytes that the remote peer has sent to the server BytesDownload uint64 // upload bytes are the number of bytes that the remote peer has received from the server + + BackendExtras map[string]any // additional backend specific extras, e.g. for the mikrotik backend this contains the name of the peer } func (p PhysicalPeer) GetPresharedKey() *wgtypes.Key { diff --git a/internal/logger.go b/internal/logger.go index 994c236..9bca8c8 100644 --- a/internal/logger.go +++ b/internal/logger.go @@ -12,8 +12,8 @@ import ( "sync" ) -// SetupLogging initializes the global logger with the given level and format -func SetupLogging(level string, pretty, json bool) { +// GetLoggingHandler initializes a slog.Handler based on the provided logging level and format options. +func GetLoggingHandler(level string, pretty, json bool) slog.Handler { var logLevel = new(slog.LevelVar) switch strings.ToLower(level) { @@ -46,6 +46,13 @@ func SetupLogging(level string, pretty, json bool) { handler = slog.NewTextHandler(output, opts) } + return handler +} + +// SetupLogging initializes the global logger with the given level and format +func SetupLogging(level string, pretty, json bool) { + handler := GetLoggingHandler(level, pretty, json) + logger := slog.New(handler) slog.SetDefault(logger) diff --git a/internal/lowlevel/mikrotik.go b/internal/lowlevel/mikrotik.go new file mode 100644 index 0000000..8224463 --- /dev/null +++ b/internal/lowlevel/mikrotik.go @@ -0,0 +1,291 @@ +package lowlevel + +import ( + "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" +) + +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:"details,omitempty"` +} + +func (e MikrotikApiError) String() string { + return fmt.Sprintf("API error %d: %s - %s", e.Code, e.Message, e.Details) +} + +type GenericJsonObject map[string]any + +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() +} + +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, + } + + if cfg.ApiTimeout == 0 { + cfg.ApiTimeout = 30 * time.Second // Default timeout for API requests + } + + 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.ApiTimeout, + } + + 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, "GET", 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 +} + +const ( + MikrotikApiStatusOk = "success" + MikrotikApiStatusError = "error" +) + +const ( + MikrotikApiErrorCodeUnknown = iota + 600 + MikrotikApiErrorCodeRequestPreparationFailed + MikrotikApiErrorCodeRequestFailed + MikrotikApiErrorCodeResponseDecodeFailed +) + +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 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.ApiTimeout) + 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.ApiTimeout) + 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 +}