mirror of
https://github.com/h44z/wg-portal.git
synced 2026-09-03 21:36:44 +00:00
Add read-only support and UI indicators for MikroTik dynamic peers (#733)
* Add dynamic Mikrotik peer support & UI/i18n Introduce handling for dynamically created Mikrotik peers: add Dynamic/IsDynamic fields to domain, peer and interface models and API models; include "dynamic" in Mikrotik queries; skip updates/deletes for dynamic peers; sync dynamic flag when restoring state. Prevent modifying/deleting dynamic peers in manager. UI: disable selection/edit for dynamic peers and show badge. Implement Mikrotik interface hook execution. Add i18n keys/translations for dynamic-peer messages across languages. * Fix Mikrotik hooks implementation and UI visibility
This commit is contained in:
@@ -296,7 +296,7 @@ func (c *MikrotikController) GetPeers(ctx context.Context, deviceId domain.Inter
|
||||
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", "client-dns",
|
||||
"public-key", "private-key", "preshared-key", "mtu", "disabled", "rx", "tx", "responder", "client-dns", "dynamic",
|
||||
},
|
||||
Filters: map[string]string{
|
||||
"interface": string(deviceId),
|
||||
@@ -376,6 +376,7 @@ func (c *MikrotikController) convertWireGuardPeer(peer lowlevel.GenericJsonObjec
|
||||
Comment: peer.GetString("comment"),
|
||||
IsResponder: peer.GetBool("responder"),
|
||||
Disabled: peer.GetBool("disabled"),
|
||||
Dynamic: peer.GetBool("dynamic"),
|
||||
ClientEndpoint: peer.GetString("client-endpoint"),
|
||||
ClientAddress: peer.GetString("client-address"),
|
||||
ClientDns: peer.GetString("client-dns"),
|
||||
@@ -614,15 +615,24 @@ func (c *MikrotikController) SavePeer(
|
||||
return err
|
||||
}
|
||||
|
||||
peerId := physicalPeer.GetExtras().(domain.MikrotikPeerExtras).Id
|
||||
oldExtras := physicalPeer.GetExtras().(domain.MikrotikPeerExtras)
|
||||
peerId := oldExtras.Id
|
||||
dynamic := oldExtras.Dynamic
|
||||
|
||||
physicalPeer, err = updateFunc(physicalPeer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newExtras := physicalPeer.GetExtras().(domain.MikrotikPeerExtras)
|
||||
newExtras.Id = peerId // ensure the ID is not changed
|
||||
newExtras.Id = peerId // ensure the ID is not changed
|
||||
newExtras.Dynamic = dynamic // ensure the dynamic flag is preserved
|
||||
physicalPeer.SetExtras(newExtras)
|
||||
|
||||
if newExtras.Dynamic {
|
||||
slog.Debug("skipping update for dynamic Mikrotik peer", "peer", id, "interface", deviceId)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := c.updatePeer(ctx, deviceId, physicalPeer); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -638,7 +648,7 @@ func (c *MikrotikController) getOrCreatePeer(
|
||||
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",
|
||||
"client-endpoint", "client-keepalive", "allowed-address", "client-dns", "comment", "disabled", "responder", "dynamic",
|
||||
},
|
||||
Filters: map[string]string{
|
||||
"public-key": string(id),
|
||||
@@ -742,7 +752,7 @@ func (c *MikrotikController) DeletePeer(
|
||||
defer mutex.Unlock()
|
||||
|
||||
wgReply := c.client.Query(ctx, "/interface/wireguard/peers", &lowlevel.MikrotikRequestOptions{
|
||||
PropList: []string{".id"},
|
||||
PropList: []string{".id", "dynamic"},
|
||||
Filters: map[string]string{
|
||||
"public-key": string(id),
|
||||
"interface": string(deviceId),
|
||||
@@ -754,6 +764,10 @@ func (c *MikrotikController) DeletePeer(
|
||||
if len(wgReply.Data) == 0 {
|
||||
return nil // peer does not exist, nothing to delete
|
||||
}
|
||||
if wgReply.Data[0].GetBool("dynamic") {
|
||||
slog.Debug("skipping deletion of dynamic Mikrotik peer", "peer", id, "interface", deviceId)
|
||||
return nil
|
||||
}
|
||||
|
||||
peerId := wgReply.Data[0].GetString(".id")
|
||||
deleteReply := c.client.Delete(ctx, "/interface/wireguard/peers/"+peerId)
|
||||
@@ -769,12 +783,51 @@ func (c *MikrotikController) DeletePeer(
|
||||
// region wg-quick-related
|
||||
|
||||
func (c *MikrotikController) ExecuteInterfaceHook(
|
||||
_ context.Context,
|
||||
_ domain.InterfaceIdentifier,
|
||||
_ string,
|
||||
ctx context.Context,
|
||||
id domain.InterfaceIdentifier,
|
||||
hookCmd string,
|
||||
) error {
|
||||
// TODO implement me
|
||||
slog.Error("interface hooks are not yet supported for Mikrotik backends, please open an issue on GitHub")
|
||||
if hookCmd == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
scriptName := fmt.Sprintf("wg-portal-hook-%s-%d", id, time.Now().UnixNano())
|
||||
|
||||
// Replace %i with the interface ID to mimic wg-quick behavior
|
||||
scriptSource := strings.ReplaceAll(hookCmd, "%i", string(id))
|
||||
// Inject the interface ID as a local variable for convenience in RouterOS scripts
|
||||
scriptSource = fmt.Sprintf(":local WGInterface \"%s\";\n%s", id, scriptSource)
|
||||
|
||||
slog.Debug("executing Mikrotik script for interface hook", "interface", id, "script", scriptSource)
|
||||
|
||||
createReply := c.client.Create(ctx, "/system/script", lowlevel.GenericJsonObject{
|
||||
"name": scriptName,
|
||||
"source": scriptSource,
|
||||
"policy": "ftp,reboot,read,write,policy,test,password,sniff,sensitive,romon",
|
||||
})
|
||||
if createReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("failed to create temporary script for hook: %v", createReply.Error)
|
||||
}
|
||||
|
||||
scriptId := createReply.Data.GetString(".id")
|
||||
if scriptId == "" {
|
||||
scriptId = scriptName
|
||||
}
|
||||
|
||||
defer func() {
|
||||
cleanupReply := c.client.Delete(ctx, "/system/script/"+scriptId)
|
||||
if cleanupReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
slog.Warn("failed to remove temporary Mikrotik script", "scriptId", scriptId, "error", cleanupReply.Error)
|
||||
}
|
||||
}()
|
||||
|
||||
runReply := c.client.ExecList(ctx, "/system/script/run", lowlevel.GenericJsonObject{
|
||||
"number": scriptId,
|
||||
})
|
||||
if runReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("failed to execute hook script %s: %v", scriptName, runReply.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
|
||||
names = append(names, model.SettingsBackendNames{
|
||||
Id: controller.Id,
|
||||
Name: displayName,
|
||||
Type: controller.Type,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -20,4 +20,5 @@ type Settings struct {
|
||||
type SettingsBackendNames struct {
|
||||
Id string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ type Interface struct {
|
||||
PublicKey string `json:"PublicKey" example:"abcdef=="` // public Key of the server interface
|
||||
Disabled bool `json:"Disabled"` // flag that specifies if the interface is enabled (up) or not (down)
|
||||
DisabledReason string `json:"DisabledReason"` // the reason why the interface has been disabled
|
||||
IsDynamic bool `json:"IsDynamic"` // specifies if the interface is a dynamic object
|
||||
SaveConfig bool `json:"SaveConfig"` // automatically persist config changes to the wgX.conf file
|
||||
CreateDefaultPeer bool `json:"CreateDefaultPeer"` // if true, default peers will be created for this interface
|
||||
|
||||
@@ -65,6 +66,7 @@ func NewInterface(src *domain.Interface, peers []domain.Peer) *Interface {
|
||||
PublicKey: src.PublicKey,
|
||||
Disabled: src.IsDisabled(),
|
||||
DisabledReason: src.DisabledReason,
|
||||
IsDynamic: src.IsDynamic,
|
||||
SaveConfig: src.SaveConfig,
|
||||
CreateDefaultPeer: src.CreateDefaultPeer,
|
||||
ListenPort: src.ListenPort,
|
||||
@@ -153,6 +155,7 @@ func NewDomainInterface(src *Interface) *domain.Interface {
|
||||
PreDown: src.PreDown,
|
||||
PostDown: src.PostDown,
|
||||
SaveConfig: src.SaveConfig,
|
||||
IsDynamic: src.IsDynamic,
|
||||
CreateDefaultPeer: src.CreateDefaultPeer,
|
||||
DisplayName: src.DisplayName,
|
||||
Type: domain.InterfaceType(src.Mode),
|
||||
|
||||
@@ -49,6 +49,7 @@ type Peer struct {
|
||||
DisabledReason string `json:"DisabledReason"` // the reason why the peer has been disabled
|
||||
ExpiresAt ExpiryDate `json:"ExpiresAt,omitempty"` // expiry dates for peers
|
||||
Notes string `json:"Notes"` // a note field for peers
|
||||
IsDynamic bool `json:"IsDynamic"` // specifies if the peer is dynamically created by mikrotik
|
||||
|
||||
Endpoint ConfigOption[string] `json:"Endpoint"` // the endpoint address
|
||||
EndpointPublicKey ConfigOption[string] `json:"EndpointPublicKey"` // the endpoint public key
|
||||
@@ -90,6 +91,7 @@ func NewPeer(src *domain.Peer) *Peer {
|
||||
DisabledReason: src.DisabledReason,
|
||||
ExpiresAt: ExpiryDate{src.ExpiresAt},
|
||||
Notes: src.Notes,
|
||||
IsDynamic: src.IsDynamic,
|
||||
Endpoint: ConfigOptionFromDomain(src.Endpoint),
|
||||
EndpointPublicKey: ConfigOptionFromDomain(src.EndpointPublicKey),
|
||||
AllowedIPs: StringSliceConfigOptionFromDomain(src.AllowedIPsStr),
|
||||
@@ -148,6 +150,7 @@ func NewDomainPeer(src *Peer) *domain.Peer {
|
||||
InterfaceIdentifier: domain.InterfaceIdentifier(src.InterfaceIdentifier),
|
||||
Disabled: nil, // set below
|
||||
DisabledReason: src.DisabledReason,
|
||||
IsDynamic: src.IsDynamic,
|
||||
ExpiresAt: src.ExpiresAt.Time,
|
||||
Notes: src.Notes,
|
||||
Interface: domain.PeerInterfaceConfig{
|
||||
|
||||
@@ -63,6 +63,7 @@ func (c *ControllerManager) registerLocalController() error {
|
||||
Config: config.BackendBase{
|
||||
Id: config.LocalBackendName,
|
||||
DisplayName: "Local WireGuard Controller",
|
||||
Type: "local",
|
||||
IgnoredInterfaces: c.cfg.Backend.IgnoredLocalInterfaces,
|
||||
},
|
||||
Implementation: localController,
|
||||
@@ -82,6 +83,7 @@ func (c *ControllerManager) registerMikrotikControllers() error {
|
||||
return fmt.Errorf("failed to create Mikrotik controller for backend %s: %w", backendConfig.Id, err)
|
||||
}
|
||||
|
||||
backendConfig.BackendBase.Type = "mikrotik"
|
||||
c.controllers[domain.InterfaceBackend(backendConfig.Id)] = backendInstance{
|
||||
Config: backendConfig.BackendBase,
|
||||
Implementation: controller,
|
||||
@@ -102,6 +104,7 @@ func (c *ControllerManager) registerPfsenseControllers() error {
|
||||
return fmt.Errorf("failed to create pfSense controller for backend %s: %w", backendConfig.Id, err)
|
||||
}
|
||||
|
||||
backendConfig.BackendBase.Type = "pfsense"
|
||||
c.controllers[domain.InterfaceBackend(backendConfig.Id)] = backendInstance{
|
||||
Config: backendConfig.BackendBase,
|
||||
Implementation: controller,
|
||||
|
||||
@@ -307,9 +307,19 @@ func (m Manager) RestoreInterfaceState(
|
||||
physicalPeers, _ := controller.GetPeers(ctx, iface.Identifier)
|
||||
for _, physicalPeer := range physicalPeers {
|
||||
isWgPortalPeer := false
|
||||
for _, peer := range peers {
|
||||
if peer.Identifier == domain.PeerIdentifier(physicalPeer.PublicKey) {
|
||||
for i := range peers {
|
||||
if peers[i].Identifier == domain.PeerIdentifier(physicalPeer.PublicKey) {
|
||||
isWgPortalPeer = true
|
||||
|
||||
// Sync IsDynamic flag if it was changed on the backend
|
||||
physConverted := domain.ConvertPhysicalPeer(&physicalPeer)
|
||||
if peers[i].IsDynamic != physConverted.IsDynamic {
|
||||
peers[i].IsDynamic = physConverted.IsDynamic
|
||||
_ = m.db.SavePeer(ctx, peers[i].Identifier, func(p *domain.Peer) (*domain.Peer, error) {
|
||||
p.IsDynamic = physConverted.IsDynamic
|
||||
return p, nil
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,7 +620,11 @@ func (m Manager) getFreshPeerIpConfig(ctx context.Context, iface *domain.Interfa
|
||||
return
|
||||
}
|
||||
|
||||
func (m Manager) validatePeerModifications(ctx context.Context, _, _ *domain.Peer) error {
|
||||
func (m Manager) validatePeerModifications(ctx context.Context, old, _ *domain.Peer) error {
|
||||
if old != nil && old.IsDynamic {
|
||||
return fmt.Errorf("cannot modify dynamic peer: %w", domain.ErrInvalidData)
|
||||
}
|
||||
|
||||
currentUser := domain.GetUserInfo(ctx)
|
||||
|
||||
if !currentUser.IsAdmin && !m.cfg.Core.SelfProvisioningAllowed {
|
||||
@@ -649,7 +653,11 @@ func (m Manager) validatePeerCreation(ctx context.Context, _, new *domain.Peer)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) validatePeerDeletion(ctx context.Context, _ *domain.Peer) error {
|
||||
func (m Manager) validatePeerDeletion(ctx context.Context, peer *domain.Peer) error {
|
||||
if peer != nil && peer.IsDynamic {
|
||||
return fmt.Errorf("cannot delete dynamic peer: %w", domain.ErrInvalidData)
|
||||
}
|
||||
|
||||
currentUser := domain.GetUserInfo(ctx)
|
||||
|
||||
if !currentUser.IsAdmin && !m.cfg.Core.SelfProvisioningAllowed {
|
||||
|
||||
@@ -61,6 +61,7 @@ func (b *Backend) Validate() error {
|
||||
type BackendBase struct {
|
||||
Id string `yaml:"id"` // A unique id for the backend
|
||||
DisplayName string `yaml:"display_name"` // A display name for the backend
|
||||
Type string `yaml:"-"` // The type of the backend (e.g. local, mikrotik, pfsense)
|
||||
|
||||
IgnoredInterfaces []string `yaml:"ignored_interfaces"` // A list of interface names that should be ignored by this backend (e.g., "wg0")
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ type MikrotikPeerExtras struct {
|
||||
Comment string
|
||||
IsResponder bool
|
||||
Disabled bool
|
||||
Dynamic bool
|
||||
ClientEndpoint string
|
||||
ClientAddress string
|
||||
ClientDns string
|
||||
|
||||
@@ -58,6 +58,8 @@ type Interface struct {
|
||||
CreateDefaultPeer bool // if true, default peers will be created for this interface
|
||||
Backend InterfaceBackend // the backend that is used to manage the interface (wgctrl, mikrotik, ...)
|
||||
DriverType string // the interface driver type (linux, software, ...)
|
||||
IsDynamic bool `gorm:"column:is_dynamic;default:false"` // specifies if the interface is a dynamic object
|
||||
|
||||
Disabled *time.Time `gorm:"index"` // flag that specifies if the interface is enabled (up) or not (down)
|
||||
DisabledReason string // the reason why the interface has been disabled
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ type Peer struct {
|
||||
|
||||
// Interface settings for the peer, used to generate the [interface] section in the peer config file
|
||||
Interface PeerInterfaceConfig `gorm:"embedded"`
|
||||
|
||||
IsDynamic bool `gorm:"column:is_dynamic;default:false"` // specifies if the peer is a dynamic object (from Mikrotik)
|
||||
}
|
||||
|
||||
func (p *Peer) IsDisabled() bool {
|
||||
@@ -264,6 +266,7 @@ func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer {
|
||||
Interface: PeerInterfaceConfig{
|
||||
KeyPair: pp.KeyPair,
|
||||
},
|
||||
IsDynamic: false,
|
||||
}
|
||||
|
||||
if pp.GetExtras() == nil {
|
||||
@@ -277,6 +280,7 @@ func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer {
|
||||
extras := pp.GetExtras().(MikrotikPeerExtras)
|
||||
peer.Notes = extras.Comment
|
||||
peer.DisplayName = extras.Name
|
||||
peer.IsDynamic = extras.Dynamic
|
||||
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
|
||||
@@ -369,6 +373,7 @@ func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) {
|
||||
ClientAddress: CidrsToString(p.Interface.Addresses),
|
||||
ClientDns: p.Interface.DnsStr.GetValue(),
|
||||
ClientKeepalive: p.PersistentKeepalive.GetValue(),
|
||||
Dynamic: p.IsDynamic,
|
||||
}
|
||||
pp.SetExtras(extras)
|
||||
case ControllerTypeLocal:
|
||||
|
||||
Reference in New Issue
Block a user