mirror of https://github.com/h44z/wg-portal.git
				
				
				
			wip: implement mikrotik rest api client (#426)
This commit is contained in:
		
							parent
							
								
									d5ce889e4f
								
							
						
					
					
						commit
						e934232e0b
					
				|  | @ -89,6 +89,10 @@ func NewLocalController(cfg *config.Config) (*LocalController, error) { | ||||||
| 	return repo, nil | 	return repo, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (c LocalController) GetId() domain.InterfaceBackend { | ||||||
|  | 	return config.LocalBackendName | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // region wireguard-related
 | // region wireguard-related
 | ||||||
| 
 | 
 | ||||||
| func (c LocalController) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) { | func (c LocalController) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) { | ||||||
|  |  | ||||||
|  | @ -2,38 +2,261 @@ package wgcontroller | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
|  | 	"fmt" | ||||||
|  | 	"time" | ||||||
| 
 | 
 | ||||||
|  | 	"github.com/h44z/wg-portal/internal/config" | ||||||
| 	"github.com/h44z/wg-portal/internal/domain" | 	"github.com/h44z/wg-portal/internal/domain" | ||||||
|  | 	"github.com/h44z/wg-portal/internal/lowlevel" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type MikrotikController struct { | type MikrotikController struct { | ||||||
|  | 	coreCfg *config.Config | ||||||
|  | 	cfg     *config.BackendMikrotik | ||||||
|  | 
 | ||||||
|  | 	client *lowlevel.MikrotikApiClient | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func NewMikrotikController() (*MikrotikController, error) { | func NewMikrotikController(coreCfg *config.Config, cfg *config.BackendMikrotik) (*MikrotikController, error) { | ||||||
| 	return &MikrotikController{}, nil | 	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
 | // region wireguard-related
 | ||||||
| 
 | 
 | ||||||
| func (c MikrotikController) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) { | func (c MikrotikController) GetInterfaces(ctx context.Context) ([]domain.PhysicalInterface, error) { | ||||||
| 	// TODO implement me
 | 	wgReply := c.client.Query(ctx, "/interface/wireguard", &lowlevel.MikrotikRequestOptions{ | ||||||
| 	panic("implement me") | 		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, | 	*domain.PhysicalInterface, | ||||||
| 	error, | 	error, | ||||||
| ) { | ) { | ||||||
| 	// TODO implement me
 | 	wgReply := c.client.Query(ctx, "/interface/wireguard", &lowlevel.MikrotikRequestOptions{ | ||||||
| 	panic("implement me") | 		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, | 	[]domain.PhysicalPeer, | ||||||
| 	error, | 	error, | ||||||
| ) { | ) { | ||||||
| 	// TODO implement me
 | 	wgReply := c.client.Query(ctx, "/interface/wireguard/peers", &lowlevel.MikrotikRequestOptions{ | ||||||
| 	panic("implement me") | 		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( | func (c MikrotikController) SaveInterface( | ||||||
|  | @ -42,12 +265,12 @@ func (c MikrotikController) SaveInterface( | ||||||
| 	updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error), | 	updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error), | ||||||
| ) error { | ) error { | ||||||
| 	// TODO implement me
 | 	// TODO implement me
 | ||||||
| 	panic("implement me") | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c MikrotikController) DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error { | func (c MikrotikController) DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error { | ||||||
| 	// TODO implement me
 | 	// TODO implement me
 | ||||||
| 	panic("implement me") | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c MikrotikController) SavePeer( | func (c MikrotikController) SavePeer( | ||||||
|  | @ -57,7 +280,7 @@ func (c MikrotikController) SavePeer( | ||||||
| 	updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error), | 	updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error), | ||||||
| ) error { | ) error { | ||||||
| 	// TODO implement me
 | 	// TODO implement me
 | ||||||
| 	panic("implement me") | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c MikrotikController) DeletePeer( | func (c MikrotikController) DeletePeer( | ||||||
|  | @ -66,7 +289,7 @@ func (c MikrotikController) DeletePeer( | ||||||
| 	id domain.PeerIdentifier, | 	id domain.PeerIdentifier, | ||||||
| ) error { | ) error { | ||||||
| 	// TODO implement me
 | 	// TODO implement me
 | ||||||
| 	panic("implement me") | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // endregion wireguard-related
 | // endregion wireguard-related
 | ||||||
|  |  | ||||||
|  | @ -13,6 +13,7 @@ import ( | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type InterfaceController interface { | type InterfaceController interface { | ||||||
|  | 	GetId() domain.InterfaceBackend | ||||||
| 	GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) | 	GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) | ||||||
| 	GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) | 	GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) | ||||||
| 	GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error) | 	GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error) | ||||||
|  | @ -92,7 +93,7 @@ func (c *ControllerManager) registerMikrotikControllers() error { | ||||||
| 			continue | 			continue | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		controller, err := wgcontroller.NewMikrotikController() // TODO: Pass backendConfig to the controller constructor
 | 		controller, err := wgcontroller.NewMikrotikController(c.cfg, &backendConfig) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return fmt.Errorf("failed to create Mikrotik controller for backend %s: %w", backendConfig.Id, err) | 			return fmt.Errorf("failed to create Mikrotik controller for backend %s: %w", backendConfig.Id, err) | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | @ -149,7 +149,7 @@ func (m Manager) ImportNewInterfaces(ctx context.Context, filter ...domain.Inter | ||||||
| 				return 0, err | 				return 0, err | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			err = m.importInterface(ctx, &physicalInterface, physicalPeers) | 			err = m.importInterface(ctx, wgBackend, &physicalInterface, physicalPeers) | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| 				return 0, fmt.Errorf("import of %s failed: %w", physicalInterface.Identifier, err) | 				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 | 	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() | 	now := time.Now() | ||||||
| 	iface := domain.ConvertPhysicalInterface(in) | 	iface := domain.ConvertPhysicalInterface(in) | ||||||
| 	iface.BaseModel = domain.BaseModel{ | 	iface.BaseModel = domain.BaseModel{ | ||||||
|  | @ -779,6 +784,7 @@ func (m Manager) importInterface(ctx context.Context, in *domain.PhysicalInterfa | ||||||
| 		CreatedAt: now, | 		CreatedAt: now, | ||||||
| 		UpdatedAt: now, | 		UpdatedAt: now, | ||||||
| 	} | 	} | ||||||
|  | 	iface.Backend = backend.GetId() | ||||||
| 	iface.PeerDefAllowedIPsStr = iface.AddressStr() | 	iface.PeerDefAllowedIPsStr = iface.AddressStr() | ||||||
| 
 | 
 | ||||||
| 	existingInterface, err := m.db.GetInterface(ctx, iface.Identifier) | 	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] + ")" | 		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) { | 	err := m.db.SavePeer(ctx, peer.Identifier, func(_ *domain.Peer) (*domain.Peer, error) { | ||||||
| 		return peer, nil | 		return peer, nil | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
|  | @ -2,6 +2,7 @@ package config | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"fmt" | 	"fmt" | ||||||
|  | 	"time" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| const LocalBackendName = "local" | const LocalBackendName = "local" | ||||||
|  | @ -46,7 +47,11 @@ type BackendBase struct { | ||||||
| type BackendMikrotik struct { | type BackendMikrotik struct { | ||||||
| 	BackendBase `yaml:",inline"` // Embed the base fields
 | 	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")
 | 	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"` | 	ApiUser      string        `yaml:"api_user"` | ||||||
| 	ApiPassword string `yaml:"api_password"` | 	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
 | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -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
 | 	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
 | 	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 { | func (p PhysicalPeer) GetPresharedKey() *wgtypes.Key { | ||||||
|  |  | ||||||
|  | @ -12,8 +12,8 @@ import ( | ||||||
| 	"sync" | 	"sync" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // SetupLogging initializes the global logger with the given level and format
 | // GetLoggingHandler initializes a slog.Handler based on the provided logging level and format options.
 | ||||||
| func SetupLogging(level string, pretty, json bool) { | func GetLoggingHandler(level string, pretty, json bool) slog.Handler { | ||||||
| 	var logLevel = new(slog.LevelVar) | 	var logLevel = new(slog.LevelVar) | ||||||
| 
 | 
 | ||||||
| 	switch strings.ToLower(level) { | 	switch strings.ToLower(level) { | ||||||
|  | @ -46,6 +46,13 @@ func SetupLogging(level string, pretty, json bool) { | ||||||
| 		handler = slog.NewTextHandler(output, opts) | 		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) | 	logger := slog.New(handler) | ||||||
| 
 | 
 | ||||||
| 	slog.SetDefault(logger) | 	slog.SetDefault(logger) | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  | } | ||||||
		Loading…
	
		Reference in New Issue