From f086ba26052a1b44d5316e9367b312359e1251c1 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Fri, 6 Jun 2025 22:21:47 +0200 Subject: [PATCH] wip: basic CRUD for peer (#426) --- internal/adapters/wgcontroller/mikrotik.go | 154 ++++++++++++++++-- internal/app/app.go | 2 +- .../app/wireguard/wireguard_interfaces.go | 11 ++ internal/domain/controller.go | 15 +- internal/domain/peer.go | 26 ++- internal/lowlevel/mikrotik.go | 2 +- 6 files changed, 188 insertions(+), 22 deletions(-) diff --git a/internal/adapters/wgcontroller/mikrotik.go b/internal/adapters/wgcontroller/mikrotik.go index aff49c9..647f6ad 100644 --- a/internal/adapters/wgcontroller/mikrotik.go +++ b/internal/adapters/wgcontroller/mikrotik.go @@ -5,6 +5,7 @@ import ( "fmt" "slices" "strconv" + "strings" "time" "github.com/h44z/wg-portal/internal/config" @@ -210,7 +211,7 @@ func (c MikrotikController) GetPeers(ctx context.Context, deviceId domain.Interf 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", + "public-key", "private-key", "preshared-key", "mtu", "disabled", "rx", "tx", "responder", "client-dns", }, Filters: map[string]string{ "interface": string(deviceId), @@ -241,7 +242,7 @@ func (c MikrotikController) convertWireGuardPeer(peer lowlevel.GenericJsonObject error, ) { keepAliveSeconds := 0 - duration, err := time.ParseDuration(peer.GetString("client-keepalive")) + duration, err := time.ParseDuration(peer.GetString("persistent-keepalive")) if err == nil { keepAliveSeconds = int(duration.Seconds()) } @@ -261,6 +262,12 @@ func (c MikrotikController) convertWireGuardPeer(peer lowlevel.GenericJsonObject allowedAddresses, _ := domain.CidrsFromString(peer.GetString("allowed-address")) + clientKeepAliveSeconds := 0 + duration, err = time.ParseDuration(peer.GetString("client-keepalive")) + if err == nil { + clientKeepAliveSeconds = int(duration.Seconds()) + } + peerModel := domain.PhysicalPeer{ Identifier: domain.PeerIdentifier(peer.GetString("public-key")), Endpoint: currentEndpoint, @@ -279,12 +286,15 @@ func (c MikrotikController) convertWireGuardPeer(peer lowlevel.GenericJsonObject } peerModel.SetExtras(domain.MikrotikPeerExtras{ - Name: peer.GetString("name"), - Comment: peer.GetString("comment"), - IsResponder: peer.GetBool("responder"), - ClientEndpoint: peer.GetString("client-endpoint"), - ClientAddress: peer.GetString("client-address"), - Disabled: peer.GetBool("disabled"), + Id: peer.GetString(".id"), + Name: peer.GetString("name"), + Comment: peer.GetString("comment"), + IsResponder: peer.GetBool("responder"), + Disabled: peer.GetBool("disabled"), + ClientEndpoint: peer.GetString("client-endpoint"), + ClientAddress: peer.GetString("client-address"), + ClientDns: peer.GetString("client-dns"), + ClientKeepalive: clientKeepAliveSeconds, }) return peerModel, nil @@ -477,9 +487,12 @@ func (c MikrotikController) DeleteInterface(ctx context.Context, id domain.Inter "name": string(id), }, }) - if wgReply.Status != lowlevel.MikrotikApiStatusOk || len(wgReply.Data) == 0 { + if wgReply.Status != lowlevel.MikrotikApiStatusOk { return fmt.Errorf("unable to find WireGuard interface %s: %v", id, wgReply.Error) } + if len(wgReply.Data) == 0 { + return nil // interface does not exist, nothing to delete + } deviceId := wgReply.Data[0].GetString(".id") deleteReply := c.client.Delete(ctx, "/interface/wireguard/"+deviceId) @@ -491,21 +504,136 @@ func (c MikrotikController) DeleteInterface(ctx context.Context, id domain.Inter } func (c MikrotikController) SavePeer( - _ context.Context, + ctx context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier, updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error), ) error { - // TODO implement me + physicalPeer, err := c.getOrCreatePeer(ctx, deviceId, id) + if err != nil { + return err + } + + peerId := physicalPeer.GetExtras().(domain.MikrotikPeerExtras).Id + physicalPeer, err = updateFunc(physicalPeer) + if err != nil { + return err + } + newExtras := physicalPeer.GetExtras().(domain.MikrotikPeerExtras) + newExtras.Id = peerId // ensure the ID is not changed + physicalPeer.SetExtras(newExtras) + + if err := c.updatePeer(ctx, deviceId, physicalPeer); err != nil { + return err + } + + return nil +} + +func (c MikrotikController) getOrCreatePeer( + ctx context.Context, + deviceId domain.InterfaceIdentifier, + id domain.PeerIdentifier, +) (*domain.PhysicalPeer, error) { + wgReply := c.client.Query(ctx, "/interface/wireguard/peers", &lowlevel.MikrotikRequestOptions{ + PropList: []string{ + ".id", "name", "public-key", "private-key", "preshared-key", "persistent-keepalive", "client-address", + "client-endpoint", "client-keepalive", "allowed-address", "client-dns", "comment", "disabled", "responder", + }, + Filters: map[string]string{ + "public-key": string(id), + "interface": string(deviceId), + }, + }) + if wgReply.Status == lowlevel.MikrotikApiStatusOk && len(wgReply.Data) > 0 { + existingPeer, err := c.convertWireGuardPeer(wgReply.Data[0]) + if err != nil { + return nil, err + } + return &existingPeer, nil + } + + // create a new peer if it does not exist + createReply := c.client.Create(ctx, "/interface/wireguard/peers", lowlevel.GenericJsonObject{ + "name": fmt.Sprintf("tmp-wg-%s", id[0:8]), + "interface": string(deviceId), + "public-key": string(id), // public key will be set later + "allowed-address": "169.254.254.254/32", // allowed addresses will be set later + }) + if createReply.Status == lowlevel.MikrotikApiStatusOk { + newPeer, err := c.convertWireGuardPeer(createReply.Data) + if err != nil { + return nil, err + } + return &newPeer, nil + } + + return nil, fmt.Errorf("failed to create peer %s for interface %s: %v", id, deviceId, createReply.Error) +} + +func (c MikrotikController) updatePeer( + ctx context.Context, + deviceId domain.InterfaceIdentifier, + pp *domain.PhysicalPeer, +) error { + extras := pp.GetExtras().(domain.MikrotikPeerExtras) + peerId := extras.Id + + endpoint := pp.Endpoint + endpointPort := "51820" // default port if not set + if s := strings.Split(endpoint, ":"); len(s) == 2 { + endpoint = s[0] + endpointPort = s[1] + } + + wgReply := c.client.Update(ctx, "/interface/wireguard/peers/"+peerId, lowlevel.GenericJsonObject{ + "name": extras.Name, + "comment": extras.Comment, + "preshared-key": pp.PresharedKey, + "public-key": pp.KeyPair.PublicKey, + "private-key": pp.KeyPair.PrivateKey, + "persistent-keepalive": (time.Duration(pp.PersistentKeepalive) * time.Second).String(), + "disabled": strconv.FormatBool(extras.Disabled), + "responder": strconv.FormatBool(extras.IsResponder), + "client-endpoint": extras.ClientEndpoint, + "client-address": extras.ClientAddress, + "client-keepalive": (time.Duration(extras.ClientKeepalive) * time.Second).String(), + "client-dns": extras.ClientDns, + "endpoint-address": endpoint, + "endpoint-port": endpointPort, + }) + if wgReply.Status != lowlevel.MikrotikApiStatusOk { + return fmt.Errorf("failed to update peer %s on interface %s: %v", pp.Identifier, deviceId, wgReply.Error) + } + return nil } func (c MikrotikController) DeletePeer( - _ context.Context, + ctx context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier, ) error { - // TODO implement me + wgReply := c.client.Query(ctx, "/interface/wireguard/peers", &lowlevel.MikrotikRequestOptions{ + PropList: []string{".id"}, + Filters: map[string]string{ + "public-key": string(id), + "interface": string(deviceId), + }, + }) + if wgReply.Status != lowlevel.MikrotikApiStatusOk { + return fmt.Errorf("unable to find WireGuard peer %s for interface %s: %v", id, deviceId, wgReply.Error) + } + if len(wgReply.Data) == 0 { + return nil // peer does not exist, nothing to delete + } + + peerId := wgReply.Data[0].GetString(".id") + deleteReply := c.client.Delete(ctx, "/interface/wireguard/peers/"+peerId) + if deleteReply.Status != lowlevel.MikrotikApiStatusOk { + return fmt.Errorf("failed to delete WireGuard peer %s for interface %s: %v", id, deviceId, deleteReply.Error) + } + return nil } diff --git a/internal/app/app.go b/internal/app/app.go index 1eb24cb..e33cfc0 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -46,7 +46,7 @@ func Initialize( users: users, } - startupContext, cancel := context.WithTimeout(context.Background(), 30*time.Second) + startupContext, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() // Switch to admin user context diff --git a/internal/app/wireguard/wireguard_interfaces.go b/internal/app/wireguard/wireguard_interfaces.go index be9f41a..90465de 100644 --- a/internal/app/wireguard/wireguard_interfaces.go +++ b/internal/app/wireguard/wireguard_interfaces.go @@ -787,6 +787,17 @@ func (m Manager) importInterface( iface.Backend = backend.GetId() iface.PeerDefAllowedIPsStr = iface.AddressStr() + // try to predict the interface type based on the number of peers + switch len(peers) { + case 0: + iface.Type = domain.InterfaceTypeAny // no peers means this is an unknown interface + case 1: + iface.Type = domain.InterfaceTypeClient // one peer means this is a client interface + default: // multiple peers means this is a server interface + + iface.Type = domain.InterfaceTypeServer + } + existingInterface, err := m.db.GetInterface(ctx, iface.Identifier) if err != nil && !errors.Is(err, domain.ErrNotFound) { return err diff --git a/internal/domain/controller.go b/internal/domain/controller.go index 791741a..a94e116 100644 --- a/internal/domain/controller.go +++ b/internal/domain/controller.go @@ -16,10 +16,13 @@ type MikrotikInterfaceExtras struct { } type MikrotikPeerExtras struct { - Name string - Comment string - IsResponder bool - ClientEndpoint string - ClientAddress string - Disabled bool + Id string // internal mikrotik ID + Name string + Comment string + IsResponder bool + Disabled bool + ClientEndpoint string + ClientAddress string + ClientDns string + ClientKeepalive int } diff --git a/internal/domain/peer.go b/internal/domain/peer.go index fc082bd..bfc879a 100644 --- a/internal/domain/peer.go +++ b/internal/domain/peer.go @@ -271,7 +271,15 @@ func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer { extras := pp.GetExtras().(MikrotikPeerExtras) peer.Notes = extras.Comment peer.DisplayName = extras.Name - peer.Endpoint = NewConfigOption(extras.ClientEndpoint, true) + if extras.ClientEndpoint != "" { // if the client endpoint is set, we assume that this is a client peer + peer.Endpoint = NewConfigOption(extras.ClientEndpoint, true) + peer.Interface.Type = InterfaceTypeClient + peer.Interface.Addresses, _ = CidrsFromString(extras.ClientAddress) + peer.Interface.DnsStr = NewConfigOption(extras.ClientDns, true) + peer.PersistentKeepalive = NewConfigOption(extras.ClientKeepalive, true) + } else { + peer.Interface.Type = InterfaceTypeServer + } if extras.Disabled { peer.Disabled = &now peer.DisabledReason = "Disabled by Mikrotik controller" @@ -302,6 +310,22 @@ func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) { pp.PresharedKey = p.PresharedKey pp.PublicKey = p.Interface.PublicKey pp.PersistentKeepalive = p.PersistentKeepalive.GetValue() + + switch pp.ImportSource { + case ControllerTypeMikrotik: + extras := MikrotikPeerExtras{ + Id: "", + Name: p.DisplayName, + Comment: p.Notes, + IsResponder: false, + Disabled: p.IsDisabled(), + ClientEndpoint: p.Endpoint.GetValue(), + ClientAddress: CidrsToString(p.Interface.Addresses), + ClientDns: p.Interface.DnsStr.GetValue(), + ClientKeepalive: p.PersistentKeepalive.GetValue(), + } + pp.SetExtras(extras) + } } type PeerCreationRequest struct { diff --git a/internal/lowlevel/mikrotik.go b/internal/lowlevel/mikrotik.go index bcad3e5..79b53d0 100644 --- a/internal/lowlevel/mikrotik.go +++ b/internal/lowlevel/mikrotik.go @@ -42,7 +42,7 @@ type MikrotikApiResponse[T any] struct { type MikrotikApiError struct { Code int `json:"error,omitempty"` Message string `json:"message,omitempty"` - Details string `json:"details,omitempty"` + Details string `json:"detail,omitempty"` } func (e *MikrotikApiError) String() string {